import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; import { TraceGraphNodeKind } from "./buildTraceGraphNodes"; import { isDoGroupChild } from "./traceDoGroupLayout"; import { isTransferInboundPutaway, warehouseCodesMatch } from "./tracePutawayUtils"; import type { TraceGraphLayoutNode, TraceGraphPhase } from "./traceGraphLayout"; export const sortNodesInPhase = (a: TraceGraphLayoutNode, b: TraceGraphLayoutNode): number => { if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; return a.sequenceIndex - b.sequenceIndex; }; const earliestNodeInPhase = ( nodes: TraceGraphLayoutNode[], phase: TraceGraphPhase, dayKey?: string, ): TraceGraphLayoutNode | null => { let best: TraceGraphLayoutNode | null = null; nodes.forEach((n) => { if (n.phase !== phase) return; if (dayKey != null && n.dayKey !== dayKey) return; if (!best || n.sortKey < best.sortKey) best = n; }); return best; }; const earliestSortKey = ( nodes: TraceGraphLayoutNode[], phase: TraceGraphPhase, dayKey?: string, ): number => earliestNodeInPhase(nodes, phase, dayKey)?.sortKey ?? Number.MAX_SAFE_INTEGER; /** Sort phases by earliest event time; tie-break with canonical phase order. */ const sortPhasesByTimestamp = ( nodes: TraceGraphLayoutNode[], phases: TraceGraphPhase[], phaseOrder: TraceGraphPhase[], dayKey?: string, ): TraceGraphPhase[] => [...phases].sort((a, b) => { const ta = earliestSortKey(nodes, a, dayKey); const tb = earliestSortKey(nodes, b, dayKey); if (ta !== tb) return ta - tb; return phaseOrder.indexOf(a) - phaseOrder.indexOf(b); }); export const sortPhasesByEarliestEvent = ( nodes: TraceGraphLayoutNode[], phases: TraceGraphPhase[], phaseOrder: TraceGraphPhase[], dayKey?: string, ): TraceGraphPhase[] => sortPhasesByTimestamp(nodes, phases, phaseOrder, dayKey); /** FG cross-day flow: chronological across days; same calendar day keeps canonical phase order for edges. */ const sortFgPhasesForCrossDayFlow = ( nodes: TraceGraphLayoutNode[], phases: TraceGraphPhase[], phaseOrder: TraceGraphPhase[], ): TraceGraphPhase[] => [...phases].sort((a, b) => { const nodeA = earliestNodeInPhase(nodes, a); const nodeB = earliestNodeInPhase(nodes, b); const ta = nodeA?.sortKey ?? Number.MAX_SAFE_INTEGER; const tb = nodeB?.sortKey ?? Number.MAX_SAFE_INTEGER; const sameDay = nodeA != null && nodeB != null && nodeA.dayKey !== "—" && nodeA.dayKey === nodeB.dayKey; if (sameDay) { return phaseOrder.indexOf(a) - phaseOrder.indexOf(b); } if (ta !== tb) return ta - tb; return phaseOrder.indexOf(a) - phaseOrder.indexOf(b); }); export const phaseFromKind = ( kind: TraceGraphNodeKind, phaseOrder: TraceGraphPhase[], traceLotNo?: string, refType?: string | null, ): TraceGraphPhase => { const materialPrelude = Boolean(traceLotNo?.trim()); switch (kind) { case "MATERIAL_IN": return "INBOUND"; case "JO_CREATED": // Sit with 工單提料 so 建立工單 → 工單提料 group stay in one lane. if (phaseOrder.includes("MATERIAL_PICK")) return "MATERIAL_PICK"; return "INBOUND"; case "MATERIAL_QC": case "QC": case "FAIL": return "QC"; case "MATERIAL_PICK": return "MATERIAL_PICK"; case "PICK_GROUP": return "MATERIAL_PICK"; case "PRODUCTION_STEP": case "BYPRODUCT": case "SCRAP": case "DEFECT": return "PRODUCTION"; case "OPEN": return "INBOUND"; case "DO_OUT": case "REPLENISHMENT_CREATED": case "JO_OUT": case "PO_OUT": case "DO_GROUP": case "RETURN": return "OUTBOUND"; case "REPACK": return "WAREHOUSE"; case "PURCHASE": return "PURCHASE"; case "RECEIPT": case "IN": return "INBOUND"; case "PUTAWAY": if (isTransferInboundPutaway(refType)) return "WAREHOUSE"; return "PUTAWAY"; case "TRANSFER": return "WAREHOUSE"; case "ADJUSTMENT": // Material BOM ADJ stock-in lives next to material putaway (上架 → 庫存調整). return materialPrelude ? "PUTAWAY" : "WAREHOUSE"; case "OUT": return "OUTBOUND"; case "STOCK_TAKE": return "STOCK_TAKE"; case "EXPIRED": case "DEPLETED": return "OUTBOUND"; default: return phaseOrder.includes("WAREHOUSE") ? "WAREHOUSE" : phaseOrder[0]; } }; export const materialInputsHaveProduction = (inputs: ItemLotTraceMaterialInput[]): boolean => inputs.some( (m) => (m.productionSteps?.length ?? 0) > 0 || (m.nestedJoPrelude?.materialInputs.length ? materialInputsHaveProduction(m.nestedJoPrelude.materialInputs) : false), ); const NO_FLOW_EDGE_SOURCE_KINDS = new Set(["EXPIRED", "DEPLETED"]); const NO_FLOW_EDGE_KINDS = new Set([ "REPACK", "BYPRODUCT", ]); const TERMINAL_TARGET_KINDS = new Set(["EXPIRED", "DEPLETED"]); const TERMINAL_PREDECESSOR_KINDS = new Set([ "PUTAWAY", "TRANSFER", "ADJUSTMENT", "STOCK_TAKE", "REPACK", "DO_OUT", "DO_GROUP", "JO_OUT", "PO_OUT", "OUT", "RETURN", ]); const nodeScopePrefix = (node: TraceGraphLayoutNode): string => { const match = /^loc-(\d+)-/.exec(node.id); return match ? `loc-${match[1]}-` : ""; }; const buildTerminalStateEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ) => { const terminals = nodes.filter((n) => TERMINAL_TARGET_KINDS.has(n.kind)); terminals.forEach((terminal) => { const scope = nodeScopePrefix(terminal); const scoped = nodes.filter((n) => nodeScopePrefix(n) === scope); const predecessors = scoped .filter((n) => TERMINAL_PREDECESSOR_KINDS.has(n.kind) && n.id !== terminal.id) .sort(sortNodesInPhase); const from = predecessors[predecessors.length - 1]; if (from) add(from, terminal); }); }; const WAREHOUSE_FLOW_KINDS = new Set(["TRANSFER", "ADJUSTMENT"]); /** Predecessors that represent on-hand inventory state (skip QC / production). */ const INVENTORY_STATE_KINDS = new Set([ "PUTAWAY", "TRANSFER", "ADJUSTMENT", "OUT", "DO_OUT", "JO_OUT", "PO_OUT", "DO_GROUP", "STOCK_TAKE", "RETURN", "RECEIPT", "IN", "OPEN", ]); export const isMaterialPreludeNode = (n: TraceGraphLayoutNode): boolean => (["MATERIAL_IN", "MATERIAL_QC", "MATERIAL_PICK"] as TraceGraphNodeKind[]).includes(n.kind) || (n.kind === "JO_CREATED" && Boolean(n.traceLotNo?.trim())) || (n.kind === "PUTAWAY" && Boolean(n.traceLotNo?.trim())) || (n.kind === "ADJUSTMENT" && Boolean(n.traceLotNo?.trim())) || (n.kind === "PRODUCTION_STEP" && Boolean(n.traceLotNo?.trim())) || ((n.kind === "SCRAP" || n.kind === "DEFECT") && Boolean(n.traceLotNo?.trim())) || ((n.kind === "PURCHASE" || n.kind === "RECEIPT") && Boolean(n.traceLotNo?.trim())); const materialLotKey = (n: TraceGraphLayoutNode): string | null => { const lot = n.traceLotNo?.trim(); if (!lot) return null; const item = n.traceItemCode?.trim() ?? ""; return `${item}::${lot}`; }; const resolvePickFlowTarget = ( pick: TraceGraphLayoutNode, nodes: TraceGraphLayoutNode[], ): TraceGraphLayoutNode => { const groupId = pick.doGroupId?.trim(); if (!groupId) return pick; return nodes.find((n) => n.id === groupId) ?? pick; }; const PICK_OR_OUTBOUND_TARGET_KINDS = new Set([ "MATERIAL_PICK", "PICK_GROUP", "DO_OUT", "DO_GROUP", "JO_OUT", "PO_OUT", "OUT", ]); export const shouldSkipTraceFlowEdge = ( from: TraceGraphLayoutNode, to: TraceGraphLayoutNode, ): boolean => { if (from.kind === "JO_CREATED" && !isMaterialPreludeNode(from)) { if ( to.kind === "MATERIAL_PICK" || to.kind === "PICK_GROUP" || to.kind === "PRODUCTION_STEP" ) { return false; } return true; } // Allow PRODUCTION_STEP → PRODUCTION_STEP (process chain). Block other sources into steps // except material picks / JO_CREATED fallback (dedicated builders). if ( to.kind === "PRODUCTION_STEP" && from.kind !== "MATERIAL_PICK" && from.kind !== "PICK_GROUP" && from.kind !== "PRODUCTION_STEP" && from.kind !== "JO_CREATED" ) { return true; } if ( (from.kind === "QC" || from.kind === "FAIL" || from.kind === "MATERIAL_QC") && to.kind === "PRODUCTION_STEP" ) { return true; } if (from.kind === "PRODUCTION_STEP" && to.kind === "JO_CREATED") { return true; } if (from.kind === "PUTAWAY" && (to.kind === "QC" || to.kind === "FAIL")) { return true; } if (from.kind === "PRODUCTION_STEP" && PICK_OR_OUTBOUND_TARGET_KINDS.has(to.kind)) { return true; } if (to.kind === "REPLENISHMENT_CREATED") { // Inventory / warehouse → 建立補貨; block generic phase noise. return !( from.kind === "PUTAWAY" || from.kind === "TRANSFER" || from.kind === "ADJUSTMENT" || from.kind === "OPEN" || from.kind === "RECEIPT" || from.kind === "IN" ); } if (from.kind === "REPLENISHMENT_CREATED") { if (to.kind === "DO_GROUP") return false; if (to.kind !== "DO_OUT") return true; const targetSolId = from.replenishmentStockOutLineId; if (targetSolId != null && doOutNodeMatchesStockOutLineId(to.id, targetSolId)) { return false; } const doCode = from.refCode?.trim(); if (doCode && doOutMatchesDeliveryCode(to, doCode)) return false; return true; } return false; }; const doOutNodeMatchesStockOutLineId = (nodeId: string, stockOutLineId: number): boolean => nodeId === `do-${stockOutLineId}` || nodeId.endsWith(`-${stockOutLineId}`); const doOutMatchesDeliveryCode = (doOut: TraceGraphLayoutNode, doCode: string): boolean => { const code = doCode.trim(); if (!code) return false; if (doOut.refCode?.trim() === code) return true; if (doOut.title?.includes(code)) return true; if (doOut.subtitle?.includes(code)) return true; return (doOut.details ?? []).some((d) => d.value?.trim() === code); }; const shouldSkipGenericPhaseEdge = shouldSkipTraceFlowEdge; const productionScopeKey = (n: TraceGraphLayoutNode): string => n.traceLotNo?.trim() || "__fg__"; const normWh = (w?: string | null): string => (w ?? "").trim().toUpperCase(); const normRefType = (refType?: string | null): string => (refType ?? "").trim().toUpperCase(); const pickSourcePutawayForTransfer = ( putaways: TraceGraphLayoutNode[], transfer: TraceGraphLayoutNode, ): TraceGraphLayoutNode | null => { const fromWh = normWh(transfer.transferFromWarehouse); const candidates = putaways.filter((p) => p.sortKey <= transfer.sortKey); const whMatched = fromWh.length > 0 ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, fromWh)) : candidates; if (!whMatched.length) return null; const nonTransfer = whMatched.filter((p) => normRefType(p.refType) !== "TRANSFER"); const pool = nonTransfer.length > 0 ? nonTransfer : whMatched; return [...pool].sort(sortNodesInPhase).at(-1) ?? null; }; const pickDestPutawayForTransfer = ( putaways: TraceGraphLayoutNode[], transfer: TraceGraphLayoutNode, ): TraceGraphLayoutNode | null => { const toWh = normWh(transfer.transferToWarehouse); const candidates = putaways.filter((p) => p.sortKey >= transfer.sortKey); const whMatched = toWh.length > 0 ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, toWh)) : candidates; if (!whMatched.length) return null; const transferPutaways = whMatched.filter((p) => normRefType(p.refType) === "TRANSFER"); const pool = transferPutaways.length > 0 ? transferPutaways : whMatched; return [...pool].sort(sortNodesInPhase)[0] ?? null; }; const connectPutawayToWarehousePhase = ( fromList: TraceGraphLayoutNode[], toList: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const transfers = toList.filter((n) => n.kind === "TRANSFER"); transfers.forEach((tr) => { const src = pickSourcePutawayForTransfer(fromList, tr); if (src) add(src, tr); }); const transferInbounds = toList.filter( (n) => n.kind === "PUTAWAY" && normRefType(n.refType) === "TRANSFER", ); transferInbounds.forEach((dest) => { const src = pickSourcePutawayForTransfer(fromList, dest); if (src) add(src, dest); }); if (transfers.length > 0 || transferInbounds.length > 0) { const linked = new Set([ ...transfers.map((n) => n.id), ...transferInbounds.map((n) => n.id), ]); const remaining = toList.filter((n) => !linked.has(n.id)); remaining.forEach((to) => { // ADJUSTMENT / other warehouse events must link from same warehouse only. const from = pickUpstreamForTarget(fromList, to); if (from) add(from, to); }); return; } toList.forEach((to) => { const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!; add(from, to); }); }; const buildTransferPutawayEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const putaways = nodes.filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n)); const transfers = nodes.filter((n) => n.kind === "TRANSFER"); transfers.forEach((tr) => { const src = pickSourcePutawayForTransfer(putaways, tr); if (src) add(src, tr); const dest = pickDestPutawayForTransfer(putaways, tr); if (dest) add(tr, dest); }); // Every 轉倉入庫 card needs an inbound edge (not only the first WAREHOUSE node). const destInbound = putaways.filter((p) => normRefType(p.refType) === "TRANSFER"); destInbound.forEach((dest) => { const src = pickSourcePutawayForTransfer(putaways, dest); if (src && src.id !== dest.id) add(src, dest); }); }; const PRELUDE_MATERIAL_PHASES: TraceGraphPhase[] = [ "PURCHASE", "INBOUND", "PRODUCTION", "QC", "PUTAWAY", "MATERIAL_PICK", ]; const buildMaterialLotFlowEdgePairs = ( nodes: TraceGraphLayoutNode[], phaseOrder: TraceGraphPhase[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { if (!phaseOrder.includes("MATERIAL_PICK")) return; const preludePhases = PRELUDE_MATERIAL_PHASES.filter((p) => phaseOrder.includes(p)); const byLot = new Map(); nodes.forEach((n) => { if (!isMaterialPreludeNode(n)) return; // JO_CREATED lives in MATERIAL_PICK for layout, but its early planStart would pull that // phase before PUTAWAY and break 已上架 → 工單提料. Dedicated builder handles JO→提料. if (n.kind === "JO_CREATED") return; const key = materialLotKey(n); if (!key) return; const list = byLot.get(key) ?? []; list.push(n); byLot.set(key, list); }); const nodesInPhase = (lotNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) => lotNodes .filter((n) => n.phase === phase && !NO_FLOW_EDGE_KINDS.has(n.kind)) .sort(sortNodesInPhase); byLot.forEach((lotNodes) => { const phasesPresent = sortPhasesByEarliestEvent( lotNodes, preludePhases.filter((p) => nodesInPhase(lotNodes, p).length > 0), phaseOrder, ); for (let i = 0; i < phasesPresent.length - 1; i++) { const fromPhase = phasesPresent[i]; const toPhase = phasesPresent[i + 1]; const fromList = nodesInPhase(lotNodes, fromPhase); const toList = nodesInPhase(lotNodes, toPhase); if (!fromList.length || !toList.length) continue; if (toPhase === "MATERIAL_PICK") { const upstream = fromList[fromList.length - 1]!; const seenPickTargets = new Set(); toList .filter((n) => n.kind === "MATERIAL_PICK" || n.kind === "PICK_GROUP") .forEach((pick) => { const target = resolvePickFlowTarget(pick, nodes); if (seenPickTargets.has(target.id)) return; seenPickTargets.add(target.id); add(upstream, target); }); } else { add(fromList[fromList.length - 1]!, toList[0]!); } } // Same PUTAWAY lane: material 上架 → 庫存調整 (FG warehouse pattern). const putaways = lotNodes .filter((n) => n.kind === "PUTAWAY" && !NO_FLOW_EDGE_KINDS.has(n.kind)) .sort(sortNodesInPhase); const adjustments = lotNodes .filter((n) => n.kind === "ADJUSTMENT" && !NO_FLOW_EDGE_KINDS.has(n.kind)) .sort(sortNodesInPhase); if (putaways.length && adjustments.length) { adjustments.forEach((adj) => { const targetWh = adj.warehouseCode?.trim(); const matched = targetWh ? putaways.filter((p) => warehouseCodesMatch(p.warehouseCode, targetWh)) : putaways; const from = (matched.length ? matched : putaways).at(-1); if (from) add(from, adj); }); } }); }; const buildPutawayToOutboundEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const inventorySources = nodes .filter( (n) => !isMaterialPreludeNode(n) && (n.kind === "PUTAWAY" || n.kind === "TRANSFER" || n.kind === "ADJUSTMENT" || n.kind === "OPEN" || n.kind === "RECEIPT" || n.kind === "IN"), ) .sort(sortNodesInPhase); if (!inventorySources.length) return; const outboundTargets = nodes.filter( (n) => !isDoGroupChild(n) && (n.kind === "DO_OUT" || n.kind === "DO_GROUP" || n.kind === "JO_OUT" || n.kind === "PO_OUT" || n.kind === "OUT" || n.kind === "REPLENISHMENT_CREATED"), ); if (!outboundTargets.length) return; outboundTargets.forEach((target) => { const from = pickUpstreamForTarget(inventorySources, target); if (from && !shouldSkipGenericPhaseEdge(from, target)) add(from, target); }); }; const buildPutawayToStockTakeEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const putaways = nodes .filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n)) .sort(sortNodesInPhase); if (!putaways.length) return; nodes .filter((n) => n.kind === "STOCK_TAKE" && !isDoGroupChild(n)) .forEach((stockTake) => { const scope = nodeScopePrefix(stockTake); // Stock take must stay within the same inventory-lot scope (loc-* / primary). const scopedPutaways = putaways.filter((n) => nodeScopePrefix(n) === scope); const from = pickUpstreamForTarget(scopedPutaways, stockTake); if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake); }); }; const inventoryWarehouseOf = (n: TraceGraphLayoutNode): string | undefined => { if (n.kind === "TRANSFER") { return n.transferToWarehouse?.trim() || n.warehouseCode?.trim(); } return n.warehouseCode?.trim(); }; const pickUpstreamForTarget = ( fromList: TraceGraphLayoutNode[], target: TraceGraphLayoutNode, ): TraceGraphLayoutNode | null => { if (!fromList.length) return null; const byTime = fromList.filter( (n) => n.column <= target.column || n.sortKey <= target.sortKey, ); const pool = byTime.length > 0 ? byTime : fromList; const targetWh = target.warehouseCode?.trim(); if (targetWh) { const matched = pool.filter((n) => warehouseCodesMatch(inventoryWarehouseOf(n), targetWh), ); if (matched.length) return matched[matched.length - 1]!; // No warehouse match in time window — do not link across warehouses. const anyMatched = fromList.filter((n) => warehouseCodesMatch(inventoryWarehouseOf(n), targetWh), ); return anyMatched.length ? anyMatched[anyMatched.length - 1]! : null; } const scope = nodeScopePrefix(target); const scoped = pool.filter((n) => nodeScopePrefix(n) === scope); if (scoped.length) return scoped[scoped.length - 1]!; const upstreamWhs = new Set( pool .map((n) => (inventoryWarehouseOf(n) ?? "").trim().toUpperCase()) .filter(Boolean), ); if (upstreamWhs.size <= 1) return pool[pool.length - 1]!; return null; }; const buildFgLotFlowEdgePairs = ( nodes: TraceGraphLayoutNode[], phaseOrder: TraceGraphPhase[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const fgPhases = phaseOrder.filter((p) => p !== "MATERIAL_PICK"); const fgNodes = nodes.filter( (n) => !isMaterialPreludeNode(n) && !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) && !NO_FLOW_EDGE_KINDS.has(n.kind) && !isDoGroupChild(n), ); if (fgNodes.length < 2) return; const nodesInPhase = (phase: TraceGraphPhase) => fgNodes.filter((n) => n.phase === phase).sort(sortNodesInPhase); const phasesPresent = sortFgPhasesForCrossDayFlow( fgNodes, fgPhases.filter((p) => nodesInPhase(p).length > 0), phaseOrder, ); for (let i = 0; i < phasesPresent.length - 1; i++) { const fromPhase = phasesPresent[i]; const toPhase = phasesPresent[i + 1]; const fromList = nodesInPhase(fromPhase); const toList = nodesInPhase(toPhase); if (!fromList.length || !toList.length) continue; if (fromPhase === "OUTBOUND" && toPhase === "STOCK_TAKE") continue; if ( fromPhase === "OUTBOUND" && (toPhase === "WAREHOUSE" || toPhase === "PUTAWAY" || toPhase === "STOCK_TAKE") ) { continue; } if (fromPhase === "PUTAWAY" && toPhase === "WAREHOUSE") { connectPutawayToWarehousePhase(fromList, toList, add); continue; } if (toPhase === "OUTBOUND") { toList.forEach((out) => { const scope = nodeScopePrefix(out); const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope); const from = pickUpstreamForTarget(scopedFrom, out); if (from && !shouldSkipGenericPhaseEdge(from, out)) add(from, out); }); } else if (toPhase === "STOCK_TAKE") { toList.forEach((stockTake) => { const scope = nodeScopePrefix(stockTake); const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope); const from = pickUpstreamForTarget(scopedFrom, stockTake); if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake); }); } else if (toList.length > 1) { const targets = toPhase === "PUTAWAY" ? (() => { const nonTransfer = toList.filter((n) => normRefType(n.refType) !== "TRANSFER"); return nonTransfer.length > 0 ? nonTransfer : toList; })() : toList; targets.forEach((to) => { const from = pickUpstreamForTarget(fromList, to); if (from && !shouldSkipGenericPhaseEdge(from, to)) add(from, to); }); } else { const to = toList[0]!; const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!; if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to); } } }; const sortProductionStepsForChain = ( a: TraceGraphLayoutNode, b: TraceGraphLayoutNode, ): number => { const sa = a.bomProcessSeqNo; const sb = b.bomProcessSeqNo; if (sa != null && sb != null && sa !== sb) return sa - sb; if (sa != null && sb == null) return -1; if (sa == null && sb != null) return 1; return sortNodesInPhase(a, b); }; const resolveProductionTargetForPick = ( scope: string, pick: TraceGraphLayoutNode, prodSteps: TraceGraphLayoutNode[], ): TraceGraphLayoutNode | null => { const productionByScopeAndProcess = new Map(); prodSteps.forEach((n) => { if (n.bomProcessId == null) return; const key = `${productionScopeKey(n)}::${n.bomProcessId}`; const list = productionByScopeAndProcess.get(key) ?? []; list.push(n); productionByScopeAndProcess.set(key, list); }); const inScope = (n: TraceGraphLayoutNode) => productionScopeKey(n) === scope; if (pick.bomProcessId != null) { const byId = productionByScopeAndProcess.get(`${scope}::${pick.bomProcessId}`); if (byId?.length) return [...byId].sort(sortProductionStepsForChain)[0]!; } if (pick.bomProcessSeqNo != null) { const bySeq = prodSteps.filter( (n) => inScope(n) && n.bomProcessSeqNo === pick.bomProcessSeqNo, ); if (bySeq.length) return [...bySeq].sort(sortProductionStepsForChain)[0]!; } const itemCode = pick.traceItemCode?.trim().toUpperCase(); if (itemCode) { const byItem = prodSteps.filter( (n) => inScope(n) && (n.stepMaterialItemCodes ?? []).some( (code) => code.trim().toUpperCase() === itemCode, ), ); if (byItem.length) return [...byItem].sort(sortProductionStepsForChain)[0]!; } return null; }; const buildMaterialPickToProductionStepEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const prodSteps = nodes.filter( (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n), ); if (!prodSteps.length) return; const connectPickOrGroup = ( source: TraceGraphLayoutNode, pick: TraceGraphLayoutNode, seenTargets: Set, ) => { const scope = pick.feedsProductionScopeLotNo?.trim() || source.feedsProductionScopeLotNo?.trim() || "__fg__"; const target = resolveProductionTargetForPick(scope, pick, prodSteps); if (!target || seenTargets.has(target.id)) return; seenTargets.add(target.id); add( { ...source, feedsProductionScopeLotNo: pick.feedsProductionScopeLotNo ?? source.feedsProductionScopeLotNo, }, target, ); }; nodes .filter((n) => n.kind === "PICK_GROUP") .forEach((group) => { const children = nodes.filter( (n) => n.doGroupId === group.id && n.kind === "MATERIAL_PICK", ); const seenTargets = new Set(); children.forEach((child) => connectPickOrGroup(group, child, seenTargets)); }); nodes .filter((n) => n.kind === "MATERIAL_PICK" && !n.doGroupId) .forEach((pick) => connectPickOrGroup(pick, pick, new Set())); }; const buildProductionToFgQcEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const prodSteps = nodes.filter( (n) => n.kind === "PRODUCTION_STEP" && !isMaterialPreludeNode(n) && !isDoGroupChild(n), ); const qcNodes = nodes.filter((n) => n.kind === "QC" && !isMaterialPreludeNode(n)); if (!prodSteps.length || !qcNodes.length) return; const lastProd = [...prodSteps].sort(sortNodesInPhase).at(-1)!; const firstQc = [...qcNodes].sort(sortNodesInPhase)[0]!; if (firstQc.sortKey >= lastProd.sortKey) add(lastProd, firstQc); }; const buildProductionStepChainEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const byScope = new Map(); nodes .filter((n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n)) .forEach((n) => { const key = productionScopeKey(n); const list = byScope.get(key) ?? []; list.push(n); byScope.set(key, list); }); byScope.forEach((list) => { const sorted = [...list].sort(sortProductionStepsForChain); for (let i = 0; i < sorted.length - 1; i++) { add(sorted[i]!, sorted[i + 1]!); } }); }; const joCodeForPickTarget = ( target: TraceGraphLayoutNode, nodes: TraceGraphLayoutNode[], ): string | null => { // Prefer jobOrderCode — po.consoCode is often a TI ticket or blank, and refCode is PI-*. const fromNode = (n: TraceGraphLayoutNode): string | null => { const jo = n.jobOrderCode?.trim(); if (jo) return jo; const conso = n.consoCode?.trim(); if (conso && /^JO[-_]/i.test(conso)) return conso; return null; }; if (target.kind === "PICK_GROUP") { const direct = fromNode(target); if (direct) return direct; const child = nodes.find((n) => n.doGroupId === target.id && fromNode(n)); return child ? fromNode(child) : null; } if (target.kind === "MATERIAL_PICK") { return fromNode(target); } return null; }; const buildJoCreatedToMaterialPickEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const joCreatedNodes = nodes.filter((n) => n.kind === "JO_CREATED"); if (!joCreatedNodes.length) return; const pickTargets = nodes.filter( (n) => !isDoGroupChild(n) && (n.kind === "PICK_GROUP" || (n.kind === "MATERIAL_PICK" && !n.doGroupId?.trim())), ); if (!pickTargets.length) return; const feedLotForTarget = (target: TraceGraphLayoutNode): string => { if (target.kind === "PICK_GROUP") { return ( nodes .find( (n) => n.doGroupId === target.id && Boolean(n.feedsProductionScopeLotNo?.trim()), ) ?.feedsProductionScopeLotNo?.trim() ?? "" ); } return target.feedsProductionScopeLotNo?.trim() ?? ""; }; pickTargets.forEach((target) => { const pickJoCode = joCodeForPickTarget(target, nodes); const matches = joCreatedNodes.filter((jo) => { const joCode = jo.refCode?.trim(); return Boolean(joCode && pickJoCode && joCode === pickJoCode); }); if (!matches.length) return; const feedLot = feedLotForTarget(target); let pool: TraceGraphLayoutNode[]; if (feedLot) { const scoped = matches.filter((jo) => jo.traceLotNo?.trim() === feedLot); pool = scoped.length ? scoped : matches; } else { // FG 工單提料: prefer the FG 建立工單 card (no material lot), not nested JO output. const fg = matches.filter((jo) => !jo.traceLotNo?.trim()); pool = fg.length ? fg : matches; } const joCreated = [...pool].sort(sortNodesInPhase)[0]!; add(joCreated, target); }); }; /** When a JO has no 提料 that reaches 生產步驟, link 工單建立 → first 生產步驟 in scope. */ const buildJoCreatedToProductionStepEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const joCreatedNodes = nodes.filter((n) => n.kind === "JO_CREATED"); const prodSteps = nodes.filter( (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n), ); if (!joCreatedNodes.length || !prodSteps.length) return; const pickTargets = nodes.filter( (n) => !isDoGroupChild(n) && (n.kind === "PICK_GROUP" || (n.kind === "MATERIAL_PICK" && !n.doGroupId?.trim())), ); const feedLotForTarget = (target: TraceGraphLayoutNode): string => { if (target.kind === "PICK_GROUP") { return ( nodes .find( (n) => n.doGroupId === target.id && Boolean(n.feedsProductionScopeLotNo?.trim()), ) ?.feedsProductionScopeLotNo?.trim() ?? "" ); } return target.feedsProductionScopeLotNo?.trim() ?? ""; }; const materialPicksForTarget = (target: TraceGraphLayoutNode): TraceGraphLayoutNode[] => { if (target.kind === "PICK_GROUP") { return nodes.filter((n) => n.doGroupId === target.id && n.kind === "MATERIAL_PICK"); } return [target]; }; /** Only suppress fallback when a pick for this JO can actually reach a production step. */ const joHasPickReachingProduction = (jo: TraceGraphLayoutNode): boolean => { const joCode = jo.refCode?.trim(); if (!joCode) return false; const joLot = jo.traceLotNo?.trim() ?? ""; const scope = productionScopeKey(jo); return pickTargets.some((target) => { if (joCodeForPickTarget(target, nodes) !== joCode) return false; const feedLot = feedLotForTarget(target); if (joLot) { // Nested JO: only picks that feed this SF lot. if (feedLot !== joLot) return false; } else if (feedLot) { // FG JO: ignore nested-scoped picks. return false; } return materialPicksForTarget(target).some( (pick) => resolveProductionTargetForPick(scope, pick, prodSteps) != null, ); }); }; joCreatedNodes.forEach((jo) => { if (joHasPickReachingProduction(jo)) return; const scope = productionScopeKey(jo); const firstStep = prodSteps .filter((n) => productionScopeKey(n) === scope) .sort(sortProductionStepsForChain)[0]; if (firstStep) add(jo, firstStep); }); }; const resolveDoOutFlowTarget = ( doOut: TraceGraphLayoutNode, nodes: TraceGraphLayoutNode[], ): TraceGraphLayoutNode => { const groupId = doOut.doGroupId?.trim(); if (!groupId) return doOut; return nodes.find((n) => n.id === groupId) ?? doOut; }; const findDoOutForReplenishment = ( replenishment: TraceGraphLayoutNode, nodes: TraceGraphLayoutNode[], ): TraceGraphLayoutNode | null => { const stockOutLineId = replenishment.replenishmentStockOutLineId; const doOuts = nodes.filter((n) => n.kind === "DO_OUT"); if (stockOutLineId != null) { const bySol = doOuts.find((n) => doOutNodeMatchesStockOutLineId(n.id, stockOutLineId)); if (bySol) return bySol; } const doCode = replenishment.refCode?.trim(); if (!doCode) return null; const byCode = doOuts.filter((n) => doOutMatchesDeliveryCode(n, doCode)); if (!byCode.length) return null; const replenishFlagged = byCode.filter((n) => n.doOutboundIsReplenish); const pool = replenishFlagged.length ? replenishFlagged : byCode; return [...pool].sort(sortNodesInPhase)[0] ?? null; }; const buildReplenishmentToDoOutEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { nodes .filter((n) => n.kind === "REPLENISHMENT_CREATED") .forEach((replenishment) => { const doOut = findDoOutForReplenishment(replenishment, nodes); if (!doOut) return; add(replenishment, resolveDoOutFlowTarget(doOut, nodes)); }); }; const connectWarehousePhaseChainEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const chainNodes = nodes .filter( (n) => !isMaterialPreludeNode(n) && !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) && !NO_FLOW_EDGE_KINDS.has(n.kind) && !isDoGroupChild(n), ) .sort(sortNodesInPhase); chainNodes .filter((n) => WAREHOUSE_FLOW_KINDS.has(n.kind)) .forEach((warehouseNode) => { if (warehouseNode.kind === "TRANSFER") { const putaways = chainNodes.filter( (n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n), ); const src = pickSourcePutawayForTransfer(putaways, warehouseNode); if (src) { add(src, warehouseNode); return; } } const predecessors = chainNodes.filter((n) => n.sortKey < warehouseNode.sortKey); const targetWh = warehouseNode.warehouseCode?.trim(); const sameWarehouse = (n: TraceGraphLayoutNode) => !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh); const warehousePredecessors = predecessors.filter( (n) => WAREHOUSE_FLOW_KINDS.has(n.kind) && sameWarehouse(n), ); if (warehousePredecessors.length > 0) { add(warehousePredecessors[warehousePredecessors.length - 1]!, warehouseNode); return; } const inventoryPredecessors = predecessors.filter( (n) => INVENTORY_STATE_KINDS.has(n.kind) && sameWarehouse(n), ); if (inventoryPredecessors.length > 0) { add(inventoryPredecessors[inventoryPredecessors.length - 1]!, warehouseNode); return; } // ADJUSTMENT with a known warehouse must not fall back to a different warehouse. if (warehouseNode.kind === "ADJUSTMENT" && targetWh) return; const lastBefore = predecessors[predecessors.length - 1]; if (lastBefore) add(lastBefore, warehouseNode); }); }; const connectStockTakeInboundEdges = ( nodes: TraceGraphLayoutNode[], add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, ): void => { const chainNodes = nodes .filter( (n) => !isMaterialPreludeNode(n) && !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) && !NO_FLOW_EDGE_KINDS.has(n.kind) && !isDoGroupChild(n), ) .sort(sortNodesInPhase); const inventoryPredecessorKinds = new Set([ "PUTAWAY", "TRANSFER", "ADJUSTMENT", "REPACK", "RECEIPT", "IN", "OPEN", ]); chainNodes .filter((n) => n.kind === "STOCK_TAKE") .forEach((stockTake) => { const targetScope = nodeScopePrefix(stockTake); const targetWh = stockTake.warehouseCode?.trim(); const targetLot = stockTake.traceLotNo?.trim(); const sameLot = (n: TraceGraphLayoutNode) => { if (nodeScopePrefix(n) !== targetScope) return false; if (!targetLot) return true; const fromLot = n.traceLotNo?.trim(); return !fromLot || fromLot === targetLot; }; const sameWarehouse = (n: TraceGraphLayoutNode) => !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh); const predecessors = chainNodes.filter( (n) => n.kind !== "STOCK_TAKE" && n.sortKey < stockTake.sortKey && sameLot(n), ); if (!predecessors.length) return; const putawayPredecessors = predecessors.filter( (n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n) && sameWarehouse(n), ); const inventoryPredecessors = predecessors.filter( (n) => inventoryPredecessorKinds.has(n.kind) && sameWarehouse(n), ); const from = putawayPredecessors[putawayPredecessors.length - 1] ?? inventoryPredecessors[inventoryPredecessors.length - 1] ?? // Known warehouse: never fall back across warehouses / other lots. (targetWh ? undefined : predecessors[predecessors.length - 1]); if (from) add(from, stockTake); }); }; export const buildTraceFlowEdgePairs = ( nodes: TraceGraphLayoutNode[], phaseOrder: TraceGraphPhase[], ): Array<{ fromId: string; toId: string }> => { const pairs: Array<{ fromId: string; toId: string }> = []; const seen = new Set(); const add = (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => { if (NO_FLOW_EDGE_SOURCE_KINDS.has(from.kind) || NO_FLOW_EDGE_KINDS.has(from.kind)) return; if (NO_FLOW_EDGE_KINDS.has(to.kind)) return; const key = `${from.id}->${to.id}`; if (seen.has(key)) return; seen.add(key); pairs.push({ fromId: from.id, toId: to.id }); }; const byDay = new Map(); nodes.forEach((n) => { const list = byDay.get(n.dayKey) ?? []; list.push(n); byDay.set(n.dayKey, list); }); const sortedDays = Array.from(byDay.keys()).sort((a, b) => { if (a === "—") return 1; if (b === "—") return -1; return a.localeCompare(b); }); const nodesInPhase = (dayNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) => dayNodes .filter( (n) => n.phase === phase && !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) && !NO_FLOW_EDGE_KINDS.has(n.kind) && !isDoGroupChild(n), ) .sort(sortNodesInPhase); for (const dayKey of sortedDays) { const dayNodes = byDay.get(dayKey)!; const phasesPresent = sortPhasesByEarliestEvent( dayNodes, phaseOrder.filter((p) => nodesInPhase(dayNodes, p).length > 0), phaseOrder, dayKey, ); for (let i = 0; i < phasesPresent.length - 1; i++) { const fromList = nodesInPhase(dayNodes, phasesPresent[i]); const toList = nodesInPhase(dayNodes, phasesPresent[i + 1]); if (!fromList.length || !toList.length) continue; const from = fromList[fromList.length - 1]!; const to = toList[0]!; if (isMaterialPreludeNode(from) || isMaterialPreludeNode(to)) continue; // Dedicated builder owns JO_CREATED → PRODUCTION_STEP (avoid day-phase noise). if (from.kind === "JO_CREATED" && to.kind === "PRODUCTION_STEP") continue; if (phasesPresent[i] === "OUTBOUND" && phasesPresent[i + 1] === "STOCK_TAKE") { continue; } if (phasesPresent[i] === "PUTAWAY" && phasesPresent[i + 1] === "WAREHOUSE") { connectPutawayToWarehousePhase(fromList, toList, add); continue; } if (phasesPresent[i + 1] === "OUTBOUND" || phasesPresent[i + 1] === "STOCK_TAKE") { toList.forEach((target) => { const scope = nodeScopePrefix(target); const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope); const matched = pickUpstreamForTarget(scopedFrom, target); if (matched && !shouldSkipGenericPhaseEdge(matched, target)) add(matched, target); }); continue; } if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to); } } buildMaterialLotFlowEdgePairs(nodes, phaseOrder, add); buildProductionStepChainEdges(nodes, add); buildProductionToFgQcEdges(nodes, add); buildJoCreatedToMaterialPickEdges(nodes, add); buildJoCreatedToProductionStepEdges(nodes, add); buildReplenishmentToDoOutEdges(nodes, add); buildMaterialPickToProductionStepEdges(nodes, add); buildFgLotFlowEdgePairs(nodes, phaseOrder, add); buildPutawayToOutboundEdges(nodes, add); buildPutawayToStockTakeEdges(nodes, add); buildTransferPutawayEdges(nodes, add); connectWarehousePhaseChainEdges(nodes, add); connectStockTakeInboundEdges(nodes, add); buildTerminalStateEdges(nodes, add); return pairs; }; export type TraceFlowEdgePair = { fromId: string; toId: string }; /** Build edges from backend traceGraph when present; merge with local semantics. */ export const resolveTraceFlowEdgePairs = ( nodes: TraceGraphLayoutNode[], phaseOrder: TraceGraphPhase[], backendEdges?: Array<{ fromKey: string; toKey: string }> | null, ): TraceFlowEdgePair[] => { const local = buildTraceFlowEdgePairs(nodes, phaseOrder); if (!backendEdges?.length) return local; const nodeByKey = new Map(nodes.map((n) => [n.id, n])); const merged = new Map(); const addPair = (fromId: string, toId: string) => { const from = nodeByKey.get(fromId); const to = nodeByKey.get(toId); if (!from || !to) return; if (shouldSkipTraceFlowEdge(from, to)) return; merged.set(`${fromId}->${toId}`, { fromId, toId }); }; backendEdges.forEach((e) => addPair(e.fromKey, e.toKey)); local.forEach((e) => addPair(e.fromId, e.toId)); return Array.from(merged.values()); };