import dayjs from "dayjs"; import { ItemLotTraceJoContext, ItemLotTraceOrigin, ItemLotTraceQcResult, ItemLotTraceResponse, ItemLotTraceStockTakeRecordDetail } from "@/app/api/itemTracing"; import { TraceLabelTranslator, groupQcResultsBySession, resolveDoOutboundChipFlags } from "./traceLabelUtils"; import { formatQty, formatSignedQty } from "./traceQtyUtils"; import { docLinkFromRefType, detailsOf, field, fieldIf, parseExpirySortKey, parseSortKey, pickStatusDetailFields, pickStatusNodeLabels } from "./traceNodeFactory"; import { isPendingPutawayOriginStatus, isTransferInboundPutaway, matchTransferForInboundPutaway, normRefType, resolvePutawayPresentation, shouldEmitDepletedForScope, shouldEmitTransferForScope, } from "./tracePutawayUtils"; import { resolveDoOutboundDocLink, resolveJoPickDocLink, buildPickOrderTargetDateMap, resolvePickOrderTargetDate } from "./traceDocLinkUtils"; import { formatStockTakeRoundLabel, resolveStockTakeAcceptedQty, resolveStockTakeBookQty, stockTakeEventSubtitle, } from "./traceStockTakeUtils"; export type TraceGraphNodeKind = | "MATERIAL_IN" | "JO_CREATED" | "MATERIAL_QC" | "MATERIAL_PICK" | "PRODUCTION_STEP" | "BYPRODUCT" | "SCRAP" | "DEFECT" | "OPEN" | "FAIL" | "DO_OUT" | "REPLENISHMENT_CREATED" | "JO_OUT" | "PO_OUT" | "DO_GROUP" | "PICK_GROUP" | "RETURN" | "REPACK" | "PURCHASE" | "RECEIPT" | "PUTAWAY" | "IN" | "OUT" | "QC" | "STOCK_TAKE" | "ADJUSTMENT" | "TRANSFER" | "EXPIRED" | "DEPLETED"; export type TraceGraphDocLinkKind = "jo" | "po" | "pick" | "workbench" | "jodetail"; export interface TraceGraphDetailField { label: string; value: string; linkKind?: TraceGraphDocLinkKind; linkCode?: string; linkId?: number | null; consoCode?: string; linkTicketNo?: string; linkTargetDate?: string; variant?: "default" | "qcCriteriaList"; qcCriteriaItems?: QcCriteriaItem[]; /** MUI palette key for status value text (success / error / warning / info). */ valueColor?: "success" | "error" | "warning" | "info" | "default"; } export interface QcCriteriaItem { name: string; description?: string; passed: boolean; failQty?: number; } export interface TraceGraphNodeLabels { nodeReceipt: string; nodePurchase: string; nodePutaway: string; nodePutawayTransfer: string; putawayTransferDetail: string; nodeJoCreated: string; nodeQcPass: string; nodeQcFail: string; nodeStockTake: string; nodeAdjustment: string; nodeTransfer: string; nodeDoOut: string; nodeJoOut: string; nodePoOut: string; directionIn: string; directionOut: string; formatQcSubtitle: (failQty: number, acceptedQty: number) => string; } export interface TraceGraphNode { id: string; kind: TraceGraphNodeKind; timestamp: string | null; sortKey: number; title: string; subtitle: string; qty?: number; uom?: string; meta?: string; refType?: string; refCode?: string; refId?: number | null; docLinkKind?: TraceGraphDocLinkKind; /** Workbench deep-link ticket (TI-*). */ docLinkTicketNo?: string; docLinkTargetDate?: string; traceLotNo?: string; traceItemCode?: string; consoCode?: string; categoryLabel?: string; /** Translated QC type for chip label (IQC / EPQC / …). */ qcTypeLabel?: string; /** 待上架 / 已上架 for PUTAWAY nodes (chip). */ putawayStatusLabel?: string; /** 加單 / 補貨 flags for DO_OUT title chips (detail uses outboundKindLabel). */ doOutboundIsExtra?: boolean; doOutboundIsReplenish?: boolean; outboundKindLabel?: string; /** Longer status explanation in detail panel (e.g. transfer inbound). */ putawayStatusDetail?: string; /** Warehouse where stock was shelved (PUTAWAY) or transfer endpoints (TRANSFER). */ warehouseCode?: string; transferFromWarehouse?: string; transferToWarehouse?: string; /** BOM process step this node belongs to (material pick / production step mapping). */ bomProcessId?: number | null; bomProcessSeqNo?: number | null; assignedStepName?: string; /** Item codes consumed by this production step (from BOM process materials). */ stepMaterialItemCodes?: string[]; /** Semi-finished lot whose nested production this pick feeds (nested JO only). */ feedsProductionScopeLotNo?: string; /** Job order that owns this material pick / pick group. */ jobOrderCode?: string; details: TraceGraphDetailField[]; /** When set, this node is rendered inside a DO group box. */ doGroupId?: string; /** DO_GROUP container size (layout only). */ groupBoxWidth?: number; groupBoxHeight?: number; groupMemberCount?: number; /** Summed outbound qty for DO_GROUP header. */ groupTotalQty?: number; groupUom?: string; /** Links REPLENISHMENT_CREATED → matching DO_OUT (stockOutLineId). */ replenishmentStockOutLineId?: number; /** IN / OUT for ADJUSTMENT nodes (signed variance display). */ adjustmentDirection?: string; /** Stocktake lifecycle record detail (for expandable STOCK_TAKE nodes). */ stockTakeRecordDetail?: ItemLotTraceStockTakeRecordDetail; /** Owning inventory_lot for multi-location graph scopes. */ inventoryLotId?: number; /** Translated pick-line statuses for JO pick cards. */ processingStatusLabel?: string; matchStatusLabel?: string; /** Raw status codes (for coloring). */ processingStatus?: string; matchStatus?: string; } export interface TraceGraphDetailLabels extends TraceGraphNodeLabels { tr: TraceLabelTranslator; detailQcCriteria: string; detailQcType: string; detailQcUnknownItem: string; nodeExpired: string; nodeDepleted: string; detailQty: string; detailTime: string; detailHandler: string; detailStockTaker: string; detailApprover: string; detailWarehouse: string; detailRemarks: string; detailFailCategory: string; detailProcessDescription: string; detailType: string; detailRef: string; detailStockTakeCode: string; detailAdjustmentRef: string; detailReplenishmentCode: string; detailReason: string; detailReturnRef: string; detailSourceDoc: string; detailPurchaseOrderNo: string; detailInboundRef: string; detailInboundSiNo: string; detailPutawayBin: string; detailDirection: string; detailStatus: string; detailSupplier: string; detailMaterial: string; detailItemCode: string; detailItemName: string; detailLot: string; detailAcceptedQty: string; detailFailQty: string; detailFrom: string; detailTo: string; detailVariance: string; detailBefore: string; detailAfter: string; detailStockTakeFirstCount: string; detailStockTakeSecondCount: string; detailStockTakeApproverCount: string; detailScrapQty: string; detailDefectQty: string; detailEquipment: string; detailProcessStep: string; detailStepMaterials: string; detailAssignedStep: string; detailPickTargetDate: string; detailProductLotNo: string; detailOrderQty: string; detailPutAwayQty: string; detailPurchaseUnit: string; detailSupplyTo: string; detailStockTakeRound: string; detailStockTakeSection: string; detailLocation: string; deliveryOrder: string; deliveryNoteCode: string; ticketNo: string; pickOrder: string; jobOrder: string; expiryDate: string; totalAvailable: string; itemCode: string; categoryPurchase: string; categoryProduction: string; categoryInbound: string; categoryReceipt: string; categoryPutaway: string; categoryTransfer: string; categoryStockTake: string; categoryOpen: string; categoryAdjustment: string; categoryPick: string; categoryQc: string; categoryOutbound: string; categoryTerminal: string; processingStatus: string; matchStatus: string; } const categoryForKind = (kind: TraceGraphNodeKind, labels: TraceGraphDetailLabels): string => { switch (kind) { case "MATERIAL_IN": return labels.categoryPurchase; case "JO_CREATED": return labels.categoryProduction; case "MATERIAL_PICK": return labels.categoryPick; case "PRODUCTION_STEP": case "BYPRODUCT": case "SCRAP": case "DEFECT": return labels.categoryProduction; case "OPEN": return labels.categoryOpen; case "FAIL": return labels.categoryQc; case "DO_OUT": case "JO_OUT": case "PO_OUT": case "RETURN": return labels.categoryOutbound; case "REPACK": return labels.categoryTransfer; case "PURCHASE": return labels.categoryPurchase; case "RECEIPT": return labels.categoryReceipt; case "PUTAWAY": return labels.categoryPutaway; case "MATERIAL_QC": case "QC": return labels.categoryQc; case "IN": return labels.categoryInbound; case "TRANSFER": return labels.categoryTransfer; case "STOCK_TAKE": return labels.categoryStockTake; case "ADJUSTMENT": return labels.categoryAdjustment; case "OUT": return labels.categoryOutbound; case "EXPIRED": case "DEPLETED": return labels.categoryTerminal; default: return labels.detailType; } }; /** Movement refTypes covered by dedicated trace arrays (avoid duplicate graph nodes). */ const MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS = new Set([ "TRANSFER", "ADJ", "OPEN", "TKE", ]); /** Stock-take ledger movements are represented by stockTakeEvents nodes. */ const isStockTakeLedgerMovement = (movementType: string) => { const mt = movementType.trim().toUpperCase(); return mt === "TKE" || mt === "STOCKTAKE"; }; const norm = (v: string | null | undefined) => (v ?? "").trim().toUpperCase(); const doMovementDedupKey = ( pickOrderCode: string, timestamp: string | null | undefined, qty: number, ) => `${pickOrderCode.trim()}|${timestamp ?? ""}|${qty}`; const buildCoveredDoMovementKeys = (data: ItemLotTraceResponse): Set => new Set( (data.doDeliveries ?? []).map((d) => doMovementDedupKey(d.pickOrderCode, d.timestamp, d.qty), ), ); export interface TraceGraphBuildScope { /** e.g. `loc-42-` for alternate inventory_lot nodes (aligned with backend traceGraph keys). */ idPrefix?: string; locationBlockKeys?: boolean; inventoryLotId?: number; defaultWarehouseCode?: string; /** When true, TRANSFER cards are omitted; destination shows 轉倉入庫 putaway only. */ mergedMultiLocation?: boolean; } export const scopePrefix = (scope?: TraceGraphBuildScope) => scope?.idPrefix ?? ""; export const withNodeScope = (node: TraceGraphNode, scope?: TraceGraphBuildScope): TraceGraphNode => { if (!scope?.inventoryLotId && !scope?.defaultWarehouseCode) return node; return { ...node, inventoryLotId: scope.inventoryLotId ?? node.inventoryLotId, warehouseCode: node.warehouseCode?.trim() || scope.defaultWarehouseCode || node.warehouseCode, }; }; const buildQcNodes = ( qcResults: ItemLotTraceQcResult[], labels: TraceGraphDetailLabels, stockUom: string, seqStart: number, scope?: TraceGraphBuildScope, ): { nodes: TraceGraphNode[]; nextSeq: number } => { const idPfx = scopePrefix(scope); const lb = scope?.locationBlockKeys ?? false; const nodes: TraceGraphNode[] = []; let seq = seqStart; groupQcResultsBySession(qcResults).forEach((group, i) => { const first = group[0]; if (!first) return; const allPassed = group.every((q) => q.qcPassed); const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0); const metaParts = [ first.handledBy, ...group.map((q) => q.remarks).filter(Boolean), ].filter(Boolean); const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? ""; nodes.push({ id: lb ? `${idPfx}qc-${i}-${first.stockInLineId}` : `${idPfx}qc-${i}-${first.stockInLineId}-${first.created}`, kind: "QC", timestamp: first.created, sortKey: parseSortKey(first.created, seq++), title: allPassed ? labels.nodeQcPass : labels.nodeQcFail, subtitle: "", qty: totalFail, uom: stockUom, meta: metaParts.length ? Array.from(new Set(metaParts)).join(" · ") : undefined, categoryLabel: labels.categoryQc, qcTypeLabel: labels.tr.qcType(qcTypeRaw), details: detailsOf( field(labels.detailStatus, labels.tr.qcPassed(allPassed)), field(labels.detailQcType, labels.tr.qcType(qcTypeRaw)), field(labels.detailQcCriteria, "", { variant: "qcCriteriaList", qcCriteriaItems: group.map((q) => ({ name: q.qcItemName?.trim() || q.qcItemCode?.trim() || labels.detailQcUnknownItem, description: q.qcItemDescription?.trim() || undefined, passed: q.qcPassed, failQty: q.failQty > 0 ? q.failQty : undefined, })), }), field(labels.detailAcceptedQty, formatQty(first.acceptedQty, stockUom)), field(labels.detailFailQty, formatQty(totalFail, stockUom)), field(labels.detailHandler, first.handledBy), field(labels.detailTime, first.created), fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join(";")), ), }); }); return { nodes, nextSeq: seq }; }; const resolveJoCreatedTimestamp = ( data: ItemLotTraceResponse, refCode: string | null | undefined, fallback: string | null | undefined, ): string | null => { const code = refCode?.trim(); if (!code) return fallback ?? null; const candidates: Array = [ data.joPrelude?.jobOrder, ...(data.joPrelude?.materialInputs ?? []).map((m) => m.nestedJoPrelude?.jobOrder), ]; for (const jo of candidates) { if (jo?.jobOrderCode?.trim() === code && jo.createdAt?.trim()) { return jo.createdAt; } } return fallback ?? null; }; const poInboundKey = ( refCode: string | null | undefined, refId: number | null | undefined, qty: number, ): string => `${(refCode ?? "").trim()}|${refId ?? 0}|${qty}`; const buildInboundOriginNodes = ( data: ItemLotTraceResponse, labels: TraceGraphDetailLabels, stockUom: string, seqStart: number, scope: TraceGraphBuildScope | undefined, existingPurchaseCodes: Set, coveredPoInbound: Set, coveredJoInbound: Set, ): { nodes: TraceGraphNode[]; nextSeq: number } => { const idPfx = scopePrefix(scope); const lb = scope?.locationBlockKeys ?? false; const defaultWh = scope?.defaultWarehouseCode; const nodes: TraceGraphNode[] = []; let seq = seqStart; (data.origins ?? []).forEach((origin, i) => { const originType = norm(origin.type); const linkKind = docLinkFromRefType(origin.type); const supplierLabel = [origin.supplierCode, origin.supplierName].filter(Boolean).join(" "); if (originType === "PO") { const refCode = origin.refCode?.trim(); if (!refCode) return; coveredPoInbound.add(poInboundKey(origin.refCode, origin.refId, origin.acceptedQty)); if (!existingPurchaseCodes.has(refCode)) { existingPurchaseCodes.add(refCode); nodes.push({ id: lb ? `${idPfx}purchase-origin-${origin.refId ?? i}-${origin.stockInLineId}` : `${idPfx}purchase-origin-${origin.refId ?? i}-${origin.stockInLineId}`, kind: "PURCHASE", timestamp: origin.receiptDate, sortKey: parseSortKey(origin.receiptDate, seq++), title: labels.nodePurchase, subtitle: [supplierLabel, data.lot.itemCode].filter(Boolean).join(" · ") || "—", qty: origin.acceptedQty, uom: stockUom, refType: "PO", refCode, refId: origin.refId, docLinkKind: "po", categoryLabel: labels.categoryPurchase, details: [ field(labels.detailSupplier, supplierLabel || "—"), field(labels.detailOrderQty, formatQty(origin.acceptedQty, stockUom)), field(labels.detailPurchaseOrderNo, refCode, { linkKind: "po", linkCode: refCode, linkId: origin.refId, }), field(labels.detailTime, origin.receiptDate), ], }); } nodes.push({ id: `${idPfx}origin-${i}-${origin.stockInLineId}`, kind: "RECEIPT", timestamp: origin.receiptDate, sortKey: parseSortKey(origin.receiptDate, seq++), title: labels.nodeReceipt, subtitle: [refCode, supplierLabel].filter(Boolean).join(" · ") || "—", qty: origin.acceptedQty, uom: stockUom, refType: origin.type, refCode, refId: origin.refId, docLinkKind: linkKind, warehouseCode: defaultWh, categoryLabel: categoryForKind("RECEIPT", labels), details: detailsOf( field(labels.detailPurchaseOrderNo, refCode, { linkKind, linkCode: refCode, linkId: origin.refId, }), field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)), field(labels.detailTime, origin.receiptDate), ), }); return; } if (originType === "JO") { const refCode = origin.refCode?.trim(); if (!refCode) return; const joKey = `${refCode}|${origin.refId ?? 0}`; coveredJoInbound.add(joKey); const eventTimestamp = resolveJoCreatedTimestamp(data, refCode, origin.receiptDate); nodes.push({ id: `${idPfx}origin-${i}-${origin.stockInLineId}`, kind: "JO_CREATED", timestamp: eventTimestamp, sortKey: parseSortKey(eventTimestamp, seq++), title: labels.nodeJoCreated, subtitle: refCode, qty: origin.acceptedQty, uom: stockUom, refType: origin.type, refCode, refId: origin.refId, docLinkKind: linkKind, warehouseCode: defaultWh, categoryLabel: categoryForKind("JO_CREATED", labels), details: detailsOf( field(labels.detailType, labels.nodeJoCreated), field(labels.jobOrder, refCode, { linkKind, linkCode: refCode, linkId: origin.refId, }), field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)), field(labels.detailTime, eventTimestamp), ), }); } }); return { nodes, nextSeq: seq }; }; export const buildTraceGraphNodes = ( data: ItemLotTraceResponse, labels: TraceGraphDetailLabels, scope?: TraceGraphBuildScope, ): TraceGraphNode[] => { const nodes: TraceGraphNode[] = []; let seq = 0; const stockUom = data.lot.uom; const idPfx = scopePrefix(scope); const lb = scope?.locationBlockKeys ?? false; const primaryWh = data.warehouseLines.map((w) => w.warehouseCode).filter(Boolean).join(" / ") || undefined; const defaultScope: TraceGraphBuildScope | undefined = scope ? scope : primaryWh ? { defaultWarehouseCode: primaryWh, inventoryLotId: data.lot.inventoryLotId } : { inventoryLotId: data.lot.inventoryLotId }; const mergedMultiLocation = defaultScope?.mergedMultiLocation ?? false; const existingPurchaseCodes = new Set(); const coveredPoInbound = new Set(); const coveredJoInbound = new Set(); (data.purchaseEvents ?? []).forEach((purchase, i) => { const purchaseUnit = purchase.purchaseUnit; const supplierLabel = [purchase.supplierCode, purchase.supplierName].filter(Boolean).join(" "); const poCode = purchase.purchaseOrderCode?.trim(); if (poCode) existingPurchaseCodes.add(poCode); const subtitle = [supplierLabel, purchase.itemCode, purchase.itemName].filter(Boolean).join(" · ") || "—"; const meta = [supplierLabel].filter(Boolean).join(" · "); nodes.push({ id: `${idPfx}purchase-${i}-${purchase.purchaseOrderLineId}`, kind: "PURCHASE", timestamp: purchase.orderDate, sortKey: parseSortKey(purchase.orderDate, seq++), title: labels.nodePurchase, subtitle, qty: purchase.orderQty, uom: purchaseUnit, meta: meta || undefined, refType: "PO", refCode: purchase.purchaseOrderCode, refId: purchase.purchaseOrderId, docLinkKind: "po", categoryLabel: labels.categoryPurchase, details: [ field(labels.detailItemCode, purchase.itemCode), field(labels.detailItemName, purchase.itemName), field(labels.detailSupplier, supplierLabel), field(labels.detailOrderQty, formatQty(purchase.orderQty, purchaseUnit)), field(labels.detailPurchaseUnit, purchase.purchaseUnit), field(labels.detailPurchaseOrderNo, purchase.purchaseOrderCode, { linkKind: "po", linkCode: purchase.purchaseOrderCode, linkId: purchase.purchaseOrderId, }), field(labels.detailTime, purchase.orderDate), ], }); }); const originBuilt = buildInboundOriginNodes( data, labels, stockUom, seq, defaultScope, existingPurchaseCodes, coveredPoInbound, coveredJoInbound, ); nodes.push(...originBuilt.nodes); seq = originBuilt.nextSeq; const coveredDoMovementKeys = buildCoveredDoMovementKeys(data); const pickOrderTargetDateMap = buildPickOrderTargetDateMap(data); const doMetaByPickCode = new Map< string, { isExtra: boolean; isReplenish: boolean; consoCode?: string; } >(); (data.doDeliveries ?? []).forEach((d) => { const code = d.pickOrderCode?.trim(); if (!code) return; const chipFlags = resolveDoOutboundChipFlags({ isExtra: d.isExtra, isReplenish: d.isReplenish, ticketNo: d.ticketNo, consoCode: d.consoCode, releaseType: d.releaseType, deliveryOrderPickOrderId: d.deliveryOrderPickOrderId, relationshipId: d.relationshipId, }); doMetaByPickCode.set(code, { isExtra: chipFlags.isExtra, isReplenish: chipFlags.isReplenish, consoCode: d.consoCode?.trim() || code, }); }); data.movements.forEach((m, i) => { const refType = norm(m.refType); if (MOVEMENT_REF_TYPES_IN_DEDICATED_ARRAYS.has(refType)) return; if (isStockTakeLedgerMovement(m.movementType)) return; if ( refType === "DO" && coveredDoMovementKeys.has(doMovementDedupKey(m.refCode, m.timestamp, m.qty)) ) { return; } if (refType === "RETURN" && (data.returnEvents?.length ?? 0) > 0) return; if (m.direction === "IN" && refType === "PO") { if (coveredPoInbound.has(poInboundKey(m.refCode, m.refId, m.qty))) return; coveredPoInbound.add(poInboundKey(m.refCode, m.refId, m.qty)); } if (m.direction === "IN" && refType === "JO") { const joKey = `${(m.refCode ?? "").trim()}|${m.refId ?? 0}`; if (coveredJoInbound.has(joKey)) return; coveredJoInbound.add(joKey); } const isDoOut = refType === "DO" && m.direction !== "IN"; const isJoOut = refType === "JO_PICK" && m.direction !== "IN"; const isPoOut = refType === "PO_PICK" && m.direction !== "IN"; const kind: TraceGraphNodeKind = m.direction === "IN" ? refType === "PO" ? "RECEIPT" : refType === "JO" ? "JO_CREATED" : "IN" : isDoOut ? "DO_OUT" : isJoOut ? "JO_OUT" : isPoOut ? "PO_OUT" : "OUT"; const eventTimestamp = kind === "JO_CREATED" ? resolveJoCreatedTimestamp(data, m.refCode, m.timestamp) : m.timestamp; const title = kind === "RECEIPT" ? labels.nodeReceipt : kind === "JO_CREATED" ? labels.nodeJoCreated : kind === "DO_OUT" ? `${labels.nodeDoOut} · ${m.refCode || "—"}` : kind === "JO_OUT" ? `${labels.nodeJoOut} · ${m.refCode || "—"}` : kind === "PO_OUT" ? `${labels.nodePoOut} · ${m.refCode || "—"}` : `${labels.tr.refType(m.refType)} · ${labels.tr.movementType(m.movementType)}`; const subtitle = kind === "DO_OUT" || kind === "JO_OUT" || kind === "PO_OUT" ? "" : [m.refCode, m.warehouseCode].filter(Boolean).join(" · ") || "—"; const meta = [m.handledBy, m.remarks].filter(Boolean).join(" · "); const linkKind = isDoOut || isJoOut || isPoOut ? "pick" : docLinkFromRefType(m.refType); const receiptDetails = kind === "RECEIPT" ? detailsOf( field(labels.detailPurchaseOrderNo, m.refCode, { linkKind, linkCode: m.refCode, linkId: m.refId, }), field(labels.detailQty, formatQty(m.qty, stockUom)), field(labels.detailHandler, m.handledBy), field(labels.detailTime, m.timestamp), fieldIf(labels.detailRemarks, m.remarks), ) : kind === "DO_OUT" ? detailsOf( field(labels.detailType, labels.nodeDoOut), field(labels.pickOrder, m.refCode, { linkKind: "pick", linkCode: m.refCode, linkId: m.refId, consoCode: m.refCode, }), field(labels.detailWarehouse, m.warehouseCode), field(labels.detailQty, formatQty(m.qty, stockUom)), field(labels.detailHandler, m.handledBy), field(labels.detailTime, m.timestamp), fieldIf(labels.detailRemarks, m.remarks), ) : kind === "JO_OUT" ? detailsOf( field(labels.detailType, labels.nodeJoOut), field(labels.pickOrder, m.refCode, { linkKind: "jodetail", linkCode: m.refCode, linkId: m.refId, consoCode: m.refCode, linkTargetDate: resolvePickOrderTargetDate( pickOrderTargetDateMap, m.refCode, m.timestamp, ), }), field( labels.detailPickTargetDate, resolvePickOrderTargetDate(pickOrderTargetDateMap, m.refCode, m.timestamp), ), field(labels.detailWarehouse, m.warehouseCode), field(labels.detailQty, formatQty(m.qty, stockUom)), ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus, { always: true, }), field(labels.detailHandler, m.handledBy), field(labels.detailTime, m.timestamp), fieldIf(labels.detailRemarks, m.remarks), ) : kind === "PO_OUT" ? detailsOf( field(labels.detailType, labels.nodePoOut), field(labels.pickOrder, m.refCode, { linkKind: "pick", linkCode: m.refCode, linkId: m.refId, consoCode: m.refCode, }), field(labels.detailWarehouse, m.warehouseCode), field(labels.detailQty, formatQty(m.qty, stockUom)), field(labels.detailHandler, m.handledBy), field(labels.detailTime, m.timestamp), fieldIf(labels.detailRemarks, m.remarks), ) : detailsOf( field( labels.detailType, kind === "JO_CREATED" ? labels.nodeJoCreated : labels.tr.movementType(m.movementType), ), field(labels.detailDirection, labels.tr.direction(m.direction)), field(labels.detailSourceDoc, m.refCode, { linkKind, linkCode: m.refCode, linkId: m.refId, }), field(labels.detailWarehouse, m.warehouseCode), field(labels.detailQty, formatQty(m.qty, stockUom)), field(labels.detailHandler, m.handledBy), field(labels.detailTime, eventTimestamp), fieldIf(labels.detailRemarks, m.remarks), ); const pickMeta = isDoOut ? doMetaByPickCode.get(m.refCode?.trim() ?? "") : undefined; const delivery = isDoOut ? (data.doDeliveries ?? []).find((d) => d.pickOrderCode?.trim() === m.refCode?.trim()) : undefined; const docLink = isDoOut ? resolveDoOutboundDocLink({ pickOrderCode: delivery?.pickOrderCode || m.refCode, pickOrderId: delivery?.pickOrderId ?? m.refId, deliveryOrderCode: delivery?.deliveryOrderCode, ticketNo: delivery?.ticketNo, outboundTicketNo: m.outboundTicketNo, consoCode: delivery?.consoCode || m.pickConsoCode, timestamp: m.timestamp, deliveryOrderPickOrderId: delivery?.deliveryOrderPickOrderId ?? m.deliveryOrderPickOrderId, }) : null; const joPickTargetDate = isJoOut ? resolvePickOrderTargetDate(pickOrderTargetDateMap, m.refCode, m.timestamp) : undefined; const joDocLink = isJoOut ? resolveJoPickDocLink({ pickOrderCode: m.refCode, pickOrderId: m.refId, timestamp: m.timestamp, targetDate: joPickTargetDate, }) : null; const movementChip = isDoOut ? resolveDoOutboundChipFlags({ isExtra: pickMeta ? undefined : m.doOutboundIsExtra, isReplenish: pickMeta ? undefined : m.doOutboundIsReplenish, ticketNo: pickMeta ? undefined : m.outboundTicketNo, consoCode: pickMeta ? undefined : (m.pickConsoCode || m.refCode), deliveryOrderPickOrderId: pickMeta ? undefined : m.deliveryOrderPickOrderId, relationshipId: pickMeta ? undefined : m.relationshipId, }) : null; const chipFlags = pickMeta ? resolveDoOutboundChipFlags({ isExtra: pickMeta.isExtra, isReplenish: pickMeta.isReplenish, consoCode: pickMeta.consoCode, }) : movementChip; nodes.push({ id: lb ? `${idPfx}mov-${i}-${m.refCode}` : `${idPfx}mov-${i}-${m.refCode}-${eventTimestamp}`, kind, timestamp: eventTimestamp, sortKey: parseSortKey(eventTimestamp, seq++), title, subtitle, qty: m.qty, uom: stockUom, meta: meta || undefined, refType: m.refType, refCode: isDoOut ? (docLink?.displayCode ?? m.refCode) : m.refCode, refId: m.refId, docLinkKind: isDoOut ? docLink?.kind : isJoOut ? joDocLink?.kind : linkKind, docLinkTicketNo: docLink?.ticketNo, docLinkTargetDate: isDoOut ? docLink?.targetDate : joDocLink?.targetDate, consoCode: isDoOut ? (docLink?.consoCode ?? pickMeta?.consoCode ?? (m.pickConsoCode?.trim() || m.refCode)) : isJoOut || isPoOut ? (m.pickConsoCode?.trim() || m.refCode?.trim() || undefined) : undefined, doOutboundIsExtra: chipFlags?.isExtra, doOutboundIsReplenish: chipFlags?.isReplenish, warehouseCode: m.warehouseCode?.trim() || undefined, categoryLabel: categoryForKind(kind, labels), ...(isJoOut ? pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus, { always: true }) : {}), details: receiptDetails, }); }); const qcBuilt = buildQcNodes(data.qcResults, labels, stockUom, seq, scope); nodes.push(...qcBuilt.nodes); seq = qcBuilt.nextSeq; const coveredPutawaySilIds = new Set(); const putawayLabels = { nodePutaway: labels.nodePutaway, nodePutawayTransfer: labels.nodePutawayTransfer, putawayTransferDetail: labels.putawayTransferDetail, }; (data.putawayEvents ?? []).forEach((putaway, i) => { const matchedTransfer = isTransferInboundPutaway(putaway.refType) ? matchTransferForInboundPutaway(putaway, data.transfers ?? []) : undefined; if (isTransferInboundPutaway(putaway.refType)) { // Represented by TRANSFER cards (one per TR-*); skip destination putaway cards. if (putaway.stockInLineId != null) { coveredPutawaySilIds.add(putaway.stockInLineId); } return; } const linkKind = docLinkFromRefType(putaway.refType); const pres = resolvePutawayPresentation(labels.tr, putawayLabels, putaway.status, putaway.refType); const meta = [pres.chipLabel, putaway.handledBy, putaway.warehouseCode].filter(Boolean).join(" · "); nodes.push({ id: lb ? `${idPfx}putaway-${i}-${putaway.inventoryLotLineId}` : `${idPfx}putaway-${i}-${putaway.inventoryLotLineId}-${putaway.timestamp}`, kind: "PUTAWAY", timestamp: putaway.timestamp, sortKey: parseSortKey(putaway.timestamp, seq++), title: pres.title, subtitle: [pres.chipLabel, putaway.refCode, putaway.warehouseCode].filter(Boolean).join(" · ") || "—", qty: putaway.qty, uom: stockUom, meta: meta || undefined, refType: putaway.refType, refCode: putaway.refCode, refId: putaway.refId, warehouseCode: putaway.warehouseCode, transferFromWarehouse: matchedTransfer?.fromWarehouse ?? undefined, transferToWarehouse: matchedTransfer?.toWarehouse ?? undefined, docLinkKind: linkKind, putawayStatusLabel: pres.chipLabel, putawayStatusDetail: pres.statusDetail, categoryLabel: labels.categoryPutaway, details: [ field(labels.detailStatus, pres.statusDetail), field( isTransferInboundPutaway(putaway.refType) ? labels.detailInboundSiNo : normRefType(putaway.refType) === "PO" ? labels.detailPurchaseOrderNo : normRefType(putaway.refType) === "JO" ? labels.jobOrder : labels.detailSourceDoc, putaway.refCode, { linkKind, linkCode: putaway.refCode, linkId: putaway.refId, }), ...(matchedTransfer ? [ field(labels.detailFrom, matchedTransfer.fromWarehouse), field(labels.detailTo, matchedTransfer.toWarehouse), ] : []), field(labels.detailPutawayBin, putaway.warehouseCode), field(labels.detailQty, formatQty(putaway.qty, stockUom)), field(labels.detailHandler, putaway.handledBy), field(labels.detailTime, putaway.timestamp), ], }); if (putaway.stockInLineId != null) { coveredPutawaySilIds.add(putaway.stockInLineId); } }); (data.origins ?? []).forEach((origin, i) => { if (coveredPutawaySilIds.has(origin.stockInLineId)) return; if (!isPendingPutawayOriginStatus(origin.status)) return; const originType = norm(origin.type); if (!["PO", "JO", "TKE", "STOCK_IN"].includes(originType)) return; if (isTransferInboundPutaway(origin.type)) return; const pres = resolvePutawayPresentation(labels.tr, putawayLabels, origin.status, origin.type); const linkKind = docLinkFromRefType(origin.type); nodes.push({ id: `putaway-pending-${origin.stockInLineId}-${i}`, kind: "PUTAWAY", timestamp: origin.receiptDate, sortKey: parseSortKey(origin.receiptDate, seq++), title: pres.title, subtitle: [pres.chipLabel, origin.refCode].filter(Boolean).join(" · ") || "—", qty: origin.acceptedQty, uom: stockUom, refType: origin.type, refCode: origin.refCode, refId: origin.refId, docLinkKind: linkKind, putawayStatusLabel: pres.chipLabel, putawayStatusDetail: pres.statusDetail, categoryLabel: labels.categoryPutaway, details: [ field(labels.detailStatus, pres.statusDetail), field( originType === "PO" ? labels.detailPurchaseOrderNo : originType === "JO" ? labels.jobOrder : labels.detailSourceDoc, origin.refCode, { linkKind, linkCode: origin.refCode, linkId: origin.refId, }), field(labels.detailQty, formatQty(origin.acceptedQty, stockUom)), field(labels.detailTime, origin.receiptDate), ], }); }); data.stockTakeEvents.forEach((e, i) => { const roundLabel = formatStockTakeRoundLabel(e); const detail = e.recordDetail ?? undefined; const bookQty = resolveStockTakeBookQty(detail, e.beforeQty); const acceptedQty = resolveStockTakeAcceptedQty(detail, e.afterQty); const varianceQty = detail?.varianceQty != null ? Number(detail.varianceQty) : e.varianceQty; const meta = [e.approver, roundLabel, e.stockTakeSection, `Δ ${formatQty(varianceQty, stockUom)}`] .filter(Boolean) .join(" · "); nodes.push({ id: lb ? `${idPfx}st-${i}-${e.stockTakeCode}` : `${idPfx}st-${i}-${e.stockTakeCode}-${e.timestamp}`, kind: "STOCK_TAKE", timestamp: e.timestamp, sortKey: parseSortKey(e.timestamp, seq++), title: labels.nodeStockTake, subtitle: stockTakeEventSubtitle(e), qty: acceptedQty ?? undefined, uom: stockUom, meta: meta || undefined, refCode: e.stockTakeCode, warehouseCode: e.warehouseCode?.trim() || undefined, traceLotNo: e.lotNo?.trim() || data.lot.lotNo?.trim() || undefined, categoryLabel: labels.categoryStockTake, stockTakeRecordDetail: detail, details: [ field(labels.detailStockTakeCode, e.stockTakeCode), fieldIf(labels.detailStockTakeRound, roundLabel || undefined), fieldIf(labels.detailStockTakeSection, e.stockTakeSection), fieldIf(labels.detailLocation, e.warehouseCode), fieldIf(labels.detailLot, e.lotNo || data.lot.lotNo), fieldIf(labels.detailItemCode, e.itemCode), fieldIf(labels.detailItemName, e.itemName), field(labels.detailBefore, formatQty(bookQty, stockUom)), fieldIf( labels.detailStockTakeFirstCount, detail?.pickerFirstQty != null ? formatQty(detail.pickerFirstQty, stockUom) : undefined, ), fieldIf( labels.detailStockTakeSecondCount, detail?.pickerSecondQty != null ? formatQty(detail.pickerSecondQty, stockUom) : undefined, ), fieldIf( labels.detailStockTakeApproverCount, detail?.lastSelect === 3 && detail.approverQty != null ? formatQty(detail.approverQty, stockUom) : undefined, ), field(labels.detailAfter, formatQty(acceptedQty, stockUom)), field(labels.detailVariance, formatQty(varianceQty, stockUom)), fieldIf(labels.detailStockTaker, detail?.stockTakerName), fieldIf(labels.detailApprover, detail?.approverName), // Fallback when record detail is absent: keep legacy single handler field. !detail?.stockTakerName && !detail?.approverName ? field(labels.detailHandler, e.approver) : null, field(labels.detailTime, e.timestamp), ].filter((row): row is TraceGraphDetailField => Boolean(row)), }); }); data.adjustments.forEach((a, i) => { if (norm(a.adjustmentType) === "TKE" && (data.stockTakeEvents?.length ?? 0) > 0) return; const meta = [a.handledBy, a.reason].filter(Boolean).join(" · "); const adjustmentWh = a.warehouseCode?.trim() || defaultScope?.defaultWarehouseCode; nodes.push({ id: lb ? `${idPfx}adj-${i}-${a.refCode}-${a.timestamp}` : `${idPfx}adj-${i}-${a.refCode}-${a.timestamp}-${a.direction}`, kind: "ADJUSTMENT", timestamp: a.timestamp, sortKey: parseSortKey(a.timestamp, seq++), title: `${labels.nodeAdjustment} (${labels.tr.direction(a.direction)})`, subtitle: [labels.tr.adjustmentType(a.adjustmentType), a.refCode].filter(Boolean).join(" · ") || "—", qty: a.qty, uom: stockUom, adjustmentDirection: a.direction, meta: meta || undefined, refCode: a.refCode, warehouseCode: adjustmentWh, categoryLabel: labels.categoryAdjustment, details: detailsOf( field(labels.detailVariance, formatSignedQty(a.qty, a.direction, stockUom)), field(labels.detailAdjustmentRef, a.refCode), fieldIf(labels.detailRemarks, a.reason), ...(adjustmentWh ? [field(labels.detailWarehouse, adjustmentWh)] : []), field(labels.detailHandler, a.handledBy), field(labels.detailTime, a.timestamp), ), }); }); const normWh = (w?: string | null) => (w ?? "").trim().toUpperCase(); data.transfers.forEach((tr, i) => { if (!shouldEmitTransferForScope(defaultScope?.locationBlockKeys)) { return; } const inboundSi = (data.putawayEvents ?? []).find( (p) => isTransferInboundPutaway(p.refType) && normWh(p.warehouseCode) === normWh(tr.toWarehouse), ); nodes.push({ id: lb ? `${idPfx}tr-${i}-${tr.transferCode}` : `${idPfx}tr-${i}-${tr.transferCode}-${tr.timestamp}`, kind: "TRANSFER", timestamp: tr.timestamp, sortKey: parseSortKey(tr.timestamp, seq++), title: labels.nodeTransfer, subtitle: "", qty: tr.qty, uom: stockUom, refCode: tr.transferCode, transferFromWarehouse: tr.fromWarehouse, transferToWarehouse: tr.toWarehouse, categoryLabel: labels.categoryTransfer, details: [ field(labels.detailFrom, tr.fromWarehouse), field(labels.detailTo, tr.toWarehouse), field(labels.detailQty, formatQty(tr.qty, stockUom)), ...(inboundSi?.refCode ? [field(labels.detailInboundSiNo, inboundSi.refCode)] : []), field(labels.detailTime, tr.timestamp), ], }); }); const totalAvailable = data.warehouseLines.reduce((s, w) => s + (w.availableQty ?? 0), 0); const expiry = data.lot.expiryDate; if (expiry && dayjs(expiry).isValid() && dayjs(expiry).isBefore(dayjs(), "day")) { nodes.push({ id: `${idPfx}terminal-expired-${data.lot.lotNo}`, kind: "EXPIRED", timestamp: expiry, sortKey: parseExpirySortKey(expiry, seq + 1_000_000), title: labels.nodeExpired, subtitle: expiry, categoryLabel: labels.categoryTerminal, details: [ field(labels.expiryDate, expiry), field(labels.totalAvailable, formatQty(totalAvailable, stockUom)), field(labels.detailStatus, labels.nodeExpired), ], }); } if ( data.warehouseLines.length > 0 && shouldEmitDepletedForScope( totalAvailable, defaultScope?.defaultWarehouseCode, data.transfers ?? [], mergedMultiLocation, ) ) { const lastTs = data.movements[0]?.timestamp ?? data.outboundUsage[0]?.timestamp ?? data.lot.stockInDate; nodes.push({ id: `${idPfx}terminal-depleted-${data.lot.lotNo}`, kind: "DEPLETED", timestamp: lastTs, sortKey: parseSortKey(lastTs, seq + 2_000_000), title: labels.nodeDepleted, subtitle: `${labels.totalAvailable}: ${formatQty(0, stockUom)}`, categoryLabel: labels.categoryTerminal, details: [ field(labels.totalAvailable, formatQty(0, stockUom)), field(labels.detailTime, lastTs), field(labels.detailStatus, labels.nodeDepleted), ], }); } return nodes .map((n) => withNodeScope(n, defaultScope)) .sort((a, b) => { if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; return a.id.localeCompare(b.id); }); }; export { categoryForKind };