|
- 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<string> =>
- 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<ItemLotTraceJoContext | undefined> = [
- 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<string>,
- coveredPoInbound: Set<string>,
- coveredJoInbound: Set<string>,
- ): { 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<string>();
- const coveredPoInbound = new Set<string>();
- const coveredJoInbound = new Set<string>();
-
- (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<number>();
- 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 };
|