fix(oa-board): correct duration and sku flow matching

This commit is contained in:
jingchuan 2026-08-06 10:53:56 +08:00
parent 4efb7f4083
commit f60dbfa159
6 changed files with 208 additions and 56 deletions

View File

@ -631,7 +631,7 @@ function filterOaBoardItems(items: OaBoardProjectListItemDTO[], filters: FilterS
return false
}
const isOvertime = item.overallTimeoutFlag || getOvertimeStages(item).length > 0
const isOvertime = isCurrentlyOvertime(item)
if (filters.timeoutStatus === 'YES' && !isOvertime) {
return false
@ -726,6 +726,10 @@ function getOvertimeStages(item: OaBoardProjectListItemDTO): OvertimeStage[] {
}]
}
function isCurrentlyOvertime(item: OaBoardProjectListItemDTO) {
return item.overallTimeoutFlag || getOvertimeStages(item).length > 0
}
function getHistoricalOvertimeStages(item: OaBoardProjectListItemDTO): OvertimeStage[] {
const currentStageNames = splitStageText(
item.formSummary.approvalStageDisplay || item.currentApprovalStageName,
@ -2208,8 +2212,7 @@ function DetailTableRow({
historicalOvertimeStages,
timeoutFollowups,
)
const isOvertime =
item.overallTimeoutFlag || currentOvertimeStages.length > 0 || historicalOvertimeStages.length > 0
const isOvertime = isCurrentlyOvertime(item)
const timeoutFollowupSummary = getTimeoutFollowupSummary(followupDisplayStages, timeoutFollowups)
const competitorLink = formatCompetitorLinkSummary(item.formSummary.competitorLink)
const primaryStyleImage = item.styleImages[0]

View File

@ -0,0 +1,40 @@
import { resolveOaBrandPlanningGraph } from '../oa-brand-planning-graph'
describe('品牌策划看板 OA 流程关联', () => {
const olderGraph = { id: 'XP2026070900000292' }
const newerGraph = { id: 'XP2026072300000344' }
const links = [
{
graph: olderGraph,
spu: 'GBPM-BG0077',
sourceBusinessId: '202607091757000211686',
},
{
graph: newerGraph,
spu: 'GBPM-BG0077',
sourceBusinessId: '202607230904000546805',
},
]
it('优先按 SKU 需求流程号关联,不被同 SPU 的其他流程覆盖', () => {
expect(
resolveOaBrandPlanningGraph(links, {
spu: 'GBPM-BG0077',
demandFlowNo: '202607091757000211686',
}),
).toBe(olderGraph)
})
it('同一 SPU 有多个 OA 流程且没有精确关联时,不猜测关联对象', () => {
expect(resolveOaBrandPlanningGraph(links, { spu: 'GBPM-BG0077' })).toBeNull()
})
it('只有一个 OA 流程的 SPU 可作为兼容兜底', () => {
expect(
resolveOaBrandPlanningGraph(
[{ graph: olderGraph, spu: 'GBPM-BG0077', sourceBusinessId: '202607091757000211686' }],
{ spu: 'GBPM-BG0077' },
),
).toBe(olderGraph)
})
})

View File

@ -0,0 +1,36 @@
export type OaBrandPlanningGraphLink<TGraph> = {
graph: TGraph
sourceBusinessId: string | null | undefined
spu: string | null | undefined
}
function normalizeMatchValue(value: string | null | undefined) {
const normalized = String(value ?? '').trim().toUpperCase()
return normalized || null
}
/**
* Demand-flow IDs identify the exact OA project for a SKU. SPU is only a
* fallback when there is one unambiguous OA project for that SPU.
*/
export function resolveOaBrandPlanningGraph<TGraph>(
links: readonly OaBrandPlanningGraphLink<TGraph>[],
input: {
demandFlowNo?: string | null
spu?: string | null
},
): TGraph | null {
const demandFlowNo = normalizeMatchValue(input.demandFlowNo)
if (demandFlowNo) {
const exactMatch = links.find(
(link) => normalizeMatchValue(link.sourceBusinessId) === demandFlowNo,
)
if (exactMatch) return exactMatch.graph
}
const spu = normalizeMatchValue(input.spu)
if (!spu) return null
const spuMatches = links.filter((link) => normalizeMatchValue(link.spu) === spu)
return spuMatches.length === 1 ? spuMatches[0].graph : null
}

View File

@ -1,4 +1,52 @@
import { getRunningTasks } from '../oa-board-sync.service'
import {
calculateOaBoardOverallActualDays,
getRunningTasks,
type OaBoardLifecycleFlowRecord,
} from '../oa-board-sync.service'
function flow(
overrides: Partial<OaBoardLifecycleFlowRecord>,
): OaBoardLifecycleFlowRecord {
return {
flowType: 'review',
status: 'COMPLETED',
createdAtSource: new Date('2026-08-01T06:01:00.000Z'),
updatedAtSource: new Date('2026-08-01T08:45:40.000Z'),
finishedAtSource: new Date('2026-08-01T08:45:00.000Z'),
...overrides,
}
}
describe('OA 看板总实际时效', () => {
it('按立项发起到最终流程结束的真实历时计算,不累计子表单处理时长', () => {
const actualDays = calculateOaBoardOverallActualDays([
flow({ flowType: 'review' }),
flow({
flowType: 'order',
createdAtSource: new Date('2026-08-05T01:29:00.000Z'),
updatedAtSource: new Date('2026-08-05T01:42:00.000Z'),
finishedAtSource: new Date('2026-08-05T01:42:00.000Z'),
}),
flow({
flowType: 'pricing',
createdAtSource: new Date('2026-08-05T01:45:00.000Z'),
updatedAtSource: new Date('2026-08-05T01:51:44.000Z'),
finishedAtSource: new Date('2026-08-05T01:49:00.000Z'),
}),
])
expect(actualDays).toBe(3.83)
})
it('进行中的项目计算至当前时间', () => {
const actualDays = calculateOaBoardOverallActualDays(
[flow({ status: 'RUNNING', finishedAtSource: null })],
new Date('2026-08-03T06:01:00.000Z'),
)
expect(actualDays).toBe(2)
})
})
describe('OA 看板当前任务选择', () => {
it('同一节点退回后优先保留重新分配的运行任务', () => {

View File

@ -1469,35 +1469,54 @@ function pickCurrentFlow(records: NormalizedFlowRecord[]): NormalizedFlowRecord
})[0] ?? null
}
export type OaBoardLifecycleFlowRecord = Pick<
NormalizedFlowRecord,
'flowType' | 'status' | 'createdAtSource' | 'updatedAtSource' | 'finishedAtSource'
>
/**
* The total SLA measures elapsed project time, rather than the sum of individual
* form-handling durations. Parallel OA workflows are therefore counted once.
*/
export function calculateOaBoardOverallActualDays(
records: readonly OaBoardLifecycleFlowRecord[],
now = new Date(),
) {
const dates = (selector: (record: OaBoardLifecycleFlowRecord) => Date | null) =>
records
.map(selector)
.filter((value): value is Date => Boolean(value))
const reviewStartedAt = dates((record) =>
record.flowType === 'review' ? record.createdAtSource : null,
).sort((left, right) => left.getTime() - right.getTime())[0]
const firstStartedAt = dates((record) => record.createdAtSource).sort(
(left, right) => left.getTime() - right.getTime(),
)[0]
const startedAt = reviewStartedAt ?? firstStartedAt ?? null
if (!startedAt) return null
const isRunning = records.some((record) =>
OA_BOARD_RUNNING_STATUSES.has(normalizeText(record.status)?.toUpperCase() ?? ''),
)
const latestFinishedAt = dates((record) => record.finishedAtSource).sort(
(left, right) => right.getTime() - left.getTime(),
)[0]
const latestUpdatedAt = dates((record) => record.updatedAtSource).sort(
(left, right) => right.getTime() - left.getTime(),
)[0]
if (!isRunning && !latestFinishedAt && !latestUpdatedAt) return null
return calculateHours(
startedAt,
isRunning ? null : latestFinishedAt ?? latestUpdatedAt,
now,
)
}
function computeOverallActualHours(records: NormalizedFlowRecord[]) {
const byType = new Map<OaBoardFlowType, number>()
records.forEach((record) => {
if (record.actualHours === null) return
const current = byType.get(record.flowType) ?? 0
byType.set(record.flowType, Math.max(current, record.actualHours))
})
const reviewHours = byType.get('review') ?? 0
const orderHours = byType.get('order') ?? 0
const artHours = byType.get('art') ?? 0
const pricingHours = byType.get('pricing') ?? 0
const mainHours = byType.get('main') ?? 0
const reviewRecord = records.find((record) => record.flowType === 'review') ?? null
if (isOrderCompletionFinalPlan(reviewRecord)) {
const total = reviewHours + orderHours
return total > 0 ? Number(total.toFixed(2)) : null
}
const total =
reviewHours + Math.max(orderHours, artHours) + pricingHours
if (total > 0) {
return Number(total.toFixed(2))
}
return mainHours > 0 ? Number(mainHours.toFixed(2)) : null
return calculateOaBoardOverallActualDays(records)
}
function getFlowLifecycleStatus(record: NormalizedFlowRecord | null) {

View File

@ -11,6 +11,7 @@ import {
createPlanningConnectionConfig,
queryOaBoardSnapshotPreview,
} from './oa-board-sync.service'
import { resolveOaBrandPlanningGraph } from '@/lib/oa-brand-planning-graph'
const OA_BRAND_PLANNING_SNAPSHOT_TABLE = 'oa_brand_planning_board_snapshot'
@ -119,6 +120,7 @@ type ProductSkuSource = {
size: string
color: string
barcode: string
demandFlowNo: string
}
type ProductSource = {
@ -386,38 +388,41 @@ function buildSnapshotRows(
graphs: OaBoardProjectGraphInput[],
syncVersion: string,
) {
const graphBySpu = new Map<string, OaBoardProjectGraphInput>()
graphs.forEach((graph) => {
const spu = getGraphSpu(graph)
if (spu) graphBySpu.set(spu, graph)
})
const graphLinks = graphs.map((graph) => ({
graph,
spu: getGraphSpu(graph),
sourceBusinessId: graph.sourceBusinessId,
}))
return products.flatMap<BrandPlanningSnapshotInput>((product) => {
const payload = asRecord(product.payload)
const profile = product.profile
const productSkus = product.skuItems
const graph = graphBySpu.get(product.spu.trim().toUpperCase())
const reviewFlow = findFlow(graph, 'review')
const artFlow = findFlow(graph, 'art')
// The board follows the new-product review workflow. Historical SKU records
// without a matching review flow are not actionable for brand planning.
if (!graph || !reviewFlow) return []
return productSkus.flatMap<BrandPlanningSnapshotInput>((sku) => {
const graph = resolveOaBrandPlanningGraph(graphLinks, {
spu: product.spu,
demandFlowNo: sku.demandFlowNo,
})
const reviewFlow = findFlow(graph ?? undefined, 'review')
const taskAssignedAt = dateFromUnknown(
readFlowField(artFlow, ['品牌策划主管分配任务结束时间']),
)
const planCompletedAt = dateFromUnknown(readFlowField(artFlow, ['出策划案结束时间']))
const visualCompletedAt = dateFromUnknown(readFlowField(artFlow, ['出图结束时间']))
const detailCompletedAt = dateFromUnknown(readFlowField(artFlow, ['平面结束时间']))
const planningTaskAssignedAt = findTaskDate(
artFlow,
['品牌策划主管分配任务'],
'finished',
)
const planningReviewAt = findTaskDate(artFlow, ['品牌策划主管审核'], 'finished')
// The board follows the new-product review workflow. Historical SKU records
// without a matching review flow are not actionable for brand planning.
if (!graph || !reviewFlow) return []
return productSkus.map((sku) => {
const artFlow = findFlow(graph, 'art')
const taskAssignedAt = dateFromUnknown(
readFlowField(artFlow, ['品牌策划主管分配任务结束时间']),
)
const planCompletedAt = dateFromUnknown(readFlowField(artFlow, ['出策划案结束时间']))
const visualCompletedAt = dateFromUnknown(readFlowField(artFlow, ['出图结束时间']))
const detailCompletedAt = dateFromUnknown(readFlowField(artFlow, ['平面结束时间']))
const planningTaskAssignedAt = findTaskDate(
artFlow,
['品牌策划主管分配任务'],
'finished',
)
const planningReviewAt = findTaskDate(artFlow, ['品牌策划主管审核'], 'finished')
const rowKey = `${product.id}:${sku?.skuItemId || sku?.id || 'PRODUCT'}`
const adaptableBrands = toTextList(payload.adaptableBrands)
@ -507,6 +512,7 @@ async function loadProducts(): Promise<ProductSource[]> {
size: true,
color: true,
barcode: true,
demandFlowNo: true,
},
},
},