|
- 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<TraceGraphNodeKind>(["EXPIRED", "DEPLETED"]);
-
- const NO_FLOW_EDGE_KINDS = new Set<TraceGraphNodeKind>([
- "REPACK",
- "BYPRODUCT",
- ]);
-
- const TERMINAL_TARGET_KINDS = new Set<TraceGraphNodeKind>(["EXPIRED", "DEPLETED"]);
-
- const TERMINAL_PREDECESSOR_KINDS = new Set<TraceGraphNodeKind>([
- "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<TraceGraphNodeKind>(["TRANSFER", "ADJUSTMENT"]);
-
- /** Predecessors that represent on-hand inventory state (skip QC / production). */
- const INVENTORY_STATE_KINDS = new Set<TraceGraphNodeKind>([
- "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<TraceGraphNodeKind>([
- "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<string>([
- ...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<string, TraceGraphLayoutNode[]>();
-
- 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<string>();
- 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<string, TraceGraphLayoutNode[]>();
- 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<string>,
- ) => {
- 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<string>();
- 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<string, TraceGraphLayoutNode[]>();
- 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<TraceGraphNodeKind>([
- "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<string>();
-
- 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<string, TraceGraphLayoutNode[]>();
- 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<string, TraceFlowEdgePair>();
- 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());
- };
|