From f60dbfa159ba7eeca3a88b9f98843318f55e7b8a Mon Sep 17 00:00:00 2001 From: jingchuan Date: Thu, 6 Aug 2026 10:53:56 +0800 Subject: [PATCH] fix(oa-board): correct duration and sku flow matching --- .../src/app/intake/oa-board/OaBoardClient.tsx | 9 ++- .../__tests__/oa-brand-planning-graph.test.ts | 40 ++++++++++ .../src/lib/oa-brand-planning-graph.ts | 36 +++++++++ .../__tests__/oa-board-sync.service.test.ts | 50 ++++++++++++- .../server/services/oa-board-sync.service.ts | 75 ++++++++++++------- .../oa-brand-planning-board.service.ts | 54 +++++++------ 6 files changed, 208 insertions(+), 56 deletions(-) create mode 100644 furniture-3d-system/src/lib/__tests__/oa-brand-planning-graph.test.ts create mode 100644 furniture-3d-system/src/lib/oa-brand-planning-graph.ts diff --git a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx index 359624e3..8918ce62 100644 --- a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx +++ b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx @@ -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] diff --git a/furniture-3d-system/src/lib/__tests__/oa-brand-planning-graph.test.ts b/furniture-3d-system/src/lib/__tests__/oa-brand-planning-graph.test.ts new file mode 100644 index 00000000..8105361d --- /dev/null +++ b/furniture-3d-system/src/lib/__tests__/oa-brand-planning-graph.test.ts @@ -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) + }) +}) diff --git a/furniture-3d-system/src/lib/oa-brand-planning-graph.ts b/furniture-3d-system/src/lib/oa-brand-planning-graph.ts new file mode 100644 index 00000000..b3d65e4f --- /dev/null +++ b/furniture-3d-system/src/lib/oa-brand-planning-graph.ts @@ -0,0 +1,36 @@ +export type OaBrandPlanningGraphLink = { + 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( + links: readonly OaBrandPlanningGraphLink[], + 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 +} diff --git a/furniture-3d-system/src/server/services/__tests__/oa-board-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/oa-board-sync.service.test.ts index bda3081b..aabb70ad 100644 --- a/furniture-3d-system/src/server/services/__tests__/oa-board-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/oa-board-sync.service.test.ts @@ -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 { + 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('同一节点退回后优先保留重新分配的运行任务', () => { diff --git a/furniture-3d-system/src/server/services/oa-board-sync.service.ts b/furniture-3d-system/src/server/services/oa-board-sync.service.ts index 92cf095d..f8281ca6 100644 --- a/furniture-3d-system/src/server/services/oa-board-sync.service.ts +++ b/furniture-3d-system/src/server/services/oa-board-sync.service.ts @@ -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() - - 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) { diff --git a/furniture-3d-system/src/server/services/oa-brand-planning-board.service.ts b/furniture-3d-system/src/server/services/oa-brand-planning-board.service.ts index 322bda37..b9da45ec 100644 --- a/furniture-3d-system/src/server/services/oa-brand-planning-board.service.ts +++ b/furniture-3d-system/src/server/services/oa-brand-planning-board.service.ts @@ -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() - 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((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((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 { size: true, color: true, barcode: true, + demandFlowNo: true, }, }, },