import { ItemLotTraceJoPrelude, ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; import { TraceGraphDetailField, TraceGraphDetailLabels, TraceGraphNode, } from "./buildTraceGraphNodes"; import { buildProductionGraphNodes, ProductionGraphLabels, } from "./buildProductionGraphNodes"; import { groupQcResultsBySession, } from "./traceLabelUtils"; import { formatQty, formatSignedQty } from "./traceQtyUtils"; import { createMaterialPickNode, docLinkFromOriginType, docLinkFromRefType, field, fieldIf, detailsOf, isJoProducedMaterial, parseSortKey, } from "./traceNodeFactory"; import { buildPickOrderTargetDateMapFromPrelude } from "./traceDocLinkUtils"; import { resolvePutawayPresentation, isTransferInboundPutaway } from "./tracePutawayUtils"; export interface JoPreludeGraphLabels extends TraceGraphDetailLabels { nodeMaterialIn: string; nodeMaterialPick: string; nodePurchase: string; nodeReceipt: string; nodeJoCreated: string; pickOrder: string; } type LotPickRef = { pickOrderCode: string; pickOrderId: number | null; consoCode: string; }; type PreludeBuildContext = { seenPurchaseLines: Set; seenInboundLots: Set; seenReceiptLots: Set; seenQcKeys: Set; seenPutawayKeys: Set; pickOrderTargetDateMap: Map; seq: number; }; const materialLotKey = (itemCode: string, lotNo: string) => `${itemCode}::${lotNo}`; const mergeLotPickMaps = ( target: Map, source: Map, ): void => { source.forEach((picks, key) => { const list = target.get(key) ?? []; picks.forEach((p) => { if (!list.some((existing) => existing.pickOrderCode === p.pickOrderCode)) { list.push(p); } }); if (list.length) target.set(key, list); }); }; const buildLotPickOrderMap = ( materialInputs: ItemLotTraceJoPrelude["materialInputs"], ): Map => { const map = new Map(); materialInputs.forEach((m) => { const code = m.pickOrderCode?.trim(); if (!code) return; const key = materialLotKey(m.materialItemCode, m.materialLotNo); const list = map.get(key) ?? []; if (!list.some((p) => p.pickOrderCode === code)) { list.push({ pickOrderCode: code, pickOrderId: m.pickOrderId, consoCode: m.consoCode, }); } map.set(key, list); const nestedInputs = m.nestedJoPrelude?.materialInputs ?? []; if (nestedInputs.length) { mergeLotPickMaps(map, buildLotPickOrderMapRecursive(nestedInputs)); } }); return map; }; const buildLotPickOrderMapRecursive = ( materialInputs: ItemLotTraceMaterialInput[], ): Map => buildLotPickOrderMap(materialInputs); const pickOrdersForLot = ( map: Map, itemCode: string, lotNo: string, ): LotPickRef[] => map.get(materialLotKey(itemCode, lotNo)) ?? []; const pickOrderCodesLabel = (picks: LotPickRef[]) => picks.map((p) => p.pickOrderCode).join(" · "); const appendPickToSubtitle = (base: string, picks: LotPickRef[], pickLabel: string): string => { if (!picks.length) return base; const suffix = `${pickLabel}: ${pickOrderCodesLabel(picks)}`; return base ? `${base} · ${suffix}` : suffix; }; const pickOrderDetailFields = ( picks: LotPickRef[], pickLabel: string, ): TraceGraphDetailField[] => picks.map((p) => field(pickLabel, p.pickOrderCode, { linkKind: "pick", linkCode: p.pickOrderCode, linkId: p.pickOrderId, consoCode: p.consoCode || p.pickOrderCode, }), ); const nextSeq = (ctx: PreludeBuildContext): number => ctx.seq++; const pickCtx = (ctx: PreludeBuildContext) => ({ nextSeq: () => nextSeq(ctx), pickOrderTargetDate: (pickOrderCode: string) => ctx.pickOrderTargetDateMap.get(pickOrderCode?.trim() ?? ""), }); const buildMaterialProductionNodes = ( m: ItemLotTraceMaterialInput, labels: JoPreludeGraphLabels & Partial, ctx: PreludeBuildContext, ): TraceGraphNode[] => { const steps = m.productionSteps ?? []; if (!steps.length || !labels.nodeProductionStep || !labels.nodeScrap || !labels.nodeDefect) return []; const matUom = m.materialUom?.trim() || ""; return buildProductionGraphNodes(steps, [], labels as ProductionGraphLabels, matUom).map((node) => ({ ...node, id: `mat-prod-${m.materialLotNo}-${node.id}`, traceLotNo: m.materialLotNo, traceItemCode: m.materialItemCode, subtitle: [m.materialItemCode, m.materialLotNo, node.subtitle].filter(Boolean).join(" · "), })); }; const buildMaterialStockInPreludeNodes = ( m: ItemLotTraceMaterialInput, labels: JoPreludeGraphLabels, lotPicks: Map, ctx: PreludeBuildContext, ): TraceGraphNode[] => { const nodes: TraceGraphNode[] = []; const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); const lotId = m.materialInventoryLotId; const origin = m.stockInOrigin; const matUom = m.materialUom?.trim() || ""; (m.purchaseEvents ?? []).forEach((p) => { if (ctx.seenPurchaseLines.has(p.purchaseOrderLineId)) return; ctx.seenPurchaseLines.add(p.purchaseOrderLineId); const purchaseUnit = p.purchaseUnit; const supplierLabel = [p.supplierCode, p.supplierName].filter(Boolean).join(" "); const subtitle = [supplierLabel, p.itemCode, p.itemName].filter(Boolean).join(" · ") || m.materialItemCode; const meta = [supplierLabel].filter(Boolean).join(" · "); nodes.push({ id: `mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}`, kind: "PURCHASE", timestamp: p.orderDate, sortKey: parseSortKey(p.orderDate, nextSeq(ctx)), title: labels.nodePurchase, subtitle: appendPickToSubtitle(subtitle, picks, labels.pickOrder), qty: p.orderQty, uom: purchaseUnit, meta: meta || undefined, refType: "PO", refCode: p.purchaseOrderCode, refId: p.purchaseOrderId, docLinkKind: "po", traceLotNo: m.materialLotNo, traceItemCode: m.materialItemCode, categoryLabel: labels.categoryPurchase, details: [ field(labels.detailItemCode, p.itemCode), field(labels.detailItemName, p.itemName), field(labels.detailSupplier, supplierLabel), field(labels.detailOrderQty, formatQty(p.orderQty, purchaseUnit)), field(labels.detailPurchaseUnit, p.purchaseUnit), field(labels.detailPurchaseOrderNo, p.purchaseOrderCode, { linkKind: "po", linkCode: p.purchaseOrderCode, linkId: p.purchaseOrderId, }), field(labels.detailTime, p.orderDate), field(labels.detailLot, m.materialLotNo), ...pickOrderDetailFields(picks, labels.pickOrder), ], }); }); if (origin && lotId != null) { const originType = origin.type.trim().toUpperCase(); const linkKind = docLinkFromOriginType(origin.type); const subtitle = [ m.materialLotNo, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" "), ] .filter(Boolean) .join(" · "); if (originType === "PO" && !ctx.seenReceiptLots.has(lotId)) { ctx.seenReceiptLots.add(lotId); nodes.push({ id: `mat-receipt-${lotId}-${origin.stockInLineId}`, kind: "RECEIPT", timestamp: origin.receiptDate, sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)), title: labels.nodeReceipt, subtitle: appendPickToSubtitle( [origin.refCode, subtitle].filter(Boolean).join(" · ") || m.materialItemCode, picks, labels.pickOrder, ), qty: origin.acceptedQty, uom: matUom, refType: origin.type, refCode: origin.refCode, refId: origin.refId, docLinkKind: linkKind, traceLotNo: m.materialLotNo, traceItemCode: m.materialItemCode, categoryLabel: labels.categoryReceipt, details: [ field(labels.detailPurchaseOrderNo, origin.refCode, { linkKind, linkCode: origin.refCode, linkId: origin.refId, }), field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")), field(labels.detailItemCode, m.materialItemCode), field(labels.detailLot, m.materialLotNo), field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), field(labels.detailTime, origin.receiptDate), ...pickOrderDetailFields(picks, labels.pickOrder), ], }); } else if ( originType !== "PO" && originType !== "ADJ" && !ctx.seenInboundLots.has(lotId) ) { // Nested JO_CREATED already represents this JO output lot (with 提料 links). const skipJoOriginBecauseNested = originType === "JO" && Boolean(m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim()); if (!skipJoOriginBecauseNested) { // ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整). ctx.seenInboundLots.add(lotId); const isJoOrigin = originType === "JO"; nodes.push({ id: `mat-in-${lotId}-${origin.stockInLineId}`, kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN", timestamp: origin.receiptDate, sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)), title: isJoOrigin ? labels.nodeJoCreated : `${labels.tr.refType(origin.type)} · ${labels.nodeMaterialIn}`, subtitle: appendPickToSubtitle( subtitle || m.materialItemCode, picks, labels.pickOrder, ), qty: origin.acceptedQty, uom: matUom, refType: origin.type, refCode: origin.refCode, refId: origin.refId, docLinkKind: linkKind, traceLotNo: m.materialLotNo, traceItemCode: m.materialItemCode, categoryLabel: isJoOrigin ? labels.categoryProduction : labels.categoryPurchase, details: [ field(labels.detailType, isJoOrigin ? labels.nodeJoCreated : labels.tr.refType(origin.type)), field( isJoOrigin ? labels.jobOrder : originType === "PO" ? labels.detailPurchaseOrderNo : labels.detailSourceDoc, origin.refCode, { linkKind, linkCode: origin.refCode, linkId: origin.refId, }), field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")), field(labels.detailItemCode, m.materialItemCode), field(labels.detailLot, m.materialLotNo), field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), field(labels.detailTime, origin.receiptDate), ...pickOrderDetailFields(picks, labels.pickOrder), ], }); } } } groupQcResultsBySession(m.qcResults).forEach((group) => { const first = group[0]; if (!first) return; const key = `${first.stockInLineId}-${first.created}`; if (ctx.seenQcKeys.has(key)) return; ctx.seenQcKeys.add(key); const allPassed = group.every((q) => q.qcPassed); const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0); const meta = [first.handledBy, ...group.map((q) => q.remarks).filter(Boolean), m.materialLotNo] .filter(Boolean) .join(" · "); const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? ""; nodes.push({ id: `mat-qc-${key}`, kind: "MATERIAL_QC", timestamp: first.created, sortKey: parseSortKey(first.created, nextSeq(ctx)), title: allPassed ? labels.nodeQcPass : labels.nodeQcFail, subtitle: "", qty: totalFail, uom: matUom, meta: meta || undefined, traceLotNo: m.materialLotNo, traceItemCode: m.materialItemCode, categoryLabel: labels.categoryQc, qcTypeLabel: labels.tr.qcType(qcTypeRaw), details: detailsOf( field(labels.detailItemCode, m.materialItemCode), field(labels.detailLot, m.materialLotNo), field(labels.detailStatus, labels.tr.qcPassed(allPassed)), field(labels.detailQcType, labels.tr.qcType(qcTypeRaw)), field(labels.detailQcCriteria, "", { variant: "qcCriteriaList", qcCriteriaItems: group.map((q) => ({ name: q.qcItemName?.trim() || q.qcItemCode?.trim() || labels.detailQcUnknownItem, description: q.qcItemDescription?.trim() || undefined, passed: q.qcPassed, failQty: q.failQty > 0 ? q.failQty : undefined, })), }), field(labels.detailAcceptedQty, formatQty(first.acceptedQty, matUom)), field(labels.detailFailQty, formatQty(totalFail, matUom)), field(labels.detailHandler, first.handledBy), field(labels.detailTime, first.created), fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join(";")), ...pickOrderDetailFields(picks, labels.pickOrder), ), }); }); let lastMaterialPutawayWh = ""; (m.putawayEvents ?? []).forEach((p) => { const key = `${p.inventoryLotLineId}-${p.timestamp}`; if (ctx.seenPutawayKeys.has(key)) return; if (isTransferInboundPutaway(p.refType)) return; ctx.seenPutawayKeys.add(key); const linkKind = docLinkFromRefType(p.refType); const pres = resolvePutawayPresentation( labels.tr, { nodePutaway: labels.nodePutaway, nodePutawayTransfer: labels.nodePutawayTransfer, putawayTransferDetail: labels.putawayTransferDetail, }, p.status, p.refType, ); const meta = [pres.chipLabel, p.handledBy, p.warehouseCode].filter(Boolean).join(" · "); if (p.warehouseCode?.trim()) lastMaterialPutawayWh = p.warehouseCode.trim(); nodes.push({ id: `mat-putaway-${p.inventoryLotLineId}-${p.timestamp}`, kind: "PUTAWAY", timestamp: p.timestamp, sortKey: parseSortKey(p.timestamp, nextSeq(ctx)), title: pres.title, subtitle: appendPickToSubtitle( [pres.chipLabel, p.refCode, p.warehouseCode, m.materialLotNo].filter(Boolean).join(" · ") || m.materialItemCode, picks, labels.pickOrder, ), qty: p.qty, uom: matUom, meta: meta || undefined, refType: p.refType, refCode: p.refCode, refId: p.refId, docLinkKind: linkKind, putawayStatusLabel: pres.chipLabel, putawayStatusDetail: pres.statusDetail, warehouseCode: p.warehouseCode, traceLotNo: m.materialLotNo, traceItemCode: m.materialItemCode, categoryLabel: labels.categoryPutaway, details: [ field(labels.detailStatus, pres.statusDetail), field(labels.detailItemCode, m.materialItemCode), field(labels.detailLot, m.materialLotNo), field( isTransferInboundPutaway(p.refType) ? labels.detailInboundSiNo : (p.refType ?? "").trim().toUpperCase() === "PO" ? labels.detailPurchaseOrderNo : (p.refType ?? "").trim().toUpperCase() === "JO" ? labels.jobOrder : labels.detailSourceDoc, p.refCode, { linkKind, linkCode: p.refCode, linkId: p.refId, }), field(labels.detailPutawayBin, p.warehouseCode), field(labels.detailQty, formatQty(p.qty, matUom)), field(labels.detailHandler, p.handledBy), field(labels.detailTime, p.timestamp), ...pickOrderDetailFields(picks, labels.pickOrder), ], }); }); // ADJ stock-in: match FG — 上架 → 庫存調整 (入), not orphaned 「調整 · 原料入庫」. if (origin && lotId != null) { const originType = origin.type.trim().toUpperCase(); if (originType === "ADJ" && !ctx.seenInboundLots.has(lotId)) { ctx.seenInboundLots.add(lotId); const adjWh = lastMaterialPutawayWh; const meta = [labels.tr.stockInStatus(origin.status)].filter(Boolean).join(" · "); // Place at/after related putaway so chronological X and 上架 → 調整 edges match FG. const lastPutawayTs = (m.putawayEvents ?? []) .map((p) => p.timestamp) .filter((t): t is string => Boolean(t?.trim())) .sort() .at(-1); const adjTimestamp = lastPutawayTs && parseSortKey(lastPutawayTs, 0) >= parseSortKey(origin.receiptDate, 0) ? lastPutawayTs : origin.receiptDate; // Keep sortKey strictly after matching putaway so layout X stays 上架 → 調整 // (same calendar time previously ordered adj left of putaway → back-arrow). const putawaySort = lastPutawayTs ? parseSortKey(lastPutawayTs, 0) : null; const adjSortBase = parseSortKey(adjTimestamp, nextSeq(ctx)); nodes.push({ id: `mat-adj-${lotId}-${origin.stockInLineId}`, kind: "ADJUSTMENT", timestamp: origin.receiptDate, sortKey: putawaySort != null && adjSortBase <= putawaySort ? putawaySort + 1 : adjSortBase, title: `${labels.nodeAdjustment} (${labels.tr.direction("IN")})`, subtitle: appendPickToSubtitle( [labels.tr.adjustmentType("ADJ"), origin.refCode || m.materialLotNo] .filter(Boolean) .join(" · ") || m.materialItemCode, picks, labels.pickOrder, ), qty: origin.acceptedQty, uom: matUom, adjustmentDirection: "IN", meta: meta || undefined, refType: origin.type, refCode: origin.refCode, refId: origin.refId, warehouseCode: adjWh || undefined, traceLotNo: m.materialLotNo, traceItemCode: m.materialItemCode, categoryLabel: labels.categoryAdjustment, details: detailsOf( field(labels.detailVariance, formatSignedQty(origin.acceptedQty, "IN", matUom)), field(labels.detailAdjustmentRef, origin.refCode), field(labels.detailItemCode, m.materialItemCode), field(labels.detailLot, m.materialLotNo), ...(adjWh ? [field(labels.detailWarehouse, adjWh)] : []), field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), field(labels.detailTime, origin.receiptDate), ...pickOrderDetailFields(picks, labels.pickOrder), ), }); } } return nodes; }; const buildNestedJoCreatedNode = ( m: ItemLotTraceMaterialInput, labels: JoPreludeGraphLabels, picks: LotPickRef[], ctx: PreludeBuildContext, ): TraceGraphNode | null => { const nested = m.nestedJoPrelude; const jo = nested?.jobOrder; if (!jo?.jobOrderCode?.trim()) return null; const nestedInputs = nested?.materialInputs ?? []; const nestedPickTs = nestedInputs .map((input) => input.pickedAt) .find((ts) => ts?.trim()); const nestedProdTs = (m.productionSteps ?? []) .map((step) => step.startTime) .find((ts) => ts?.trim()); const origin = m.stockInOrigin; const ts = jo.createdAt?.trim() || jo.planStart?.trim() || nestedPickTs?.trim() || nestedProdTs?.trim() || origin?.receiptDate?.trim() || null; const matUom = m.materialUom?.trim() || ""; const qty = origin?.acceptedQty ?? jo.reqQty; return { id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`, kind: "JO_CREATED", timestamp: ts, sortKey: parseSortKey(ts, nextSeq(ctx)), title: labels.nodeJoCreated, subtitle: appendPickToSubtitle( [m.materialLotNo, m.materialItemCode].filter(Boolean).join(" · ") || jo.jobOrderCode, picks, labels.pickOrder, ), qty, uom: matUom, refType: "JO", refCode: jo.jobOrderCode, refId: jo.jobOrderId, docLinkKind: "jo", traceLotNo: m.materialLotNo, traceItemCode: m.materialItemCode, categoryLabel: labels.categoryProduction, details: [ field(labels.detailType, labels.nodeJoCreated), field(labels.jobOrder, jo.jobOrderCode, { linkKind: "jo", linkCode: jo.jobOrderCode, linkId: jo.jobOrderId, }), field(labels.detailItemCode, m.materialItemCode), field(labels.detailLot, m.materialLotNo), field(labels.detailQty, formatQty(qty, matUom)), field(labels.detailTime, ts), ...pickOrderDetailFields(picks, labels.pickOrder), ], }; }; const buildMaterialInputChainNodes = ( materialInputs: ItemLotTraceMaterialInput[], labels: JoPreludeGraphLabels & Partial, lotPicks: Map, ctx: PreludeBuildContext, ): TraceGraphNode[] => { const nodes: TraceGraphNode[] = []; materialInputs.forEach((m) => { const joProduced = isJoProducedMaterial(m); const nestedInputs = m.nestedJoPrelude?.materialInputs ?? []; const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); if (joProduced && m.nestedJoPrelude) { const nestedJoCreated = buildNestedJoCreatedNode(m, labels, picks, ctx); if (nestedJoCreated) nodes.push(nestedJoCreated); } if (joProduced && nestedInputs.length > 0) { const nestedLotPicks = buildLotPickOrderMap(nestedInputs); nodes.push( ...buildMaterialInputChainNodes( nestedInputs, labels, nestedLotPicks, ctx, ), ); nestedInputs.forEach((nm, ni) => { nodes.push(createMaterialPickNode(nm, ni, labels, pickCtx(ctx), m.materialLotNo)); }); } if (joProduced && (m.productionSteps?.length ?? 0) > 0) { nodes.push(...buildMaterialProductionNodes(m, labels, ctx)); } nodes.push(...buildMaterialStockInPreludeNodes(m, labels, lotPicks, ctx)); }); return nodes; }; export const buildJoPreludeGraphNodes = ( joPrelude: ItemLotTraceJoPrelude, labels: JoPreludeGraphLabels & Partial, ): TraceGraphNode[] => { const ctx: PreludeBuildContext = { seenPurchaseLines: new Set(), seenInboundLots: new Set(), seenReceiptLots: new Set(), seenQcKeys: new Set(), seenPutawayKeys: new Set(), pickOrderTargetDateMap: buildPickOrderTargetDateMapFromPrelude(joPrelude), seq: 0, }; const lotPicks = buildLotPickOrderMap(joPrelude.materialInputs); const nodes: TraceGraphNode[] = [ ...buildMaterialInputChainNodes(joPrelude.materialInputs, labels, lotPicks, ctx), ...joPrelude.materialInputs.map((m, i) => createMaterialPickNode(m, i, labels, pickCtx(ctx))), ]; return nodes.sort((a, b) => { if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; return a.id.localeCompare(b.id); }); };