FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

1258 line
42 KiB

  1. import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing";
  2. import { TraceGraphNodeKind } from "./buildTraceGraphNodes";
  3. import { isDoGroupChild } from "./traceDoGroupLayout";
  4. import { isTransferInboundPutaway, warehouseCodesMatch } from "./tracePutawayUtils";
  5. import type { TraceGraphLayoutNode, TraceGraphPhase } from "./traceGraphLayout";
  6. export const sortNodesInPhase = (a: TraceGraphLayoutNode, b: TraceGraphLayoutNode): number => {
  7. if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
  8. return a.sequenceIndex - b.sequenceIndex;
  9. };
  10. const earliestNodeInPhase = (
  11. nodes: TraceGraphLayoutNode[],
  12. phase: TraceGraphPhase,
  13. dayKey?: string,
  14. ): TraceGraphLayoutNode | null => {
  15. let best: TraceGraphLayoutNode | null = null;
  16. nodes.forEach((n) => {
  17. if (n.phase !== phase) return;
  18. if (dayKey != null && n.dayKey !== dayKey) return;
  19. if (!best || n.sortKey < best.sortKey) best = n;
  20. });
  21. return best;
  22. };
  23. const earliestSortKey = (
  24. nodes: TraceGraphLayoutNode[],
  25. phase: TraceGraphPhase,
  26. dayKey?: string,
  27. ): number => earliestNodeInPhase(nodes, phase, dayKey)?.sortKey ?? Number.MAX_SAFE_INTEGER;
  28. /** Sort phases by earliest event time; tie-break with canonical phase order. */
  29. const sortPhasesByTimestamp = (
  30. nodes: TraceGraphLayoutNode[],
  31. phases: TraceGraphPhase[],
  32. phaseOrder: TraceGraphPhase[],
  33. dayKey?: string,
  34. ): TraceGraphPhase[] =>
  35. [...phases].sort((a, b) => {
  36. const ta = earliestSortKey(nodes, a, dayKey);
  37. const tb = earliestSortKey(nodes, b, dayKey);
  38. if (ta !== tb) return ta - tb;
  39. return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
  40. });
  41. export const sortPhasesByEarliestEvent = (
  42. nodes: TraceGraphLayoutNode[],
  43. phases: TraceGraphPhase[],
  44. phaseOrder: TraceGraphPhase[],
  45. dayKey?: string,
  46. ): TraceGraphPhase[] => sortPhasesByTimestamp(nodes, phases, phaseOrder, dayKey);
  47. /** FG cross-day flow: chronological across days; same calendar day keeps canonical phase order for edges. */
  48. const sortFgPhasesForCrossDayFlow = (
  49. nodes: TraceGraphLayoutNode[],
  50. phases: TraceGraphPhase[],
  51. phaseOrder: TraceGraphPhase[],
  52. ): TraceGraphPhase[] =>
  53. [...phases].sort((a, b) => {
  54. const nodeA = earliestNodeInPhase(nodes, a);
  55. const nodeB = earliestNodeInPhase(nodes, b);
  56. const ta = nodeA?.sortKey ?? Number.MAX_SAFE_INTEGER;
  57. const tb = nodeB?.sortKey ?? Number.MAX_SAFE_INTEGER;
  58. const sameDay =
  59. nodeA != null &&
  60. nodeB != null &&
  61. nodeA.dayKey !== "—" &&
  62. nodeA.dayKey === nodeB.dayKey;
  63. if (sameDay) {
  64. return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
  65. }
  66. if (ta !== tb) return ta - tb;
  67. return phaseOrder.indexOf(a) - phaseOrder.indexOf(b);
  68. });
  69. export const phaseFromKind = (
  70. kind: TraceGraphNodeKind,
  71. phaseOrder: TraceGraphPhase[],
  72. traceLotNo?: string,
  73. refType?: string | null,
  74. ): TraceGraphPhase => {
  75. const materialPrelude = Boolean(traceLotNo?.trim());
  76. switch (kind) {
  77. case "MATERIAL_IN":
  78. return "INBOUND";
  79. case "JO_CREATED":
  80. // Sit with 工單提料 so 建立工單 → 工單提料 group stay in one lane.
  81. if (phaseOrder.includes("MATERIAL_PICK")) return "MATERIAL_PICK";
  82. return "INBOUND";
  83. case "MATERIAL_QC":
  84. case "QC":
  85. case "FAIL":
  86. return "QC";
  87. case "MATERIAL_PICK":
  88. return "MATERIAL_PICK";
  89. case "PICK_GROUP":
  90. return "MATERIAL_PICK";
  91. case "PRODUCTION_STEP":
  92. case "BYPRODUCT":
  93. case "SCRAP":
  94. case "DEFECT":
  95. return "PRODUCTION";
  96. case "OPEN":
  97. return "INBOUND";
  98. case "DO_OUT":
  99. case "REPLENISHMENT_CREATED":
  100. case "JO_OUT":
  101. case "PO_OUT":
  102. case "DO_GROUP":
  103. case "RETURN":
  104. return "OUTBOUND";
  105. case "REPACK":
  106. return "WAREHOUSE";
  107. case "PURCHASE":
  108. return "PURCHASE";
  109. case "RECEIPT":
  110. case "IN":
  111. return "INBOUND";
  112. case "PUTAWAY":
  113. if (isTransferInboundPutaway(refType)) return "WAREHOUSE";
  114. return "PUTAWAY";
  115. case "TRANSFER":
  116. return "WAREHOUSE";
  117. case "ADJUSTMENT":
  118. // Material BOM ADJ stock-in lives next to material putaway (上架 → 庫存調整).
  119. return materialPrelude ? "PUTAWAY" : "WAREHOUSE";
  120. case "OUT":
  121. return "OUTBOUND";
  122. case "STOCK_TAKE":
  123. return "STOCK_TAKE";
  124. case "EXPIRED":
  125. case "DEPLETED":
  126. return "OUTBOUND";
  127. default:
  128. return phaseOrder.includes("WAREHOUSE") ? "WAREHOUSE" : phaseOrder[0];
  129. }
  130. };
  131. export const materialInputsHaveProduction = (inputs: ItemLotTraceMaterialInput[]): boolean =>
  132. inputs.some(
  133. (m) =>
  134. (m.productionSteps?.length ?? 0) > 0 ||
  135. (m.nestedJoPrelude?.materialInputs.length
  136. ? materialInputsHaveProduction(m.nestedJoPrelude.materialInputs)
  137. : false),
  138. );
  139. const NO_FLOW_EDGE_SOURCE_KINDS = new Set<TraceGraphNodeKind>(["EXPIRED", "DEPLETED"]);
  140. const NO_FLOW_EDGE_KINDS = new Set<TraceGraphNodeKind>([
  141. "REPACK",
  142. "BYPRODUCT",
  143. ]);
  144. const TERMINAL_TARGET_KINDS = new Set<TraceGraphNodeKind>(["EXPIRED", "DEPLETED"]);
  145. const TERMINAL_PREDECESSOR_KINDS = new Set<TraceGraphNodeKind>([
  146. "PUTAWAY",
  147. "TRANSFER",
  148. "ADJUSTMENT",
  149. "STOCK_TAKE",
  150. "REPACK",
  151. "DO_OUT",
  152. "DO_GROUP",
  153. "JO_OUT",
  154. "PO_OUT",
  155. "OUT",
  156. "RETURN",
  157. ]);
  158. const nodeScopePrefix = (node: TraceGraphLayoutNode): string => {
  159. const match = /^loc-(\d+)-/.exec(node.id);
  160. return match ? `loc-${match[1]}-` : "";
  161. };
  162. const buildTerminalStateEdges = (
  163. nodes: TraceGraphLayoutNode[],
  164. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  165. ) => {
  166. const terminals = nodes.filter((n) => TERMINAL_TARGET_KINDS.has(n.kind));
  167. terminals.forEach((terminal) => {
  168. const scope = nodeScopePrefix(terminal);
  169. const scoped = nodes.filter((n) => nodeScopePrefix(n) === scope);
  170. const predecessors = scoped
  171. .filter((n) => TERMINAL_PREDECESSOR_KINDS.has(n.kind) && n.id !== terminal.id)
  172. .sort(sortNodesInPhase);
  173. const from = predecessors[predecessors.length - 1];
  174. if (from) add(from, terminal);
  175. });
  176. };
  177. const WAREHOUSE_FLOW_KINDS = new Set<TraceGraphNodeKind>(["TRANSFER", "ADJUSTMENT"]);
  178. /** Predecessors that represent on-hand inventory state (skip QC / production). */
  179. const INVENTORY_STATE_KINDS = new Set<TraceGraphNodeKind>([
  180. "PUTAWAY",
  181. "TRANSFER",
  182. "ADJUSTMENT",
  183. "OUT",
  184. "DO_OUT",
  185. "JO_OUT",
  186. "PO_OUT",
  187. "DO_GROUP",
  188. "STOCK_TAKE",
  189. "RETURN",
  190. "RECEIPT",
  191. "IN",
  192. "OPEN",
  193. ]);
  194. export const isMaterialPreludeNode = (n: TraceGraphLayoutNode): boolean =>
  195. (["MATERIAL_IN", "MATERIAL_QC", "MATERIAL_PICK"] as TraceGraphNodeKind[]).includes(n.kind) ||
  196. (n.kind === "JO_CREATED" && Boolean(n.traceLotNo?.trim())) ||
  197. (n.kind === "PUTAWAY" && Boolean(n.traceLotNo?.trim())) ||
  198. (n.kind === "ADJUSTMENT" && Boolean(n.traceLotNo?.trim())) ||
  199. (n.kind === "PRODUCTION_STEP" && Boolean(n.traceLotNo?.trim())) ||
  200. ((n.kind === "SCRAP" || n.kind === "DEFECT") && Boolean(n.traceLotNo?.trim())) ||
  201. ((n.kind === "PURCHASE" || n.kind === "RECEIPT") && Boolean(n.traceLotNo?.trim()));
  202. const materialLotKey = (n: TraceGraphLayoutNode): string | null => {
  203. const lot = n.traceLotNo?.trim();
  204. if (!lot) return null;
  205. const item = n.traceItemCode?.trim() ?? "";
  206. return `${item}::${lot}`;
  207. };
  208. const resolvePickFlowTarget = (
  209. pick: TraceGraphLayoutNode,
  210. nodes: TraceGraphLayoutNode[],
  211. ): TraceGraphLayoutNode => {
  212. const groupId = pick.doGroupId?.trim();
  213. if (!groupId) return pick;
  214. return nodes.find((n) => n.id === groupId) ?? pick;
  215. };
  216. const PICK_OR_OUTBOUND_TARGET_KINDS = new Set<TraceGraphNodeKind>([
  217. "MATERIAL_PICK",
  218. "PICK_GROUP",
  219. "DO_OUT",
  220. "DO_GROUP",
  221. "JO_OUT",
  222. "PO_OUT",
  223. "OUT",
  224. ]);
  225. export const shouldSkipTraceFlowEdge = (
  226. from: TraceGraphLayoutNode,
  227. to: TraceGraphLayoutNode,
  228. ): boolean => {
  229. if (from.kind === "JO_CREATED" && !isMaterialPreludeNode(from)) {
  230. if (
  231. to.kind === "MATERIAL_PICK" ||
  232. to.kind === "PICK_GROUP" ||
  233. to.kind === "PRODUCTION_STEP"
  234. ) {
  235. return false;
  236. }
  237. return true;
  238. }
  239. // Allow PRODUCTION_STEP → PRODUCTION_STEP (process chain). Block other sources into steps
  240. // except material picks / JO_CREATED fallback (dedicated builders).
  241. if (
  242. to.kind === "PRODUCTION_STEP" &&
  243. from.kind !== "MATERIAL_PICK" &&
  244. from.kind !== "PICK_GROUP" &&
  245. from.kind !== "PRODUCTION_STEP" &&
  246. from.kind !== "JO_CREATED"
  247. ) {
  248. return true;
  249. }
  250. if (
  251. (from.kind === "QC" || from.kind === "FAIL" || from.kind === "MATERIAL_QC") &&
  252. to.kind === "PRODUCTION_STEP"
  253. ) {
  254. return true;
  255. }
  256. if (from.kind === "PRODUCTION_STEP" && to.kind === "JO_CREATED") {
  257. return true;
  258. }
  259. if (from.kind === "PUTAWAY" && (to.kind === "QC" || to.kind === "FAIL")) {
  260. return true;
  261. }
  262. if (from.kind === "PRODUCTION_STEP" && PICK_OR_OUTBOUND_TARGET_KINDS.has(to.kind)) {
  263. return true;
  264. }
  265. if (to.kind === "REPLENISHMENT_CREATED") {
  266. // Inventory / warehouse → 建立補貨; block generic phase noise.
  267. return !(
  268. from.kind === "PUTAWAY" ||
  269. from.kind === "TRANSFER" ||
  270. from.kind === "ADJUSTMENT" ||
  271. from.kind === "OPEN" ||
  272. from.kind === "RECEIPT" ||
  273. from.kind === "IN"
  274. );
  275. }
  276. if (from.kind === "REPLENISHMENT_CREATED") {
  277. if (to.kind === "DO_GROUP") return false;
  278. if (to.kind !== "DO_OUT") return true;
  279. const targetSolId = from.replenishmentStockOutLineId;
  280. if (targetSolId != null && doOutNodeMatchesStockOutLineId(to.id, targetSolId)) {
  281. return false;
  282. }
  283. const doCode = from.refCode?.trim();
  284. if (doCode && doOutMatchesDeliveryCode(to, doCode)) return false;
  285. return true;
  286. }
  287. return false;
  288. };
  289. const doOutNodeMatchesStockOutLineId = (nodeId: string, stockOutLineId: number): boolean =>
  290. nodeId === `do-${stockOutLineId}` || nodeId.endsWith(`-${stockOutLineId}`);
  291. const doOutMatchesDeliveryCode = (doOut: TraceGraphLayoutNode, doCode: string): boolean => {
  292. const code = doCode.trim();
  293. if (!code) return false;
  294. if (doOut.refCode?.trim() === code) return true;
  295. if (doOut.title?.includes(code)) return true;
  296. if (doOut.subtitle?.includes(code)) return true;
  297. return (doOut.details ?? []).some((d) => d.value?.trim() === code);
  298. };
  299. const shouldSkipGenericPhaseEdge = shouldSkipTraceFlowEdge;
  300. const productionScopeKey = (n: TraceGraphLayoutNode): string =>
  301. n.traceLotNo?.trim() || "__fg__";
  302. const normWh = (w?: string | null): string => (w ?? "").trim().toUpperCase();
  303. const normRefType = (refType?: string | null): string => (refType ?? "").trim().toUpperCase();
  304. const pickSourcePutawayForTransfer = (
  305. putaways: TraceGraphLayoutNode[],
  306. transfer: TraceGraphLayoutNode,
  307. ): TraceGraphLayoutNode | null => {
  308. const fromWh = normWh(transfer.transferFromWarehouse);
  309. const candidates = putaways.filter((p) => p.sortKey <= transfer.sortKey);
  310. const whMatched =
  311. fromWh.length > 0
  312. ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, fromWh))
  313. : candidates;
  314. if (!whMatched.length) return null;
  315. const nonTransfer = whMatched.filter((p) => normRefType(p.refType) !== "TRANSFER");
  316. const pool = nonTransfer.length > 0 ? nonTransfer : whMatched;
  317. return [...pool].sort(sortNodesInPhase).at(-1) ?? null;
  318. };
  319. const pickDestPutawayForTransfer = (
  320. putaways: TraceGraphLayoutNode[],
  321. transfer: TraceGraphLayoutNode,
  322. ): TraceGraphLayoutNode | null => {
  323. const toWh = normWh(transfer.transferToWarehouse);
  324. const candidates = putaways.filter((p) => p.sortKey >= transfer.sortKey);
  325. const whMatched =
  326. toWh.length > 0
  327. ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, toWh))
  328. : candidates;
  329. if (!whMatched.length) return null;
  330. const transferPutaways = whMatched.filter((p) => normRefType(p.refType) === "TRANSFER");
  331. const pool = transferPutaways.length > 0 ? transferPutaways : whMatched;
  332. return [...pool].sort(sortNodesInPhase)[0] ?? null;
  333. };
  334. const connectPutawayToWarehousePhase = (
  335. fromList: TraceGraphLayoutNode[],
  336. toList: TraceGraphLayoutNode[],
  337. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  338. ): void => {
  339. const transfers = toList.filter((n) => n.kind === "TRANSFER");
  340. transfers.forEach((tr) => {
  341. const src = pickSourcePutawayForTransfer(fromList, tr);
  342. if (src) add(src, tr);
  343. });
  344. const transferInbounds = toList.filter(
  345. (n) => n.kind === "PUTAWAY" && normRefType(n.refType) === "TRANSFER",
  346. );
  347. transferInbounds.forEach((dest) => {
  348. const src = pickSourcePutawayForTransfer(fromList, dest);
  349. if (src) add(src, dest);
  350. });
  351. if (transfers.length > 0 || transferInbounds.length > 0) {
  352. const linked = new Set<string>([
  353. ...transfers.map((n) => n.id),
  354. ...transferInbounds.map((n) => n.id),
  355. ]);
  356. const remaining = toList.filter((n) => !linked.has(n.id));
  357. remaining.forEach((to) => {
  358. // ADJUSTMENT / other warehouse events must link from same warehouse only.
  359. const from = pickUpstreamForTarget(fromList, to);
  360. if (from) add(from, to);
  361. });
  362. return;
  363. }
  364. toList.forEach((to) => {
  365. const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!;
  366. add(from, to);
  367. });
  368. };
  369. const buildTransferPutawayEdges = (
  370. nodes: TraceGraphLayoutNode[],
  371. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  372. ): void => {
  373. const putaways = nodes.filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n));
  374. const transfers = nodes.filter((n) => n.kind === "TRANSFER");
  375. transfers.forEach((tr) => {
  376. const src = pickSourcePutawayForTransfer(putaways, tr);
  377. if (src) add(src, tr);
  378. const dest = pickDestPutawayForTransfer(putaways, tr);
  379. if (dest) add(tr, dest);
  380. });
  381. // Every 轉倉入庫 card needs an inbound edge (not only the first WAREHOUSE node).
  382. const destInbound = putaways.filter((p) => normRefType(p.refType) === "TRANSFER");
  383. destInbound.forEach((dest) => {
  384. const src = pickSourcePutawayForTransfer(putaways, dest);
  385. if (src && src.id !== dest.id) add(src, dest);
  386. });
  387. };
  388. const PRELUDE_MATERIAL_PHASES: TraceGraphPhase[] = [
  389. "PURCHASE",
  390. "INBOUND",
  391. "PRODUCTION",
  392. "QC",
  393. "PUTAWAY",
  394. "MATERIAL_PICK",
  395. ];
  396. const buildMaterialLotFlowEdgePairs = (
  397. nodes: TraceGraphLayoutNode[],
  398. phaseOrder: TraceGraphPhase[],
  399. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  400. ): void => {
  401. if (!phaseOrder.includes("MATERIAL_PICK")) return;
  402. const preludePhases = PRELUDE_MATERIAL_PHASES.filter((p) => phaseOrder.includes(p));
  403. const byLot = new Map<string, TraceGraphLayoutNode[]>();
  404. nodes.forEach((n) => {
  405. if (!isMaterialPreludeNode(n)) return;
  406. // JO_CREATED lives in MATERIAL_PICK for layout, but its early planStart would pull that
  407. // phase before PUTAWAY and break 已上架 → 工單提料. Dedicated builder handles JO→提料.
  408. if (n.kind === "JO_CREATED") return;
  409. const key = materialLotKey(n);
  410. if (!key) return;
  411. const list = byLot.get(key) ?? [];
  412. list.push(n);
  413. byLot.set(key, list);
  414. });
  415. const nodesInPhase = (lotNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) =>
  416. lotNodes
  417. .filter((n) => n.phase === phase && !NO_FLOW_EDGE_KINDS.has(n.kind))
  418. .sort(sortNodesInPhase);
  419. byLot.forEach((lotNodes) => {
  420. const phasesPresent = sortPhasesByEarliestEvent(
  421. lotNodes,
  422. preludePhases.filter((p) => nodesInPhase(lotNodes, p).length > 0),
  423. phaseOrder,
  424. );
  425. for (let i = 0; i < phasesPresent.length - 1; i++) {
  426. const fromPhase = phasesPresent[i];
  427. const toPhase = phasesPresent[i + 1];
  428. const fromList = nodesInPhase(lotNodes, fromPhase);
  429. const toList = nodesInPhase(lotNodes, toPhase);
  430. if (!fromList.length || !toList.length) continue;
  431. if (toPhase === "MATERIAL_PICK") {
  432. const upstream = fromList[fromList.length - 1]!;
  433. const seenPickTargets = new Set<string>();
  434. toList
  435. .filter((n) => n.kind === "MATERIAL_PICK" || n.kind === "PICK_GROUP")
  436. .forEach((pick) => {
  437. const target = resolvePickFlowTarget(pick, nodes);
  438. if (seenPickTargets.has(target.id)) return;
  439. seenPickTargets.add(target.id);
  440. add(upstream, target);
  441. });
  442. } else {
  443. add(fromList[fromList.length - 1]!, toList[0]!);
  444. }
  445. }
  446. // Same PUTAWAY lane: material 上架 → 庫存調整 (FG warehouse pattern).
  447. const putaways = lotNodes
  448. .filter((n) => n.kind === "PUTAWAY" && !NO_FLOW_EDGE_KINDS.has(n.kind))
  449. .sort(sortNodesInPhase);
  450. const adjustments = lotNodes
  451. .filter((n) => n.kind === "ADJUSTMENT" && !NO_FLOW_EDGE_KINDS.has(n.kind))
  452. .sort(sortNodesInPhase);
  453. if (putaways.length && adjustments.length) {
  454. adjustments.forEach((adj) => {
  455. const targetWh = adj.warehouseCode?.trim();
  456. const matched = targetWh
  457. ? putaways.filter((p) => warehouseCodesMatch(p.warehouseCode, targetWh))
  458. : putaways;
  459. const from = (matched.length ? matched : putaways).at(-1);
  460. if (from) add(from, adj);
  461. });
  462. }
  463. });
  464. };
  465. const buildPutawayToOutboundEdges = (
  466. nodes: TraceGraphLayoutNode[],
  467. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  468. ): void => {
  469. const inventorySources = nodes
  470. .filter(
  471. (n) =>
  472. !isMaterialPreludeNode(n) &&
  473. (n.kind === "PUTAWAY" ||
  474. n.kind === "TRANSFER" ||
  475. n.kind === "ADJUSTMENT" ||
  476. n.kind === "OPEN" ||
  477. n.kind === "RECEIPT" ||
  478. n.kind === "IN"),
  479. )
  480. .sort(sortNodesInPhase);
  481. if (!inventorySources.length) return;
  482. const outboundTargets = nodes.filter(
  483. (n) =>
  484. !isDoGroupChild(n) &&
  485. (n.kind === "DO_OUT" ||
  486. n.kind === "DO_GROUP" ||
  487. n.kind === "JO_OUT" ||
  488. n.kind === "PO_OUT" ||
  489. n.kind === "OUT" ||
  490. n.kind === "REPLENISHMENT_CREATED"),
  491. );
  492. if (!outboundTargets.length) return;
  493. outboundTargets.forEach((target) => {
  494. const from = pickUpstreamForTarget(inventorySources, target);
  495. if (from && !shouldSkipGenericPhaseEdge(from, target)) add(from, target);
  496. });
  497. };
  498. const buildPutawayToStockTakeEdges = (
  499. nodes: TraceGraphLayoutNode[],
  500. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  501. ): void => {
  502. const putaways = nodes
  503. .filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n))
  504. .sort(sortNodesInPhase);
  505. if (!putaways.length) return;
  506. nodes
  507. .filter((n) => n.kind === "STOCK_TAKE" && !isDoGroupChild(n))
  508. .forEach((stockTake) => {
  509. const scope = nodeScopePrefix(stockTake);
  510. // Stock take must stay within the same inventory-lot scope (loc-* / primary).
  511. const scopedPutaways = putaways.filter((n) => nodeScopePrefix(n) === scope);
  512. const from = pickUpstreamForTarget(scopedPutaways, stockTake);
  513. if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake);
  514. });
  515. };
  516. const inventoryWarehouseOf = (n: TraceGraphLayoutNode): string | undefined => {
  517. if (n.kind === "TRANSFER") {
  518. return n.transferToWarehouse?.trim() || n.warehouseCode?.trim();
  519. }
  520. return n.warehouseCode?.trim();
  521. };
  522. const pickUpstreamForTarget = (
  523. fromList: TraceGraphLayoutNode[],
  524. target: TraceGraphLayoutNode,
  525. ): TraceGraphLayoutNode | null => {
  526. if (!fromList.length) return null;
  527. const byTime = fromList.filter(
  528. (n) => n.column <= target.column || n.sortKey <= target.sortKey,
  529. );
  530. const pool = byTime.length > 0 ? byTime : fromList;
  531. const targetWh = target.warehouseCode?.trim();
  532. if (targetWh) {
  533. const matched = pool.filter((n) =>
  534. warehouseCodesMatch(inventoryWarehouseOf(n), targetWh),
  535. );
  536. if (matched.length) return matched[matched.length - 1]!;
  537. // No warehouse match in time window — do not link across warehouses.
  538. const anyMatched = fromList.filter((n) =>
  539. warehouseCodesMatch(inventoryWarehouseOf(n), targetWh),
  540. );
  541. return anyMatched.length ? anyMatched[anyMatched.length - 1]! : null;
  542. }
  543. const scope = nodeScopePrefix(target);
  544. const scoped = pool.filter((n) => nodeScopePrefix(n) === scope);
  545. if (scoped.length) return scoped[scoped.length - 1]!;
  546. const upstreamWhs = new Set(
  547. pool
  548. .map((n) => (inventoryWarehouseOf(n) ?? "").trim().toUpperCase())
  549. .filter(Boolean),
  550. );
  551. if (upstreamWhs.size <= 1) return pool[pool.length - 1]!;
  552. return null;
  553. };
  554. const buildFgLotFlowEdgePairs = (
  555. nodes: TraceGraphLayoutNode[],
  556. phaseOrder: TraceGraphPhase[],
  557. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  558. ): void => {
  559. const fgPhases = phaseOrder.filter((p) => p !== "MATERIAL_PICK");
  560. const fgNodes = nodes.filter(
  561. (n) =>
  562. !isMaterialPreludeNode(n) &&
  563. !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
  564. !NO_FLOW_EDGE_KINDS.has(n.kind) &&
  565. !isDoGroupChild(n),
  566. );
  567. if (fgNodes.length < 2) return;
  568. const nodesInPhase = (phase: TraceGraphPhase) =>
  569. fgNodes.filter((n) => n.phase === phase).sort(sortNodesInPhase);
  570. const phasesPresent = sortFgPhasesForCrossDayFlow(
  571. fgNodes,
  572. fgPhases.filter((p) => nodesInPhase(p).length > 0),
  573. phaseOrder,
  574. );
  575. for (let i = 0; i < phasesPresent.length - 1; i++) {
  576. const fromPhase = phasesPresent[i];
  577. const toPhase = phasesPresent[i + 1];
  578. const fromList = nodesInPhase(fromPhase);
  579. const toList = nodesInPhase(toPhase);
  580. if (!fromList.length || !toList.length) continue;
  581. if (fromPhase === "OUTBOUND" && toPhase === "STOCK_TAKE") continue;
  582. if (
  583. fromPhase === "OUTBOUND" &&
  584. (toPhase === "WAREHOUSE" || toPhase === "PUTAWAY" || toPhase === "STOCK_TAKE")
  585. ) {
  586. continue;
  587. }
  588. if (fromPhase === "PUTAWAY" && toPhase === "WAREHOUSE") {
  589. connectPutawayToWarehousePhase(fromList, toList, add);
  590. continue;
  591. }
  592. if (toPhase === "OUTBOUND") {
  593. toList.forEach((out) => {
  594. const scope = nodeScopePrefix(out);
  595. const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
  596. const from = pickUpstreamForTarget(scopedFrom, out);
  597. if (from && !shouldSkipGenericPhaseEdge(from, out)) add(from, out);
  598. });
  599. } else if (toPhase === "STOCK_TAKE") {
  600. toList.forEach((stockTake) => {
  601. const scope = nodeScopePrefix(stockTake);
  602. const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
  603. const from = pickUpstreamForTarget(scopedFrom, stockTake);
  604. if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake);
  605. });
  606. } else if (toList.length > 1) {
  607. const targets =
  608. toPhase === "PUTAWAY"
  609. ? (() => {
  610. const nonTransfer = toList.filter((n) => normRefType(n.refType) !== "TRANSFER");
  611. return nonTransfer.length > 0 ? nonTransfer : toList;
  612. })()
  613. : toList;
  614. targets.forEach((to) => {
  615. const from = pickUpstreamForTarget(fromList, to);
  616. if (from && !shouldSkipGenericPhaseEdge(from, to)) add(from, to);
  617. });
  618. } else {
  619. const to = toList[0]!;
  620. const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!;
  621. if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to);
  622. }
  623. }
  624. };
  625. const sortProductionStepsForChain = (
  626. a: TraceGraphLayoutNode,
  627. b: TraceGraphLayoutNode,
  628. ): number => {
  629. const sa = a.bomProcessSeqNo;
  630. const sb = b.bomProcessSeqNo;
  631. if (sa != null && sb != null && sa !== sb) return sa - sb;
  632. if (sa != null && sb == null) return -1;
  633. if (sa == null && sb != null) return 1;
  634. return sortNodesInPhase(a, b);
  635. };
  636. const resolveProductionTargetForPick = (
  637. scope: string,
  638. pick: TraceGraphLayoutNode,
  639. prodSteps: TraceGraphLayoutNode[],
  640. ): TraceGraphLayoutNode | null => {
  641. const productionByScopeAndProcess = new Map<string, TraceGraphLayoutNode[]>();
  642. prodSteps.forEach((n) => {
  643. if (n.bomProcessId == null) return;
  644. const key = `${productionScopeKey(n)}::${n.bomProcessId}`;
  645. const list = productionByScopeAndProcess.get(key) ?? [];
  646. list.push(n);
  647. productionByScopeAndProcess.set(key, list);
  648. });
  649. const inScope = (n: TraceGraphLayoutNode) => productionScopeKey(n) === scope;
  650. if (pick.bomProcessId != null) {
  651. const byId = productionByScopeAndProcess.get(`${scope}::${pick.bomProcessId}`);
  652. if (byId?.length) return [...byId].sort(sortProductionStepsForChain)[0]!;
  653. }
  654. if (pick.bomProcessSeqNo != null) {
  655. const bySeq = prodSteps.filter(
  656. (n) => inScope(n) && n.bomProcessSeqNo === pick.bomProcessSeqNo,
  657. );
  658. if (bySeq.length) return [...bySeq].sort(sortProductionStepsForChain)[0]!;
  659. }
  660. const itemCode = pick.traceItemCode?.trim().toUpperCase();
  661. if (itemCode) {
  662. const byItem = prodSteps.filter(
  663. (n) =>
  664. inScope(n) &&
  665. (n.stepMaterialItemCodes ?? []).some(
  666. (code) => code.trim().toUpperCase() === itemCode,
  667. ),
  668. );
  669. if (byItem.length) return [...byItem].sort(sortProductionStepsForChain)[0]!;
  670. }
  671. return null;
  672. };
  673. const buildMaterialPickToProductionStepEdges = (
  674. nodes: TraceGraphLayoutNode[],
  675. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  676. ): void => {
  677. const prodSteps = nodes.filter(
  678. (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n),
  679. );
  680. if (!prodSteps.length) return;
  681. const connectPickOrGroup = (
  682. source: TraceGraphLayoutNode,
  683. pick: TraceGraphLayoutNode,
  684. seenTargets: Set<string>,
  685. ) => {
  686. const scope = pick.feedsProductionScopeLotNo?.trim() || source.feedsProductionScopeLotNo?.trim() || "__fg__";
  687. const target = resolveProductionTargetForPick(scope, pick, prodSteps);
  688. if (!target || seenTargets.has(target.id)) return;
  689. seenTargets.add(target.id);
  690. add(
  691. {
  692. ...source,
  693. feedsProductionScopeLotNo: pick.feedsProductionScopeLotNo ?? source.feedsProductionScopeLotNo,
  694. },
  695. target,
  696. );
  697. };
  698. nodes
  699. .filter((n) => n.kind === "PICK_GROUP")
  700. .forEach((group) => {
  701. const children = nodes.filter(
  702. (n) => n.doGroupId === group.id && n.kind === "MATERIAL_PICK",
  703. );
  704. const seenTargets = new Set<string>();
  705. children.forEach((child) => connectPickOrGroup(group, child, seenTargets));
  706. });
  707. nodes
  708. .filter((n) => n.kind === "MATERIAL_PICK" && !n.doGroupId)
  709. .forEach((pick) => connectPickOrGroup(pick, pick, new Set()));
  710. };
  711. const buildProductionToFgQcEdges = (
  712. nodes: TraceGraphLayoutNode[],
  713. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  714. ): void => {
  715. const prodSteps = nodes.filter(
  716. (n) => n.kind === "PRODUCTION_STEP" && !isMaterialPreludeNode(n) && !isDoGroupChild(n),
  717. );
  718. const qcNodes = nodes.filter((n) => n.kind === "QC" && !isMaterialPreludeNode(n));
  719. if (!prodSteps.length || !qcNodes.length) return;
  720. const lastProd = [...prodSteps].sort(sortNodesInPhase).at(-1)!;
  721. const firstQc = [...qcNodes].sort(sortNodesInPhase)[0]!;
  722. if (firstQc.sortKey >= lastProd.sortKey) add(lastProd, firstQc);
  723. };
  724. const buildProductionStepChainEdges = (
  725. nodes: TraceGraphLayoutNode[],
  726. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  727. ): void => {
  728. const byScope = new Map<string, TraceGraphLayoutNode[]>();
  729. nodes
  730. .filter((n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n))
  731. .forEach((n) => {
  732. const key = productionScopeKey(n);
  733. const list = byScope.get(key) ?? [];
  734. list.push(n);
  735. byScope.set(key, list);
  736. });
  737. byScope.forEach((list) => {
  738. const sorted = [...list].sort(sortProductionStepsForChain);
  739. for (let i = 0; i < sorted.length - 1; i++) {
  740. add(sorted[i]!, sorted[i + 1]!);
  741. }
  742. });
  743. };
  744. const joCodeForPickTarget = (
  745. target: TraceGraphLayoutNode,
  746. nodes: TraceGraphLayoutNode[],
  747. ): string | null => {
  748. // Prefer jobOrderCode — po.consoCode is often a TI ticket or blank, and refCode is PI-*.
  749. const fromNode = (n: TraceGraphLayoutNode): string | null => {
  750. const jo = n.jobOrderCode?.trim();
  751. if (jo) return jo;
  752. const conso = n.consoCode?.trim();
  753. if (conso && /^JO[-_]/i.test(conso)) return conso;
  754. return null;
  755. };
  756. if (target.kind === "PICK_GROUP") {
  757. const direct = fromNode(target);
  758. if (direct) return direct;
  759. const child = nodes.find((n) => n.doGroupId === target.id && fromNode(n));
  760. return child ? fromNode(child) : null;
  761. }
  762. if (target.kind === "MATERIAL_PICK") {
  763. return fromNode(target);
  764. }
  765. return null;
  766. };
  767. const buildJoCreatedToMaterialPickEdges = (
  768. nodes: TraceGraphLayoutNode[],
  769. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  770. ): void => {
  771. const joCreatedNodes = nodes.filter((n) => n.kind === "JO_CREATED");
  772. if (!joCreatedNodes.length) return;
  773. const pickTargets = nodes.filter(
  774. (n) =>
  775. !isDoGroupChild(n) &&
  776. (n.kind === "PICK_GROUP" ||
  777. (n.kind === "MATERIAL_PICK" && !n.doGroupId?.trim())),
  778. );
  779. if (!pickTargets.length) return;
  780. const feedLotForTarget = (target: TraceGraphLayoutNode): string => {
  781. if (target.kind === "PICK_GROUP") {
  782. return (
  783. nodes
  784. .find(
  785. (n) =>
  786. n.doGroupId === target.id && Boolean(n.feedsProductionScopeLotNo?.trim()),
  787. )
  788. ?.feedsProductionScopeLotNo?.trim() ?? ""
  789. );
  790. }
  791. return target.feedsProductionScopeLotNo?.trim() ?? "";
  792. };
  793. pickTargets.forEach((target) => {
  794. const pickJoCode = joCodeForPickTarget(target, nodes);
  795. const matches = joCreatedNodes.filter((jo) => {
  796. const joCode = jo.refCode?.trim();
  797. return Boolean(joCode && pickJoCode && joCode === pickJoCode);
  798. });
  799. if (!matches.length) return;
  800. const feedLot = feedLotForTarget(target);
  801. let pool: TraceGraphLayoutNode[];
  802. if (feedLot) {
  803. const scoped = matches.filter((jo) => jo.traceLotNo?.trim() === feedLot);
  804. pool = scoped.length ? scoped : matches;
  805. } else {
  806. // FG 工單提料: prefer the FG 建立工單 card (no material lot), not nested JO output.
  807. const fg = matches.filter((jo) => !jo.traceLotNo?.trim());
  808. pool = fg.length ? fg : matches;
  809. }
  810. const joCreated = [...pool].sort(sortNodesInPhase)[0]!;
  811. add(joCreated, target);
  812. });
  813. };
  814. /** When a JO has no 提料 that reaches 生產步驟, link 工單建立 → first 生產步驟 in scope. */
  815. const buildJoCreatedToProductionStepEdges = (
  816. nodes: TraceGraphLayoutNode[],
  817. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  818. ): void => {
  819. const joCreatedNodes = nodes.filter((n) => n.kind === "JO_CREATED");
  820. const prodSteps = nodes.filter(
  821. (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n),
  822. );
  823. if (!joCreatedNodes.length || !prodSteps.length) return;
  824. const pickTargets = nodes.filter(
  825. (n) =>
  826. !isDoGroupChild(n) &&
  827. (n.kind === "PICK_GROUP" ||
  828. (n.kind === "MATERIAL_PICK" && !n.doGroupId?.trim())),
  829. );
  830. const feedLotForTarget = (target: TraceGraphLayoutNode): string => {
  831. if (target.kind === "PICK_GROUP") {
  832. return (
  833. nodes
  834. .find(
  835. (n) =>
  836. n.doGroupId === target.id && Boolean(n.feedsProductionScopeLotNo?.trim()),
  837. )
  838. ?.feedsProductionScopeLotNo?.trim() ?? ""
  839. );
  840. }
  841. return target.feedsProductionScopeLotNo?.trim() ?? "";
  842. };
  843. const materialPicksForTarget = (target: TraceGraphLayoutNode): TraceGraphLayoutNode[] => {
  844. if (target.kind === "PICK_GROUP") {
  845. return nodes.filter((n) => n.doGroupId === target.id && n.kind === "MATERIAL_PICK");
  846. }
  847. return [target];
  848. };
  849. /** Only suppress fallback when a pick for this JO can actually reach a production step. */
  850. const joHasPickReachingProduction = (jo: TraceGraphLayoutNode): boolean => {
  851. const joCode = jo.refCode?.trim();
  852. if (!joCode) return false;
  853. const joLot = jo.traceLotNo?.trim() ?? "";
  854. const scope = productionScopeKey(jo);
  855. return pickTargets.some((target) => {
  856. if (joCodeForPickTarget(target, nodes) !== joCode) return false;
  857. const feedLot = feedLotForTarget(target);
  858. if (joLot) {
  859. // Nested JO: only picks that feed this SF lot.
  860. if (feedLot !== joLot) return false;
  861. } else if (feedLot) {
  862. // FG JO: ignore nested-scoped picks.
  863. return false;
  864. }
  865. return materialPicksForTarget(target).some(
  866. (pick) => resolveProductionTargetForPick(scope, pick, prodSteps) != null,
  867. );
  868. });
  869. };
  870. joCreatedNodes.forEach((jo) => {
  871. if (joHasPickReachingProduction(jo)) return;
  872. const scope = productionScopeKey(jo);
  873. const firstStep = prodSteps
  874. .filter((n) => productionScopeKey(n) === scope)
  875. .sort(sortProductionStepsForChain)[0];
  876. if (firstStep) add(jo, firstStep);
  877. });
  878. };
  879. const resolveDoOutFlowTarget = (
  880. doOut: TraceGraphLayoutNode,
  881. nodes: TraceGraphLayoutNode[],
  882. ): TraceGraphLayoutNode => {
  883. const groupId = doOut.doGroupId?.trim();
  884. if (!groupId) return doOut;
  885. return nodes.find((n) => n.id === groupId) ?? doOut;
  886. };
  887. const findDoOutForReplenishment = (
  888. replenishment: TraceGraphLayoutNode,
  889. nodes: TraceGraphLayoutNode[],
  890. ): TraceGraphLayoutNode | null => {
  891. const stockOutLineId = replenishment.replenishmentStockOutLineId;
  892. const doOuts = nodes.filter((n) => n.kind === "DO_OUT");
  893. if (stockOutLineId != null) {
  894. const bySol = doOuts.find((n) => doOutNodeMatchesStockOutLineId(n.id, stockOutLineId));
  895. if (bySol) return bySol;
  896. }
  897. const doCode = replenishment.refCode?.trim();
  898. if (!doCode) return null;
  899. const byCode = doOuts.filter((n) => doOutMatchesDeliveryCode(n, doCode));
  900. if (!byCode.length) return null;
  901. const replenishFlagged = byCode.filter((n) => n.doOutboundIsReplenish);
  902. const pool = replenishFlagged.length ? replenishFlagged : byCode;
  903. return [...pool].sort(sortNodesInPhase)[0] ?? null;
  904. };
  905. const buildReplenishmentToDoOutEdges = (
  906. nodes: TraceGraphLayoutNode[],
  907. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  908. ): void => {
  909. nodes
  910. .filter((n) => n.kind === "REPLENISHMENT_CREATED")
  911. .forEach((replenishment) => {
  912. const doOut = findDoOutForReplenishment(replenishment, nodes);
  913. if (!doOut) return;
  914. add(replenishment, resolveDoOutFlowTarget(doOut, nodes));
  915. });
  916. };
  917. const connectWarehousePhaseChainEdges = (
  918. nodes: TraceGraphLayoutNode[],
  919. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  920. ): void => {
  921. const chainNodes = nodes
  922. .filter(
  923. (n) =>
  924. !isMaterialPreludeNode(n) &&
  925. !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
  926. !NO_FLOW_EDGE_KINDS.has(n.kind) &&
  927. !isDoGroupChild(n),
  928. )
  929. .sort(sortNodesInPhase);
  930. chainNodes
  931. .filter((n) => WAREHOUSE_FLOW_KINDS.has(n.kind))
  932. .forEach((warehouseNode) => {
  933. if (warehouseNode.kind === "TRANSFER") {
  934. const putaways = chainNodes.filter(
  935. (n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n),
  936. );
  937. const src = pickSourcePutawayForTransfer(putaways, warehouseNode);
  938. if (src) {
  939. add(src, warehouseNode);
  940. return;
  941. }
  942. }
  943. const predecessors = chainNodes.filter((n) => n.sortKey < warehouseNode.sortKey);
  944. const targetWh = warehouseNode.warehouseCode?.trim();
  945. const sameWarehouse = (n: TraceGraphLayoutNode) =>
  946. !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh);
  947. const warehousePredecessors = predecessors.filter(
  948. (n) => WAREHOUSE_FLOW_KINDS.has(n.kind) && sameWarehouse(n),
  949. );
  950. if (warehousePredecessors.length > 0) {
  951. add(warehousePredecessors[warehousePredecessors.length - 1]!, warehouseNode);
  952. return;
  953. }
  954. const inventoryPredecessors = predecessors.filter(
  955. (n) => INVENTORY_STATE_KINDS.has(n.kind) && sameWarehouse(n),
  956. );
  957. if (inventoryPredecessors.length > 0) {
  958. add(inventoryPredecessors[inventoryPredecessors.length - 1]!, warehouseNode);
  959. return;
  960. }
  961. // ADJUSTMENT with a known warehouse must not fall back to a different warehouse.
  962. if (warehouseNode.kind === "ADJUSTMENT" && targetWh) return;
  963. const lastBefore = predecessors[predecessors.length - 1];
  964. if (lastBefore) add(lastBefore, warehouseNode);
  965. });
  966. };
  967. const connectStockTakeInboundEdges = (
  968. nodes: TraceGraphLayoutNode[],
  969. add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void,
  970. ): void => {
  971. const chainNodes = nodes
  972. .filter(
  973. (n) =>
  974. !isMaterialPreludeNode(n) &&
  975. !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
  976. !NO_FLOW_EDGE_KINDS.has(n.kind) &&
  977. !isDoGroupChild(n),
  978. )
  979. .sort(sortNodesInPhase);
  980. const inventoryPredecessorKinds = new Set<TraceGraphNodeKind>([
  981. "PUTAWAY",
  982. "TRANSFER",
  983. "ADJUSTMENT",
  984. "REPACK",
  985. "RECEIPT",
  986. "IN",
  987. "OPEN",
  988. ]);
  989. chainNodes
  990. .filter((n) => n.kind === "STOCK_TAKE")
  991. .forEach((stockTake) => {
  992. const targetScope = nodeScopePrefix(stockTake);
  993. const targetWh = stockTake.warehouseCode?.trim();
  994. const targetLot = stockTake.traceLotNo?.trim();
  995. const sameLot = (n: TraceGraphLayoutNode) => {
  996. if (nodeScopePrefix(n) !== targetScope) return false;
  997. if (!targetLot) return true;
  998. const fromLot = n.traceLotNo?.trim();
  999. return !fromLot || fromLot === targetLot;
  1000. };
  1001. const sameWarehouse = (n: TraceGraphLayoutNode) =>
  1002. !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh);
  1003. const predecessors = chainNodes.filter(
  1004. (n) =>
  1005. n.kind !== "STOCK_TAKE" &&
  1006. n.sortKey < stockTake.sortKey &&
  1007. sameLot(n),
  1008. );
  1009. if (!predecessors.length) return;
  1010. const putawayPredecessors = predecessors.filter(
  1011. (n) =>
  1012. n.kind === "PUTAWAY" && !isMaterialPreludeNode(n) && sameWarehouse(n),
  1013. );
  1014. const inventoryPredecessors = predecessors.filter(
  1015. (n) => inventoryPredecessorKinds.has(n.kind) && sameWarehouse(n),
  1016. );
  1017. const from =
  1018. putawayPredecessors[putawayPredecessors.length - 1] ??
  1019. inventoryPredecessors[inventoryPredecessors.length - 1] ??
  1020. // Known warehouse: never fall back across warehouses / other lots.
  1021. (targetWh ? undefined : predecessors[predecessors.length - 1]);
  1022. if (from) add(from, stockTake);
  1023. });
  1024. };
  1025. export const buildTraceFlowEdgePairs = (
  1026. nodes: TraceGraphLayoutNode[],
  1027. phaseOrder: TraceGraphPhase[],
  1028. ): Array<{ fromId: string; toId: string }> => {
  1029. const pairs: Array<{ fromId: string; toId: string }> = [];
  1030. const seen = new Set<string>();
  1031. const add = (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => {
  1032. if (NO_FLOW_EDGE_SOURCE_KINDS.has(from.kind) || NO_FLOW_EDGE_KINDS.has(from.kind)) return;
  1033. if (NO_FLOW_EDGE_KINDS.has(to.kind)) return;
  1034. const key = `${from.id}->${to.id}`;
  1035. if (seen.has(key)) return;
  1036. seen.add(key);
  1037. pairs.push({ fromId: from.id, toId: to.id });
  1038. };
  1039. const byDay = new Map<string, TraceGraphLayoutNode[]>();
  1040. nodes.forEach((n) => {
  1041. const list = byDay.get(n.dayKey) ?? [];
  1042. list.push(n);
  1043. byDay.set(n.dayKey, list);
  1044. });
  1045. const sortedDays = Array.from(byDay.keys()).sort((a, b) => {
  1046. if (a === "—") return 1;
  1047. if (b === "—") return -1;
  1048. return a.localeCompare(b);
  1049. });
  1050. const nodesInPhase = (dayNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) =>
  1051. dayNodes
  1052. .filter(
  1053. (n) =>
  1054. n.phase === phase &&
  1055. !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) &&
  1056. !NO_FLOW_EDGE_KINDS.has(n.kind) &&
  1057. !isDoGroupChild(n),
  1058. )
  1059. .sort(sortNodesInPhase);
  1060. for (const dayKey of sortedDays) {
  1061. const dayNodes = byDay.get(dayKey)!;
  1062. const phasesPresent = sortPhasesByEarliestEvent(
  1063. dayNodes,
  1064. phaseOrder.filter((p) => nodesInPhase(dayNodes, p).length > 0),
  1065. phaseOrder,
  1066. dayKey,
  1067. );
  1068. for (let i = 0; i < phasesPresent.length - 1; i++) {
  1069. const fromList = nodesInPhase(dayNodes, phasesPresent[i]);
  1070. const toList = nodesInPhase(dayNodes, phasesPresent[i + 1]);
  1071. if (!fromList.length || !toList.length) continue;
  1072. const from = fromList[fromList.length - 1]!;
  1073. const to = toList[0]!;
  1074. if (isMaterialPreludeNode(from) || isMaterialPreludeNode(to)) continue;
  1075. // Dedicated builder owns JO_CREATED → PRODUCTION_STEP (avoid day-phase noise).
  1076. if (from.kind === "JO_CREATED" && to.kind === "PRODUCTION_STEP") continue;
  1077. if (phasesPresent[i] === "OUTBOUND" && phasesPresent[i + 1] === "STOCK_TAKE") {
  1078. continue;
  1079. }
  1080. if (phasesPresent[i] === "PUTAWAY" && phasesPresent[i + 1] === "WAREHOUSE") {
  1081. connectPutawayToWarehousePhase(fromList, toList, add);
  1082. continue;
  1083. }
  1084. if (phasesPresent[i + 1] === "OUTBOUND" || phasesPresent[i + 1] === "STOCK_TAKE") {
  1085. toList.forEach((target) => {
  1086. const scope = nodeScopePrefix(target);
  1087. const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope);
  1088. const matched = pickUpstreamForTarget(scopedFrom, target);
  1089. if (matched && !shouldSkipGenericPhaseEdge(matched, target)) add(matched, target);
  1090. });
  1091. continue;
  1092. }
  1093. if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to);
  1094. }
  1095. }
  1096. buildMaterialLotFlowEdgePairs(nodes, phaseOrder, add);
  1097. buildProductionStepChainEdges(nodes, add);
  1098. buildProductionToFgQcEdges(nodes, add);
  1099. buildJoCreatedToMaterialPickEdges(nodes, add);
  1100. buildJoCreatedToProductionStepEdges(nodes, add);
  1101. buildReplenishmentToDoOutEdges(nodes, add);
  1102. buildMaterialPickToProductionStepEdges(nodes, add);
  1103. buildFgLotFlowEdgePairs(nodes, phaseOrder, add);
  1104. buildPutawayToOutboundEdges(nodes, add);
  1105. buildPutawayToStockTakeEdges(nodes, add);
  1106. buildTransferPutawayEdges(nodes, add);
  1107. connectWarehousePhaseChainEdges(nodes, add);
  1108. connectStockTakeInboundEdges(nodes, add);
  1109. buildTerminalStateEdges(nodes, add);
  1110. return pairs;
  1111. };
  1112. export type TraceFlowEdgePair = { fromId: string; toId: string };
  1113. /** Build edges from backend traceGraph when present; merge with local semantics. */
  1114. export const resolveTraceFlowEdgePairs = (
  1115. nodes: TraceGraphLayoutNode[],
  1116. phaseOrder: TraceGraphPhase[],
  1117. backendEdges?: Array<{ fromKey: string; toKey: string }> | null,
  1118. ): TraceFlowEdgePair[] => {
  1119. const local = buildTraceFlowEdgePairs(nodes, phaseOrder);
  1120. if (!backendEdges?.length) return local;
  1121. const nodeByKey = new Map(nodes.map((n) => [n.id, n]));
  1122. const merged = new Map<string, TraceFlowEdgePair>();
  1123. const addPair = (fromId: string, toId: string) => {
  1124. const from = nodeByKey.get(fromId);
  1125. const to = nodeByKey.get(toId);
  1126. if (!from || !to) return;
  1127. if (shouldSkipTraceFlowEdge(from, to)) return;
  1128. merged.set(`${fromId}->${toId}`, { fromId, toId });
  1129. };
  1130. backendEdges.forEach((e) => addPair(e.fromKey, e.toKey));
  1131. local.forEach((e) => addPair(e.fromId, e.toId));
  1132. return Array.from(merged.values());
  1133. };