FPSMS-frontend
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

1211 líneas
42 KiB

  1. import dayjs from "dayjs";
  2. import { ItemLotTraceJoContext, ItemLotTraceOrigin, ItemLotTraceQcResult, ItemLotTraceResponse, ItemLotTraceStockTakeRecordDetail } from "@/app/api/itemTracing";
  3. import { TraceLabelTranslator, groupQcResultsBySession, resolveDoOutboundChipFlags } from "./traceLabelUtils";
  4. import { formatQty, formatSignedQty } from "./traceQtyUtils";
  5. import { docLinkFromRefType, detailsOf, field, fieldIf, parseExpirySortKey, parseSortKey, pickStatusDetailFields, pickStatusNodeLabels } from "./traceNodeFactory";
  6. import {
  7. isPendingPutawayOriginStatus,
  8. isTransferInboundPutaway,
  9. matchTransferForInboundPutaway,
  10. normRefType,
  11. resolvePutawayPresentation,
  12. shouldEmitDepletedForScope,
  13. shouldEmitTransferForScope,
  14. } from "./tracePutawayUtils";
  15. import { resolveDoOutboundDocLink, resolveJoPickDocLink, buildPickOrderTargetDateMap, resolvePickOrderTargetDate } from "./traceDocLinkUtils";
  16. import {
  17. formatStockTakeRoundLabel,
  18. resolveStockTakeAcceptedQty,
  19. resolveStockTakeBookQty,
  20. stockTakeEventSubtitle,
  21. } from "./traceStockTakeUtils";
  22. export type TraceGraphNodeKind =
  23. | "MATERIAL_IN"
  24. | "JO_CREATED"
  25. | "MATERIAL_QC"
  26. | "MATERIAL_PICK"
  27. | "PRODUCTION_STEP"
  28. | "BYPRODUCT"
  29. | "SCRAP"
  30. | "DEFECT"
  31. | "OPEN"
  32. | "FAIL"
  33. | "DO_OUT"
  34. | "REPLENISHMENT_CREATED"
  35. | "JO_OUT"
  36. | "PO_OUT"
  37. | "DO_GROUP"
  38. | "PICK_GROUP"
  39. | "RETURN"
  40. | "REPACK"
  41. | "PURCHASE"
  42. | "RECEIPT"
  43. | "PUTAWAY"
  44. | "IN"
  45. | "OUT"
  46. | "QC"
  47. | "STOCK_TAKE"
  48. | "ADJUSTMENT"
  49. | "TRANSFER"
  50. | "EXPIRED"
  51. | "DEPLETED";
  52. export type TraceGraphDocLinkKind = "jo" | "po" | "pick" | "workbench" | "jodetail";
  53. export interface TraceGraphDetailField {
  54. label: string;
  55. value: string;
  56. linkKind?: TraceGraphDocLinkKind;
  57. linkCode?: string;
  58. linkId?: number | null;
  59. consoCode?: string;
  60. linkTicketNo?: string;
  61. linkTargetDate?: string;
  62. variant?: "default" | "qcCriteriaList";
  63. qcCriteriaItems?: QcCriteriaItem[];
  64. /** MUI palette key for status value text (success / error / warning / info). */
  65. valueColor?: "success" | "error" | "warning" | "info" | "default";
  66. }
  67. export interface QcCriteriaItem {
  68. name: string;
  69. description?: string;
  70. passed: boolean;
  71. failQty?: number;
  72. }
  73. export interface TraceGraphNodeLabels {
  74. nodeReceipt: string;
  75. nodePurchase: string;
  76. nodePutaway: string;
  77. nodePutawayTransfer: string;
  78. putawayTransferDetail: string;
  79. nodeJoCreated: string;
  80. nodeQcPass: string;
  81. nodeQcFail: string;
  82. nodeStockTake: string;
  83. nodeAdjustment: string;
  84. nodeTransfer: string;
  85. nodeDoOut: string;
  86. nodeJoOut: string;
  87. nodePoOut: string;
  88. directionIn: string;
  89. directionOut: string;
  90. formatQcSubtitle: (failQty: number, acceptedQty: number) => string;
  91. }
  92. export interface TraceGraphNode {
  93. id: string;
  94. kind: TraceGraphNodeKind;
  95. timestamp: string | null;
  96. sortKey: number;
  97. title: string;
  98. subtitle: string;
  99. qty?: number;
  100. uom?: string;
  101. meta?: string;
  102. refType?: string;
  103. refCode?: string;
  104. refId?: number | null;
  105. docLinkKind?: TraceGraphDocLinkKind;
  106. /** Workbench deep-link ticket (TI-*). */
  107. docLinkTicketNo?: string;
  108. docLinkTargetDate?: string;
  109. traceLotNo?: string;
  110. traceItemCode?: string;
  111. consoCode?: string;
  112. categoryLabel?: string;
  113. /** Translated QC type for chip label (IQC / EPQC / …). */
  114. qcTypeLabel?: string;
  115. /** 待上架 / 已上架 for PUTAWAY nodes (chip). */
  116. putawayStatusLabel?: string;
  117. /** 加單 / 補貨 flags for DO_OUT title chips (detail uses outboundKindLabel). */
  118. doOutboundIsExtra?: boolean;
  119. doOutboundIsReplenish?: boolean;
  120. outboundKindLabel?: string;
  121. /** Longer status explanation in detail panel (e.g. transfer inbound). */
  122. putawayStatusDetail?: string;
  123. /** Warehouse where stock was shelved (PUTAWAY) or transfer endpoints (TRANSFER). */
  124. warehouseCode?: string;
  125. transferFromWarehouse?: string;
  126. transferToWarehouse?: string;
  127. /** BOM process step this node belongs to (material pick / production step mapping). */
  128. bomProcessId?: number | null;
  129. bomProcessSeqNo?: number | null;
  130. assignedStepName?: string;
  131. /** Item codes consumed by this production step (from BOM process materials). */
  132. stepMaterialItemCodes?: string[];
  133. /** Semi-finished lot whose nested production this pick feeds (nested JO only). */
  134. feedsProductionScopeLotNo?: string;
  135. /** Job order that owns this material pick / pick group. */
  136. jobOrderCode?: string;
  137. details: TraceGraphDetailField[];
  138. /** When set, this node is rendered inside a DO group box. */
  139. doGroupId?: string;
  140. /** DO_GROUP container size (layout only). */
  141. groupBoxWidth?: number;
  142. groupBoxHeight?: number;
  143. groupMemberCount?: number;
  144. /** Summed outbound qty for DO_GROUP header. */
  145. groupTotalQty?: number;
  146. groupUom?: string;
  147. /** Links REPLENISHMENT_CREATED → matching DO_OUT (stockOutLineId). */
  148. replenishmentStockOutLineId?: number;
  149. /** IN / OUT for ADJUSTMENT nodes (signed variance display). */
  150. adjustmentDirection?: string;
  151. /** Stocktake lifecycle record detail (for expandable STOCK_TAKE nodes). */
  152. stockTakeRecordDetail?: ItemLotTraceStockTakeRecordDetail;
  153. /** Owning inventory_lot for multi-location graph scopes. */
  154. inventoryLotId?: number;
  155. /** Translated pick-line statuses for JO pick cards. */
  156. processingStatusLabel?: string;
  157. matchStatusLabel?: string;
  158. /** Raw status codes (for coloring). */
  159. processingStatus?: string;
  160. matchStatus?: string;
  161. }
  162. export interface TraceGraphDetailLabels extends TraceGraphNodeLabels {
  163. tr: TraceLabelTranslator;
  164. detailQcCriteria: string;
  165. detailQcType: string;
  166. detailQcUnknownItem: string;
  167. nodeExpired: string;
  168. nodeDepleted: string;
  169. detailQty: string;
  170. detailTime: string;
  171. detailHandler: string;
  172. detailStockTaker: string;
  173. detailApprover: string;
  174. detailWarehouse: string;
  175. detailRemarks: string;
  176. detailFailCategory: string;
  177. detailProcessDescription: string;
  178. detailType: string;
  179. detailRef: string;
  180. detailStockTakeCode: string;
  181. detailAdjustmentRef: string;
  182. detailReplenishmentCode: string;
  183. detailReason: string;
  184. detailReturnRef: string;
  185. detailSourceDoc: string;
  186. detailPurchaseOrderNo: string;
  187. detailInboundRef: string;
  188. detailInboundSiNo: string;
  189. detailPutawayBin: string;
  190. detailDirection: string;
  191. detailStatus: string;
  192. detailSupplier: string;
  193. detailMaterial: string;
  194. detailItemCode: string;
  195. detailItemName: string;
  196. detailLot: string;
  197. detailAcceptedQty: string;
  198. detailFailQty: string;
  199. detailFrom: string;
  200. detailTo: string;
  201. detailVariance: string;
  202. detailBefore: string;
  203. detailAfter: string;
  204. detailStockTakeFirstCount: string;
  205. detailStockTakeSecondCount: string;
  206. detailStockTakeApproverCount: string;
  207. detailScrapQty: string;
  208. detailDefectQty: string;
  209. detailEquipment: string;
  210. detailProcessStep: string;
  211. detailStepMaterials: string;
  212. detailAssignedStep: string;
  213. detailPickTargetDate: string;
  214. detailProductLotNo: string;
  215. detailOrderQty: string;
  216. detailPutAwayQty: string;
  217. detailPurchaseUnit: string;
  218. detailSupplyTo: string;
  219. detailStockTakeRound: string;
  220. detailStockTakeSection: string;
  221. detailLocation: string;
  222. deliveryOrder: string;
  223. deliveryNoteCode: string;
  224. ticketNo: string;
  225. pickOrder: string;
  226. jobOrder: string;
  227. expiryDate: string;
  228. totalAvailable: string;
  229. itemCode: string;
  230. categoryPurchase: string;
  231. categoryProduction: string;
  232. categoryInbound: string;
  233. categoryReceipt: string;
  234. categoryPutaway: string;
  235. categoryTransfer: string;
  236. categoryStockTake: string;
  237. categoryOpen: string;
  238. categoryAdjustment: string;
  239. categoryPick: string;
  240. categoryQc: string;
  241. categoryOutbound: string;
  242. categoryTerminal: string;
  243. processingStatus: string;
  244. matchStatus: string;
  245. }
  246. const categoryForKind = (kind: TraceGraphNodeKind, labels: TraceGraphDetailLabels): string => {
  247. switch (kind) {
  248. case "MATERIAL_IN":
  249. return labels.categoryPurchase;
  250. case "JO_CREATED":
  251. return labels.categoryProduction;
  252. case "MATERIAL_PICK":
  253. return labels.categoryPick;
  254. case "PRODUCTION_STEP":
  255. case "BYPRODUCT":
  256. case "SCRAP":
  257. case "DEFECT":
  258. return labels.categoryProduction;
  259. case "OPEN":
  260. return labels.categoryOpen;
  261. case "FAIL":
  262. return labels.categoryQc;
  263. case "DO_OUT":
  264. case "JO_OUT":
  265. case "PO_OUT":
  266. case "RETURN":
  267. return labels.categoryOutbound;
  268. case "REPACK":
  269. return labels.categoryTransfer;
  270. case "PURCHASE":
  271. return labels.categoryPurchase;
  272. case "RECEIPT":
  273. return labels.categoryReceipt;
  274. case "PUTAWAY":
  275. return labels.categoryPutaway;
  276. case "MATERIAL_QC":
  277. case "QC":
  278. return labels.categoryQc;
  279. case "IN":
  280. return labels.categoryInbound;
  281. case "TRANSFER":
  282. return labels.categoryTransfer;
  283. case "STOCK_TAKE":
  284. return labels.categoryStockTake;
  285. case "ADJUSTMENT":
  286. return labels.categoryAdjustment;
  287. case "OUT":
  288. return labels.categoryOutbound;
  289. case "EXPIRED":
  290. case "DEPLETED":
  291. return labels.categoryTerminal;
  292. default:
  293. return labels.detailType;
  294. }
  295. };
  296. /** Movement refTypes covered by dedicated trace arrays (avoid duplicate graph nodes). */
  297. const MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS = new Set([
  298. "TRANSFER",
  299. "ADJ",
  300. "OPEN",
  301. "TKE",
  302. ]);
  303. /** Stock-take ledger movements are represented by stockTakeEvents nodes. */
  304. const isStockTakeLedgerMovement = (movementType: string) => {
  305. const mt = movementType.trim().toUpperCase();
  306. return mt === "TKE" || mt === "STOCKTAKE";
  307. };
  308. const norm = (v: string | null | undefined) => (v ?? "").trim().toUpperCase();
  309. const doMovementDedupKey = (
  310. pickOrderCode: string,
  311. timestamp: string | null | undefined,
  312. qty: number,
  313. ) => `${pickOrderCode.trim()}|${timestamp ?? ""}|${qty}`;
  314. const buildCoveredDoMovementKeys = (data: ItemLotTraceResponse): Set<string> =>
  315. new Set(
  316. (data.doDeliveries ?? []).map((d) =>
  317. doMovementDedupKey(d.pickOrderCode, d.timestamp, d.qty),
  318. ),
  319. );
  320. export interface TraceGraphBuildScope {
  321. /** e.g. `loc-42-` for alternate inventory_lot nodes (aligned with backend traceGraph keys). */
  322. idPrefix?: string;
  323. locationBlockKeys?: boolean;
  324. inventoryLotId?: number;
  325. defaultWarehouseCode?: string;
  326. /** When true, TRANSFER cards are omitted; destination shows 轉倉入庫 putaway only. */
  327. mergedMultiLocation?: boolean;
  328. }
  329. export const scopePrefix = (scope?: TraceGraphBuildScope) => scope?.idPrefix ?? "";
  330. export const withNodeScope = (node: TraceGraphNode, scope?: TraceGraphBuildScope): TraceGraphNode => {
  331. if (!scope?.inventoryLotId && !scope?.defaultWarehouseCode) return node;
  332. return {
  333. ...node,
  334. inventoryLotId: scope.inventoryLotId ?? node.inventoryLotId,
  335. warehouseCode: node.warehouseCode?.trim() || scope.defaultWarehouseCode || node.warehouseCode,
  336. };
  337. };
  338. const buildQcNodes = (
  339. qcResults: ItemLotTraceQcResult[],
  340. labels: TraceGraphDetailLabels,
  341. stockUom: string,
  342. seqStart: number,
  343. scope?: TraceGraphBuildScope,
  344. ): { nodes: TraceGraphNode[]; nextSeq: number } => {
  345. const idPfx = scopePrefix(scope);
  346. const lb = scope?.locationBlockKeys ?? false;
  347. const nodes: TraceGraphNode[] = [];
  348. let seq = seqStart;
  349. groupQcResultsBySession(qcResults).forEach((group, i) => {
  350. const first = group[0];
  351. if (!first) return;
  352. const allPassed = group.every((q) => q.qcPassed);
  353. const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0);
  354. const metaParts = [
  355. first.handledBy,
  356. ...group.map((q) => q.remarks).filter(Boolean),
  357. ].filter(Boolean);
  358. const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? "";
  359. nodes.push({
  360. id: lb
  361. ? `${idPfx}qc-${i}-${first.stockInLineId}`
  362. : `${idPfx}qc-${i}-${first.stockInLineId}-${first.created}`,
  363. kind: "QC",
  364. timestamp: first.created,
  365. sortKey: parseSortKey(first.created, seq++),
  366. title: allPassed ? labels.nodeQcPass : labels.nodeQcFail,
  367. subtitle: "",
  368. qty: totalFail,
  369. uom: stockUom,
  370. meta: metaParts.length ? Array.from(new Set(metaParts)).join(" · ") : undefined,
  371. categoryLabel: labels.categoryQc,
  372. qcTypeLabel: labels.tr.qcType(qcTypeRaw),
  373. details: detailsOf(
  374. field(labels.detailStatus, labels.tr.qcPassed(allPassed)),
  375. field(labels.detailQcType, labels.tr.qcType(qcTypeRaw)),
  376. field(labels.detailQcCriteria, "", {
  377. variant: "qcCriteriaList",
  378. qcCriteriaItems: group.map((q) => ({
  379. name: q.qcItemName?.trim() || q.qcItemCode?.trim() || labels.detailQcUnknownItem,
  380. description: q.qcItemDescription?.trim() || undefined,
  381. passed: q.qcPassed,
  382. failQty: q.failQty > 0 ? q.failQty : undefined,
  383. })),
  384. }),
  385. field(labels.detailAcceptedQty, formatQty(first.acceptedQty, stockUom)),
  386. field(labels.detailFailQty, formatQty(totalFail, stockUom)),
  387. field(labels.detailHandler, first.handledBy),
  388. field(labels.detailTime, first.created),
  389. fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join(";")),
  390. ),
  391. });
  392. });
  393. return { nodes, nextSeq: seq };
  394. };
  395. const resolveJoCreatedTimestamp = (
  396. data: ItemLotTraceResponse,
  397. refCode: string | null | undefined,
  398. fallback: string | null | undefined,
  399. ): string | null => {
  400. const code = refCode?.trim();
  401. if (!code) return fallback ?? null;
  402. const candidates: Array<ItemLotTraceJoContext | undefined> = [
  403. data.joPrelude?.jobOrder,
  404. ...(data.joPrelude?.materialInputs ?? []).map((m) => m.nestedJoPrelude?.jobOrder),
  405. ];
  406. for (const jo of candidates) {
  407. if (jo?.jobOrderCode?.trim() === code && jo.createdAt?.trim()) {
  408. return jo.createdAt;
  409. }
  410. }
  411. return fallback ?? null;
  412. };
  413. const poInboundKey = (
  414. refCode: string | null | undefined,
  415. refId: number | null | undefined,
  416. qty: number,
  417. ): string => `${(refCode ?? "").trim()}|${refId ?? 0}|${qty}`;
  418. const buildInboundOriginNodes = (
  419. data: ItemLotTraceResponse,
  420. labels: TraceGraphDetailLabels,
  421. stockUom: string,
  422. seqStart: number,
  423. scope: TraceGraphBuildScope | undefined,
  424. existingPurchaseCodes: Set<string>,
  425. coveredPoInbound: Set<string>,
  426. coveredJoInbound: Set<string>,
  427. ): { nodes: TraceGraphNode[]; nextSeq: number } => {
  428. const idPfx = scopePrefix(scope);
  429. const lb = scope?.locationBlockKeys ?? false;
  430. const defaultWh = scope?.defaultWarehouseCode;
  431. const nodes: TraceGraphNode[] = [];
  432. let seq = seqStart;
  433. (data.origins ?? []).forEach((origin, i) => {
  434. const originType = norm(origin.type);
  435. const linkKind = docLinkFromRefType(origin.type);
  436. const supplierLabel = [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ");
  437. if (originType === "PO") {
  438. const refCode = origin.refCode?.trim();
  439. if (!refCode) return;
  440. coveredPoInbound.add(poInboundKey(origin.refCode, origin.refId, origin.acceptedQty));
  441. if (!existingPurchaseCodes.has(refCode)) {
  442. existingPurchaseCodes.add(refCode);
  443. nodes.push({
  444. id: lb
  445. ? `${idPfx}purchase-origin-${origin.refId ?? i}-${origin.stockInLineId}`
  446. : `${idPfx}purchase-origin-${origin.refId ?? i}-${origin.stockInLineId}`,
  447. kind: "PURCHASE",
  448. timestamp: origin.receiptDate,
  449. sortKey: parseSortKey(origin.receiptDate, seq++),
  450. title: labels.nodePurchase,
  451. subtitle: [supplierLabel, data.lot.itemCode].filter(Boolean).join(" · ") || "—",
  452. qty: origin.acceptedQty,
  453. uom: stockUom,
  454. refType: "PO",
  455. refCode,
  456. refId: origin.refId,
  457. docLinkKind: "po",
  458. categoryLabel: labels.categoryPurchase,
  459. details: [
  460. field(labels.detailSupplier, supplierLabel || "—"),
  461. field(labels.detailOrderQty, formatQty(origin.acceptedQty, stockUom)),
  462. field(labels.detailPurchaseOrderNo, refCode, {
  463. linkKind: "po",
  464. linkCode: refCode,
  465. linkId: origin.refId,
  466. }),
  467. field(labels.detailTime, origin.receiptDate),
  468. ],
  469. });
  470. }
  471. nodes.push({
  472. id: `${idPfx}origin-${i}-${origin.stockInLineId}`,
  473. kind: "RECEIPT",
  474. timestamp: origin.receiptDate,
  475. sortKey: parseSortKey(origin.receiptDate, seq++),
  476. title: labels.nodeReceipt,
  477. subtitle: [refCode, supplierLabel].filter(Boolean).join(" · ") || "—",
  478. qty: origin.acceptedQty,
  479. uom: stockUom,
  480. refType: origin.type,
  481. refCode,
  482. refId: origin.refId,
  483. docLinkKind: linkKind,
  484. warehouseCode: defaultWh,
  485. categoryLabel: categoryForKind("RECEIPT", labels),
  486. details: detailsOf(
  487. field(labels.detailPurchaseOrderNo, refCode, {
  488. linkKind,
  489. linkCode: refCode,
  490. linkId: origin.refId,
  491. }),
  492. field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)),
  493. field(labels.detailTime, origin.receiptDate),
  494. ),
  495. });
  496. return;
  497. }
  498. if (originType === "JO") {
  499. const refCode = origin.refCode?.trim();
  500. if (!refCode) return;
  501. const joKey = `${refCode}|${origin.refId ?? 0}`;
  502. coveredJoInbound.add(joKey);
  503. const eventTimestamp = resolveJoCreatedTimestamp(data, refCode, origin.receiptDate);
  504. nodes.push({
  505. id: `${idPfx}origin-${i}-${origin.stockInLineId}`,
  506. kind: "JO_CREATED",
  507. timestamp: eventTimestamp,
  508. sortKey: parseSortKey(eventTimestamp, seq++),
  509. title: labels.nodeJoCreated,
  510. subtitle: refCode,
  511. qty: origin.acceptedQty,
  512. uom: stockUom,
  513. refType: origin.type,
  514. refCode,
  515. refId: origin.refId,
  516. docLinkKind: linkKind,
  517. warehouseCode: defaultWh,
  518. categoryLabel: categoryForKind("JO_CREATED", labels),
  519. details: detailsOf(
  520. field(labels.detailType, labels.nodeJoCreated),
  521. field(labels.jobOrder, refCode, {
  522. linkKind,
  523. linkCode: refCode,
  524. linkId: origin.refId,
  525. }),
  526. field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)),
  527. field(labels.detailTime, eventTimestamp),
  528. ),
  529. });
  530. }
  531. });
  532. return { nodes, nextSeq: seq };
  533. };
  534. export const buildTraceGraphNodes = (
  535. data: ItemLotTraceResponse,
  536. labels: TraceGraphDetailLabels,
  537. scope?: TraceGraphBuildScope,
  538. ): TraceGraphNode[] => {
  539. const nodes: TraceGraphNode[] = [];
  540. let seq = 0;
  541. const stockUom = data.lot.uom;
  542. const idPfx = scopePrefix(scope);
  543. const lb = scope?.locationBlockKeys ?? false;
  544. const primaryWh =
  545. data.warehouseLines.map((w) => w.warehouseCode).filter(Boolean).join(" / ") || undefined;
  546. const defaultScope: TraceGraphBuildScope | undefined = scope
  547. ? scope
  548. : primaryWh
  549. ? { defaultWarehouseCode: primaryWh, inventoryLotId: data.lot.inventoryLotId }
  550. : { inventoryLotId: data.lot.inventoryLotId };
  551. const mergedMultiLocation = defaultScope?.mergedMultiLocation ?? false;
  552. const existingPurchaseCodes = new Set<string>();
  553. const coveredPoInbound = new Set<string>();
  554. const coveredJoInbound = new Set<string>();
  555. (data.purchaseEvents ?? []).forEach((purchase, i) => {
  556. const purchaseUnit = purchase.purchaseUnit;
  557. const supplierLabel = [purchase.supplierCode, purchase.supplierName].filter(Boolean).join(" ");
  558. const poCode = purchase.purchaseOrderCode?.trim();
  559. if (poCode) existingPurchaseCodes.add(poCode);
  560. const subtitle =
  561. [supplierLabel, purchase.itemCode, purchase.itemName].filter(Boolean).join(" · ") || "—";
  562. const meta = [supplierLabel].filter(Boolean).join(" · ");
  563. nodes.push({
  564. id: `${idPfx}purchase-${i}-${purchase.purchaseOrderLineId}`,
  565. kind: "PURCHASE",
  566. timestamp: purchase.orderDate,
  567. sortKey: parseSortKey(purchase.orderDate, seq++),
  568. title: labels.nodePurchase,
  569. subtitle,
  570. qty: purchase.orderQty,
  571. uom: purchaseUnit,
  572. meta: meta || undefined,
  573. refType: "PO",
  574. refCode: purchase.purchaseOrderCode,
  575. refId: purchase.purchaseOrderId,
  576. docLinkKind: "po",
  577. categoryLabel: labels.categoryPurchase,
  578. details: [
  579. field(labels.detailItemCode, purchase.itemCode),
  580. field(labels.detailItemName, purchase.itemName),
  581. field(labels.detailSupplier, supplierLabel),
  582. field(labels.detailOrderQty, formatQty(purchase.orderQty, purchaseUnit)),
  583. field(labels.detailPurchaseUnit, purchase.purchaseUnit),
  584. field(labels.detailPurchaseOrderNo, purchase.purchaseOrderCode, {
  585. linkKind: "po",
  586. linkCode: purchase.purchaseOrderCode,
  587. linkId: purchase.purchaseOrderId,
  588. }),
  589. field(labels.detailTime, purchase.orderDate),
  590. ],
  591. });
  592. });
  593. const originBuilt = buildInboundOriginNodes(
  594. data,
  595. labels,
  596. stockUom,
  597. seq,
  598. defaultScope,
  599. existingPurchaseCodes,
  600. coveredPoInbound,
  601. coveredJoInbound,
  602. );
  603. nodes.push(...originBuilt.nodes);
  604. seq = originBuilt.nextSeq;
  605. const coveredDoMovementKeys = buildCoveredDoMovementKeys(data);
  606. const pickOrderTargetDateMap = buildPickOrderTargetDateMap(data);
  607. const doMetaByPickCode = new Map<
  608. string,
  609. {
  610. isExtra: boolean;
  611. isReplenish: boolean;
  612. consoCode?: string;
  613. }
  614. >();
  615. (data.doDeliveries ?? []).forEach((d) => {
  616. const code = d.pickOrderCode?.trim();
  617. if (!code) return;
  618. const chipFlags = resolveDoOutboundChipFlags({
  619. isExtra: d.isExtra,
  620. isReplenish: d.isReplenish,
  621. ticketNo: d.ticketNo,
  622. consoCode: d.consoCode,
  623. releaseType: d.releaseType,
  624. deliveryOrderPickOrderId: d.deliveryOrderPickOrderId,
  625. relationshipId: d.relationshipId,
  626. });
  627. doMetaByPickCode.set(code, {
  628. isExtra: chipFlags.isExtra,
  629. isReplenish: chipFlags.isReplenish,
  630. consoCode: d.consoCode?.trim() || code,
  631. });
  632. });
  633. data.movements.forEach((m, i) => {
  634. const refType = norm(m.refType);
  635. if (MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS.has(refType)) return;
  636. if (isStockTakeLedgerMovement(m.movementType)) return;
  637. if (
  638. refType === "DO" &&
  639. coveredDoMovementKeys.has(doMovementDedupKey(m.refCode, m.timestamp, m.qty))
  640. ) {
  641. return;
  642. }
  643. if (refType === "RETURN" && (data.returnEvents?.length ?? 0) > 0) return;
  644. if (m.direction === "IN" && refType === "PO") {
  645. if (coveredPoInbound.has(poInboundKey(m.refCode, m.refId, m.qty))) return;
  646. coveredPoInbound.add(poInboundKey(m.refCode, m.refId, m.qty));
  647. }
  648. if (m.direction === "IN" && refType === "JO") {
  649. const joKey = `${(m.refCode ?? "").trim()}|${m.refId ?? 0}`;
  650. if (coveredJoInbound.has(joKey)) return;
  651. coveredJoInbound.add(joKey);
  652. }
  653. const isDoOut = refType === "DO" && m.direction !== "IN";
  654. const isJoOut = refType === "JO_PICK" && m.direction !== "IN";
  655. const isPoOut = refType === "PO_PICK" && m.direction !== "IN";
  656. const kind: TraceGraphNodeKind =
  657. m.direction === "IN"
  658. ? refType === "PO"
  659. ? "RECEIPT"
  660. : refType === "JO"
  661. ? "JO_CREATED"
  662. : "IN"
  663. : isDoOut
  664. ? "DO_OUT"
  665. : isJoOut
  666. ? "JO_OUT"
  667. : isPoOut
  668. ? "PO_OUT"
  669. : "OUT";
  670. const eventTimestamp =
  671. kind === "JO_CREATED"
  672. ? resolveJoCreatedTimestamp(data, m.refCode, m.timestamp)
  673. : m.timestamp;
  674. const title =
  675. kind === "RECEIPT"
  676. ? labels.nodeReceipt
  677. : kind === "JO_CREATED"
  678. ? labels.nodeJoCreated
  679. : kind === "DO_OUT"
  680. ? `${labels.nodeDoOut} · ${m.refCode || "—"}`
  681. : kind === "JO_OUT"
  682. ? `${labels.nodeJoOut} · ${m.refCode || "—"}`
  683. : kind === "PO_OUT"
  684. ? `${labels.nodePoOut} · ${m.refCode || "—"}`
  685. : `${labels.tr.refType(m.refType)} · ${labels.tr.movementType(m.movementType)}`;
  686. const subtitle =
  687. kind === "DO_OUT" || kind === "JO_OUT" || kind === "PO_OUT"
  688. ? ""
  689. : [m.refCode, m.warehouseCode].filter(Boolean).join(" · ") || "—";
  690. const meta = [m.handledBy, m.remarks].filter(Boolean).join(" · ");
  691. const linkKind = isDoOut || isJoOut || isPoOut ? "pick" : docLinkFromRefType(m.refType);
  692. const receiptDetails =
  693. kind === "RECEIPT"
  694. ? detailsOf(
  695. field(labels.detailPurchaseOrderNo, m.refCode, {
  696. linkKind,
  697. linkCode: m.refCode,
  698. linkId: m.refId,
  699. }),
  700. field(labels.detailQty, formatQty(m.qty, stockUom)),
  701. field(labels.detailHandler, m.handledBy),
  702. field(labels.detailTime, m.timestamp),
  703. fieldIf(labels.detailRemarks, m.remarks),
  704. )
  705. : kind === "DO_OUT"
  706. ? detailsOf(
  707. field(labels.detailType, labels.nodeDoOut),
  708. field(labels.pickOrder, m.refCode, {
  709. linkKind: "pick",
  710. linkCode: m.refCode,
  711. linkId: m.refId,
  712. consoCode: m.refCode,
  713. }),
  714. field(labels.detailWarehouse, m.warehouseCode),
  715. field(labels.detailQty, formatQty(m.qty, stockUom)),
  716. field(labels.detailHandler, m.handledBy),
  717. field(labels.detailTime, m.timestamp),
  718. fieldIf(labels.detailRemarks, m.remarks),
  719. )
  720. : kind === "JO_OUT"
  721. ? detailsOf(
  722. field(labels.detailType, labels.nodeJoOut),
  723. field(labels.pickOrder, m.refCode, {
  724. linkKind: "jodetail",
  725. linkCode: m.refCode,
  726. linkId: m.refId,
  727. consoCode: m.refCode,
  728. linkTargetDate: resolvePickOrderTargetDate(
  729. pickOrderTargetDateMap,
  730. m.refCode,
  731. m.timestamp,
  732. ),
  733. }),
  734. field(
  735. labels.detailPickTargetDate,
  736. resolvePickOrderTargetDate(pickOrderTargetDateMap, m.refCode, m.timestamp),
  737. ),
  738. field(labels.detailWarehouse, m.warehouseCode),
  739. field(labels.detailQty, formatQty(m.qty, stockUom)),
  740. ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus, {
  741. always: true,
  742. }),
  743. field(labels.detailHandler, m.handledBy),
  744. field(labels.detailTime, m.timestamp),
  745. fieldIf(labels.detailRemarks, m.remarks),
  746. )
  747. : kind === "PO_OUT"
  748. ? detailsOf(
  749. field(labels.detailType, labels.nodePoOut),
  750. field(labels.pickOrder, m.refCode, {
  751. linkKind: "pick",
  752. linkCode: m.refCode,
  753. linkId: m.refId,
  754. consoCode: m.refCode,
  755. }),
  756. field(labels.detailWarehouse, m.warehouseCode),
  757. field(labels.detailQty, formatQty(m.qty, stockUom)),
  758. field(labels.detailHandler, m.handledBy),
  759. field(labels.detailTime, m.timestamp),
  760. fieldIf(labels.detailRemarks, m.remarks),
  761. )
  762. : detailsOf(
  763. field(
  764. labels.detailType,
  765. kind === "JO_CREATED" ? labels.nodeJoCreated : labels.tr.movementType(m.movementType),
  766. ),
  767. field(labels.detailDirection, labels.tr.direction(m.direction)),
  768. field(labels.detailSourceDoc, m.refCode, {
  769. linkKind,
  770. linkCode: m.refCode,
  771. linkId: m.refId,
  772. }),
  773. field(labels.detailWarehouse, m.warehouseCode),
  774. field(labels.detailQty, formatQty(m.qty, stockUom)),
  775. field(labels.detailHandler, m.handledBy),
  776. field(labels.detailTime, eventTimestamp),
  777. fieldIf(labels.detailRemarks, m.remarks),
  778. );
  779. const pickMeta = isDoOut ? doMetaByPickCode.get(m.refCode?.trim() ?? "") : undefined;
  780. const delivery = isDoOut
  781. ? (data.doDeliveries ?? []).find((d) => d.pickOrderCode?.trim() === m.refCode?.trim())
  782. : undefined;
  783. const docLink = isDoOut
  784. ? resolveDoOutboundDocLink({
  785. pickOrderCode: delivery?.pickOrderCode || m.refCode,
  786. pickOrderId: delivery?.pickOrderId ?? m.refId,
  787. deliveryOrderCode: delivery?.deliveryOrderCode,
  788. ticketNo: delivery?.ticketNo,
  789. outboundTicketNo: m.outboundTicketNo,
  790. consoCode: delivery?.consoCode || m.pickConsoCode,
  791. timestamp: m.timestamp,
  792. deliveryOrderPickOrderId:
  793. delivery?.deliveryOrderPickOrderId ?? m.deliveryOrderPickOrderId,
  794. })
  795. : null;
  796. const joPickTargetDate = isJoOut
  797. ? resolvePickOrderTargetDate(pickOrderTargetDateMap, m.refCode, m.timestamp)
  798. : undefined;
  799. const joDocLink = isJoOut
  800. ? resolveJoPickDocLink({
  801. pickOrderCode: m.refCode,
  802. pickOrderId: m.refId,
  803. timestamp: m.timestamp,
  804. targetDate: joPickTargetDate,
  805. })
  806. : null;
  807. const movementChip = isDoOut
  808. ? resolveDoOutboundChipFlags({
  809. isExtra: pickMeta ? undefined : m.doOutboundIsExtra,
  810. isReplenish: pickMeta ? undefined : m.doOutboundIsReplenish,
  811. ticketNo: pickMeta ? undefined : m.outboundTicketNo,
  812. consoCode: pickMeta ? undefined : (m.pickConsoCode || m.refCode),
  813. deliveryOrderPickOrderId: pickMeta
  814. ? undefined
  815. : m.deliveryOrderPickOrderId,
  816. relationshipId: pickMeta ? undefined : m.relationshipId,
  817. })
  818. : null;
  819. const chipFlags = pickMeta
  820. ? resolveDoOutboundChipFlags({
  821. isExtra: pickMeta.isExtra,
  822. isReplenish: pickMeta.isReplenish,
  823. consoCode: pickMeta.consoCode,
  824. })
  825. : movementChip;
  826. nodes.push({
  827. id: lb
  828. ? `${idPfx}mov-${i}-${m.refCode}`
  829. : `${idPfx}mov-${i}-${m.refCode}-${eventTimestamp}`,
  830. kind,
  831. timestamp: eventTimestamp,
  832. sortKey: parseSortKey(eventTimestamp, seq++),
  833. title,
  834. subtitle,
  835. qty: m.qty,
  836. uom: stockUom,
  837. meta: meta || undefined,
  838. refType: m.refType,
  839. refCode: isDoOut ? (docLink?.displayCode ?? m.refCode) : m.refCode,
  840. refId: m.refId,
  841. docLinkKind: isDoOut ? docLink?.kind : isJoOut ? joDocLink?.kind : linkKind,
  842. docLinkTicketNo: docLink?.ticketNo,
  843. docLinkTargetDate: isDoOut ? docLink?.targetDate : joDocLink?.targetDate,
  844. consoCode: isDoOut
  845. ? (docLink?.consoCode ?? pickMeta?.consoCode ?? (m.pickConsoCode?.trim() || m.refCode))
  846. : isJoOut || isPoOut
  847. ? (m.pickConsoCode?.trim() || m.refCode?.trim() || undefined)
  848. : undefined,
  849. doOutboundIsExtra: chipFlags?.isExtra,
  850. doOutboundIsReplenish: chipFlags?.isReplenish,
  851. warehouseCode: m.warehouseCode?.trim() || undefined,
  852. categoryLabel: categoryForKind(kind, labels),
  853. ...(isJoOut
  854. ? pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus, { always: true })
  855. : {}),
  856. details: receiptDetails,
  857. });
  858. });
  859. const qcBuilt = buildQcNodes(data.qcResults, labels, stockUom, seq, scope);
  860. nodes.push(...qcBuilt.nodes);
  861. seq = qcBuilt.nextSeq;
  862. const coveredPutawaySilIds = new Set<number>();
  863. const putawayLabels = {
  864. nodePutaway: labels.nodePutaway,
  865. nodePutawayTransfer: labels.nodePutawayTransfer,
  866. putawayTransferDetail: labels.putawayTransferDetail,
  867. };
  868. (data.putawayEvents ?? []).forEach((putaway, i) => {
  869. const matchedTransfer = isTransferInboundPutaway(putaway.refType)
  870. ? matchTransferForInboundPutaway(putaway, data.transfers ?? [])
  871. : undefined;
  872. if (isTransferInboundPutaway(putaway.refType)) {
  873. // Represented by TRANSFER cards (one per TR-*); skip destination putaway cards.
  874. if (putaway.stockInLineId != null) {
  875. coveredPutawaySilIds.add(putaway.stockInLineId);
  876. }
  877. return;
  878. }
  879. const linkKind = docLinkFromRefType(putaway.refType);
  880. const pres = resolvePutawayPresentation(labels.tr, putawayLabels, putaway.status, putaway.refType);
  881. const meta = [pres.chipLabel, putaway.handledBy, putaway.warehouseCode].filter(Boolean).join(" · ");
  882. nodes.push({
  883. id: lb
  884. ? `${idPfx}putaway-${i}-${putaway.inventoryLotLineId}`
  885. : `${idPfx}putaway-${i}-${putaway.inventoryLotLineId}-${putaway.timestamp}`,
  886. kind: "PUTAWAY",
  887. timestamp: putaway.timestamp,
  888. sortKey: parseSortKey(putaway.timestamp, seq++),
  889. title: pres.title,
  890. subtitle: [pres.chipLabel, putaway.refCode, putaway.warehouseCode].filter(Boolean).join(" · ") || "—",
  891. qty: putaway.qty,
  892. uom: stockUom,
  893. meta: meta || undefined,
  894. refType: putaway.refType,
  895. refCode: putaway.refCode,
  896. refId: putaway.refId,
  897. warehouseCode: putaway.warehouseCode,
  898. transferFromWarehouse: matchedTransfer?.fromWarehouse ?? undefined,
  899. transferToWarehouse: matchedTransfer?.toWarehouse ?? undefined,
  900. docLinkKind: linkKind,
  901. putawayStatusLabel: pres.chipLabel,
  902. putawayStatusDetail: pres.statusDetail,
  903. categoryLabel: labels.categoryPutaway,
  904. details: [
  905. field(labels.detailStatus, pres.statusDetail),
  906. field(
  907. isTransferInboundPutaway(putaway.refType)
  908. ? labels.detailInboundSiNo
  909. : normRefType(putaway.refType) === "PO"
  910. ? labels.detailPurchaseOrderNo
  911. : normRefType(putaway.refType) === "JO"
  912. ? labels.jobOrder
  913. : labels.detailSourceDoc,
  914. putaway.refCode,
  915. {
  916. linkKind,
  917. linkCode: putaway.refCode,
  918. linkId: putaway.refId,
  919. }),
  920. ...(matchedTransfer
  921. ? [
  922. field(labels.detailFrom, matchedTransfer.fromWarehouse),
  923. field(labels.detailTo, matchedTransfer.toWarehouse),
  924. ]
  925. : []),
  926. field(labels.detailPutawayBin, putaway.warehouseCode),
  927. field(labels.detailQty, formatQty(putaway.qty, stockUom)),
  928. field(labels.detailHandler, putaway.handledBy),
  929. field(labels.detailTime, putaway.timestamp),
  930. ],
  931. });
  932. if (putaway.stockInLineId != null) {
  933. coveredPutawaySilIds.add(putaway.stockInLineId);
  934. }
  935. });
  936. (data.origins ?? []).forEach((origin, i) => {
  937. if (coveredPutawaySilIds.has(origin.stockInLineId)) return;
  938. if (!isPendingPutawayOriginStatus(origin.status)) return;
  939. const originType = norm(origin.type);
  940. if (!["PO", "JO", "TKE", "STOCK_IN"].includes(originType)) return;
  941. if (isTransferInboundPutaway(origin.type)) return;
  942. const pres = resolvePutawayPresentation(labels.tr, putawayLabels, origin.status, origin.type);
  943. const linkKind = docLinkFromRefType(origin.type);
  944. nodes.push({
  945. id: `putaway-pending-${origin.stockInLineId}-${i}`,
  946. kind: "PUTAWAY",
  947. timestamp: origin.receiptDate,
  948. sortKey: parseSortKey(origin.receiptDate, seq++),
  949. title: pres.title,
  950. subtitle: [pres.chipLabel, origin.refCode].filter(Boolean).join(" · ") || "—",
  951. qty: origin.acceptedQty,
  952. uom: stockUom,
  953. refType: origin.type,
  954. refCode: origin.refCode,
  955. refId: origin.refId,
  956. docLinkKind: linkKind,
  957. putawayStatusLabel: pres.chipLabel,
  958. putawayStatusDetail: pres.statusDetail,
  959. categoryLabel: labels.categoryPutaway,
  960. details: [
  961. field(labels.detailStatus, pres.statusDetail),
  962. field(
  963. originType === "PO"
  964. ? labels.detailPurchaseOrderNo
  965. : originType === "JO"
  966. ? labels.jobOrder
  967. : labels.detailSourceDoc,
  968. origin.refCode,
  969. {
  970. linkKind,
  971. linkCode: origin.refCode,
  972. linkId: origin.refId,
  973. }),
  974. field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)),
  975. field(labels.detailTime, origin.receiptDate),
  976. ],
  977. });
  978. });
  979. data.stockTakeEvents.forEach((e, i) => {
  980. const roundLabel = formatStockTakeRoundLabel(e);
  981. const detail = e.recordDetail ?? undefined;
  982. const bookQty = resolveStockTakeBookQty(detail, e.beforeQty);
  983. const acceptedQty = resolveStockTakeAcceptedQty(detail, e.afterQty);
  984. const varianceQty =
  985. detail?.varianceQty != null ? Number(detail.varianceQty) : e.varianceQty;
  986. const meta = [e.approver, roundLabel, e.stockTakeSection, `Δ ${formatQty(varianceQty, stockUom)}`]
  987. .filter(Boolean)
  988. .join(" · ");
  989. nodes.push({
  990. id: lb
  991. ? `${idPfx}st-${i}-${e.stockTakeCode}`
  992. : `${idPfx}st-${i}-${e.stockTakeCode}-${e.timestamp}`,
  993. kind: "STOCK_TAKE",
  994. timestamp: e.timestamp,
  995. sortKey: parseSortKey(e.timestamp, seq++),
  996. title: labels.nodeStockTake,
  997. subtitle: stockTakeEventSubtitle(e),
  998. qty: acceptedQty ?? undefined,
  999. uom: stockUom,
  1000. meta: meta || undefined,
  1001. refCode: e.stockTakeCode,
  1002. warehouseCode: e.warehouseCode?.trim() || undefined,
  1003. traceLotNo: e.lotNo?.trim() || data.lot.lotNo?.trim() || undefined,
  1004. categoryLabel: labels.categoryStockTake,
  1005. stockTakeRecordDetail: detail,
  1006. details: [
  1007. field(labels.detailStockTakeCode, e.stockTakeCode),
  1008. fieldIf(labels.detailStockTakeRound, roundLabel || undefined),
  1009. fieldIf(labels.detailStockTakeSection, e.stockTakeSection),
  1010. fieldIf(labels.detailLocation, e.warehouseCode),
  1011. fieldIf(labels.detailLot, e.lotNo || data.lot.lotNo),
  1012. fieldIf(labels.detailItemCode, e.itemCode),
  1013. fieldIf(labels.detailItemName, e.itemName),
  1014. field(labels.detailBefore, formatQty(bookQty, stockUom)),
  1015. fieldIf(
  1016. labels.detailStockTakeFirstCount,
  1017. detail?.pickerFirstQty != null
  1018. ? formatQty(detail.pickerFirstQty, stockUom)
  1019. : undefined,
  1020. ),
  1021. fieldIf(
  1022. labels.detailStockTakeSecondCount,
  1023. detail?.pickerSecondQty != null
  1024. ? formatQty(detail.pickerSecondQty, stockUom)
  1025. : undefined,
  1026. ),
  1027. fieldIf(
  1028. labels.detailStockTakeApproverCount,
  1029. detail?.lastSelect === 3 && detail.approverQty != null
  1030. ? formatQty(detail.approverQty, stockUom)
  1031. : undefined,
  1032. ),
  1033. field(labels.detailAfter, formatQty(acceptedQty, stockUom)),
  1034. field(labels.detailVariance, formatQty(varianceQty, stockUom)),
  1035. fieldIf(labels.detailStockTaker, detail?.stockTakerName),
  1036. fieldIf(labels.detailApprover, detail?.approverName),
  1037. // Fallback when record detail is absent: keep legacy single handler field.
  1038. !detail?.stockTakerName && !detail?.approverName
  1039. ? field(labels.detailHandler, e.approver)
  1040. : null,
  1041. field(labels.detailTime, e.timestamp),
  1042. ].filter((row): row is TraceGraphDetailField => Boolean(row)),
  1043. });
  1044. });
  1045. data.adjustments.forEach((a, i) => {
  1046. if (norm(a.adjustmentType) === "TKE" && (data.stockTakeEvents?.length ?? 0) > 0) return;
  1047. const meta = [a.handledBy, a.reason].filter(Boolean).join(" · ");
  1048. const adjustmentWh =
  1049. a.warehouseCode?.trim() || defaultScope?.defaultWarehouseCode;
  1050. nodes.push({
  1051. id: lb
  1052. ? `${idPfx}adj-${i}-${a.refCode}-${a.timestamp}`
  1053. : `${idPfx}adj-${i}-${a.refCode}-${a.timestamp}-${a.direction}`,
  1054. kind: "ADJUSTMENT",
  1055. timestamp: a.timestamp,
  1056. sortKey: parseSortKey(a.timestamp, seq++),
  1057. title: `${labels.nodeAdjustment} (${labels.tr.direction(a.direction)})`,
  1058. subtitle: [labels.tr.adjustmentType(a.adjustmentType), a.refCode].filter(Boolean).join(" · ") || "—",
  1059. qty: a.qty,
  1060. uom: stockUom,
  1061. adjustmentDirection: a.direction,
  1062. meta: meta || undefined,
  1063. refCode: a.refCode,
  1064. warehouseCode: adjustmentWh,
  1065. categoryLabel: labels.categoryAdjustment,
  1066. details: detailsOf(
  1067. field(labels.detailVariance, formatSignedQty(a.qty, a.direction, stockUom)),
  1068. field(labels.detailAdjustmentRef, a.refCode),
  1069. fieldIf(labels.detailRemarks, a.reason),
  1070. ...(adjustmentWh ? [field(labels.detailWarehouse, adjustmentWh)] : []),
  1071. field(labels.detailHandler, a.handledBy),
  1072. field(labels.detailTime, a.timestamp),
  1073. ),
  1074. });
  1075. });
  1076. const normWh = (w?: string | null) => (w ?? "").trim().toUpperCase();
  1077. data.transfers.forEach((tr, i) => {
  1078. if (!shouldEmitTransferForScope(defaultScope?.locationBlockKeys)) {
  1079. return;
  1080. }
  1081. const inboundSi = (data.putawayEvents ?? []).find(
  1082. (p) =>
  1083. isTransferInboundPutaway(p.refType) &&
  1084. normWh(p.warehouseCode) === normWh(tr.toWarehouse),
  1085. );
  1086. nodes.push({
  1087. id: lb
  1088. ? `${idPfx}tr-${i}-${tr.transferCode}`
  1089. : `${idPfx}tr-${i}-${tr.transferCode}-${tr.timestamp}`,
  1090. kind: "TRANSFER",
  1091. timestamp: tr.timestamp,
  1092. sortKey: parseSortKey(tr.timestamp, seq++),
  1093. title: labels.nodeTransfer,
  1094. subtitle: "",
  1095. qty: tr.qty,
  1096. uom: stockUom,
  1097. refCode: tr.transferCode,
  1098. transferFromWarehouse: tr.fromWarehouse,
  1099. transferToWarehouse: tr.toWarehouse,
  1100. categoryLabel: labels.categoryTransfer,
  1101. details: [
  1102. field(labels.detailFrom, tr.fromWarehouse),
  1103. field(labels.detailTo, tr.toWarehouse),
  1104. field(labels.detailQty, formatQty(tr.qty, stockUom)),
  1105. ...(inboundSi?.refCode
  1106. ? [field(labels.detailInboundSiNo, inboundSi.refCode)]
  1107. : []),
  1108. field(labels.detailTime, tr.timestamp),
  1109. ],
  1110. });
  1111. });
  1112. const totalAvailable = data.warehouseLines.reduce((s, w) => s + (w.availableQty ?? 0), 0);
  1113. const expiry = data.lot.expiryDate;
  1114. if (expiry && dayjs(expiry).isValid() && dayjs(expiry).isBefore(dayjs(), "day")) {
  1115. nodes.push({
  1116. id: `${idPfx}terminal-expired-${data.lot.lotNo}`,
  1117. kind: "EXPIRED",
  1118. timestamp: expiry,
  1119. sortKey: parseExpirySortKey(expiry, seq + 1_000_000),
  1120. title: labels.nodeExpired,
  1121. subtitle: expiry,
  1122. categoryLabel: labels.categoryTerminal,
  1123. details: [
  1124. field(labels.expiryDate, expiry),
  1125. field(labels.totalAvailable, formatQty(totalAvailable, stockUom)),
  1126. field(labels.detailStatus, labels.nodeExpired),
  1127. ],
  1128. });
  1129. }
  1130. if (
  1131. data.warehouseLines.length > 0 &&
  1132. shouldEmitDepletedForScope(
  1133. totalAvailable,
  1134. defaultScope?.defaultWarehouseCode,
  1135. data.transfers ?? [],
  1136. mergedMultiLocation,
  1137. )
  1138. ) {
  1139. const lastTs =
  1140. data.movements[0]?.timestamp ??
  1141. data.outboundUsage[0]?.timestamp ??
  1142. data.lot.stockInDate;
  1143. nodes.push({
  1144. id: `${idPfx}terminal-depleted-${data.lot.lotNo}`,
  1145. kind: "DEPLETED",
  1146. timestamp: lastTs,
  1147. sortKey: parseSortKey(lastTs, seq + 2_000_000),
  1148. title: labels.nodeDepleted,
  1149. subtitle: `${labels.totalAvailable}: ${formatQty(0, stockUom)}`,
  1150. categoryLabel: labels.categoryTerminal,
  1151. details: [
  1152. field(labels.totalAvailable, formatQty(0, stockUom)),
  1153. field(labels.detailTime, lastTs),
  1154. field(labels.detailStatus, labels.nodeDepleted),
  1155. ],
  1156. });
  1157. }
  1158. return nodes
  1159. .map((n) => withNodeScope(n, defaultScope))
  1160. .sort((a, b) => {
  1161. if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey;
  1162. return a.id.localeCompare(b.id);
  1163. });
  1164. };
  1165. export { categoryForKind };