fix(oa-board): correct duration and sku flow matching
This commit is contained in:
parent
4efb7f4083
commit
f60dbfa159
@ -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]
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
36
furniture-3d-system/src/lib/oa-brand-planning-graph.ts
Normal file
36
furniture-3d-system/src/lib/oa-brand-planning-graph.ts
Normal 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
|
||||
}
|
||||
@ -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('同一节点退回后优先保留重新分配的运行任务', () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user