diff --git a/src/app/(main)/itemTracing/page.tsx b/src/app/(main)/itemTracing/page.tsx new file mode 100644 index 0000000..4c3c8e4 --- /dev/null +++ b/src/app/(main)/itemTracing/page.tsx @@ -0,0 +1,44 @@ +import { I18nProvider, getServerI18n } from "@/i18n"; +import ItemTracing from "@/components/ItemTracing"; +import { AUTH } from "@/authorities"; +import { authOptions } from "@/config/authConfig"; +import { Stack, Typography } from "@mui/material"; +import { Metadata } from "next"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import ItemTracingLoading from "@/components/ItemTracing/ItemTracingLoading"; + +export const metadata: Metadata = { + title: "Item Tracing", +}; + +const canAccess = (abilities: string[]) => + abilities.includes(AUTH.ITEM_TRACING); + +const ItemTracingPage: React.FC = async () => { + const session = await getServerSession(authOptions); + const abilities = session?.user?.abilities ?? []; + if (!canAccess(abilities)) { + redirect("/dashboard"); + } + + const { t } = await getServerI18n("itemTracing"); + + return ( + <> + + + {t("title")} + + + + }> + + + + + ); +}; + +export default ItemTracingPage; diff --git a/src/app/api/itemTracing/actions.ts b/src/app/api/itemTracing/actions.ts new file mode 100644 index 0000000..91be59c --- /dev/null +++ b/src/app/api/itemTracing/actions.ts @@ -0,0 +1,24 @@ +"use server"; + +import { BASE_API_URL } from "@/config/api"; +import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { ItemLotTraceParams, ItemLotTraceResponse } from "."; +import { parseItemLotTraceResponse } from "./schema"; + +export const fetchItemLotTrace = async ( + params: ItemLotTraceParams, +): Promise => { + const query = convertObjToURLSearchParams(params as Record); + const json = await serverFetchJson( + `${BASE_API_URL}/inventoryLotLine/trace?${query}`, + { method: "GET" }, + ); + return parseItemLotTraceResponse(json) as unknown as ItemLotTraceResponse; +}; + +/** + * Backend also exposes GET /inventoryLotLine/trace/location/{inventoryLotId} for + * lazy location blocks. The main /trace payload already includes locationBlocks, + * so the UI uses that eager path and does not call a separate location fetch. + */ diff --git a/src/app/api/itemTracing/index.ts b/src/app/api/itemTracing/index.ts new file mode 100644 index 0000000..2ffe85d --- /dev/null +++ b/src/app/api/itemTracing/index.ts @@ -0,0 +1,493 @@ +export interface ItemLotTraceLotInfo { + inventoryLotId: number; + lotNo: string; + itemId: number; + itemCode: string; + itemName: string; + expiryDate: string | null; + productionDate: string | null; + stockInDate: string | null; + uom: string; + primaryStockInLineId: number | null; +} + +export interface ItemLotTraceWarehouseLine { + inventoryLotLineId: number; + warehouseCode: string; + inQty: number; + outQty: number; + issueQty: number; + status: string; + availableQty: number; +} + +export interface ItemLotTraceAlternateLocation { + inventoryLotId: number; + stockInLineId: number | null; + inventoryLotLineId: number; + warehouseCode: string; + availableQty: number; +} + +/** + * Per-location lifecycle trace block for one alternate inventory_lot + * sharing the same lotNo+itemId. Contains only location-scoped dimensions. + * Lot-level dimensions (BOM, JO prelude, production, byproducts, lot relations) + * are NOT included — they're at the top level of ItemLotTraceResponse. + */ +export interface ItemLotTraceLocationBlock { + inventoryLotId: number; + lotNo: string; + itemCode: string; + itemName?: string; + uom?: string; + expiryDate?: string | null; + productionDate?: string | null; + stockInDate?: string | null; + stockInLineId: number | null; + warehouseLines: ItemLotTraceWarehouseLine[]; + origins: ItemLotTraceOrigin[]; + purchaseEvents?: ItemLotTracePurchaseEvent[]; + qcResults: ItemLotTraceQcResult[]; + putawayEvents: ItemLotTracePutawayEvent[]; + movements: ItemLotTraceMovement[]; + outboundUsage: ItemLotTraceOutboundUsage[]; + stockTakeEvents: ItemLotTraceStockTakeEvent[]; + adjustments: ItemLotTraceAdjustment[]; + transfers: ItemLotTraceTransfer[]; + openMovements: ItemLotTraceOpenMovement[]; + failEvents: ItemLotTraceFailEvent[]; + doDeliveries: ItemLotTraceDoDelivery[]; + replenishmentEvents?: ItemLotTraceReplenishmentEvent[]; + returnEvents: ItemLotTraceReturnEvent[]; +} + +export interface ItemLotTraceReplenishmentEvent { + replenishmentId: number; + replenishmentCode: string; + sourceDoCode: string; + sourceDoId: number | null; + shopCode: string; + shopName: string; + itemNo: string; + itemName: string; + reason: string; + replenishQty: number; + handler: string; + timestamp: string | null; + pickOrderLineId: number | null; + stockOutLineId: number | null; +} + +export interface ItemLotTraceOrigin { + stockInLineId: number; + type: string; + refCode: string; + refId: number | null; + supplierCode: string; + supplierName: string; + dnNo: string; + receiptDate: string | null; + acceptedQty: number; + status: string; +} + +export interface ItemLotTracePurchaseEvent { + purchaseOrderLineId: number; + purchaseOrderId: number | null; + purchaseOrderCode: string; + itemCode: string; + itemName: string; + orderQty: number; + putAwayQty: number; + purchaseUnit: string; + supplierCode: string; + supplierName: string; + orderDate: string | null; + status: string; +} + +export interface ItemLotTraceQcResult { + stockInLineId: number; + qcPassed: boolean; + failQty: number; + acceptedQty: number; + remarks: string; + handledBy: string; + created: string | null; + qcItemCode?: string; + qcItemName?: string; + qcItemDescription?: string; + qcType?: string; +} + +export interface ItemLotTraceMovement { + direction: string; + qty: number; + movementType: string; + refType: string; + refCode: string; + refId: number | null; + warehouseCode: string; + handledBy: string; + timestamp: string | null; + remarks: string; + pickConsoCode?: string; + outboundTicketNo?: string; + deliveryOrderPickOrderId?: number | null; + relationshipId?: number | null; + doOutboundIsExtra?: boolean; + doOutboundIsReplenish?: boolean; + processingStatus?: string; + matchStatus?: string; +} + +export interface ItemLotTracePutawayEvent { + inventoryLotLineId: number; + stockInLineId: number | null; + warehouseCode: string; + qty: number; + handledBy: string; + timestamp: string | null; + refType: string; + refCode: string; + refId: number | null; + status?: string; +} + +export interface ItemLotTraceOutboundUsage { + stockOutLineId: number; + qty: number; + pickOrderCode: string; + pickOrderId: number | null; + consoCode: string; + jobOrderCode: string; + jobOrderId: number | null; + deliveryOrderCode: string; + handler: string; + timestamp: string | null; + usageType: string; +} + +export interface ItemLotTraceStockTakeRecordDetail { + stockTakeLineId: number; + bookQty: number; + pickerFirstQty?: number | null; + pickerFirstBadQty?: number | null; + pickerSecondQty?: number | null; + pickerSecondBadQty?: number | null; + approverQty?: number | null; + approverBadQty?: number | null; + stockTakerName?: string | null; + approverName?: string | null; + stockTakeStartTime?: string | null; + stockTakeEndTime?: string | null; + approverTime?: string | null; + recordStatus?: string | null; + lastSelect?: number | null; + varianceQty?: number | null; +} + +export interface ItemLotTraceStockTakeEvent { + stockTakeCode: string; + lotNo?: string; + itemCode?: string; + itemName?: string; + warehouseCode?: string; + stockTakeSection?: string; + stockTakeRoundId?: number | null; + stockTakeRoundName?: string; + varianceQty: number; + beforeQty: number; + afterQty: number; + approver: string; + timestamp: string | null; + recordDetail?: ItemLotTraceStockTakeRecordDetail | null; +} + +export interface ItemLotTraceAdjustment { + adjustmentType: string; + direction: string; + qty: number; + reason: string; + handledBy: string; + timestamp: string | null; + refCode: string; + warehouseCode?: string; +} + +export interface ItemLotTraceTransfer { + fromWarehouse: string; + toWarehouse: string; + qty: number; + transferCode: string; + timestamp: string | null; +} + +export interface ItemLotTraceBomDownstream { + jobOrderCode: string; + jobOrderId: number | null; + finishedItemCode: string; + finishedLotNo: string; + finishedStockInLineId: number | null; + fgQty: number; + fgUom?: string; + materialQtyUsed: number; +} + +export interface ItemLotTraceBomUpstream { + jobOrderCode: string; + jobOrderId: number | null; + materialItemCode: string; + materialLotNo: string; + materialStockInLineId: number | null; + materialQty: number; + bomQtyPerUnit: number | null; +} + +export interface ItemLotTraceBomRecipeLine { + materialItemCode: string; + materialItemName: string; + qtyPerUnit: number; + uom: string; + bomProcessId?: number | null; + bomProcessSeqNo?: number | null; + assignedStepName?: string; +} + +export interface ItemLotTraceStepMaterialRef { + materialItemCode: string; + materialItemName: string; + qtyPerUnit: number; + uom: string; +} + +export interface ItemLotTraceBomTrace { + direction: string; + downstream: ItemLotTraceBomDownstream[]; + upstream: ItemLotTraceBomUpstream[]; + bomRecipe: ItemLotTraceBomRecipeLine[]; +} + +export interface ItemLotTraceJoContext { + jobOrderId: number; + jobOrderCode: string; + planStart: string | null; + /** Earliest jo_pick_order.created for this JO. */ + createdAt?: string | null; + reqQty: number; + status: string; +} + +export interface ItemLotTraceJoPickLine { + pickOrderLineId: number; + itemCode: string; + itemName: string; + requiredQty: number; + pickedQty: number; + status: string; +} + +export interface ItemLotTraceJoPickOrder { + pickOrderId: number; + pickOrderCode: string; + consoCode: string; + status: string; + targetDate: string | null; + completeDate: string | null; + releasedDate: string | null; + lines: ItemLotTraceJoPickLine[]; +} + +export interface ItemLotTraceMaterialInput { + jobOrderCode: string; + jobOrderId: number | null; + materialItemCode: string; + materialItemName: string; + materialLotNo: string; + materialStockInLineId: number | null; + materialInventoryLotId: number | null; + materialQty: number; + materialUom?: string; + bomQtyPerUnit: number | null; + bomProcessId?: number | null; + bomProcessSeqNo?: number | null; + assignedStepName?: string; + pickOrderCode: string; + pickOrderId: number | null; + consoCode: string; + pickedAt: string | null; + processingStatus?: string; + matchStatus?: string; + stockInOrigin: ItemLotTraceOrigin | null; + purchaseEvents?: ItemLotTracePurchaseEvent[]; + qcResults: ItemLotTraceQcResult[]; + putawayEvents: ItemLotTracePutawayEvent[]; + /** When this material was produced by another JO, its upstream pick/prelude chain. */ + nestedJoPrelude?: ItemLotTraceJoPrelude | null; + /** Production steps from the JO that produced this material lot. */ + productionSteps?: ItemLotTraceProductionStep[]; +} + +export interface ItemLotTraceJoPrelude { + jobOrder: ItemLotTraceJoContext; + pickOrders: ItemLotTraceJoPickOrder[]; + materialInputs: ItemLotTraceMaterialInput[]; +} + +export interface ItemLotTraceProductionStep { + processLineId: number; + stepName: string; + description: string; + equipmentCode: string; + equipmentName: string; + operatorName: string; + startTime: string | null; + endTime: string | null; + outputQty: number; + scrapQty: number; + defectQty: number; + status: string; + seqNo: number | null; + bomProcessId?: number | null; + stepMaterials?: ItemLotTraceStepMaterialRef[]; +} + +/** Sibling byproduct lot from the same job order (independent item/lot, traceable). */ +export interface ItemLotTraceByproductLot { + inventoryLotId: number | null; + stockInLineId: number | null; + lotNo: string; + itemCode: string; + itemName: string; + qty: number; + uom: string; + processStepName: string; + producedAt: string | null; + jobOrderCode: string; + jobOrderId: number | null; +} + +export interface ItemLotTraceOpenMovement { + stockInLineId: number; + qty: number; + warehouseCode: string; + refCode: string; + handledBy: string; + timestamp: string | null; + remarks: string; +} + +export interface ItemLotTraceFailEvent { + failId: number; + failType: string; + category: string; + qty: number; + handlerName: string; + recordDate: string | null; + pickOrderCode: string; + pickOrderId: number | null; +} + +export interface ItemLotTraceDoDelivery { + stockOutLineId: number; + qty: number; + pickOrderCode: string; + pickOrderId: number | null; + consoCode: string; + ticketNo?: string; + deliveryOrderCode: string; + deliveryOrderId: number | null; + shopCode?: string; + shopName?: string; + handler: string; + timestamp: string | null; + releaseType?: string | null; + deliveryOrderPickOrderId?: number | null; + relationshipId?: number | null; + deliveryNoteCode?: string; + doPickOrderRecordId?: number | null; + isExtra?: boolean; + isReplenish?: boolean; +} + +export interface ItemLotTraceReturnEvent { + stockOutLineId: number; + qty: number; + refCode: string; + movementType: string; + warehouseCode: string; + handler: string; + timestamp: string | null; + remarks: string; +} + +export interface ItemLotTraceLotRelation { + relationType: string; + inventoryLotId: number | null; + lotNo: string; + itemCode: string; + itemName: string; + qty: number; + productLotNo: string; + timestamp: string | null; +} + +export interface ItemLotTraceGraphEdge { + fromKey: string; + toKey: string; + kind?: string; +} + +export interface ItemLotTraceGraphNodeRef { + key: string; + kind: string; + scopeKey?: string | null; + refCode?: string | null; + traceLotNo?: string | null; + traceItemCode?: string | null; + /** Warehouse code for multi-location visual differentiation. */ + warehouseCode?: string | null; +} + +export interface ItemLotTraceGraph { + nodes: ItemLotTraceGraphNodeRef[]; + edges: ItemLotTraceGraphEdge[]; +} + +export interface ItemLotTraceResponse { + lot: ItemLotTraceLotInfo; + warehouseLines: ItemLotTraceWarehouseLine[]; + origins: ItemLotTraceOrigin[]; + purchaseEvents?: ItemLotTracePurchaseEvent[]; + qcResults: ItemLotTraceQcResult[]; + putawayEvents: ItemLotTracePutawayEvent[]; + movements: ItemLotTraceMovement[]; + outboundUsage: ItemLotTraceOutboundUsage[]; + stockTakeEvents: ItemLotTraceStockTakeEvent[]; + adjustments: ItemLotTraceAdjustment[]; + transfers: ItemLotTraceTransfer[]; + bomTrace: ItemLotTraceBomTrace; + joPrelude: ItemLotTraceJoPrelude | null; + productionSteps: ItemLotTraceProductionStep[]; + byproductLots: ItemLotTraceByproductLot[]; + openMovements: ItemLotTraceOpenMovement[]; + failEvents: ItemLotTraceFailEvent[]; + doDeliveries: ItemLotTraceDoDelivery[]; + replenishmentEvents?: ItemLotTraceReplenishmentEvent[]; + returnEvents: ItemLotTraceReturnEvent[]; + lotRelations: ItemLotTraceLotRelation[]; + alternateLocations: ItemLotTraceAlternateLocation[]; + /** Per-location full trace blocks for alternate inventory_lot rows sharing same lotNo+itemId. + * Empty when there is only one location for this lot. */ + locationBlocks?: ItemLotTraceLocationBlock[]; + traceGraph?: ItemLotTraceGraph | null; +} + +export interface ItemLotTraceParams { + stockInLineId?: number; + inventoryLotLineId?: number; + lotNo?: string; + itemId?: number; + itemCode?: string; +} diff --git a/src/app/api/itemTracing/schema.ts b/src/app/api/itemTracing/schema.ts new file mode 100644 index 0000000..edf0774 --- /dev/null +++ b/src/app/api/itemTracing/schema.ts @@ -0,0 +1,211 @@ +import { z } from "zod"; + +const nullableStr = z.string().nullable().optional(); +const nullableNum = z.number().nullable().optional(); + +const ItemLotTraceOriginSchema = z + .object({ + stockInLineId: z.number(), + type: z.string(), + refCode: z.string(), + refId: nullableNum, + supplierCode: z.string(), + supplierName: z.string(), + dnNo: z.string(), + receiptDate: nullableStr, + acceptedQty: z.number(), + status: z.string(), + }) + .passthrough(); + +const ItemLotTraceQcResultSchema = z + .object({ + stockInLineId: z.number(), + qcPassed: z.boolean(), + failQty: z.number(), + acceptedQty: z.number(), + remarks: z.string(), + handledBy: z.string(), + created: nullableStr, + }) + .passthrough(); + +const ItemLotTracePutawayEventSchema = z + .object({ + inventoryLotLineId: z.number(), + stockInLineId: nullableNum, + warehouseCode: z.string(), + qty: z.number(), + handledBy: z.string(), + timestamp: nullableStr, + refType: z.string(), + refCode: z.string(), + refId: nullableNum, + status: z.string().optional(), + }) + .passthrough(); + +const ItemLotTraceProductionStepSchema = z + .object({ + processLineId: z.number(), + stepName: z.string(), + description: z.string(), + equipmentCode: z.string(), + equipmentName: z.string(), + operatorName: z.string(), + startTime: nullableStr, + endTime: nullableStr, + outputQty: z.number(), + scrapQty: z.number(), + defectQty: z.number(), + status: z.string(), + seqNo: nullableNum, + bomProcessId: nullableNum, + }) + .passthrough(); + +const ItemLotTraceMaterialInputSchema: z.ZodType> = z.lazy(() => + z + .object({ + jobOrderCode: z.string(), + jobOrderId: nullableNum, + materialItemCode: z.string(), + materialItemName: z.string(), + materialLotNo: z.string(), + materialStockInLineId: nullableNum, + materialInventoryLotId: nullableNum, + materialQty: z.number(), + materialUom: z.string().optional(), + bomQtyPerUnit: nullableNum, + bomProcessId: nullableNum, + bomProcessSeqNo: nullableNum, + assignedStepName: z.string().optional(), + pickOrderCode: z.string(), + pickOrderId: nullableNum, + consoCode: z.string(), + pickedAt: nullableStr, + stockInOrigin: ItemLotTraceOriginSchema.nullable().optional(), + purchaseEvents: z.array(z.record(z.unknown())).optional(), + qcResults: z.array(ItemLotTraceQcResultSchema), + putawayEvents: z.array(ItemLotTracePutawayEventSchema).optional(), + nestedJoPrelude: ItemLotTraceJoPreludeSchema.nullable().optional(), + productionSteps: z.array(ItemLotTraceProductionStepSchema).optional(), + }) + .passthrough(), +); + +const ItemLotTraceJoPreludeSchema: z.ZodType> = z.lazy(() => + z + .object({ + jobOrder: z + .object({ + jobOrderId: z.number(), + jobOrderCode: z.string(), + planStart: nullableStr, + createdAt: nullableStr.optional(), + reqQty: z.number(), + status: z.string(), + }) + .passthrough(), + pickOrders: z.array(z.record(z.unknown())), + materialInputs: z.array(ItemLotTraceMaterialInputSchema), + }) + .passthrough(), +); + +const ItemLotTraceGraphEdgeSchema = z + .object({ + fromKey: z.string(), + toKey: z.string(), + kind: z.string(), + }) + .passthrough(); + +const ItemLotTraceGraphNodeRefSchema = z + .object({ + key: z.string(), + kind: z.string(), + scopeKey: nullableStr, + refCode: nullableStr, + traceLotNo: nullableStr, + traceItemCode: nullableStr, + warehouseCode: nullableStr, + }) + .passthrough(); + +const ItemLotTraceGraphSchema = z + .object({ + nodes: z.array(ItemLotTraceGraphNodeRefSchema), + edges: z.array(ItemLotTraceGraphEdgeSchema), + }) + .passthrough() + .nullable() + .optional(); + +export const ItemLotTraceResponseSchema = z + .object({ + lot: z + .object({ + inventoryLotId: z.number(), + lotNo: z.string(), + itemId: z.number(), + itemCode: z.string(), + itemName: z.string(), + expiryDate: nullableStr, + productionDate: nullableStr, + stockInDate: nullableStr, + uom: z.string(), + primaryStockInLineId: nullableNum, + }) + .passthrough(), + warehouseLines: z.array(z.record(z.unknown())), + origins: z.array(ItemLotTraceOriginSchema), + purchaseEvents: z.array(z.record(z.unknown())).optional(), + qcResults: z.array(ItemLotTraceQcResultSchema), + putawayEvents: z.array(ItemLotTracePutawayEventSchema), + movements: z.array(z.record(z.unknown())), + outboundUsage: z.array(z.record(z.unknown())), + stockTakeEvents: z.array(z.record(z.unknown())), + adjustments: z.array(z.record(z.unknown())), + transfers: z.array(z.record(z.unknown())), + bomTrace: z.record(z.unknown()), + joPrelude: ItemLotTraceJoPreludeSchema.nullable(), + productionSteps: z.array(ItemLotTraceProductionStepSchema), + byproductLots: z.array(z.record(z.unknown())), + openMovements: z.array(z.record(z.unknown())), + failEvents: z.array(z.record(z.unknown())), + doDeliveries: z.array(z.record(z.unknown())), + replenishmentEvents: z.array(z.record(z.unknown())).optional(), + returnEvents: z.array(z.record(z.unknown())), + lotRelations: z.array(z.record(z.unknown())), + alternateLocations: z.array(z.record(z.unknown())), + locationBlocks: z.array(z.record(z.unknown())).optional(), + traceGraph: ItemLotTraceGraphSchema, + }) + .passthrough(); + +export type ItemLotTraceResponseParsed = z.infer; + +export const parseItemLotTraceResponse = (data: unknown): ItemLotTraceResponseParsed => + ItemLotTraceResponseSchema.parse(data); + +/** Collect dot-path keys for contract snapshot tests. */ +export const collectObjectPaths = ( + obj: unknown, + prefix = "", + depth = 0, + maxDepth = 6, +): string[] => { + if (obj == null || depth > maxDepth) return prefix ? [prefix] : []; + if (Array.isArray(obj)) { + if (obj.length === 0) return prefix ? [prefix] : []; + return collectObjectPaths(obj[0], prefix ? `${prefix}[]` : "[]", depth + 1, maxDepth); + } + if (typeof obj !== "object") return prefix ? [prefix] : []; + const paths: string[] = prefix ? [prefix] : []; + Object.keys(obj as Record).sort().forEach((key) => { + const next = prefix ? `${prefix}.${key}` : key; + paths.push(...collectObjectPaths((obj as Record)[key], next, depth + 1, maxDepth)); + }); + return paths; +}; diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 21f5506..cd58bae 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -1203,8 +1203,15 @@ export const fetchJobOrderPickOrdersrecords = async ( ) => { const params = new URLSearchParams(); - if (date && String(date).trim() !== "") { - params.set("date", String(date).trim()); + // Backend expects LocalDate (YYYY-MM-DD); strip any time component. + const dateOnly = (() => { + if (!date || String(date).trim() === "") return null; + const match = String(date).trim().match(/(\d{4}-\d{2}-\d{2})/); + return match ? match[1] : String(date).trim().slice(0, 10); + })(); + + if (dateOnly) { + params.set("date", dateOnly); } if (status && String(status).trim() !== "" && String(status) !== "All") { params.set("status", String(status).trim()); diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts index 67e9ae4..aa44585 100644 --- a/src/app/utils/fetchUtil.ts +++ b/src/app/utils/fetchUtil.ts @@ -2,6 +2,9 @@ import { SessionWithTokens, authOptions } from "@/config/authConfig"; import { getServerSession } from "next-auth"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { ServerFetchError } from "./serverFetchError"; + +export { ServerFetchError }; export interface BlobResponse { filename: string; @@ -21,16 +24,6 @@ export interface searchParamsProps { searchParams: { [key: string]: string | string[] | undefined }; } -export class ServerFetchError extends Error { - public readonly response: Response | undefined; - constructor(message?: string, response?: Response) { - super(message); - this.response = response; - - Object.setPrototypeOf(this, ServerFetchError.prototype); - } -} - export async function serverFetchWithNoContent(...args: FetchParams) { const response = await serverFetch(...args); diff --git a/src/app/utils/serverFetchError.ts b/src/app/utils/serverFetchError.ts new file mode 100644 index 0000000..d6dbc67 --- /dev/null +++ b/src/app/utils/serverFetchError.ts @@ -0,0 +1,21 @@ +/** Client-safe error type thrown by server fetch helpers. */ +export class ServerFetchError extends Error { + public readonly response: Response | undefined; + + constructor(message?: string, response?: Response) { + super(message); + this.response = response; + Object.setPrototypeOf(this, ServerFetchError.prototype); + } +} + +/** Works in client components after server actions (errors may lose prototype). */ +export const isNotFoundServerFetchError = (e: unknown): boolean => { + if (e instanceof ServerFetchError) { + return e.response?.status === 404; + } + if (e instanceof Error) { + return /\b404\b/.test(e.message); + } + return false; +}; diff --git a/src/authorities.ts b/src/authorities.ts index 57e4423..1107afd 100644 --- a/src/authorities.ts +++ b/src/authorities.ts @@ -9,6 +9,7 @@ export const AUTH = { ADMIN: "ADMIN", STOCK: "STOCK", INVENTORY_ADJUST: "INVENTORY_ADJUST", + ITEM_TRACING: "ITEM_TRACING", PURCHASE: "PURCHASE", STOCK_TAKE: "STOCK_TAKE", STOCK_IN_BIND: "STOCK_IN_BIND", diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index f012cd6..20e30a7 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -47,6 +47,7 @@ const pathToLabelKey: { [path: string]: string } = { "/scheduling/detailed": "nav.breadcrumb.schedulingDetailed", "/scheduling/detailed/edit": "nav.breadcrumb.schedulingDetailedEdit", "/inventory": "nav.breadcrumb.inventory", + "/itemTracing": "nav.breadcrumb.itemTracing", "/settings/importTesting": "nav.breadcrumb.importTesting", "/settings/m18ImportTesting": "nav.breadcrumb.importTesting", "/do": "nav.deliveryOrder", diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx index 3e863ac..05c24a8 100644 --- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -147,8 +147,8 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom setTab(newTab); const params = new URLSearchParams(searchParams.toString()); params.set("tab", String(newTab)); - /* ticketNo / targetDate deep-link only for "Finished Good Record" (mine) */ - if (newTab !== 2) { + /* ticketNo / targetDate deep-link for Finished Good Record tabs */ + if (newTab !== 2 && newTab !== 3) { params.delete("ticketNo"); params.delete("targetDate"); } @@ -391,10 +391,13 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom diff --git a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx index 3465d0e..bec0322 100644 --- a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx +++ b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Accordion, AccordionDetails, @@ -507,6 +507,16 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ [], ); + const initialDetailOpenedRef = useRef(false); + useEffect(() => { + if (initialDetailOpenedRef.current || loading || !initialTicketNo?.trim()) return; + const tn = initialTicketNo.trim(); + const match = records.find((r) => r.ticketNo?.trim() === tn); + if (!match) return; + initialDetailOpenedRef.current = true; + void handleDetailClick(match); + }, [records, loading, initialTicketNo, handleDetailClick]); + const handleBackToList = useCallback(() => { setShowDetailView(false); setSelectedRecord(null); diff --git a/src/components/ItemTracing/ItemTracing.tsx b/src/components/ItemTracing/ItemTracing.tsx new file mode 100644 index 0000000..ea8e637 --- /dev/null +++ b/src/components/ItemTracing/ItemTracing.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { useTranslation } from "react-i18next"; +import { fetchItemLotTrace } from "@/app/api/itemTracing/actions"; +import { ItemLotTraceResponse } from "@/app/api/itemTracing"; +import { isNotFoundServerFetchError } from "@/app/utils/serverFetchError"; +import ItemTracingScanBar from "./ItemTracingScanBar"; +import ItemTracingSummary from "./ItemTracingSummary"; +import ItemTracingFlowGraph from "./ItemTracingFlowGraph"; +import ItemTracingSections from "./ItemTracingSections"; +import { + compileTraceGraph, + type CompiledTraceGraph, +} from "./compileTraceGraph"; +import { buildTraceGraphLabels } from "./traceGraphLabels"; +import { exportItemLotTraceXlsx } from "./exportItemLotTraceXlsx"; + +export type WarehouseFocusRequest = { + inventoryLotId: number; + warehouseCode: string; +}; + +const ItemTracing: React.FC = () => { + const { t } = useTranslation("itemTracing"); + const searchParams = useSearchParams(); + const initialTraceRan = useRef(false); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [focusWarehouse, setFocusWarehouse] = + useState(null); + + const runTrace = useCallback( + async (params: { + stockInLineId?: number; + inventoryLotLineId?: number; + lotNo?: string; + itemCode?: string; + }) => { + setLoading(true); + setError(null); + try { + const res = await fetchItemLotTrace(params); + setData(res); + setFocusWarehouse(null); + } catch (e) { + console.error(e); + if (isNotFoundServerFetchError(e)) { + setError(t("notFound")); + } else { + setError(t("traceError")); + } + setData(null); + } finally { + setLoading(false); + } + }, + [t], + ); + + useEffect(() => { + if (initialTraceRan.current) return; + const stockInLineIdRaw = searchParams.get("stockInLineId"); + const lotNo = searchParams.get("lotNo")?.trim(); + const itemCode = searchParams.get("itemCode")?.trim(); + const stockInLineId = stockInLineIdRaw + ? Number(stockInLineIdRaw) + : undefined; + const hasTraceParams = + (stockInLineId != null && !Number.isNaN(stockInLineId)) || + Boolean(lotNo) || + Boolean(itemCode); + if (!hasTraceParams) return; + initialTraceRan.current = true; + void runTrace({ + stockInLineId: + stockInLineId != null && !Number.isNaN(stockInLineId) + ? stockInLineId + : undefined, + lotNo: lotNo || undefined, + itemCode: itemCode || undefined, + }); + }, [runTrace, searchParams]); + + const compiledGraph = useMemo((): CompiledTraceGraph | null => { + if (!data) return null; + const labels = buildTraceGraphLabels(t, data.lot.uom); + return compileTraceGraph(data, labels); + }, [data, t]); + + const handleExportExcel = useCallback(() => { + if (!data || !compiledGraph) return; + exportItemLotTraceXlsx(data, compiledGraph, t); + }, [data, compiledGraph, t]); + + return ( + + + {t("subtitle")} + + + + + {loading && ( + + + + )} + + {error && !loading && {error}} + + {!loading && !data && !error && ( + {t("noResult")} + )} + + {data && !loading && ( + <> + setFocusWarehouse(request)} + onExportExcel={compiledGraph ? handleExportExcel : undefined} + /> + {compiledGraph && ( + <> + + + + )} + + )} + + ); +}; + +export default ItemTracing; diff --git a/src/components/ItemTracing/ItemTracingDocLink.tsx b/src/components/ItemTracing/ItemTracingDocLink.tsx new file mode 100644 index 0000000..2aabc2d --- /dev/null +++ b/src/components/ItemTracing/ItemTracingDocLink.tsx @@ -0,0 +1,90 @@ +"use client"; + +import Link from "next/link"; +import MuiLink from "@mui/material/Link"; +import { isWorkbenchTicketNo, normalizeTargetDateForLink } from "./traceDocLinkUtils"; + +type DocLinkProps = { + kind: "jo" | "po" | "pick" | "workbench" | "jodetail"; + code: string; + id?: number | null; + consoCode?: string; + ticketNo?: string; + targetDate?: string; + /** Finished Good Record tab on /doworkbench (default: 3 = all). */ + workbenchTab?: number; + /** Completed JO pick record tab on /jodetail (default: 1 = complete records). */ + jodetailTab?: number; + /** Open in a new browser tab (used for links inside the flow graph). */ + openInNewTab?: boolean; +}; + +const stopGraphEvent = (e: React.MouseEvent) => { + e.stopPropagation(); +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 5 | v1.0.0 | 2026-07-14 */ +const ItemTracingDocLink: React.FC = ({ + kind, + code, + id, + consoCode, + ticketNo, + targetDate, + workbenchTab = 3, + jodetailTab = 1, + openInNewTab = false, +}) => { + if (!code?.trim()) return <>—; + + const linkTargetDate = normalizeTargetDateForLink(targetDate); + + let href = "#"; + if (kind === "jo" && id) href = `/jo/edit?id=${id}`; + else if (kind === "po" && id) href = `/po/edit?id=${id}`; + else if (kind === "workbench" && ticketNo?.trim()) { + const params = new URLSearchParams(); + params.set("tab", String(workbenchTab)); + params.set("ticketNo", ticketNo.trim()); + if (linkTargetDate) params.set("targetDate", linkTargetDate); + href = `/doworkbench?${params.toString()}`; + } else if (kind === "jodetail" && (consoCode || code)) { + const params = new URLSearchParams(); + params.set("tab", String(jodetailTab)); + // Prefer PI-* pick order code for jodetail search; consoCode may be PICK-*. + params.set("pickOrderCode", (code || consoCode || "").trim()); + if (linkTargetDate) params.set("targetDate", linkTargetDate); + href = `/jodetail?${params.toString()}`; + } else if (kind === "pick") { + // /pickOrder/detail?consoCode=… is retired — only link when we have a TI-* workbench ticket. + const workbenchTicket = [ticketNo, consoCode, code] + .map((v) => (v ?? "").trim()) + .find(isWorkbenchTicketNo); + if (workbenchTicket) { + const params = new URLSearchParams(); + params.set("tab", String(workbenchTab)); + params.set("ticketNo", workbenchTicket); + if (linkTargetDate) params.set("targetDate", linkTargetDate); + href = `/doworkbench?${params.toString()}`; + } else { + return <>{code}; + } + } else { + return <>{code}; + } + + return ( + + {code} + + ); +}; + +export default ItemTracingDocLink; diff --git a/src/components/ItemTracing/ItemTracingFlowGraph.tsx b/src/components/ItemTracing/ItemTracingFlowGraph.tsx new file mode 100644 index 0000000..b903af8 --- /dev/null +++ b/src/components/ItemTracing/ItemTracingFlowGraph.tsx @@ -0,0 +1,797 @@ +"use client"; + +import "@xyflow/react/dist/style.css"; + +import { + Background, + BackgroundVariant, + Controls, + MiniMap, + MarkerType, + Panel, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, + useReactFlow, + type Node, +} from "@xyflow/react"; +import { + Box, + Chip, + IconButton, + Paper, + Popover, + Stack, + Tooltip, + Typography, +} from "@mui/material"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import MapOutlinedIcon from "@mui/icons-material/MapOutlined"; +import RestartAltIcon from "@mui/icons-material/RestartAlt"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ItemLotTraceResponse } from "@/app/api/itemTracing"; +import ItemTracingNodeDetailPanel from "./ItemTracingNodeDetailPanel"; +import ItemTracingFlowGraphSearch from "./ItemTracingFlowGraphSearch"; +import { + buildReactFlowGraph, + reactFlowGraphExtent, + type TraceFlowNodeData, +} from "./buildReactFlowGraph"; +import { traceFlowNodeTypes } from "./TraceFlowNodes"; +import { traceFlowEdgeTypes } from "./TraceFlowEdge"; +import { + VIEWPORT_HEIGHT, + FIT_VIEW_MIN_ZOOM, + FIT_VIEW_MAX_ZOOM, + DO_GROUP_HEADER, +} from "./traceFlowConstants"; +import { + minimapNodeColor, + phaseChipColor, + phaseLabelKey, + kindLabelKey, +} from "./traceFlowNodeUtils"; +import { searchTraceGraphNodes } from "./traceGraphSearch"; +import type { CompiledTraceGraph } from "./compileTraceGraph"; +import { TraceGraphNode } from "./buildTraceGraphNodes"; +import type { WarehouseFocusRequest } from "./ItemTracing"; +import type { TraceGraphPhase } from "./traceGraphLayout"; + +type TraceParams = { + stockInLineId?: number; + lotNo?: string; + itemCode?: string; +}; + +type Props = { + data: ItemLotTraceResponse; + compiledGraph: CompiledTraceGraph; + onTrace?: (params: TraceParams) => void; + focusWarehouse?: WarehouseFocusRequest | null; +}; + +const DETAIL_PANEL_WIDTH = 280; + +const LEGEND_ROWS: Array<{ key: string; tipKey: string }> = [ + { key: "flowLegendTime", tipKey: "flowLegendTimeTip" }, + { key: "flowLegendPhase", tipKey: "flowLegendPhaseTip" }, + { key: "flowLegendBranch", tipKey: "flowLegendBranchTip" }, + { key: "flowLegendArrow", tipKey: "flowLegendArrowTip" }, + { key: "flowLegendPrelude", tipKey: "flowLegendPreludeTip" }, +]; + +const ItemTracingFlowGraphInner: React.FC = ({ + data, + compiledGraph, + onTrace, + focusWarehouse, +}) => { + const { t } = useTranslation("itemTracing"); + const { fitView } = useReactFlow(); + const [selectedNode, setSelectedNode] = useState(null); + const [showMinimap, setShowMinimap] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [activeMatchIndex, setActiveMatchIndex] = useState(0); + const [legendAnchor, setLegendAnchor] = useState(null); + const [hiddenPhases, setHiddenPhases] = useState>( + () => new Set(), + ); + const [collapsedGroupIds, setCollapsedGroupIds] = useState>( + () => new Set(), + ); + + const layout = compiledGraph.layout; + const hasJoPrelude = Boolean(data.joPrelude); + + const phaseLabels = useMemo( + () => + Object.fromEntries( + layout.phaseOrder.map((phase) => [phase, t(phaseLabelKey(phase))]), + ), + [layout.phaseOrder, t], + ); + + const graphElements = useMemo( + () => buildReactFlowGraph(layout, phaseLabels, compiledGraph.edgePairs), + [layout, phaseLabels, compiledGraph.edgePairs], + ); + + const nodesWithCallbacks = useMemo( + () => + graphElements.nodes.map((node) => + node.type === "traceEvent" + ? { + ...node, + data: { ...node.data, onTrace }, + } + : node, + ), + [graphElements.nodes, onTrace], + ); + + const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks); + const [edges, setEdges, onEdgesChange] = useEdgesState(graphElements.edges); + + const kindLabel = useCallback( + (kind: TraceGraphNode["kind"]) => t(kindLabelKey(kind)), + [t], + ); + + const phaseVisible = useCallback( + (phase: TraceGraphPhase | undefined) => + phase == null || hiddenPhases.size === 0 || !hiddenPhases.has(phase), + [hiddenPhases], + ); + + const visibleLayoutNodes = useMemo( + () => layout.nodes.filter((n) => phaseVisible(n.phase)), + [layout.nodes, phaseVisible], + ); + + const matchIds = useMemo( + () => searchTraceGraphNodes(visibleLayoutNodes, searchQuery, kindLabel), + [visibleLayoutNodes, searchQuery, kindLabel], + ); + + const activeNodeId = + matchIds.length > 0 ? matchIds[activeMatchIndex % matchIds.length] : null; + + const focusFitNodes = useMemo(() => { + if (!activeNodeId) return []; + const ids = new Set([activeNodeId]); + const activeRf = graphElements.nodes.find((n) => n.id === activeNodeId); + if (activeRf?.parentId) ids.add(activeRf.parentId); + graphElements.edges.forEach((edge) => { + if (edge.source === activeNodeId) ids.add(edge.target); + if (edge.target === activeNodeId) ids.add(edge.source); + }); + return Array.from(ids, (id) => ({ id })); + }, [activeNodeId, graphElements.edges, graphElements.nodes]); + + useEffect(() => { + setSearchQuery(""); + setActiveMatchIndex(0); + setSelectedNode(null); + setHiddenPhases(new Set()); + setCollapsedGroupIds(new Set()); + }, [data]); + + useEffect(() => { + setActiveMatchIndex(0); + }, [searchQuery]); + + const togglePhase = useCallback( + (phase: TraceGraphPhase) => { + setHiddenPhases((prev) => { + const next = new Set(prev); + if (next.has(phase)) next.delete(phase); + else next.add(phase); + // If every phase would be hidden, treat as "show all" + if (next.size >= layout.phaseOrder.length) return new Set(); + return next; + }); + }, + [layout.phaseOrder.length], + ); + + const toggleGroupCollapse = useCallback((groupId: string) => { + setCollapsedGroupIds((prev) => { + const next = new Set(prev); + if (next.has(groupId)) next.delete(groupId); + else next.add(groupId); + return next; + }); + }, []); + + const resetPhaseFilter = useCallback(() => setHiddenPhases(new Set()), []); + + const displayedNodes = useMemo(() => { + const hasSearch = searchQuery.trim().length > 0; + const matchSet = new Set(matchIds); + const groupIdsWithChildMatch = new Set(); + visibleLayoutNodes.forEach((n) => { + if (n.doGroupId && matchSet.has(n.id)) + groupIdsWithChildMatch.add(n.doGroupId); + }); + const visibleIds = new Set(visibleLayoutNodes.map((n) => n.id)); + layout.nodes.forEach((n) => { + if (n.doGroupId && visibleIds.has(n.id)) visibleIds.add(n.doGroupId); + }); + + return nodes + .filter((node) => { + if (node.type === "phaseLabel" || node.type === "dateHeader") + return true; + if (node.type === "doGroup" || node.type === "pickGroup") { + return layout.nodes.some( + (n) => n.doGroupId === node.id && phaseVisible(n.phase), + ); + } + if (node.type === "traceEvent") { + const layoutNode = (node.data as TraceFlowNodeData | undefined) + ?.layoutNode; + if ( + layoutNode?.doGroupId && + collapsedGroupIds.has(layoutNode.doGroupId) + ) { + return false; + } + return phaseVisible(layoutNode?.phase); + } + return true; + }) + .map((node) => { + if ( + node.type !== "traceEvent" && + node.type !== "doGroup" && + node.type !== "pickGroup" + ) { + return node; + } + const isMatch = + matchSet.has(node.id) || + ((node.type === "doGroup" || node.type === "pickGroup") && + groupIdsWithChildMatch.has(node.id)); + const isFocused = node.id === activeNodeId; + const isSelected = selectedNode?.id === node.id; + const isGroup = node.type === "doGroup" || node.type === "pickGroup"; + const groupCollapsed = isGroup && collapsedGroupIds.has(node.id); + const collapsedH = DO_GROUP_HEADER; + return { + ...node, + ...(groupCollapsed + ? { + style: { ...node.style, height: collapsedH }, + height: collapsedH, + measured: { + width: node.measured?.width ?? node.width ?? 0, + height: collapsedH, + }, + } + : {}), + data: { + ...node.data, + searchActive: hasSearch, + searchMatch: isMatch, + searchFocused: isFocused, + nodeSelected: isSelected, + ...(isGroup + ? { + groupCollapsed, + onToggleGroupCollapse: () => toggleGroupCollapse(node.id), + } + : {}), + }, + }; + }); + }, [ + nodes, + searchQuery, + matchIds, + activeNodeId, + selectedNode?.id, + layout.nodes, + visibleLayoutNodes, + phaseVisible, + collapsedGroupIds, + toggleGroupCollapse, + ]); + + const displayedEdges = useMemo(() => { + const visibleEventIds = new Set( + displayedNodes + .filter( + (n) => + n.type === "traceEvent" || + n.type === "doGroup" || + n.type === "pickGroup", + ) + .map((n) => n.id), + ); + const phaseFiltered = edges.filter( + (edge) => + visibleEventIds.has(edge.source) && visibleEventIds.has(edge.target), + ); + + const hasSearch = searchQuery.trim().length > 0 && matchIds.length > 0; + const selectedId = selectedNode?.id ?? null; + const selectedGroupId = selectedNode?.doGroupId?.trim() || null; + const hasSelection = Boolean(selectedId); + if (!hasSearch && !hasSelection) return phaseFiltered; + + const matchSet = hasSearch ? new Set(matchIds) : null; + const selectionIds = new Set(); + if (selectedId) { + selectionIds.add(selectedId); + if (selectedGroupId) selectionIds.add(selectedGroupId); + } + + const focusStroke = "#1565c0"; + const matchStroke = "#ef6c00"; + const selectStroke = "#2e7d32"; + + return phaseFiltered.map((edge) => { + const sourceMatch = matchSet?.has(edge.source) ?? false; + const targetMatch = matchSet?.has(edge.target) ?? false; + const connectsHighlighted = + Boolean(matchSet) && sourceMatch && targetMatch; + const touchesFocus = Boolean( + activeNodeId && + (edge.source === activeNodeId || edge.target === activeNodeId), + ); + const touchesSelection = + hasSelection && + (selectionIds.has(edge.source) || selectionIds.has(edge.target)); + const shouldHighlight = + connectsHighlighted || touchesFocus || touchesSelection; + + if (!shouldHighlight) { + return { + ...edge, + zIndex: 0, + style: { + ...edge.style, + stroke: "#bdbdbd", + strokeWidth: 1, + opacity: 0.18, + }, + labelStyle: { ...edge.labelStyle, opacity: 0.2 }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: "#bdbdbd", + width: 14, + height: 14, + }, + }; + } + + const stroke = touchesSelection + ? selectStroke + : touchesFocus + ? focusStroke + : matchStroke; + const strokeWidth = touchesSelection || touchesFocus ? 3 : 2.5; + const zIndex = touchesSelection ? 4 : touchesFocus ? 3 : 2; + + return { + ...edge, + zIndex, + style: { + ...edge.style, + stroke, + strokeWidth, + opacity: 1, + }, + labelStyle: { + ...edge.labelStyle, + fill: touchesSelection + ? selectStroke + : touchesFocus + ? focusStroke + : "#e65100", + fontWeight: 700, + opacity: 1, + }, + labelBgStyle: { + ...edge.labelBgStyle, + fill: touchesSelection + ? "#e8f5e9" + : touchesFocus + ? "#e3f2fd" + : "#fff8e1", + fillOpacity: 1, + }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: stroke, + width: touchesSelection || touchesFocus ? 18 : 16, + height: touchesSelection || touchesFocus ? 18 : 16, + }, + }; + }); + }, [ + edges, + searchQuery, + matchIds, + activeNodeId, + selectedNode, + displayedNodes, + ]); + + useEffect(() => { + setNodes(nodesWithCallbacks); + setEdges(graphElements.edges); + const timer = window.setTimeout(() => { + fitView({ + padding: 0.04, + minZoom: FIT_VIEW_MIN_ZOOM, + maxZoom: FIT_VIEW_MAX_ZOOM, + duration: 200, + }); + }, 50); + return () => window.clearTimeout(timer); + }, [nodesWithCallbacks, graphElements.edges, setNodes, setEdges, fitView]); + + useEffect(() => { + if (!activeNodeId || !searchQuery.trim()) return; + const timer = window.setTimeout(() => { + fitView({ + nodes: focusFitNodes, + padding: 0.55, + duration: 280, + maxZoom: 1.25, + }); + }, 60); + return () => window.clearTimeout(timer); + }, [activeNodeId, searchQuery, focusFitNodes, fitView]); + + useEffect(() => { + if (!focusWarehouse) return; + const { inventoryLotId, warehouseCode } = focusWarehouse; + const locPrefix = `loc-${inventoryLotId}-`; + const wh = warehouseCode.trim().toUpperCase(); + const focusIds = layout.nodes + .filter((n) => { + if (n.id.startsWith(locPrefix)) return true; + if (inventoryLotId !== data.lot.inventoryLotId) return false; + if (n.id.startsWith("loc-")) return false; + if (!wh) return true; + return (n.warehouseCode ?? "").trim().toUpperCase() === wh; + }) + .map((n) => n.id); + if (focusIds.length === 0) return; + setSearchQuery(warehouseCode.trim() || String(inventoryLotId)); + setActiveMatchIndex(0); + const timer = window.setTimeout(() => { + fitView({ + nodes: focusIds.map((id) => ({ id })), + padding: 0.2, + duration: 320, + maxZoom: 1.1, + }); + }, 80); + return () => window.clearTimeout(timer); + }, [focusWarehouse, layout.nodes, data.lot.inventoryLotId, fitView]); + + const handleSearchPrev = useCallback(() => { + if (matchIds.length === 0) return; + setActiveMatchIndex( + (prev) => (prev - 1 + matchIds.length) % matchIds.length, + ); + }, [matchIds.length]); + + const handleSearchNext = useCallback(() => { + if (matchIds.length === 0) return; + setActiveMatchIndex((prev) => (prev + 1) % matchIds.length); + }, [matchIds.length]); + + const handleSearchClear = useCallback(() => { + setSearchQuery(""); + setActiveMatchIndex(0); + }, []); + + const onNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + if (node.type === "traceEvent" && node.data?.layoutNode?.id) { + setSelectedNode(node.data.layoutNode); + } + }, + [], + ); + + const translateExtent = useMemo( + () => reactFlowGraphExtent(layout, graphElements.graphHeight), + [layout, graphElements.graphHeight], + ); + + if (!layout.nodes.length) { + return ( + + {t("noRecords")} + + ); + } + + return ( + + + + {t("flowGraph")} + + + setLegendAnchor(e.currentTarget)} + > + + + + + + + {hasJoPrelude ? t("flowGraphHintJo") : t("flowGraphHint")} + {" · "} + {t("flowZoomPanHint")} + + + + + {t("flowPhaseFilter")} + + + {layout.phaseOrder.map((phase) => { + const active = phaseVisible(phase); + const chipColor = phaseChipColor(phase); + return ( + togglePhase(phase)} + sx={{ + borderRadius: 1.5, + height: 28, + fontWeight: 600, + cursor: "pointer", + letterSpacing: 0.1, + opacity: active ? 1 : 0.8, + transition: "box-shadow 0.15s ease, opacity 0.15s ease, filter 0.15s ease", + "& .MuiChip-label": { px: 1.25 }, + "&:hover": { + opacity: 1, + boxShadow: 1, + filter: active ? "brightness(1.06)" : undefined, + }, + }} + /> + ); + })} + } + label={t("flowPhaseFilterReset")} + color="error" + variant="filled" + disabled={hiddenPhases.size === 0} + onClick={hiddenPhases.size > 0 ? resetPhaseFilter : undefined} + sx={{ + borderRadius: 1.5, + height: 28, + fontWeight: 700, + cursor: hiddenPhases.size === 0 ? "default" : "pointer", + letterSpacing: 0.2, + "& .MuiChip-icon": { ml: 0.5, fontSize: 16 }, + "& .MuiChip-label": { px: 1.25 }, + "&:hover": + hiddenPhases.size === 0 + ? undefined + : { + boxShadow: 1, + filter: "brightness(1.08)", + }, + }} + /> + + + + setLegendAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + > + + + {t("flowLegendTitle")} + + + {LEGEND_ROWS.map(({ key, tipKey }) => ( + + + {t(key)} + + + {t(tipKey)} + + + ))} + {hasJoPrelude && ( + + {t("flowGraphPathJo")} + + )} + + {t("flowNodeDetailHint")} + + + + + + + {selectedNode && ( + + setSelectedNode(null)} + /> + + )} + + setSelectedNode(null)} + nodesDraggable={false} + nodesConnectable={false} + elementsSelectable + panOnScroll + zoomOnScroll + minZoom={0.15} + maxZoom={2.5} + translateExtent={translateExtent} + proOptions={{ hideAttribution: true }} + style={{ width: "100%", height: "100%" }} + > + + + + + + + + setShowMinimap((v) => !v)} + aria-label={ + showMinimap ? t("flowMinimapHide") : t("flowMinimapShow") + } + sx={{ + bgcolor: "background.paper", + border: 1, + borderColor: "divider", + boxShadow: 1, + borderRadius: 1, + width: 28, + height: 28, + "&:hover": { bgcolor: "background.paper" }, + }} + > + + + + + + + + + {showMinimap && ( + { + if ( + node.type !== "traceEvent" && + node.type !== "doGroup" && + node.type !== "pickGroup" + ) { + return "#e0e0e0"; + } + const layoutNode = ( + node.data as TraceFlowNodeData | undefined + )?.layoutNode; + return layoutNode?.kind + ? minimapNodeColor(layoutNode.kind) + : "#e0e0e0"; + }} + pannable + zoomable + style={{ border: "1px solid #e0e0e0", borderRadius: 4 }} + /> + )} + + + + + ); +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.0 | 2026-07-14 */ +const ItemTracingFlowGraph: React.FC = (props) => ( + + + +); + +export default ItemTracingFlowGraph; diff --git a/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx new file mode 100644 index 0000000..169b0eb --- /dev/null +++ b/src/components/ItemTracing/ItemTracingFlowGraphSearch.tsx @@ -0,0 +1,158 @@ +"use client"; + +import ClearIcon from "@mui/icons-material/Clear"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import SearchIcon from "@mui/icons-material/Search"; +import { + Box, + IconButton, + InputAdornment, + Paper, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; + +type Props = { + query: string; + matchCount: number; + activeIndex: number; + onQueryChange: (value: string) => void; + onPrev: () => void; + onNext: () => void; + onClear: () => void; +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 4 | v1.0.0 | 2026-07-14 */ +const ItemTracingFlowGraphSearch: React.FC = ({ + query, + matchCount, + activeIndex, + onQueryChange, + onPrev, + onNext, + onClear, +}) => { + const { t } = useTranslation("itemTracing"); + const trimmed = query.trim(); + const hasQuery = trimmed.length > 0; + const matchLabel = + matchCount > 0 + ? t("flowGraphSearchMatch", { current: activeIndex + 1, total: matchCount }) + : hasQuery + ? t("flowGraphSearchNoMatch") + : ""; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + if (event.shiftKey) onPrev(); + else onNext(); + } else if (event.key === "Escape") { + event.preventDefault(); + onClear(); + } + }; + + return ( + + onQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: hasQuery ? ( + + + + + + + + ) : undefined, + }} + sx={{ + "& .MuiOutlinedInput-root": { + justifyContent: hasQuery ? "flex-start" : "center", + }, + "& .MuiOutlinedInput-input": { + textAlign: hasQuery ? "left" : "center", + color: "text.secondary", + ...(hasQuery + ? {} + : { + flex: "0 1 auto", + width: "auto", + maxWidth: "90%", + }), + }, + "& .MuiOutlinedInput-input::placeholder": { + color: "text.secondary", + opacity: 1, + textAlign: "center", + }, + ...(!hasQuery + ? { + "& .MuiInputAdornment-positionStart": { + marginRight: 0.75, + }, + } + : {}), + }} + /> + {hasQuery && ( + + 0 ? "text.secondary" : "error"} noWrap> + {matchLabel} + + + + + + + + + + + + + + + + + + + )} + + ); +}; + +export default ItemTracingFlowGraphSearch; diff --git a/src/components/ItemTracing/ItemTracingLoading.tsx b/src/components/ItemTracing/ItemTracingLoading.tsx new file mode 100644 index 0000000..e8834d6 --- /dev/null +++ b/src/components/ItemTracing/ItemTracingLoading.tsx @@ -0,0 +1,11 @@ +import { Skeleton, Stack } from "@mui/material"; + +const ItemTracingLoading: React.FC = () => ( + + + + + +); + +export default ItemTracingLoading; diff --git a/src/components/ItemTracing/ItemTracingLocations.tsx b/src/components/ItemTracing/ItemTracingLocations.tsx new file mode 100644 index 0000000..d768102 --- /dev/null +++ b/src/components/ItemTracing/ItemTracingLocations.tsx @@ -0,0 +1,790 @@ +"use client"; + +import React, { useState } from "react"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Chip, + Stack, + TableContainer, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import Inventory2Icon from "@mui/icons-material/Inventory2"; +import { useTranslation } from "react-i18next"; +import { ItemLotTraceLocationBlock } from "@/app/api/itemTracing"; +import type { WarehouseFocusRequest } from "./ItemTracing"; +import { blockWarehouseCode } from "./buildLocationBlockGraphNodes"; +import { createTraceLabelTranslator } from "./traceLabelUtils"; +import { + resolveStockTakeAcceptedQty, + resolveStockTakeBookQty, +} from "./traceStockTakeUtils"; +import { + FilterableDataTable, + type FilterableColumnDef, +} from "./itemTracingTableFilters"; + +interface Props { + locationBlocks: ItemLotTraceLocationBlock[]; + onFocusWarehouse?: (request: WarehouseFocusRequest) => void; +} + +/** Compact sub-table for sections that may have many rows. */ +function SectionTable({ + title, + rows, + columns, + getRowKey, +}: { + title: string; + rows: T[]; + columns: FilterableColumnDef[]; + getRowKey: (row: T, index: number) => string; +}) { + const { t } = useTranslation("itemTracing"); + const [showAll, setShowAll] = useState(false); + + return ( + + + {title} ({rows.length}) + + + setShowAll((v) => !v)} + showAllLabel={(count) => t("locationsShowAll", { count })} + collapseLabel={(count) => t("locationsCollapse", { count })} + /> + + + ); +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 2 | v1.0.0 | 2026-07-14 */ +const ItemTracingLocations: React.FC = ({ + locationBlocks, + onFocusWarehouse, +}) => { + const { t } = useTranslation("itemTracing"); + const tr = createTraceLabelTranslator(t); + + if (!locationBlocks || locationBlocks.length === 0) return null; + + return ( + + + + + {t("locationHeader", { count: locationBlocks.length })} + + + + + {locationBlocks.map((block) => { + const totalIn = block.warehouseLines.reduce( + (s, l) => s + (l.inQty || 0), + 0, + ); + const totalOut = block.warehouseLines.reduce( + (s, l) => s + (l.outQty || 0), + 0, + ); + const available = totalIn - totalOut; + const lastMovement = block.movements[0]; + const stockTakeVariance = block.stockTakeEvents.reduce( + (s, st) => s + (st.varianceQty || 0), + 0, + ); + + return ( + + }> + + + {block.warehouseLines + .map((l) => l.warehouseCode) + .filter(Boolean) + .join(" / ") || `Lot #${block.inventoryLotId}`} + + {block.itemName && ( + + {block.itemCode} ??{block.itemName} + + )} + 0 ? "success" : "default"} + variant="outlined" + /> + + {t("inQty")}: {totalIn} / {t("outQty")}: {totalOut} + + {block.movements.length > 0 && ( + + )} + {lastMovement?.timestamp && ( + + {t("lastMove")}: {lastMovement.timestamp} + + )} + {stockTakeVariance !== 0 && ( + + )} + {onFocusWarehouse ? ( + { + e.stopPropagation(); + onFocusWarehouse({ + inventoryLotId: block.inventoryLotId, + warehouseCode: blockWarehouseCode(block), + }); + }} + sx={{ ml: "auto" }} + /> + ) : null} + + + + + + {block.warehouseLines.length > 0 && ( + String(wl.inventoryLotLineId)} + columns={[ + { + key: "warehouse", + label: t("warehouse"), + value: (wl) => wl.warehouseCode, + }, + { + key: "inQty", + label: t("inQty"), + align: "right", + value: (wl) => wl.inQty, + }, + { + key: "outQty", + label: t("outQty"), + align: "right", + value: (wl) => wl.outQty, + }, + { + key: "available", + label: t("available"), + align: "right", + value: (wl) => wl.availableQty, + }, + { + key: "status", + label: t("status"), + value: (wl) => tr.lotLineStatus(wl.status), + }, + ]} + /> + )} + + {block.origins.length > 0 && ( + String(o.stockInLineId)} + columns={[ + { + key: "type", + label: t("type"), + value: (o) => o.type, + cell: (o) => ( + + ), + }, + { + key: "refCode", + label: t("refCode"), + value: (o) => o.refCode, + }, + { + key: "supplier", + label: t("supplier"), + value: (o) => o.supplierName || o.supplierCode, + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (o) => o.acceptedQty, + }, + { + key: "date", + label: t("date"), + value: (o) => o.receiptDate, + }, + ]} + /> + )} + + {block.qcResults.length > 0 && ( + `${qc.stockInLineId}-${qc.created}`} + columns={[ + { + key: "qcPassed", + label: t("qcPassed"), + value: (qc) => + qc.qcPassed ? t("qcPassed") : t("qcFailed"), + cell: (qc) => ( + + ), + }, + { + key: "acceptedQty", + label: t("acceptedQty"), + align: "right", + value: (qc) => qc.acceptedQty, + }, + { + key: "failQty", + label: t("failQty"), + align: "right", + value: (qc) => qc.failQty, + }, + { + key: "qcType", + label: t("detailQcType"), + value: (qc) => qc.qcType, + }, + { + key: "handler", + label: t("handler"), + value: (qc) => qc.handledBy, + }, + { + key: "date", + label: t("date"), + value: (qc) => qc.created, + }, + ]} + /> + )} + + {block.purchaseEvents && block.purchaseEvents.length > 0 && ( + String(pe.purchaseOrderLineId)} + columns={[ + { + key: "po", + label: t("detailPurchaseOrderNo"), + value: (pe) => pe.purchaseOrderCode, + }, + { + key: "supplier", + label: t("supplier"), + value: (pe) => pe.supplierName || pe.supplierCode, + }, + { + key: "orderQty", + label: t("detailOrderQty"), + align: "right", + value: (pe) => pe.orderQty, + }, + { + key: "putAwayQty", + label: t("detailPutAwayQty"), + align: "right", + value: (pe) => pe.putAwayQty, + }, + { + key: "date", + label: t("date"), + value: (pe) => pe.orderDate, + }, + ]} + /> + )} + + {block.putawayEvents.length > 0 && ( + `${pa.inventoryLotLineId}-${idx}`} + columns={[ + { + key: "warehouse", + label: t("warehouse"), + value: (pa) => pa.warehouseCode, + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (pa) => pa.qty, + }, + { + key: "handler", + label: t("handler"), + value: (pa) => pa.handledBy, + }, + { + key: "refCode", + label: t("refCode"), + value: (pa) => pa.refCode, + }, + { + key: "date", + label: t("date"), + value: (pa) => pa.timestamp, + }, + ]} + /> + )} + + {block.movements.length > 0 && ( + `${m.refCode}-${m.timestamp}-${idx}`} + columns={[ + { + key: "direction", + label: t("direction"), + value: (m) => m.direction, + cell: (m) => ( + + ), + }, + { + key: "type", + label: t("type"), + value: (m) => m.refType, + }, + { + key: "refCode", + label: t("refCode"), + value: (m) => m.refCode, + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (m) => m.qty, + }, + { + key: "warehouse", + label: t("warehouse"), + value: (m) => m.warehouseCode, + }, + { + key: "date", + label: t("date"), + value: (m) => m.timestamp, + }, + ]} + /> + )} + + {block.outboundUsage.length > 0 && ( + `${o.stockOutLineId}-${idx}`} + columns={[ + { + key: "pickOrder", + label: t("pickOrder"), + value: (o) => o.pickOrderCode || o.deliveryOrderCode, + }, + { + key: "type", + label: t("type"), + value: (o) => tr.usageType(o.usageType), + cell: (o) => ( + + ), + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (o) => o.qty, + }, + { + key: "handler", + label: t("handler"), + value: (o) => o.handler, + }, + { + key: "date", + label: t("date"), + value: (o) => o.timestamp, + }, + ]} + /> + )} + + {block.stockTakeEvents.length > 0 && ( + `${st.stockTakeCode}-${idx}`} + columns={[ + { + key: "code", + label: t("stockTakeCode"), + value: (st) => st.stockTakeCode, + }, + { + key: "section", + label: t("section"), + value: (st) => st.stockTakeSection, + }, + { + key: "round", + label: t("round"), + value: (st) => st.stockTakeRoundName, + }, + { + key: "before", + label: t("before"), + align: "right", + value: (st) => + resolveStockTakeBookQty(st.recordDetail, st.beforeQty), + }, + { + key: "after", + label: t("after"), + align: "right", + value: (st) => + resolveStockTakeAcceptedQty( + st.recordDetail, + st.afterQty, + ), + }, + { + key: "variance", + label: t("variance"), + align: "right", + value: (st) => + st.recordDetail?.varianceQty != null + ? st.recordDetail.varianceQty + : st.varianceQty, + }, + { + key: "date", + label: t("date"), + value: (st) => st.timestamp, + }, + ]} + /> + )} + + {block.adjustments.length > 0 && ( + + `${adj.refCode}-${adj.timestamp}-${idx}` + } + columns={[ + { + key: "type", + label: t("type"), + value: (adj) => adj.adjustmentType, + }, + { + key: "direction", + label: t("direction"), + value: (adj) => adj.direction, + cell: (adj) => ( + + ), + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (adj) => adj.qty, + }, + { + key: "remarks", + label: t("remarks"), + value: (adj) => adj.reason, + }, + { + key: "handler", + label: t("handler"), + value: (adj) => adj.handledBy, + }, + { + key: "date", + label: t("date"), + value: (adj) => adj.timestamp, + }, + ]} + /> + )} + + {block.transfers.length > 0 && ( + `${xfer.transferCode}-${idx}`} + columns={[ + { + key: "from", + label: t("from"), + value: (xfer) => xfer.fromWarehouse, + }, + { + key: "to", + label: t("to"), + value: (xfer) => xfer.toWarehouse, + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (xfer) => xfer.qty, + }, + { + key: "refCode", + label: t("refCode"), + value: (xfer) => xfer.transferCode, + }, + { + key: "date", + label: t("date"), + value: (xfer) => xfer.timestamp, + }, + ]} + /> + )} + + {block.doDeliveries.length > 0 && ( + `${dd.stockOutLineId}-${idx}`} + columns={[ + { + key: "pickOrder", + label: t("pickOrder"), + value: (dd) => dd.pickOrderCode, + }, + { + key: "deliveryOrder", + label: t("deliveryOrder"), + value: (dd) => dd.deliveryOrderCode, + }, + { + key: "deliveryNoteCode", + label: t("deliveryNoteCode"), + value: (dd) => dd.deliveryNoteCode || "—", + }, + { + key: "ticketNo", + label: t("ticketNo"), + value: (dd) => dd.ticketNo || "—", + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (dd) => dd.qty, + }, + { + key: "handler", + label: t("handler"), + value: (dd) => dd.handler, + }, + { + key: "date", + label: t("date"), + value: (dd) => dd.timestamp, + }, + ]} + /> + )} + + {block.failEvents.length > 0 && ( + String(fe.failId)} + columns={[ + { + key: "category", + label: t("detailFailCategory"), + value: (fe) => `${fe.failType} / ${fe.category}`, + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (fe) => fe.qty, + }, + { + key: "pickOrder", + label: t("pickOrder"), + value: (fe) => fe.pickOrderCode, + }, + { + key: "handler", + label: t("handler"), + value: (fe) => fe.handlerName, + }, + { + key: "date", + label: t("date"), + value: (fe) => fe.recordDate, + }, + ]} + /> + )} + + {block.returnEvents.length > 0 && ( + `${re.stockOutLineId}-${idx}`} + columns={[ + { + key: "type", + label: t("type"), + value: (re) => re.movementType, + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (re) => re.qty, + }, + { + key: "warehouse", + label: t("warehouse"), + value: (re) => re.warehouseCode, + }, + { + key: "remarks", + label: t("remarks"), + value: (re) => re.remarks, + }, + { + key: "date", + label: t("date"), + value: (re) => re.timestamp, + }, + ]} + /> + )} + + {block.openMovements.length > 0 && ( + String(om.stockInLineId)} + columns={[ + { + key: "warehouse", + label: t("warehouse"), + value: (om) => om.warehouseCode, + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (om) => om.qty, + }, + { + key: "refCode", + label: t("refCode"), + value: (om) => om.refCode, + }, + { + key: "handler", + label: t("handler"), + value: (om) => om.handledBy, + }, + { + key: "date", + label: t("date"), + value: (om) => om.timestamp, + }, + ]} + /> + )} + + + + ); + })} + + ); +}; + +export default ItemTracingLocations; diff --git a/src/components/ItemTracing/ItemTracingLotTraceLink.tsx b/src/components/ItemTracing/ItemTracingLotTraceLink.tsx new file mode 100644 index 0000000..840be42 --- /dev/null +++ b/src/components/ItemTracing/ItemTracingLotTraceLink.tsx @@ -0,0 +1,50 @@ +"use client"; + +import Link from "next/link"; +import MuiLink, { type LinkProps as MuiLinkProps } from "@mui/material/Link"; +import { buildItemTracingHref } from "./traceNavigationUtils"; + +type Props = { + label: string; + lotNo?: string; + itemCode?: string; + stockInLineId?: number; + /** Prevent React Flow node selection when the link sits on a graph card. */ + stopPropagation?: boolean; + variant?: MuiLinkProps["variant"]; + sx?: MuiLinkProps["sx"]; +}; + +const stopGraphEvent = (e: React.MouseEvent) => { + e.stopPropagation(); +}; + +const ItemTracingLotTraceLink: React.FC = ({ + label, + lotNo, + itemCode, + stockInLineId, + stopPropagation = false, + variant = "caption", + sx, +}) => { + if (!lotNo?.trim() && stockInLineId == null) return null; + + return ( + + {label} + + ); +}; + +export default ItemTracingLotTraceLink; diff --git a/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx b/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx new file mode 100644 index 0000000..3d22d7a --- /dev/null +++ b/src/components/ItemTracing/ItemTracingNodeDetailPanel.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { + Box, + Chip, + Divider, + IconButton, + List, + ListItem, + ListItemText, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableRow, + Typography, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { useTranslation } from "react-i18next"; +import { TraceGraphNode } from "./buildTraceGraphNodes"; +import ItemTracingDocLink from "./ItemTracingDocLink"; +import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink"; +import ItemTracingStockTakeLifecycle from "./ItemTracingStockTakeLifecycle"; +import { formatQty } from "./traceQtyUtils"; +import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils"; + +type Props = { + node: TraceGraphNode; + onClose?: () => void; +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 4 | v1.0.0 | 2026-07-14 */ +const ItemTracingNodeDetailPanel: React.FC = ({ node, onClose }) => { + const { t } = useTranslation("itemTracing"); + + const hasDocLink = node.docLinkKind && node.refCode; + const lifecycleStages = + node.kind === "STOCK_TAKE" && node.stockTakeRecordDetail + ? buildStockTakeLifecycleStages(node.stockTakeRecordDetail) + : []; + const activeStageCount = lifecycleStages.filter((s) => s.isActive).length; + + return ( + + + + + + {t("nodeDetailTitle")} + + {node.categoryLabel && ( + + )} + + {onClose && ( + + + + )} + + + + + + {hasDocLink ? ( + + ) : ( + node.title + )} + + {node.subtitle && ( + + {node.subtitle} + + )} + + + + {node.details.map((row, i) => ( + + + {row.label} + + + {row.variant === "qcCriteriaList" && row.qcCriteriaItems?.length ? ( + + {row.qcCriteriaItems.map((item, idx) => ( + + + + {item.name} + + + {!item.passed && item.failQty != null && item.failQty > 0 && ( + + {t("failQty")}: {formatQty(item.failQty, node.uom)} + + )} + + } + secondary={ + item.description ? ( + + {item.description} + + ) : null + } + /> + + ))} + + ) : row.linkKind && row.linkCode ? ( + + ) : ( + + {row.value || "—"} + + )} + + + ))} + +
+ + {node.traceLotNo ? ( + + + + ) : null} + + {lifecycleStages.length > 0 && ( + <> + + + {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeStageCount })} + + + + )} + + {node.meta && ( + <> + + + {node.meta} + + + )} +
+
+ ); +}; + +export default ItemTracingNodeDetailPanel; diff --git a/src/components/ItemTracing/ItemTracingScanBar.tsx b/src/components/ItemTracing/ItemTracingScanBar.tsx new file mode 100644 index 0000000..abcceb2 --- /dev/null +++ b/src/components/ItemTracing/ItemTracingScanBar.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { + Alert, + Button, + Chip, + Paper, + Stack, + TextField, + Typography, +} from "@mui/material"; +import QrCodeScannerIcon from "@mui/icons-material/QrCodeScanner"; +import SearchIcon from "@mui/icons-material/Search"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; + +type ScanBarProps = { + onTrace: (params: { + stockInLineId?: number; + lotNo?: string; + itemCode?: string; + }) => void; + loading: boolean; + lastLotNo?: string; +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 1 | v1.0.0 | 2026-07-14 */ +const ItemTracingScanBar: React.FC = ({ + onTrace, + loading, + lastLotNo, +}) => { + const { t } = useTranslation("itemTracing"); + const scanner = useQrCodeScannerContext(); + const [scanMode, setScanMode] = useState<"idle" | "wedge">("idle"); + const [itemCode, setItemCode] = useState(""); + const [lotNo, setLotNo] = useState(""); + const [scanError, setScanError] = useState(null); + + const startWedgeScan = useCallback(() => { + setScanError(null); + setScanMode("wedge"); + scanner.resetScan(); + scanner.startScan(); + }, [scanner]); + + const stopWedgeScan = useCallback(() => { + scanner.stopScan(); + scanner.resetScan(); + setScanMode("idle"); + }, [scanner]); + + useEffect(() => { + if (scanMode !== "wedge") return; + const itemId = scanner.result?.itemId; + const stockInLineId = scanner.result?.stockInLineId; + if (!itemId || !stockInLineId) return; + setScanError(null); + stopWedgeScan(); + onTrace({ stockInLineId: Number(stockInLineId) }); + }, [scanMode, scanner.result, onTrace, stopWedgeScan]); + + const handleManualSearch = () => { + const code = itemCode.trim(); + const lot = lotNo.trim(); + if (!code || !lot) return; + onTrace({ itemCode: code, lotNo: lot }); + }; + + return ( + + + + + {lastLotNo && ( + + )} + + + {scanError && ( + setScanError(null)}> + {scanError} + + )} + + {scanMode === "wedge" && ( + + {t("scanReady")} + + )} + + {t("manualSearch")} + + setItemCode(e.target.value)} + disabled={loading} + fullWidth + /> + setLotNo(e.target.value)} + disabled={loading} + fullWidth + /> + + + + + ); +}; + +export default ItemTracingScanBar; diff --git a/src/components/ItemTracing/ItemTracingSections.tsx b/src/components/ItemTracing/ItemTracingSections.tsx new file mode 100644 index 0000000..40df2d6 --- /dev/null +++ b/src/components/ItemTracing/ItemTracingSections.tsx @@ -0,0 +1,915 @@ +"use client"; + +import { + Box, + Chip, + Checkbox, + Link as MuiLink, + Paper, + Tab, + Tabs, + Typography, +} from "@mui/material"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ItemLotTraceResponse } from "@/app/api/itemTracing"; +import ItemTracingDocLink from "./ItemTracingDocLink"; +import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink"; +import { createTraceLabelTranslator, qcItemLabel } from "./traceLabelUtils"; +import { formatNum, formatQty } from "./traceQtyUtils"; +import { normalizeTargetDateForLink } from "./traceDocLinkUtils"; +import type { CompiledTraceGraph } from "./compileTraceGraph"; +import { buildTracePresentationRows } from "./tracePresentationAdapter"; +import { + hasMultipleLocations, + mergeScopedAdjustments, + mergeScopedOrigins, + mergeScopedOutboundUsage, + mergeScopedQcResults, + mergeScopedStockTakeEvents, + mergeScopedTransfers, +} from "./mergeLocationScopedData"; +import { + resolveStockTakeAcceptedQty, + resolveStockTakeBookQty, +} from "./traceStockTakeUtils"; +import { + FilterableDataTable, + type FilterableColumnDef, +} from "./itemTracingTableFilters"; + +type TraceParams = { stockInLineId?: number; lotNo?: string; itemCode?: string }; + +type Props = { + data: ItemLotTraceResponse; + compiledGraph: CompiledTraceGraph; + onTrace?: (params: TraceParams) => void; +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 6 | v1.0.0 | 2026-07-14 */ +const ItemTracingSections: React.FC = ({ data, compiledGraph, onTrace }) => { + const { t } = useTranslation("itemTracing"); + const [tab, setTab] = useState(0); + const { bomTrace, joPrelude, lot } = data; + const tr = useMemo(() => createTraceLabelTranslator(t), [t]); + + const presentationRows = useMemo( + () => buildTracePresentationRows(compiledGraph.nodes, data), + [compiledGraph.nodes, data], + ); + + const multiLocation = hasMultipleLocations(data); + const mergedOrigins = useMemo(() => mergeScopedOrigins(data), [data]); + const mergedQc = useMemo(() => mergeScopedQcResults(data), [data]); + const mergedOutbound = useMemo(() => mergeScopedOutboundUsage(data), [data]); + const mergedStockTake = useMemo(() => mergeScopedStockTakeEvents(data), [data]); + const mergedAdjustments = useMemo(() => mergeScopedAdjustments(data), [data]); + const mergedTransfers = useMemo(() => mergeScopedTransfers(data), [data]); + + const stockUom = lot.uom; + + const tabKeys = useMemo(() => { + const keys = [ + "origins", + "qc", + "outbound", + "stockTake", + "adjustments", + "transfers", + "bom", + ] as const; + if (joPrelude) { + return [...keys, "joPick"] as const; + } + return keys; + }, [joPrelude]); + + const activeTab = tabKeys[tab] ?? tabKeys[0]; + + const bomDirectionLabel = + bomTrace.direction === "FINISHED_GOOD" + ? t("bomDirectionFG") + : bomTrace.direction === "MATERIAL" + ? t("bomDirectionMaterial") + : t("bomDirectionUnknown"); + + const useJoMaterialInputs = joPrelude != null && joPrelude.materialInputs.length > 0; + + const checkboxCellFromStatus = ( + status?: string | null, + opts?: { checkedWhen?: Array; rejectedWhen?: Array }, + ): React.ReactNode => { + const s = String(status || "").trim().toLowerCase(); + const checkedWhen = opts?.checkedWhen ?? []; + const rejectedWhen = opts?.rejectedWhen ?? []; + + const isRejected = rejectedWhen.includes(s); + const isChecked = checkedWhen.includes(s); + + if (isRejected) { + return ( + + ); + } + if (isChecked) { + return ( + + ); + } + return ; + }; + + const originCols = useMemo((): FilterableColumnDef<(typeof mergedOrigins)[number]>[] => { + const cols: FilterableColumnDef<(typeof mergedOrigins)[number]>[] = []; + if (multiLocation) { + cols.push({ + key: "warehouse", + label: t("warehouse"), + value: (o) => o.scopeWarehouseCode, + }); + } + cols.push( + { + key: "type", + label: t("type"), + value: (o) => tr.refType(o.type), + }, + { + key: "ref", + label: t("ref"), + value: (o) => o.refCode || "—", + cell: (o) => + o.type === "PO" ? ( + + ) : o.type === "JO" ? ( + + ) : ( + o.refCode || "—" + ), + }, + { + key: "supplier", + label: t("supplier"), + value: (o) => [o.supplierCode, o.supplierName].filter(Boolean).join(" "), + }, + { key: "dnNo", label: t("dnNo"), value: (o) => o.dnNo || "—" }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (o) => formatQty(o.acceptedQty, stockUom), + }, + { + key: "status", + label: t("status"), + value: (o) => tr.stockInStatus(o.status), + }, + { + key: "timestamp", + label: t("timestamp"), + value: (o) => o.receiptDate ?? "—", + }, + ); + return cols; + }, [multiLocation, t, tr, stockUom]); + + const qcCols = useMemo((): FilterableColumnDef<(typeof mergedQc)[number]>[] => { + const cols: FilterableColumnDef<(typeof mergedQc)[number]>[] = []; + if (multiLocation) { + cols.push({ + key: "warehouse", + label: t("warehouse"), + value: (q) => q.scopeWarehouseCode, + }); + } + cols.push( + { + key: "status", + label: t("status"), + value: (q) => (q.qcPassed ? t("qcPassed") : t("qcFailed")), + cell: (q) => ( + + ), + }, + { + key: "criteria", + label: t("detailQcCriteria"), + value: (q) => qcItemLabel(q) || "—", + }, + { + key: "acceptedQty", + label: t("acceptedQty"), + align: "right", + value: (q) => formatQty(q.acceptedQty, stockUom), + }, + { + key: "failQty", + label: t("failQty"), + align: "right", + value: (q) => formatQty(q.failQty, stockUom), + }, + { + key: "remarks", + label: t("remarks"), + value: (q) => q.remarks?.trim() || "", + }, + { + key: "handler", + label: t("handler"), + value: (q) => q.handledBy || "—", + }, + { + key: "timestamp", + label: t("timestamp"), + value: (q) => q.created ?? "—", + }, + ); + return cols; + }, [multiLocation, t, stockUom]); + + const outboundCols = useMemo((): FilterableColumnDef<(typeof mergedOutbound)[number]>[] => { + const cols: FilterableColumnDef<(typeof mergedOutbound)[number]>[] = []; + if (multiLocation) { + cols.push({ + key: "warehouse", + label: t("warehouse"), + value: (u) => u.scopeWarehouseCode, + }); + } + cols.push( + { + key: "type", + label: t("type"), + value: (u) => tr.usageType(u.usageType), + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (u) => formatQty(u.qty, stockUom), + }, + { + key: "pickOrder", + label: t("pickOrder"), + value: (u) => u.pickOrderCode || u.consoCode || "—", + cell: (u) => ( + + ), + }, + { + key: "jobOrder", + label: t("jobOrder"), + value: (u) => u.jobOrderCode || "—", + cell: (u) => ( + + ), + }, + { + key: "deliveryOrder", + label: t("deliveryOrder"), + value: (u) => u.deliveryOrderCode || "—", + }, + { + key: "handler", + label: t("handler"), + value: (u) => u.handler || "—", + }, + { + key: "timestamp", + label: t("timestamp"), + value: (u) => u.timestamp ?? "—", + }, + ); + return cols; + }, [multiLocation, t, tr, stockUom]); + + const stockTakeCols = useMemo((): FilterableColumnDef<(typeof mergedStockTake)[number]>[] => { + const cols: FilterableColumnDef<(typeof mergedStockTake)[number]>[] = []; + if (multiLocation) { + cols.push({ + key: "warehouse", + label: t("warehouse"), + value: (e) => e.scopeWarehouseCode, + }); + } + cols.push( + { key: "ref", label: t("ref"), value: (e) => e.stockTakeCode }, + { + key: "round", + label: t("detailStockTakeRound"), + value: (e) => + e.stockTakeRoundName?.trim() || + (e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : "—"), + }, + { + key: "section", + label: t("detailStockTakeSection"), + value: (e) => e.stockTakeSection?.trim() || "—", + }, + { + key: "location", + label: t("detailLocation"), + value: (e) => e.warehouseCode?.trim() || "—", + }, + { + key: "lotNo", + label: t("lotNo"), + value: (e) => e.lotNo?.trim() || data.lot.lotNo || "—", + }, + { + key: "before", + label: t("before"), + align: "right", + value: (e) => + formatQty( + resolveStockTakeBookQty(e.recordDetail, e.beforeQty), + stockUom, + ), + }, + { + key: "after", + label: t("after"), + align: "right", + value: (e) => + formatQty( + resolveStockTakeAcceptedQty(e.recordDetail, e.afterQty), + stockUom, + ), + }, + { + key: "variance", + label: t("variance"), + align: "right", + value: (e) => + formatQty( + e.recordDetail?.varianceQty != null + ? e.recordDetail.varianceQty + : e.varianceQty, + stockUom, + ), + }, + { + key: "approver", + label: t("approver"), + value: (e) => e.approver || "—", + }, + { + key: "timestamp", + label: t("timestamp"), + value: (e) => e.timestamp ?? "—", + }, + ); + return cols; + }, [multiLocation, t, stockUom, data.lot.lotNo]); + + const adjustmentCols = useMemo((): FilterableColumnDef<(typeof mergedAdjustments)[number]>[] => { + const cols: FilterableColumnDef<(typeof mergedAdjustments)[number]>[] = []; + if (multiLocation) { + cols.push({ + key: "warehouse", + label: t("warehouse"), + value: (a) => a.scopeWarehouseCode, + }); + } + cols.push( + { + key: "type", + label: t("type"), + value: (a) => a.adjustmentType, + }, + { + key: "direction", + label: `${t("directionIn")}/${t("directionOut")}`, + value: (a) => a.direction, + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (a) => formatQty(a.qty, stockUom), + }, + { + key: "ref", + label: t("ref"), + value: (a) => a.refCode || "—", + }, + { + key: "remarks", + label: t("remarks"), + value: (a) => a.reason?.trim() || "", + }, + { + key: "handler", + label: t("handler"), + value: (a) => a.handledBy || "—", + }, + { + key: "timestamp", + label: t("timestamp"), + value: (a) => a.timestamp ?? "—", + }, + ); + return cols; + }, [multiLocation, t, stockUom]); + + const transferCols = useMemo((): FilterableColumnDef<(typeof mergedTransfers)[number]>[] => { + const cols: FilterableColumnDef<(typeof mergedTransfers)[number]>[] = []; + if (multiLocation) { + cols.push({ + key: "warehouse", + label: t("warehouse"), + value: (row) => row.scopeWarehouseCode, + }); + } + cols.push( + { + key: "from", + label: t("from"), + value: (row) => row.fromWarehouse || "—", + }, + { + key: "to", + label: t("to"), + value: (row) => row.toWarehouse || "—", + }, + { + key: "qty", + label: t("qty"), + align: "right", + value: (row) => formatQty(row.qty, stockUom), + }, + { + key: "ref", + label: t("ref"), + value: (row) => row.transferCode, + }, + { + key: "timestamp", + label: t("timestamp"), + value: (row) => row.timestamp ?? "—", + }, + ); + return cols; + }, [multiLocation, t, stockUom]); + + type JoMaterialInput = NonNullable["materialInputs"][number]; + type BomUpstream = (typeof bomTrace.upstream)[number]; + + const bomUpstreamJoCols = useMemo((): FilterableColumnDef[] => [ + { + key: "jobOrder", + label: t("jobOrder"), + value: (m) => m.jobOrderCode || "—", + cell: (m) => ( + + ), + }, + { key: "material", label: t("material"), value: (m) => m.materialItemCode }, + { key: "materialLot", label: t("materialLot"), value: (m) => m.materialLotNo }, + { + key: "pickOrder", + label: t("pickOrder"), + value: (m) => m.pickOrderCode || m.consoCode || "—", + cell: (m) => + m.pickOrderCode ? ( + + ) : ( + "—" + ), + }, + { + key: "pickedAt", + label: t("pickedAt"), + value: (m) => m.pickedAt ?? "—", + }, + { + key: "materialQty", + label: t("materialQty"), + align: "right", + value: (m) => formatNum(Number(m.materialQty)), + }, + { + key: "qtyPerUnit", + label: t("qtyPerUnit"), + align: "right", + value: (m) => + m.bomQtyPerUnit != null ? formatNum(Number(m.bomQtyPerUnit)) : "—", + }, + { + key: "uom", + label: t("uom"), + value: (m) => m.materialUom?.trim() || "", + }, + { + key: "processingStatus", + label: t("processingStatus"), + align: "center", + value: (m) => tr.processingStatus(m.processingStatus), + cell: (m) => + checkboxCellFromStatus(m.processingStatus, { + checkedWhen: ["completed"], + rejectedWhen: ["rejected"], + }), + }, + { + key: "matchStatus", + label: t("matchStatus"), + align: "center", + value: (m) => tr.matchStatus(m.matchStatus), + cell: (m) => checkboxCellFromStatus(m.matchStatus, { checkedWhen: ["completed"] }), + }, + { + key: "trace", + label: "", + value: () => "", + cell: (m) => + m.materialLotNo && onTrace ? ( + + onTrace({ + lotNo: m.materialLotNo, + itemCode: m.materialItemCode, + }) + } + > + {t("traceMaterialLot")} + + ) : ( + "—" + ), + }, + ], [t, tr, stockUom, onTrace]); + + const bomUpstreamCols = useMemo((): FilterableColumnDef[] => [ + { + key: "jobOrder", + label: t("jobOrder"), + value: (u) => u.jobOrderCode || "—", + cell: (u) => ( + + ), + }, + { key: "material", label: t("material"), value: (u) => u.materialItemCode }, + { key: "materialLot", label: t("materialLot"), value: (u) => u.materialLotNo }, + { + key: "materialQty", + label: t("materialQty"), + align: "right", + value: (u) => formatNum(Number(u.materialQty)), + }, + { + key: "qtyPerUnit", + label: t("qtyPerUnit"), + align: "right", + value: (u) => + u.bomQtyPerUnit != null ? formatNum(Number(u.bomQtyPerUnit)) : "—", + }, + { + key: "uom", + label: t("uom"), + value: () => stockUom, + }, + ], [t, stockUom]); + + const bomDownstreamCols = useMemo((): FilterableColumnDef<(typeof bomTrace.downstream)[number]>[] => [ + { + key: "jobOrder", + label: t("jobOrder"), + value: (d) => d.jobOrderCode || "—", + cell: (d) => ( + + ), + }, + { key: "finishedItem", label: t("finishedItem"), value: (d) => d.finishedItemCode }, + { key: "finishedLot", label: t("finishedLot"), value: (d) => d.finishedLotNo }, + { + key: "fgQty", + label: t("fgQty"), + align: "right", + value: (d) => formatQty(d.fgQty, d.fgUom?.trim() || ""), + }, + { + key: "materialQty", + label: t("materialQty"), + align: "right", + value: (d) => formatQty(d.materialQtyUsed, stockUom), + }, + ], [t, stockUom]); + + const bomRecipeCols = useMemo((): FilterableColumnDef<(typeof bomTrace.bomRecipe)[number]>[] => [ + { key: "material", label: t("material"), value: (r) => r.materialItemCode }, + { + key: "materialName", + label: `${t("material")} name`, + value: (r) => r.materialItemName, + }, + { + key: "qtyPerUnit", + label: t("qtyPerUnit"), + align: "right", + value: (r) => formatNum(Number(r.qtyPerUnit)), + }, + { key: "uom", label: t("uom"), value: (r) => r.uom }, + ], [t]); + + type JoPickRow = (typeof presentationRows.joPicks)[number]; + const joPickCols = useMemo((): FilterableColumnDef[] => [ + { + key: "pickOrder", + label: t("pickOrder"), + value: (row) => row.pickOrderCode || row.consoCode || "—", + cell: (row) => + row.pickOrderCode ? ( + + ) : ( + "—" + ), + }, + { key: "material", label: t("material"), value: (row) => row.materialItemCode }, + { + key: "lotNo", + label: t("lotNo"), + value: (row) => row.materialLotNo || "—", + cell: (row) => + row.materialLotNo ? ( + + ) : ( + "—" + ), + }, + { + key: "pickedQty", + label: t("pickedQty"), + align: "right", + value: (row) => formatQty(row.qty, row.uom || stockUom), + }, + { + key: "assignedStep", + label: t("detailAssignedStep"), + value: (row) => row.assignedStepName || "—", + }, + { + key: "timestamp", + label: t("timestamp"), + value: (row) => row.pickedAt ?? "—", + }, + ], [t, stockUom]); + + type JoPickLine = NonNullable["pickOrders"][number]["lines"][number]; + const joPickLineCols = useMemo((): FilterableColumnDef[] => [ + { + key: "material", + label: t("material"), + value: (line) => + line.itemName ? `${line.itemCode} · ${line.itemName}` : line.itemCode, + }, + { + key: "plannedQty", + label: t("requiredQty"), + align: "right", + value: (line) => formatQty(Number(line.requiredQty), stockUom), + }, + { + key: "pickedQty", + label: t("pickedQty"), + align: "right", + value: (line) => formatQty(Number(line.pickedQty), stockUom), + }, + { key: "status", label: t("status"), value: (line) => line.status }, + ], [t, stockUom]); + + return ( + + setTab(v)} + variant="scrollable" + scrollButtons="auto" + > + + + + + + + + {joPrelude && } + + + {multiLocation && ( + + {t("sectionsMultiLocationHint")} + + )} + {activeTab === "origins" && ( + `${o.inventoryLotId}-${o.stockInLineId}`} + emptyLabel={t("noRecords")} + /> + )} + + {activeTab === "qc" && ( + q.rowKey} + emptyLabel={t("noRecords")} + /> + )} + + {activeTab === "outbound" && ( + `${u.inventoryLotId}-${u.stockOutLineId}`} + emptyLabel={t("noRecords")} + /> + )} + + {activeTab === "stockTake" && ( + e.rowKey} + emptyLabel={t("noRecords")} + /> + )} + + {activeTab === "adjustments" && ( + a.rowKey} + emptyLabel={t("noRecords")} + /> + )} + + {activeTab === "transfers" && ( + row.rowKey} + emptyLabel={t("noRecords")} + /> + )} + + {activeTab === "bom" && ( + + + {t("bomDirection")}: {bomDirectionLabel} + + + + {t("bomUpstream")} + + + {useJoMaterialInputs ? ( + `up-jo-${i}`} + emptyLabel={t("noRecords")} + /> + ) : ( + `up-${i}`} + emptyLabel={t("noRecords")} + /> + )} + + + + {t("bomDownstream")} + + + `down-${i}`} + emptyLabel={t("noRecords")} + /> + + + + {t("bomRecipe")} + + `recipe-${i}`} + emptyLabel={t("noRecords")} + /> + + )} + + {activeTab === "joPick" && joPrelude && ( + + {presentationRows.joPicks.length > 0 ? ( + row.id} + emptyLabel={t("noRecords")} + /> + ) : joPrelude.pickOrders.length === 0 ? ( + + {t("noRecords")} + + ) : ( + joPrelude.pickOrders.map((po) => ( + + + + {" · "} + + + + {[ + po.targetDate && `${t("targetDate")}: ${po.targetDate}`, + po.completeDate && `${t("completeDate")}: ${po.completeDate}`, + ] + .filter(Boolean) + .join(" · ")} + + String(line.pickOrderLineId)} + emptyLabel={t("noRecords")} + /> + + )) + )} + + )} + + + ); +}; + +export default ItemTracingSections; diff --git a/src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx b/src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx new file mode 100644 index 0000000..af3b3aa --- /dev/null +++ b/src/components/ItemTracing/ItemTracingStockTakeLifecycle.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Box, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { StockTakeLifecycleStage } from "./traceStockTakeUtils"; +import { formatQty } from "./traceQtyUtils"; + +type Props = { + stages: StockTakeLifecycleStage[]; + uom?: string; +}; + +/** Vertical timeline of stock-take progression (first count → approve). */ +const ItemTracingStockTakeLifecycle: React.FC = ({ stages, uom = "" }) => { + const { t } = useTranslation("itemTracing"); + + if (!stages.length) return null; + + return ( + + {stages.map((stage, idx) => { + const isLast = idx === stages.length - 1; + return ( + + + + {!isLast && ( + + )} + + + + {t(stage.label)} + + {stage.isActive ? ( + + {[ + stage.qty != null + ? stage.key === "round-created" + ? `${t("stockTakeStageBookQty")}: ${formatQty(stage.qty, uom)}` + : formatQty(stage.qty, uom) + : null, + stage.key === "accepted" && stage.badQty != null + ? `${t("variance")}: ${formatQty(stage.badQty, uom)}` + : stage.badQty != null && stage.badQty > 0 + ? `${t("unqualifiedQty")}: ${formatQty(stage.badQty, uom)}` + : null, + stage.handler ?? null, + stage.timestamp ?? null, + ] + .filter(Boolean) + .join(" · ") || "—"} + + ) : ( + + — + + )} + + + ); + })} + + ); +}; + +export default ItemTracingStockTakeLifecycle; diff --git a/src/components/ItemTracing/ItemTracingSummary.tsx b/src/components/ItemTracing/ItemTracingSummary.tsx new file mode 100644 index 0000000..bfc8299 --- /dev/null +++ b/src/components/ItemTracing/ItemTracingSummary.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { + Box, + Button, + Chip, + Grid, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { ItemLotTraceResponse } from "@/app/api/itemTracing"; +import ItemTracingDocLink from "./ItemTracingDocLink"; +import { createTraceLabelTranslator } from "./traceLabelUtils"; +import { formatQty } from "./traceQtyUtils"; +import type { WarehouseFocusRequest } from "./ItemTracing"; + +type Props = { + data: ItemLotTraceResponse; + onFocusWarehouse?: (request: WarehouseFocusRequest) => void; + onExportExcel?: () => void; +}; + +type SummaryWarehouseRow = { + key: string; + inventoryLotId: number; + warehouseCode: string; + inQty: number; + outQty: number; + availableQty: number; + status: string; +}; + +/** FP-MTMS Version Checklist | Functions Ref. No. 2 | v1.0.0 | 2026-07-14 */ +const ItemTracingSummary: React.FC = ({ + data, + onFocusWarehouse, + onExportExcel, +}) => { + const { t } = useTranslation("itemTracing"); + const tr = useMemo(() => createTraceLabelTranslator(t), [t]); + const { lot, warehouseLines, joPrelude, alternateLocations, locationBlocks } = + data; + + const combinedWarehouseRows = useMemo((): SummaryWarehouseRow[] => { + const primary = warehouseLines.map((w) => ({ + key: `primary-${w.inventoryLotLineId}`, + inventoryLotId: lot.inventoryLotId, + warehouseCode: w.warehouseCode, + inQty: w.inQty, + outQty: w.outQty, + availableQty: w.availableQty, + status: w.status, + })); + + const fromBlocks = (locationBlocks ?? []).flatMap((block) => + block.warehouseLines.map((w) => ({ + key: `alt-${block.inventoryLotId}-${w.inventoryLotLineId}`, + inventoryLotId: block.inventoryLotId, + warehouseCode: w.warehouseCode, + inQty: w.inQty, + outQty: w.outQty, + availableQty: w.availableQty, + status: w.status, + })), + ); + + if (fromBlocks.length > 0) { + return [...primary, ...fromBlocks]; + } + + // Fallback when location blocks are absent but alternateLocations exist. + const fromAlts = alternateLocations.map((loc) => ({ + key: `alt-loc-${loc.inventoryLotId}-${loc.inventoryLotLineId}`, + inventoryLotId: loc.inventoryLotId, + warehouseCode: loc.warehouseCode, + inQty: 0, + outQty: 0, + availableQty: loc.availableQty, + status: "", + })); + return [...primary, ...fromAlts]; + }, [ + warehouseLines, + locationBlocks, + alternateLocations, + lot.inventoryLotId, + ]); + + const totalAvailable = combinedWarehouseRows.reduce( + (s, w) => s + (w.availableQty ?? 0), + 0, + ); + + return ( + + + + {t("summary")} + + {onExportExcel && ( + + )} + + + + + {lot.lotNo || "—"} + + + {lot.itemCode} · {lot.itemName} + + + + + + + + {t("expiryDate")} + + {lot.expiryDate ?? "—"} + + + + {t("productionDate")} + + {lot.productionDate ?? "—"} + + + + {t("stockInDate")} + + {lot.stockInDate ?? "—"} + + + + {t("uom")} + + {lot.uom || "—"} + + + {joPrelude && ( + + + {t("joContext")} + + + + + {t("jobOrder")} + + + {joPrelude.jobOrder.jobOrderId != null ? ( + + ) : ( + joPrelude.jobOrder.jobOrderCode || "—" + )} + + + + + {t("status")} + + + {tr.joStatus(joPrelude.jobOrder.status)} + + + + + {t("planStart")} + + + {joPrelude.jobOrder.planStart ?? "—"} + + + + + {t("plannedQty")} + + + {formatQty(Number(joPrelude.jobOrder.reqQty), lot.uom)} + + + + + )} + {combinedWarehouseRows.length > 0 && ( + + + + + {t("warehouse")} + {t("inQty")} + {t("outQty")} + {t("available")} + {t("status")} + {onFocusWarehouse && ( + {t("action")} + )} + + + + {combinedWarehouseRows.map((w) => ( + + {w.warehouseCode || "—"} + + {formatQty(w.inQty, lot.uom)} + + + {formatQty(w.outQty, lot.uom)} + + + {formatQty(w.availableQty, lot.uom)} + + + {w.status ? ( + + ) : ( + "—" + )} + + {onFocusWarehouse && ( + + {w.warehouseCode?.trim() ? ( + + ) : ( + "—" + )} + + )} + + ))} + +
+
+ )} +
+ ); +}; + +export default ItemTracingSummary; diff --git a/src/components/ItemTracing/TraceFlowEdge.tsx b/src/components/ItemTracing/TraceFlowEdge.tsx new file mode 100644 index 0000000..dbbfbcd --- /dev/null +++ b/src/components/ItemTracing/TraceFlowEdge.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"; +import { buildTraceFlowCorridorPath } from "./traceFlowEdgeLayout"; + +export type TraceFlowEdgeData = { + offset?: number; + pathMode?: "smooth" | "corridor"; + corridorY?: number; + corridorEntryOffset?: number; + corridorExitOffset?: number; + corridorBranchOffset?: number; +}; + +export const TraceFlowEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style, + markerEnd, + data, +}: EdgeProps) => { + const edgeData = data as TraceFlowEdgeData | undefined; + const offset = edgeData?.offset ?? 0; + + const path = + edgeData?.pathMode === "corridor" && edgeData.corridorY != null + ? buildTraceFlowCorridorPath( + sourceX, + sourceY, + targetX, + targetY, + edgeData.corridorY, + edgeData.corridorEntryOffset, + edgeData.corridorExitOffset, + edgeData.corridorBranchOffset, + ) + : getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 8, + offset, + })[0]; + + return ; +}; + +export const traceFlowEdgeTypes = { + traceFlow: TraceFlowEdge, +}; diff --git a/src/components/ItemTracing/TraceFlowNodes.tsx b/src/components/ItemTracing/TraceFlowNodes.tsx new file mode 100644 index 0000000..6a3be79 --- /dev/null +++ b/src/components/ItemTracing/TraceFlowNodes.tsx @@ -0,0 +1,843 @@ +"use client"; + +import { memo, useEffect, useState } from "react"; +import { + Handle, + Position, + useReactFlow, + useUpdateNodeInternals, + type Node, + type NodeProps, +} from "@xyflow/react"; +import { + Box, + Chip, + Divider, + IconButton, + Paper, + Tooltip, + Typography, +} from "@mui/material"; +import { alpha } from "@mui/material/styles"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import { useTranslation } from "react-i18next"; +import ItemTracingDocLink from "./ItemTracingDocLink"; +import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink"; +import ItemTracingStockTakeLifecycle from "./ItemTracingStockTakeLifecycle"; +import { TraceFlowNodeData } from "./buildReactFlowGraph"; +import { NODE_HEIGHT, NODE_WIDTH, NODE_WIDTH_BRANCH, DO_GROUP_HEADER } from "./traceFlowConstants"; +import { traceFlowHandleTopPercent } from "./traceFlowEdgeLayout"; +import { kindColor, kindLabelKey } from "./traceFlowNodeUtils"; +import { formatQty, formatSignedQty } from "./traceQtyUtils"; +import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils"; +import { pickStatusValueColor } from "./traceLabelUtils"; + +const PHASE_LABEL_INNER = 96; +/** Expanded stock-take stage panel (mt + maxHeight + padding) below the fixed card. */ +const STOCK_TAKE_LIFECYCLE_PANEL_EXTRA = 320; + +const GroupCollapseButton = ({ + collapsed, + onToggle, + collapseLabel, + expandLabel, +}: { + collapsed: boolean; + onToggle?: () => void; + collapseLabel: string; + expandLabel: string; +}) => ( + { + e.stopPropagation(); + onToggle?.(); + }} + onMouseDown={(e) => e.stopPropagation()} + sx={{ + pointerEvents: "all", + p: 0.25, + ml: "auto", + flexShrink: 0, + color: "warning.dark", + }} + > + {collapsed ? ( + + ) : ( + + )} + +); + +export const TraceFlowEventNode = memo(function TraceFlowEventNode({ + id, + data, +}: NodeProps>) { + const { t } = useTranslation("itemTracing"); + const { setNodes } = useReactFlow(); + const updateNodeInternals = useUpdateNodeInternals(); + const node = data.layoutNode; + const color = kindColor(node.kind); + const isQc = node.kind === "QC" || node.kind === "MATERIAL_QC"; + const isStockTake = node.kind === "STOCK_TAKE"; + const hasLifecycle = isStockTake && node.stockTakeRecordDetail != null; + const [lifecycleExpanded, setLifecycleExpanded] = useState(false); + const lifecycleStages = hasLifecycle + ? buildStockTakeLifecycleStages(node.stockTakeRecordDetail) + : []; + const nodeWidth = data.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH; + + useEffect(() => { + if (!hasLifecycle) return; + const nextH = lifecycleExpanded + ? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA + : NODE_HEIGHT; + setNodes((nds) => + nds.map((n) => { + if (n.id !== id) return n; + return { + ...n, + height: nextH, + style: { ...(n.style ?? {}), width: nodeWidth, height: nextH }, + measured: { + width: n.measured?.width ?? n.width ?? nodeWidth, + height: nextH, + }, + }; + }), + ); + const raf = requestAnimationFrame(() => updateNodeInternals(id)); + return () => cancelAnimationFrame(raf); + }, [ + hasLifecycle, + lifecycleExpanded, + id, + nodeWidth, + setNodes, + updateNodeInternals, + ]); + + const chipLabel = + node.kind === "PUTAWAY" && node.putawayStatusLabel?.trim() + ? node.putawayStatusLabel + : isQc && node.qcTypeLabel?.trim() + ? node.qcTypeLabel + : t(kindLabelKey(node.kind)); + const dateLabel = node.timestamp?.trim() ? node.timestamp : t("noTimestamp"); + const isDoOut = node.kind === "DO_OUT"; + const doOutKindChipSx = { + height: data.compact ? 18 : 20, + "& .MuiChip-label": { + px: 0.75, + fontSize: data.compact ? "0.65rem" : "0.7rem", + fontWeight: 600, + }, + } as const; + const stopCardClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + const doOutboundKindChips = + isDoOut && (node.doOutboundIsExtra || node.doOutboundIsReplenish) ? ( + <> + {node.doOutboundIsExtra ? ( + + ) : null} + {node.doOutboundIsReplenish ? ( + + ) : null} + + ) : null; + const titleContent = isDoOut ? ( + + {node.docLinkKind && node.refCode ? ( + + ) : ( + node.refCode || "—" + )} + + ) : node.docLinkKind && node.refCode ? ( + + ) : ( + node.title + ); + const incomingCount = data.incomingHandleCount ?? 0; + const outgoingCount = data.outgoingHandleCount ?? 0; + const searchActive = data.searchActive ?? false; + const searchMatch = data.searchMatch ?? false; + const searchFocused = data.searchFocused ?? false; + const nodeSelected = data.nodeSelected ?? false; + + const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; + + const activeCount = lifecycleStages.filter((s) => s.isActive).length; + + const card = ( + + {incomingCount > 0 && + Array.from({ length: incomingCount }, (_, i) => ( + + ))} + + + + {isQc && ( + + )} + + {doOutboundKindChips} + + + {titleContent} + + {node.subtitle?.trim() ? ( + + {node.subtitle} + + ) : null} + {node.kind === "MATERIAL_PICK" && node.traceItemCode?.trim() ? ( + + {t("Item")}: {node.meta?.trim() || node.traceItemCode} + + ) : null} + {node.qty != null && ( + + {node.kind === "PURCHASE" + ? t("detailOrderQty") + : node.kind === "STOCK_TAKE" + ? t("after") + : node.kind === "ADJUSTMENT" + ? t("variance") + : node.kind === "QC" || node.kind === "MATERIAL_QC" || node.kind === "FAIL" + ? t("unqualifiedQty") + : node.kind === "PRODUCTION_STEP" + ? t("processOutputQty") + : t("qty")} + :{" "} + {node.kind === "ADJUSTMENT" + ? formatSignedQty(node.qty, node.adjustmentDirection, node.uom) + : formatQty(node.qty, node.uom)} + + )} + {node.traceLotNo ? ( + + ) : null} + {node.warehouseCode?.trim() ? ( + + {t("warehouse")}: {node.warehouseCode} + + ) : null} + {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") && + node.processingStatusLabel?.trim() ? ( + + {t("processingStatus")}:{" "} + { + const c = pickStatusValueColor(node.processingStatus); + return c === "default" ? "text.primary" : `${c}.main`; + })(), + fontWeight: 700, + }} + > + {node.processingStatusLabel} + + + ) : null} + {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") && + node.matchStatusLabel?.trim() ? ( + + {t("matchStatus")}:{" "} + { + const c = pickStatusValueColor(node.matchStatus); + return c === "default" ? "text.primary" : `${c}.main`; + })(), + fontWeight: 700, + }} + > + {node.matchStatusLabel} + + + ) : null} + + {dateLabel} + + + {hasLifecycle ? ( + + { + e.stopPropagation(); + setLifecycleExpanded((v) => !v); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + setLifecycleExpanded((v) => !v); + } + }} + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 0.5, + width: "100%", + py: 0.25, + px: 0.5, + borderRadius: 1, + cursor: "pointer", + userSelect: "none", + "&:hover": { bgcolor: "action.hover" }, + }} + > + + {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })} + + {lifecycleExpanded ? ( + + ) : ( + + )} + + + ) : null} + {outgoingCount > 0 && + Array.from({ length: outgoingCount }, (_, i) => ( + + ))} + + ); + + // Inline under the card so pan/zoom keeps the panel attached (Popper portals to body). + const lifecyclePanel = + hasLifecycle && lifecycleExpanded ? ( + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onWheel={(e) => e.stopPropagation()} + sx={{ + width: nodeWidth, + maxWidth: nodeWidth, + mt: 0.75, + p: 1.25, + boxShadow: 4, + borderColor: "secondary.main", + borderWidth: 1.5, + maxHeight: STOCK_TAKE_LIFECYCLE_PANEL_EXTRA - 24, + overflow: "auto", + overscrollBehavior: "contain", + bgcolor: "background.paper", + pointerEvents: "all", + }} + > + + {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })} + + + + + ) : null; + + const wrapped = ( + + {card} + {lifecyclePanel} + + ); + + if (node.meta) { + return ( + + {wrapped} + + ); + } + return wrapped; +}); + +export const TraceFlowPhaseLabelNode = memo(function TraceFlowPhaseLabelNode({ + data, +}: NodeProps>) { + return ( + + + {data.phaseLabel} + + + ); +}); + +export const TraceFlowDateHeaderNode = memo(function TraceFlowDateHeaderNode({ + data, +}: NodeProps>) { + return ( + + {data.dateLabel} + + ); +}); + +export const TraceFlowDoGroupNode = memo(function TraceFlowDoGroupNode({ + data, +}: NodeProps>) { + const { t } = useTranslation("itemTracing"); + const node = data.layoutNode; + const incomingCount = data.incomingHandleCount ?? 0; + const searchActive = data.searchActive ?? false; + const searchMatch = data.searchMatch ?? false; + const searchFocused = data.searchFocused ?? false; + const collapsed = data.groupCollapsed === true; + const width = node.groupBoxWidth ?? NODE_WIDTH; + const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT); + const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; + + return ( + alpha(theme.palette.background.paper, 0.45), + opacity: searchActive && !searchMatch ? 0.35 : 1, + boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined, + position: "relative", + overflow: "hidden", + pointerEvents: "none", + }} + > + {incomingCount > 0 && + Array.from({ length: incomingCount }, (_, i) => ( + + ))} + alpha(theme.palette.warning.light, 0.35), + borderBottom: collapsed ? 0 : 1, + borderColor: "warning.light", + }} + > + + {node.title} + + {node.groupTotalQty != null ? ( + + {t("flowDoGroupTotalQty", { + qtyLabel: formatQty(node.groupTotalQty, node.groupUom), + })} + + ) : null} + + + + ); +}); + +export const TraceFlowPickGroupNode = memo(function TraceFlowPickGroupNode({ + data, +}: NodeProps>) { + const { t } = useTranslation("itemTracing"); + const node = data.layoutNode; + const incomingCount = data.incomingHandleCount ?? 0; + const outgoingCount = data.outgoingHandleCount ?? 0; + const searchActive = data.searchActive ?? false; + const searchMatch = data.searchMatch ?? false; + const searchFocused = data.searchFocused ?? false; + const collapsed = data.groupCollapsed === true; + const width = node.groupBoxWidth ?? NODE_WIDTH; + const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT); + const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; + + return ( + alpha(theme.palette.background.paper, 0.45), + opacity: searchActive && !searchMatch ? 0.35 : 1, + boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined, + position: "relative", + overflow: "hidden", + pointerEvents: "none", + }} + > + {incomingCount > 0 && + Array.from({ length: incomingCount }, (_, i) => ( + + ))} + alpha(theme.palette.warning.light, 0.35), + borderBottom: collapsed ? 0 : 1, + borderColor: "warning.light", + }} + > + + {node.title} + + + + {outgoingCount > 0 && + Array.from({ length: outgoingCount }, (_, i) => ( + + ))} + + ); +}); + +export const traceFlowNodeTypes = { + traceEvent: TraceFlowEventNode, + doGroup: TraceFlowDoGroupNode, + pickGroup: TraceFlowPickGroupNode, + phaseLabel: TraceFlowPhaseLabelNode, + dateHeader: TraceFlowDateHeaderNode, +}; diff --git a/src/components/ItemTracing/buildExtendedTraceGraphNodes.ts b/src/components/ItemTracing/buildExtendedTraceGraphNodes.ts new file mode 100644 index 0000000..0377490 --- /dev/null +++ b/src/components/ItemTracing/buildExtendedTraceGraphNodes.ts @@ -0,0 +1,277 @@ +import { ItemLotTraceResponse } from "@/app/api/itemTracing"; +import dayjs from "dayjs"; +import { + TraceGraphDetailLabels, + TraceGraphNode, + TraceGraphBuildScope, + scopePrefix, + withNodeScope, +} from "./buildTraceGraphNodes"; +import { formatQty } from "./traceQtyUtils"; +import { + formatDoOutboundKindLabel, + resolveDoOutboundChipFlags, +} from "./traceLabelUtils"; +import { resolveDoOutboundDocLink } from "./traceDocLinkUtils"; +import { field, fieldIf, detailsOf, parseSortKey } from "./traceNodeFactory"; + +export interface ExtendedTraceGraphLabels extends TraceGraphDetailLabels { + nodeOpen: string; + nodeFail: string; + nodeDoOut: string; + nodeReplenishmentCreated: string; + nodeReturn: string; + nodeRepack: string; + traceRepackLot: string; + doOutboundExtra: string; + doOutboundReplenish: string; + detailDoOutboundKind: string; +} + +const shopDisplayLabel = (shopCode?: string, shopName?: string): string => { + const code = shopCode?.trim(); + const name = shopName?.trim(); + if (code && name) return `${code} · ${name}`; + return name || code || ""; +}; + +export const buildExtendedTraceGraphNodes = ( + data: ItemLotTraceResponse, + labels: ExtendedTraceGraphLabels, + scope?: TraceGraphBuildScope, +): TraceGraphNode[] => { + const nodes: TraceGraphNode[] = []; + let seq = 0; + const stockUom = data.lot.uom; + const idPfx = scopePrefix(scope); + const lb = scope?.locationBlockKeys ?? false; + + (data.openMovements ?? []).forEach((o, i) => { + nodes.push({ + id: lb ? `${idPfx}open-${i}-${o.stockInLineId}` : `${idPfx}open-${o.stockInLineId}`, + kind: "OPEN", + timestamp: o.timestamp, + sortKey: parseSortKey(o.timestamp, seq++), + title: labels.nodeOpen, + subtitle: [o.refCode, o.warehouseCode].filter(Boolean).join(" · ") || "—", + qty: o.qty, + uom: stockUom, + refCode: o.refCode, + categoryLabel: labels.categoryOpen, + details: detailsOf( + field(labels.detailType, labels.nodeOpen), + field(labels.detailInboundSiNo, o.refCode), + field(labels.detailWarehouse, o.warehouseCode), + field(labels.detailQty, formatQty(o.qty, stockUom)), + field(labels.detailHandler, o.handledBy), + field(labels.detailTime, o.timestamp), + fieldIf(labels.detailRemarks, o.remarks), + ), + }); + }); + + (data.failEvents ?? []).forEach((f, i) => { + nodes.push({ + id: lb ? `${idPfx}fail-${i}-${f.failId}` : `${idPfx}fail-${f.failId}`, + kind: "FAIL", + timestamp: f.recordDate, + sortKey: parseSortKey(f.recordDate, seq++), + title: labels.nodeFail, + subtitle: [labels.tr.failType(f.failType), f.category].filter(Boolean).join(" · ") || "—", + qty: f.qty, + uom: stockUom, + refType: "JO_PICK", + refCode: f.pickOrderCode, + refId: f.pickOrderId, + docLinkKind: f.pickOrderCode ? "jodetail" : undefined, + consoCode: f.pickOrderCode, + docLinkTargetDate: f.recordDate?.trim() + ? dayjs(f.recordDate).isValid() + ? dayjs(f.recordDate).format("YYYY-MM-DD") + : f.recordDate.trim().slice(0, 10) + : undefined, + categoryLabel: labels.categoryQc, + details: detailsOf( + field(labels.detailType, labels.nodeFail), + field(labels.detailStatus, labels.tr.failType(f.failType)), + field(labels.pickOrder, f.pickOrderCode, { + linkKind: f.pickOrderCode ? "jodetail" : undefined, + linkCode: f.pickOrderCode, + linkId: f.pickOrderId, + consoCode: f.pickOrderCode, + linkTargetDate: f.recordDate?.trim() + ? dayjs(f.recordDate).isValid() + ? dayjs(f.recordDate).format("YYYY-MM-DD") + : f.recordDate.trim().slice(0, 10) + : undefined, + }), + field(labels.detailQty, formatQty(f.qty, stockUom)), + field(labels.detailHandler, f.handlerName), + field(labels.detailTime, f.recordDate), + fieldIf(labels.detailFailCategory, f.category), + ), + }); + }); + + (data.replenishmentEvents ?? []).forEach((r) => { + const supplyTo = shopDisplayLabel(r.shopCode, r.shopName); + nodes.push({ + id: `${idPfx}replenish-${r.replenishmentId}`, + kind: "REPLENISHMENT_CREATED", + timestamp: r.timestamp, + sortKey: parseSortKey(r.timestamp, seq++), + title: labels.nodeReplenishmentCreated, + subtitle: r.sourceDoCode || r.replenishmentCode || "—", + qty: r.replenishQty, + uom: stockUom, + refType: "DO", + refCode: r.sourceDoCode, + refId: r.sourceDoId, + replenishmentStockOutLineId: r.stockOutLineId ?? undefined, + categoryLabel: labels.categoryOutbound, + details: detailsOf( + field(labels.detailType, labels.nodeReplenishmentCreated), + field(labels.deliveryOrder, r.sourceDoCode), + field(labels.detailSupplyTo, supplyTo || "—"), + field(labels.detailItemCode, r.itemNo), + field(labels.detailItemName, r.itemName), + field(labels.detailReplenishmentCode, r.replenishmentCode), + field(labels.detailQty, formatQty(r.replenishQty, stockUom)), + field(labels.detailReason, r.reason), + field(labels.detailHandler, r.handler), + field(labels.detailTime, r.timestamp), + ), + }); + }); + + (data.doDeliveries ?? []).forEach((d, i) => { + const supplyTo = shopDisplayLabel(d.shopCode, d.shopName); + const kindLabels = { + extra: labels.doOutboundExtra, + replenish: labels.doOutboundReplenish, + }; + const chipFlags = resolveDoOutboundChipFlags({ + isExtra: d.isExtra, + isReplenish: d.isReplenish, + ticketNo: d.ticketNo, + consoCode: d.consoCode, + releaseType: d.releaseType, + deliveryOrderPickOrderId: d.deliveryOrderPickOrderId, + relationshipId: d.relationshipId, + }); + const outboundKindLabel = formatDoOutboundKindLabel( + chipFlags.isExtra, + chipFlags.isReplenish, + kindLabels, + ); + const doCode = d.deliveryOrderCode || "—"; + const docLink = resolveDoOutboundDocLink({ + pickOrderCode: d.pickOrderCode, + pickOrderId: d.pickOrderId, + deliveryOrderCode: d.deliveryOrderCode, + ticketNo: d.ticketNo, + consoCode: d.consoCode, + timestamp: d.timestamp, + deliveryOrderPickOrderId: d.deliveryOrderPickOrderId, + }); + nodes.push({ + id: lb ? `${idPfx}dodel-${i}-${d.stockOutLineId}` : `${idPfx}do-${d.stockOutLineId}`, + kind: "DO_OUT", + timestamp: d.timestamp, + sortKey: parseSortKey(d.timestamp, seq++), + title: `${labels.nodeDoOut} · ${doCode}`, + subtitle: "", + qty: d.qty, + uom: stockUom, + refType: "DO", + refCode: docLink.displayCode, + refId: d.deliveryOrderId ?? d.pickOrderId, + docLinkKind: docLink.kind, + docLinkTicketNo: docLink.ticketNo, + docLinkTargetDate: docLink.targetDate, + consoCode: docLink.consoCode || d.consoCode || d.pickOrderCode, + categoryLabel: labels.categoryOutbound, + doOutboundIsExtra: chipFlags.isExtra, + doOutboundIsReplenish: chipFlags.isReplenish, + outboundKindLabel, + warehouseCode: scope?.defaultWarehouseCode, + details: [ + field(labels.detailType, labels.nodeDoOut), + ...(outboundKindLabel + ? [field(labels.detailDoOutboundKind, outboundKindLabel)] + : []), + field(labels.deliveryOrder, d.deliveryOrderCode), + ...(d.deliveryNoteCode?.trim() + ? [field(labels.deliveryNoteCode, d.deliveryNoteCode.trim())] + : []), + ...(d.ticketNo?.trim() + ? [field(labels.ticketNo, d.ticketNo.trim())] + : []), + field(labels.detailSupplyTo, supplyTo || "—"), + field(labels.pickOrder, d.pickOrderCode, { + linkKind: docLink.kind, + linkCode: d.pickOrderCode, + linkId: d.pickOrderId, + consoCode: docLink.consoCode || d.consoCode || d.pickOrderCode, + linkTicketNo: docLink.ticketNo, + linkTargetDate: docLink.targetDate, + }), + field(labels.detailQty, formatQty(d.qty, stockUom)), + field(labels.detailHandler, d.handler), + field(labels.detailTime, d.timestamp), + ], + }); + }); + + (data.returnEvents ?? []).forEach((r, i) => { + nodes.push({ + id: lb + ? `${idPfx}return-${i}-${r.stockOutLineId}` + : `${idPfx}return-${r.stockOutLineId}-${r.timestamp}`, + kind: "RETURN", + timestamp: r.timestamp, + sortKey: parseSortKey(r.timestamp, seq++), + title: labels.nodeReturn, + subtitle: [labels.tr.movementType(r.movementType), r.refCode].filter(Boolean).join(" · ") || "—", + qty: r.qty, + uom: stockUom, + refCode: r.refCode, + categoryLabel: labels.categoryOutbound, + details: detailsOf( + field(labels.detailType, labels.tr.movementType(r.movementType)), + field(labels.detailReturnRef, r.refCode), + field(labels.detailWarehouse, r.warehouseCode), + field(labels.detailQty, formatQty(r.qty, stockUom)), + field(labels.detailHandler, r.handler), + field(labels.detailTime, r.timestamp), + fieldIf(labels.detailRemarks, r.remarks), + ), + }); + }); + + (data.lotRelations ?? []).forEach((rel, i) => { + nodes.push({ + id: `repack-${rel.inventoryLotId ?? i}-${rel.lotNo}`, + kind: "REPACK", + timestamp: rel.timestamp, + sortKey: parseSortKey(rel.timestamp, seq++), + title: `${rel.itemCode} · ${labels.nodeRepack}`, + subtitle: [rel.lotNo, rel.productLotNo].filter(Boolean).join(" · ") || rel.itemName, + qty: rel.qty, + uom: stockUom, + traceLotNo: rel.lotNo, + traceItemCode: rel.itemCode, + categoryLabel: labels.categoryTransfer, + details: [ + field(labels.detailType, labels.nodeRepack), + field(labels.detailMaterial, `${rel.itemCode} · ${rel.itemName}`), + field(labels.detailLot, rel.lotNo), + field(labels.detailQty, formatQty(rel.qty, stockUom)), + field(labels.detailProductLotNo, rel.productLotNo), + field(labels.detailTime, rel.timestamp), + ], + }); + }); + + return nodes.map((n) => withNodeScope(n, scope)); +}; diff --git a/src/components/ItemTracing/buildJoPreludeGraphNodes.ts b/src/components/ItemTracing/buildJoPreludeGraphNodes.ts new file mode 100644 index 0000000..537e667 --- /dev/null +++ b/src/components/ItemTracing/buildJoPreludeGraphNodes.ts @@ -0,0 +1,632 @@ +import { ItemLotTraceJoPrelude, ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; +import { + TraceGraphDetailField, + TraceGraphDetailLabels, + TraceGraphNode, +} from "./buildTraceGraphNodes"; +import { + buildProductionGraphNodes, + ProductionGraphLabels, +} from "./buildProductionGraphNodes"; +import { + groupQcResultsBySession, +} from "./traceLabelUtils"; +import { formatQty, formatSignedQty } from "./traceQtyUtils"; +import { + createMaterialPickNode, + docLinkFromOriginType, + docLinkFromRefType, + field, + fieldIf, + detailsOf, + isJoProducedMaterial, + parseSortKey, +} from "./traceNodeFactory"; +import { buildPickOrderTargetDateMapFromPrelude } from "./traceDocLinkUtils"; +import { resolvePutawayPresentation, isTransferInboundPutaway } from "./tracePutawayUtils"; + +export interface JoPreludeGraphLabels extends TraceGraphDetailLabels { + nodeMaterialIn: string; + nodeMaterialPick: string; + nodePurchase: string; + nodeReceipt: string; + nodeJoCreated: string; + pickOrder: string; +} + +type LotPickRef = { + pickOrderCode: string; + pickOrderId: number | null; + consoCode: string; +}; + +type PreludeBuildContext = { + seenPurchaseLines: Set; + seenInboundLots: Set; + seenReceiptLots: Set; + seenQcKeys: Set; + seenPutawayKeys: Set; + pickOrderTargetDateMap: Map; + seq: number; +}; + +const materialLotKey = (itemCode: string, lotNo: string) => `${itemCode}::${lotNo}`; + +const mergeLotPickMaps = ( + target: Map, + source: Map, +): void => { + source.forEach((picks, key) => { + const list = target.get(key) ?? []; + picks.forEach((p) => { + if (!list.some((existing) => existing.pickOrderCode === p.pickOrderCode)) { + list.push(p); + } + }); + if (list.length) target.set(key, list); + }); +}; + +const buildLotPickOrderMap = ( + materialInputs: ItemLotTraceJoPrelude["materialInputs"], +): Map => { + const map = new Map(); + materialInputs.forEach((m) => { + const code = m.pickOrderCode?.trim(); + if (!code) return; + const key = materialLotKey(m.materialItemCode, m.materialLotNo); + const list = map.get(key) ?? []; + if (!list.some((p) => p.pickOrderCode === code)) { + list.push({ + pickOrderCode: code, + pickOrderId: m.pickOrderId, + consoCode: m.consoCode, + }); + } + map.set(key, list); + const nestedInputs = m.nestedJoPrelude?.materialInputs ?? []; + if (nestedInputs.length) { + mergeLotPickMaps(map, buildLotPickOrderMapRecursive(nestedInputs)); + } + }); + return map; +}; + +const buildLotPickOrderMapRecursive = ( + materialInputs: ItemLotTraceMaterialInput[], +): Map => buildLotPickOrderMap(materialInputs); + +const pickOrdersForLot = ( + map: Map, + itemCode: string, + lotNo: string, +): LotPickRef[] => map.get(materialLotKey(itemCode, lotNo)) ?? []; + +const pickOrderCodesLabel = (picks: LotPickRef[]) => + picks.map((p) => p.pickOrderCode).join(" · "); + +const appendPickToSubtitle = (base: string, picks: LotPickRef[], pickLabel: string): string => { + if (!picks.length) return base; + const suffix = `${pickLabel}: ${pickOrderCodesLabel(picks)}`; + return base ? `${base} · ${suffix}` : suffix; +}; + +const pickOrderDetailFields = ( + picks: LotPickRef[], + pickLabel: string, +): TraceGraphDetailField[] => + picks.map((p) => + field(pickLabel, p.pickOrderCode, { + linkKind: "pick", + linkCode: p.pickOrderCode, + linkId: p.pickOrderId, + consoCode: p.consoCode || p.pickOrderCode, + }), + ); + +const nextSeq = (ctx: PreludeBuildContext): number => ctx.seq++; + +const pickCtx = (ctx: PreludeBuildContext) => ({ + nextSeq: () => nextSeq(ctx), + pickOrderTargetDate: (pickOrderCode: string) => + ctx.pickOrderTargetDateMap.get(pickOrderCode?.trim() ?? ""), +}); + +const buildMaterialProductionNodes = ( + m: ItemLotTraceMaterialInput, + labels: JoPreludeGraphLabels & Partial, + ctx: PreludeBuildContext, +): TraceGraphNode[] => { + const steps = m.productionSteps ?? []; + if (!steps.length || !labels.nodeProductionStep || !labels.nodeScrap || !labels.nodeDefect) return []; + + const matUom = m.materialUom?.trim() || ""; + return buildProductionGraphNodes(steps, [], labels as ProductionGraphLabels, matUom).map((node) => ({ + ...node, + id: `mat-prod-${m.materialLotNo}-${node.id}`, + traceLotNo: m.materialLotNo, + traceItemCode: m.materialItemCode, + subtitle: [m.materialItemCode, m.materialLotNo, node.subtitle].filter(Boolean).join(" · "), + })); +}; + +const buildMaterialStockInPreludeNodes = ( + m: ItemLotTraceMaterialInput, + labels: JoPreludeGraphLabels, + lotPicks: Map, + ctx: PreludeBuildContext, +): TraceGraphNode[] => { + const nodes: TraceGraphNode[] = []; + const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); + const lotId = m.materialInventoryLotId; + const origin = m.stockInOrigin; + const matUom = m.materialUom?.trim() || ""; + + (m.purchaseEvents ?? []).forEach((p) => { + if (ctx.seenPurchaseLines.has(p.purchaseOrderLineId)) return; + ctx.seenPurchaseLines.add(p.purchaseOrderLineId); + const purchaseUnit = p.purchaseUnit; + const supplierLabel = [p.supplierCode, p.supplierName].filter(Boolean).join(" "); + const subtitle = + [supplierLabel, p.itemCode, p.itemName].filter(Boolean).join(" · ") || m.materialItemCode; + const meta = [supplierLabel].filter(Boolean).join(" · "); + nodes.push({ + id: `mat-purchase-${p.purchaseOrderLineId}-${m.materialLotNo}`, + kind: "PURCHASE", + timestamp: p.orderDate, + sortKey: parseSortKey(p.orderDate, nextSeq(ctx)), + title: labels.nodePurchase, + subtitle: appendPickToSubtitle(subtitle, picks, labels.pickOrder), + qty: p.orderQty, + uom: purchaseUnit, + meta: meta || undefined, + refType: "PO", + refCode: p.purchaseOrderCode, + refId: p.purchaseOrderId, + docLinkKind: "po", + traceLotNo: m.materialLotNo, + traceItemCode: m.materialItemCode, + categoryLabel: labels.categoryPurchase, + details: [ + field(labels.detailItemCode, p.itemCode), + field(labels.detailItemName, p.itemName), + field(labels.detailSupplier, supplierLabel), + field(labels.detailOrderQty, formatQty(p.orderQty, purchaseUnit)), + field(labels.detailPurchaseUnit, p.purchaseUnit), + field(labels.detailPurchaseOrderNo, p.purchaseOrderCode, { + linkKind: "po", + linkCode: p.purchaseOrderCode, + linkId: p.purchaseOrderId, + }), + field(labels.detailTime, p.orderDate), + field(labels.detailLot, m.materialLotNo), + ...pickOrderDetailFields(picks, labels.pickOrder), + ], + }); + }); + + if (origin && lotId != null) { + const originType = origin.type.trim().toUpperCase(); + const linkKind = docLinkFromOriginType(origin.type); + const subtitle = [ + m.materialLotNo, + [origin.supplierCode, origin.supplierName].filter(Boolean).join(" "), + ] + .filter(Boolean) + .join(" · "); + + if (originType === "PO" && !ctx.seenReceiptLots.has(lotId)) { + ctx.seenReceiptLots.add(lotId); + nodes.push({ + id: `mat-receipt-${lotId}-${origin.stockInLineId}`, + kind: "RECEIPT", + timestamp: origin.receiptDate, + sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)), + title: labels.nodeReceipt, + subtitle: appendPickToSubtitle( + [origin.refCode, subtitle].filter(Boolean).join(" · ") || m.materialItemCode, + picks, + labels.pickOrder, + ), + qty: origin.acceptedQty, + uom: matUom, + refType: origin.type, + refCode: origin.refCode, + refId: origin.refId, + docLinkKind: linkKind, + traceLotNo: m.materialLotNo, + traceItemCode: m.materialItemCode, + categoryLabel: labels.categoryReceipt, + details: [ + field(labels.detailPurchaseOrderNo, origin.refCode, { + linkKind, + linkCode: origin.refCode, + linkId: origin.refId, + }), + field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")), + field(labels.detailItemCode, m.materialItemCode), + field(labels.detailLot, m.materialLotNo), + field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), + field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), + field(labels.detailTime, origin.receiptDate), + ...pickOrderDetailFields(picks, labels.pickOrder), + ], + }); + } else if ( + originType !== "PO" && + originType !== "ADJ" && + !ctx.seenInboundLots.has(lotId) + ) { + // Nested JO_CREATED already represents this JO output lot (with 提料 links). + const skipJoOriginBecauseNested = + originType === "JO" && Boolean(m.nestedJoPrelude?.jobOrder?.jobOrderCode?.trim()); + if (!skipJoOriginBecauseNested) { + // ADJ is emitted after putaways as ADJUSTMENT (FG-aligned: 上架 → 庫存調整). + ctx.seenInboundLots.add(lotId); + const isJoOrigin = originType === "JO"; + nodes.push({ + id: `mat-in-${lotId}-${origin.stockInLineId}`, + kind: isJoOrigin ? "JO_CREATED" : "MATERIAL_IN", + timestamp: origin.receiptDate, + sortKey: parseSortKey(origin.receiptDate, nextSeq(ctx)), + title: isJoOrigin + ? labels.nodeJoCreated + : `${labels.tr.refType(origin.type)} · ${labels.nodeMaterialIn}`, + subtitle: appendPickToSubtitle( + subtitle || m.materialItemCode, + picks, + labels.pickOrder, + ), + qty: origin.acceptedQty, + uom: matUom, + refType: origin.type, + refCode: origin.refCode, + refId: origin.refId, + docLinkKind: linkKind, + traceLotNo: m.materialLotNo, + traceItemCode: m.materialItemCode, + categoryLabel: isJoOrigin ? labels.categoryProduction : labels.categoryPurchase, + details: [ + field(labels.detailType, isJoOrigin ? labels.nodeJoCreated : labels.tr.refType(origin.type)), + field( + isJoOrigin + ? labels.jobOrder + : originType === "PO" + ? labels.detailPurchaseOrderNo + : labels.detailSourceDoc, + origin.refCode, + { + linkKind, + linkCode: origin.refCode, + linkId: origin.refId, + }), + field(labels.detailSupplier, [origin.supplierCode, origin.supplierName].filter(Boolean).join(" ")), + field(labels.detailItemCode, m.materialItemCode), + field(labels.detailLot, m.materialLotNo), + field(labels.detailQty, formatQty(origin.acceptedQty, matUom)), + field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), + field(labels.detailTime, origin.receiptDate), + ...pickOrderDetailFields(picks, labels.pickOrder), + ], + }); + } + } + } + + groupQcResultsBySession(m.qcResults).forEach((group) => { + const first = group[0]; + if (!first) return; + const key = `${first.stockInLineId}-${first.created}`; + if (ctx.seenQcKeys.has(key)) return; + ctx.seenQcKeys.add(key); + const allPassed = group.every((q) => q.qcPassed); + const totalFail = group.reduce((s, q) => s + (q.failQty ?? 0), 0); + const meta = [first.handledBy, ...group.map((q) => q.remarks).filter(Boolean), m.materialLotNo] + .filter(Boolean) + .join(" · "); + const qcTypeRaw = group.map((q) => q.qcType).find((t) => t?.trim()) ?? ""; + nodes.push({ + id: `mat-qc-${key}`, + kind: "MATERIAL_QC", + timestamp: first.created, + sortKey: parseSortKey(first.created, nextSeq(ctx)), + title: allPassed ? labels.nodeQcPass : labels.nodeQcFail, + subtitle: "", + qty: totalFail, + uom: matUom, + meta: meta || undefined, + traceLotNo: m.materialLotNo, + traceItemCode: m.materialItemCode, + categoryLabel: labels.categoryQc, + qcTypeLabel: labels.tr.qcType(qcTypeRaw), + details: detailsOf( + field(labels.detailItemCode, m.materialItemCode), + field(labels.detailLot, m.materialLotNo), + 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, matUom)), + field(labels.detailFailQty, formatQty(totalFail, matUom)), + field(labels.detailHandler, first.handledBy), + field(labels.detailTime, first.created), + fieldIf(labels.detailRemarks, group.map((q) => q.remarks).filter(Boolean).join(";")), + ...pickOrderDetailFields(picks, labels.pickOrder), + ), + }); + }); + + let lastMaterialPutawayWh = ""; + (m.putawayEvents ?? []).forEach((p) => { + const key = `${p.inventoryLotLineId}-${p.timestamp}`; + if (ctx.seenPutawayKeys.has(key)) return; + if (isTransferInboundPutaway(p.refType)) return; + ctx.seenPutawayKeys.add(key); + const linkKind = docLinkFromRefType(p.refType); + const pres = resolvePutawayPresentation( + labels.tr, + { + nodePutaway: labels.nodePutaway, + nodePutawayTransfer: labels.nodePutawayTransfer, + putawayTransferDetail: labels.putawayTransferDetail, + }, + p.status, + p.refType, + ); + const meta = [pres.chipLabel, p.handledBy, p.warehouseCode].filter(Boolean).join(" · "); + if (p.warehouseCode?.trim()) lastMaterialPutawayWh = p.warehouseCode.trim(); + nodes.push({ + id: `mat-putaway-${p.inventoryLotLineId}-${p.timestamp}`, + kind: "PUTAWAY", + timestamp: p.timestamp, + sortKey: parseSortKey(p.timestamp, nextSeq(ctx)), + title: pres.title, + subtitle: appendPickToSubtitle( + [pres.chipLabel, p.refCode, p.warehouseCode, m.materialLotNo].filter(Boolean).join(" · ") || + m.materialItemCode, + picks, + labels.pickOrder, + ), + qty: p.qty, + uom: matUom, + meta: meta || undefined, + refType: p.refType, + refCode: p.refCode, + refId: p.refId, + docLinkKind: linkKind, + putawayStatusLabel: pres.chipLabel, + putawayStatusDetail: pres.statusDetail, + warehouseCode: p.warehouseCode, + traceLotNo: m.materialLotNo, + traceItemCode: m.materialItemCode, + categoryLabel: labels.categoryPutaway, + details: [ + field(labels.detailStatus, pres.statusDetail), + field(labels.detailItemCode, m.materialItemCode), + field(labels.detailLot, m.materialLotNo), + field( + isTransferInboundPutaway(p.refType) + ? labels.detailInboundSiNo + : (p.refType ?? "").trim().toUpperCase() === "PO" + ? labels.detailPurchaseOrderNo + : (p.refType ?? "").trim().toUpperCase() === "JO" + ? labels.jobOrder + : labels.detailSourceDoc, + p.refCode, + { + linkKind, + linkCode: p.refCode, + linkId: p.refId, + }), + field(labels.detailPutawayBin, p.warehouseCode), + field(labels.detailQty, formatQty(p.qty, matUom)), + field(labels.detailHandler, p.handledBy), + field(labels.detailTime, p.timestamp), + ...pickOrderDetailFields(picks, labels.pickOrder), + ], + }); + }); + + // ADJ stock-in: match FG — 上架 → 庫存調整 (入), not orphaned 「調整 · 原料入庫」. + if (origin && lotId != null) { + const originType = origin.type.trim().toUpperCase(); + if (originType === "ADJ" && !ctx.seenInboundLots.has(lotId)) { + ctx.seenInboundLots.add(lotId); + const adjWh = lastMaterialPutawayWh; + const meta = [labels.tr.stockInStatus(origin.status)].filter(Boolean).join(" · "); + // Place at/after related putaway so chronological X and 上架 → 調整 edges match FG. + const lastPutawayTs = (m.putawayEvents ?? []) + .map((p) => p.timestamp) + .filter((t): t is string => Boolean(t?.trim())) + .sort() + .at(-1); + const adjTimestamp = + lastPutawayTs && + parseSortKey(lastPutawayTs, 0) >= parseSortKey(origin.receiptDate, 0) + ? lastPutawayTs + : origin.receiptDate; + // Keep sortKey strictly after matching putaway so layout X stays 上架 → 調整 + // (same calendar time previously ordered adj left of putaway → back-arrow). + const putawaySort = lastPutawayTs ? parseSortKey(lastPutawayTs, 0) : null; + const adjSortBase = parseSortKey(adjTimestamp, nextSeq(ctx)); + nodes.push({ + id: `mat-adj-${lotId}-${origin.stockInLineId}`, + kind: "ADJUSTMENT", + timestamp: origin.receiptDate, + sortKey: + putawaySort != null && adjSortBase <= putawaySort ? putawaySort + 1 : adjSortBase, + title: `${labels.nodeAdjustment} (${labels.tr.direction("IN")})`, + subtitle: appendPickToSubtitle( + [labels.tr.adjustmentType("ADJ"), origin.refCode || m.materialLotNo] + .filter(Boolean) + .join(" · ") || m.materialItemCode, + picks, + labels.pickOrder, + ), + qty: origin.acceptedQty, + uom: matUom, + adjustmentDirection: "IN", + meta: meta || undefined, + refType: origin.type, + refCode: origin.refCode, + refId: origin.refId, + warehouseCode: adjWh || undefined, + traceLotNo: m.materialLotNo, + traceItemCode: m.materialItemCode, + categoryLabel: labels.categoryAdjustment, + details: detailsOf( + field(labels.detailVariance, formatSignedQty(origin.acceptedQty, "IN", matUom)), + field(labels.detailAdjustmentRef, origin.refCode), + field(labels.detailItemCode, m.materialItemCode), + field(labels.detailLot, m.materialLotNo), + ...(adjWh ? [field(labels.detailWarehouse, adjWh)] : []), + field(labels.detailStatus, labels.tr.stockInStatus(origin.status)), + field(labels.detailTime, origin.receiptDate), + ...pickOrderDetailFields(picks, labels.pickOrder), + ), + }); + } + } + + return nodes; +}; + +const buildNestedJoCreatedNode = ( + m: ItemLotTraceMaterialInput, + labels: JoPreludeGraphLabels, + picks: LotPickRef[], + ctx: PreludeBuildContext, +): TraceGraphNode | null => { + const nested = m.nestedJoPrelude; + const jo = nested?.jobOrder; + if (!jo?.jobOrderCode?.trim()) return null; + + const nestedInputs = nested?.materialInputs ?? []; + const nestedPickTs = nestedInputs + .map((input) => input.pickedAt) + .find((ts) => ts?.trim()); + const nestedProdTs = (m.productionSteps ?? []) + .map((step) => step.startTime) + .find((ts) => ts?.trim()); + const origin = m.stockInOrigin; + const ts = + jo.createdAt?.trim() || + jo.planStart?.trim() || + nestedPickTs?.trim() || + nestedProdTs?.trim() || + origin?.receiptDate?.trim() || + null; + const matUom = m.materialUom?.trim() || ""; + const qty = origin?.acceptedQty ?? jo.reqQty; + + return { + id: `mat-jo-created-${jo.jobOrderId}-${m.materialLotNo}`, + kind: "JO_CREATED", + timestamp: ts, + sortKey: parseSortKey(ts, nextSeq(ctx)), + title: labels.nodeJoCreated, + subtitle: appendPickToSubtitle( + [m.materialLotNo, m.materialItemCode].filter(Boolean).join(" · ") || jo.jobOrderCode, + picks, + labels.pickOrder, + ), + qty, + uom: matUom, + refType: "JO", + refCode: jo.jobOrderCode, + refId: jo.jobOrderId, + docLinkKind: "jo", + traceLotNo: m.materialLotNo, + traceItemCode: m.materialItemCode, + categoryLabel: labels.categoryProduction, + details: [ + field(labels.detailType, labels.nodeJoCreated), + field(labels.jobOrder, jo.jobOrderCode, { + linkKind: "jo", + linkCode: jo.jobOrderCode, + linkId: jo.jobOrderId, + }), + field(labels.detailItemCode, m.materialItemCode), + field(labels.detailLot, m.materialLotNo), + field(labels.detailQty, formatQty(qty, matUom)), + field(labels.detailTime, ts), + ...pickOrderDetailFields(picks, labels.pickOrder), + ], + }; +}; + +const buildMaterialInputChainNodes = ( + materialInputs: ItemLotTraceMaterialInput[], + labels: JoPreludeGraphLabels & Partial, + lotPicks: Map, + ctx: PreludeBuildContext, +): TraceGraphNode[] => { + const nodes: TraceGraphNode[] = []; + + materialInputs.forEach((m) => { + const joProduced = isJoProducedMaterial(m); + const nestedInputs = m.nestedJoPrelude?.materialInputs ?? []; + const picks = pickOrdersForLot(lotPicks, m.materialItemCode, m.materialLotNo); + + if (joProduced && m.nestedJoPrelude) { + const nestedJoCreated = buildNestedJoCreatedNode(m, labels, picks, ctx); + if (nestedJoCreated) nodes.push(nestedJoCreated); + } + + if (joProduced && nestedInputs.length > 0) { + const nestedLotPicks = buildLotPickOrderMap(nestedInputs); + nodes.push( + ...buildMaterialInputChainNodes( + nestedInputs, + labels, + nestedLotPicks, + ctx, + ), + ); + nestedInputs.forEach((nm, ni) => { + nodes.push(createMaterialPickNode(nm, ni, labels, pickCtx(ctx), m.materialLotNo)); + }); + } + + if (joProduced && (m.productionSteps?.length ?? 0) > 0) { + nodes.push(...buildMaterialProductionNodes(m, labels, ctx)); + } + + nodes.push(...buildMaterialStockInPreludeNodes(m, labels, lotPicks, ctx)); + }); + + return nodes; +}; + +export const buildJoPreludeGraphNodes = ( + joPrelude: ItemLotTraceJoPrelude, + labels: JoPreludeGraphLabels & Partial, +): TraceGraphNode[] => { + const ctx: PreludeBuildContext = { + seenPurchaseLines: new Set(), + seenInboundLots: new Set(), + seenReceiptLots: new Set(), + seenQcKeys: new Set(), + seenPutawayKeys: new Set(), + pickOrderTargetDateMap: buildPickOrderTargetDateMapFromPrelude(joPrelude), + seq: 0, + }; + const lotPicks = buildLotPickOrderMap(joPrelude.materialInputs); + + const nodes: TraceGraphNode[] = [ + ...buildMaterialInputChainNodes(joPrelude.materialInputs, labels, lotPicks, ctx), + ...joPrelude.materialInputs.map((m, i) => createMaterialPickNode(m, i, labels, pickCtx(ctx))), + ]; + + return nodes.sort((a, b) => { + if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; + return a.id.localeCompare(b.id); + }); +}; diff --git a/src/components/ItemTracing/buildLocationBlockGraphNodes.ts b/src/components/ItemTracing/buildLocationBlockGraphNodes.ts new file mode 100644 index 0000000..f936182 --- /dev/null +++ b/src/components/ItemTracing/buildLocationBlockGraphNodes.ts @@ -0,0 +1,172 @@ +import { ItemLotTraceLocationBlock, ItemLotTraceResponse } from "@/app/api/itemTracing"; + +import { + + buildTraceGraphNodes, + + TraceGraphBuildScope, + + TraceGraphDetailLabels, + + TraceGraphNode, + +} from "./buildTraceGraphNodes"; + +import { + + buildExtendedTraceGraphNodes, + + ExtendedTraceGraphLabels, + +} from "./buildExtendedTraceGraphNodes"; + + + +export const blockWarehouseCode = (block: ItemLotTraceLocationBlock): string => + + block.warehouseLines + + .map((w) => w.warehouseCode) + + .filter(Boolean) + + .join(" / ") || `Lot #${block.inventoryLotId}`; + + + +const blockToTraceResponse = (block: ItemLotTraceLocationBlock): ItemLotTraceResponse => { + + const uom = block.uom ?? ""; + + return { + + lot: { + + inventoryLotId: block.inventoryLotId, + + lotNo: block.lotNo, + + itemId: 0, + + itemCode: block.itemCode, + + itemName: block.itemName ?? "", + + expiryDate: block.expiryDate ?? null, + + productionDate: block.productionDate ?? null, + + stockInDate: block.stockInDate ?? null, + + uom, + + primaryStockInLineId: block.stockInLineId, + + }, + + warehouseLines: block.warehouseLines, + + origins: block.origins, + + purchaseEvents: block.purchaseEvents ?? [], + + qcResults: block.qcResults ?? [], + + putawayEvents: block.putawayEvents ?? [], + + movements: block.movements ?? [], + + outboundUsage: block.outboundUsage ?? [], + + stockTakeEvents: block.stockTakeEvents ?? [], + + adjustments: block.adjustments ?? [], + + transfers: block.transfers ?? [], + + bomTrace: { direction: "UNKNOWN", upstream: [], downstream: [], bomRecipe: [] }, + + joPrelude: null, + + productionSteps: [], + + byproductLots: [], + + openMovements: block.openMovements ?? [], + + failEvents: block.failEvents ?? [], + + doDeliveries: block.doDeliveries ?? [], + + replenishmentEvents: block.replenishmentEvents ?? [], + + returnEvents: block.returnEvents ?? [], + + lotRelations: [], + + alternateLocations: [], + + locationBlocks: [], + + traceGraph: null, + + }; + +}; + + + +export const buildLocationBlockGraphNodes = ( + + block: ItemLotTraceLocationBlock, + + labels: TraceGraphDetailLabels & ExtendedTraceGraphLabels, + +): TraceGraphNode[] => { + + const wh = blockWarehouseCode(block); + + const scope: TraceGraphBuildScope = { + idPrefix: `loc-${block.inventoryLotId}-`, + locationBlockKeys: true, + inventoryLotId: block.inventoryLotId, + defaultWarehouseCode: wh, + mergedMultiLocation: true, + }; + + const synthetic = blockToTraceResponse(block); + + const fgNodes = buildTraceGraphNodes(synthetic, labels, scope); + + const extNodes = + + labels.nodeOpen && labels.nodeFail && labels.nodeDoOut + + ? buildExtendedTraceGraphNodes(synthetic, labels, scope) + + : []; + + + + return [...fgNodes, ...extNodes].sort((a, b) => { + + if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; + + return a.id.localeCompare(b.id); + + }); + +}; + + + +export const buildAllLocationBlockGraphNodes = ( + + blocks: ItemLotTraceLocationBlock[], + + labels: TraceGraphDetailLabels & ExtendedTraceGraphLabels, + +): TraceGraphNode[] => + + (blocks ?? []).flatMap((block) => buildLocationBlockGraphNodes(block, labels)); + diff --git a/src/components/ItemTracing/buildProductionGraphNodes.ts b/src/components/ItemTracing/buildProductionGraphNodes.ts new file mode 100644 index 0000000..86f7036 --- /dev/null +++ b/src/components/ItemTracing/buildProductionGraphNodes.ts @@ -0,0 +1,169 @@ +import { + ItemLotTraceByproductLot, + ItemLotTraceProductionStep, +} from "@/app/api/itemTracing"; +import { + TraceGraphDetailLabels, + TraceGraphNode, +} from "./buildTraceGraphNodes"; +import { formatQty } from "./traceQtyUtils"; +import { field, fieldIf, detailsOf, parseSortKey } from "./traceNodeFactory"; + +export interface ProductionGraphLabels extends TraceGraphDetailLabels { + nodeProductionStep: string; + nodeByproduct: string; + nodeScrap: string; + nodeDefect: string; + detailProcessOutputQty: string; + detailProcessScrapQty: string; + detailProcessDefectQty: string; +} + +const formatStepMaterials = ( + materials: ItemLotTraceProductionStep["stepMaterials"], +): string => { + if (!materials?.length) return "—"; + return materials + .map((mat, i) => { + const label = [mat.materialItemCode, mat.materialItemName].filter(Boolean).join(" · "); + const qty = formatQty(mat.qtyPerUnit, mat.uom); + return `${i + 1}. ${label} (${qty})`; + }) + .join("\n"); +}; + +export const buildProductionGraphNodes = ( + productionSteps: ItemLotTraceProductionStep[], + byproductLots: ItemLotTraceByproductLot[], + labels: ProductionGraphLabels, + lotUom: string, +): TraceGraphNode[] => { + const nodes: TraceGraphNode[] = []; + let seq = 0; + + productionSteps.forEach((step) => { + const ts = step.endTime ?? step.startTime; + const equip = [step.equipmentCode, step.equipmentName].filter(Boolean).join(" "); + const stepMaterialsLabel = formatStepMaterials(step.stepMaterials); + const materialCodesList = (step.stepMaterials ?? []) + .map((mat) => mat.materialItemCode?.trim()) + .filter((code): code is string => Boolean(code)); + const materialCodes = materialCodesList.join(" · "); + nodes.push({ + id: `prod-step-${step.processLineId}`, + kind: "PRODUCTION_STEP", + timestamp: ts, + sortKey: parseSortKey(ts, seq++), + title: step.stepName || labels.nodeProductionStep, + subtitle: [materialCodes, equip, labels.tr.productionStatus(step.status)] + .filter(Boolean) + .join(" · ") || "—", + qty: step.outputQty, + uom: lotUom, + meta: step.operatorName ? `${labels.detailHandler}: ${step.operatorName}` : undefined, + refType: "JO", + bomProcessId: step.bomProcessId, + bomProcessSeqNo: step.seqNo, + assignedStepName: step.stepName, + stepMaterialItemCodes: materialCodesList.length ? materialCodesList : undefined, + categoryLabel: labels.categoryProduction, + details: detailsOf( + field(labels.detailType, labels.nodeProductionStep), + field(labels.detailStatus, labels.tr.productionStatus(step.status)), + field(labels.detailProcessStep, step.stepName), + field(labels.detailStepMaterials, stepMaterialsLabel), + fieldIf(labels.detailProcessDescription, step.description), + field(labels.detailHandler, step.operatorName), + field(labels.detailTime, ts), + field(labels.detailProcessOutputQty, formatQty(step.outputQty, lotUom)), + field(labels.detailProcessScrapQty, formatQty(step.scrapQty, lotUom)), + field(labels.detailProcessDefectQty, formatQty(step.defectQty, lotUom)), + field(labels.detailEquipment, equip || "—"), + ), + }); + + const lossQty = (step.scrapQty ?? 0) + (step.defectQty ?? 0); + if (lossQty > 0) { + if ((step.scrapQty ?? 0) > 0) { + nodes.push({ + id: `scrap-${step.processLineId}`, + kind: "SCRAP", + timestamp: ts, + sortKey: parseSortKey(ts, seq++) + 1, + title: labels.nodeScrap, + subtitle: step.stepName || "—", + qty: step.scrapQty, + uom: lotUom, + bomProcessId: step.bomProcessId, + bomProcessSeqNo: step.seqNo, + assignedStepName: step.stepName, + categoryLabel: labels.categoryProduction, + details: [ + field(labels.detailType, labels.nodeScrap), + field(labels.detailProcessStep, step.stepName), + field(labels.detailProcessScrapQty, formatQty(step.scrapQty, lotUom)), + field(labels.detailTime, ts), + ], + }); + } + if ((step.defectQty ?? 0) > 0) { + nodes.push({ + id: `defect-${step.processLineId}`, + kind: "DEFECT", + timestamp: ts, + sortKey: parseSortKey(ts, seq++) + 2, + title: labels.nodeDefect, + subtitle: step.stepName || "—", + qty: step.defectQty, + uom: lotUom, + bomProcessId: step.bomProcessId, + bomProcessSeqNo: step.seqNo, + assignedStepName: step.stepName, + categoryLabel: labels.categoryProduction, + details: [ + field(labels.detailType, labels.nodeDefect), + field(labels.detailProcessStep, step.stepName), + field(labels.detailProcessDefectQty, formatQty(step.defectQty, lotUom)), + field(labels.detailTime, ts), + ], + }); + } + } + }); + + byproductLots.forEach((bp, i) => { + const subtitle = [bp.lotNo, bp.processStepName].filter(Boolean).join(" · ") || bp.itemName; + nodes.push({ + id: `byproduct-${bp.inventoryLotId ?? i}-${bp.lotNo}`, + kind: "BYPRODUCT", + timestamp: bp.producedAt, + sortKey: parseSortKey(bp.producedAt, seq++), + title: `${bp.itemCode} · ${labels.nodeByproduct}`, + subtitle, + qty: bp.qty, + uom: bp.uom, + refType: "JO", + refCode: bp.jobOrderCode, + refId: bp.jobOrderId, + docLinkKind: "jo", + traceLotNo: bp.lotNo, + traceItemCode: bp.itemCode, + categoryLabel: labels.categoryProduction, + details: [ + field(labels.detailType, labels.nodeByproduct), + field(labels.detailMaterial, `${bp.itemCode} · ${bp.itemName}`), + field(labels.detailLot, bp.lotNo), + field(labels.detailQty, formatQty(bp.qty, bp.uom)), + field(labels.detailTime, bp.producedAt), + field(labels.jobOrder, bp.jobOrderCode, { + linkKind: "jo", + linkCode: bp.jobOrderCode, + linkId: bp.jobOrderId, + }), + field(labels.detailProcessStep, bp.processStepName), + ], + }); + }); + + return nodes; +}; diff --git a/src/components/ItemTracing/buildReactFlowGraph.ts b/src/components/ItemTracing/buildReactFlowGraph.ts new file mode 100644 index 0000000..b148797 --- /dev/null +++ b/src/components/ItemTracing/buildReactFlowGraph.ts @@ -0,0 +1,323 @@ +import { MarkerType, type Edge, type Node } from "@xyflow/react"; +import { + COLUMN_WIDTH_MIN, + HEADER_HEIGHT, + LANE_GAP, + LANE_MIN_HEIGHT, + NODE_HEIGHT, + NODE_WIDTH, + NODE_WIDTH_BRANCH, + PHASE_LABEL_WIDTH, +} from "./traceFlowConstants"; +import { + CELL_PAD_X, + layoutCellMembers, + computeLaneTops, +} from "./traceFlowLayout"; +import { phaseAccent } from "./traceFlowNodeUtils"; +import { TraceGraphLayout, TraceGraphLayoutNode, buildTraceFlowEdgePairs } from "./traceGraphLayout"; +import type { TraceFlowEdgePair } from "./traceGraphSemantics"; +import { + assignTraceFlowEdgeRouting, + enrichTraceFlowCorridorRouting, + type TraceFlowNodeRect, +} from "./traceFlowEdgeLayout"; +import { + isDoGroupChild, + isFlowGroupContainer, + layoutDoGroupChildPlacements, +} from "./traceDoGroupLayout"; + +export type TraceFlowNodeData = { + layoutNode: TraceGraphLayoutNode; + compact: boolean; + onTrace?: (params: { stockInLineId?: number; lotNo?: string; itemCode?: string }) => void; + phaseLabel?: string; + dateLabel?: string; + phaseColor?: string; + incomingHandleCount?: number; + outgoingHandleCount?: number; + searchActive?: boolean; + searchMatch?: boolean; + searchFocused?: boolean; + nodeSelected?: boolean; + /** DO / pick group header collapse. */ + groupCollapsed?: boolean; + onToggleGroupCollapse?: () => void; +}; + +const cellKey = (laneIndex: number, column: number, stagger: number) => + `${laneIndex}:${column}:${stagger}`; + +export const buildReactFlowGraph = ( + layout: TraceGraphLayout, + phaseLabels: Record, + edgePairs?: TraceFlowEdgePair[], +): { nodes: Node[]; edges: Edge[]; graphHeight: number } => { + const nodes: Node[] = []; + const topLevelNodes = layout.nodes.filter((n) => !isDoGroupChild(n)); + + const cells = new Map(); + topLevelNodes.forEach((n) => { + const key = cellKey(n.laneIndex, n.column, n.dayPhaseStaggerIndex); + const list = cells.get(key) ?? []; + list.push(n); + cells.set(key, list); + }); + + const cellLayouts = new Map>(); + const { columnStarts, columnWidths, phaseSlotWidths, phaseSlotStarts } = layout.dayColumns; + + cells.forEach((members, key) => { + const col = Number(key.split(":")[1]); + const stagger = members[0]?.dayPhaseStaggerIndex ?? 0; + const slotW = phaseSlotWidths[col]?.[stagger] ?? COLUMN_WIDTH_MIN; + cellLayouts.set(key, layoutCellMembers(members, slotW - CELL_PAD_X * 2)); + }); + + const doChildrenByGroup = new Map(); + layout.nodes.forEach((n) => { + if (!n.doGroupId) return; + const list = doChildrenByGroup.get(n.doGroupId) ?? []; + list.push(n); + doChildrenByGroup.set(n.doGroupId, list); + }); + + const childPlacementsByGroup = new Map< + string, + ReturnType + >(); + doChildrenByGroup.forEach((children, groupId) => { + childPlacementsByGroup.set(groupId, layoutDoGroupChildPlacements(children)); + }); + + const { laneTops, laneHeights, totalHeight } = computeLaneTops(layout.laneCount, cells); + + layout.columnDates.forEach((date, col) => { + const dayW = columnWidths[col] ?? COLUMN_WIDTH_MIN; + const dayStart = columnStarts[col] ?? col * COLUMN_WIDTH_MIN; + nodes.push({ + id: `hdr-${col}`, + type: "dateHeader", + position: { + x: PHASE_LABEL_WIDTH + dayStart + dayW / 2 - 40, + y: 6, + }, + data: { layoutNode: {} as TraceGraphLayoutNode, compact: false, dateLabel: date }, + draggable: false, + selectable: false, + connectable: false, + focusable: false, + }); + }); + + layout.phaseOrder.forEach((phase, laneIdx) => { + const laneH = laneHeights[laneIdx] ?? LANE_MIN_HEIGHT; + nodes.push({ + id: `phase-${phase}`, + type: "phaseLabel", + position: { + x: 8, + y: laneTops[laneIdx] + Math.max(8, (laneH - 80) / 2), + }, + data: { + layoutNode: {} as TraceGraphLayoutNode, + compact: false, + phaseLabel: phaseLabels[phase] ?? phase, + phaseColor: phaseAccent(phase), + }, + draggable: false, + selectable: false, + connectable: false, + focusable: false, + }); + }); + + topLevelNodes.forEach((layoutNode) => { + const key = cellKey(layoutNode.laneIndex, layoutNode.column, layoutNode.dayPhaseStaggerIndex); + const placed = cellLayouts.get(key)?.get(layoutNode.id); + if (!placed) return; + + const dayStart = columnStarts[layoutNode.column] ?? layoutNode.column * COLUMN_WIDTH_MIN; + const phaseSlotX = + phaseSlotStarts[layoutNode.column]?.[layoutNode.dayPhaseStaggerIndex] ?? 0; + const isGroup = isFlowGroupContainer(layoutNode); + const groupW = layoutNode.groupBoxWidth ?? NODE_WIDTH; + const groupH = layoutNode.groupBoxHeight ?? NODE_HEIGHT; + + nodes.push({ + id: layoutNode.id, + type: layoutNode.kind === "PICK_GROUP" ? "pickGroup" : isGroup ? "doGroup" : "traceEvent", + position: { + x: PHASE_LABEL_WIDTH + dayStart + phaseSlotX + CELL_PAD_X + placed.x, + y: laneTops[layoutNode.laneIndex] + placed.y, + }, + data: { layoutNode, compact: placed.compact }, + draggable: false, + connectable: false, + selectable: !isGroup, + zIndex: isGroup ? 0 : 1, + style: isGroup ? { width: groupW, height: groupH } : undefined, + width: isGroup ? groupW : undefined, + height: isGroup ? groupH : undefined, + measured: { + width: isGroup ? groupW : placed.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH, + height: isGroup ? groupH : NODE_HEIGHT, + }, + }); + }); + + const parentPositions = new Map(); + nodes.forEach((node) => { + if (node.type === "doGroup" || node.type === "pickGroup") { + parentPositions.set(node.id, node.position); + } + }); + + doChildrenByGroup.forEach((children, groupId) => { + const placements = childPlacementsByGroup.get(groupId); + const parentPos = parentPositions.get(groupId); + if (!placements || !parentPos) return; + + children.forEach((layoutNode) => { + const placed = placements.get(layoutNode.id); + if (!placed) return; + + nodes.push({ + id: layoutNode.id, + type: "traceEvent", + position: { + x: parentPos.x + placed.x, + y: parentPos.y + placed.y, + }, + data: { layoutNode, compact: placed.compact }, + draggable: false, + connectable: false, + zIndex: 2, + width: NODE_WIDTH, + height: NODE_HEIGHT, + measured: { + width: NODE_WIDTH, + height: NODE_HEIGHT, + }, + }); + }); + }); + + const flowPairs = edgePairs ?? buildTraceFlowEdgePairs(layout.nodes, layout.phaseOrder); + const { routed, incomingCount, outgoingCount } = assignTraceFlowEdgeRouting(flowPairs); + + const nodeRects = new Map(); + nodes.forEach((node) => { + if (node.type !== "traceEvent" && node.type !== "doGroup" && node.type !== "pickGroup") return; + const layoutNode = node.data.layoutNode; + if (layoutNode.doGroupId) return; + nodeRects.set(node.id, { + x: node.position.x, + y: node.position.y, + width: node.measured?.width ?? NODE_WIDTH, + height: node.measured?.height ?? NODE_HEIGHT, + laneIndex: layoutNode.laneIndex, + }); + }); + + const corridorRouted = enrichTraceFlowCorridorRouting(routed, nodeRects, { + laneTops, + laneHeights, + laneGap: LANE_GAP, + }); + + const nodesWithHandles = nodes.map((node) => { + if (node.type !== "traceEvent" && node.type !== "doGroup" && node.type !== "pickGroup") return node; + if (node.data.layoutNode.doGroupId) { + return { + ...node, + data: { + ...node.data, + incomingHandleCount: 0, + outgoingHandleCount: 0, + }, + }; + } + return { + ...node, + data: { + ...node.data, + incomingHandleCount: incomingCount.get(node.id) ?? 0, + outgoingHandleCount: outgoingCount.get(node.id) ?? 0, + }, + }; + }); + + const nodeById = new Map(layout.nodes.map((n) => [n.id, n])); + + const edges: Edge[] = corridorRouted.map( + ({ + fromId, + toId, + sourceHandle, + targetHandle, + offset, + pathMode, + corridorY, + corridorEntryOffset, + corridorExitOffset, + corridorBranchOffset, + }) => { + const targetNode = nodeById.get(toId); + const ref = targetNode?.refCode?.trim(); + const flowLabel = + targetNode?.kind === "DO_GROUP" && targetNode.groupMemberCount + ? String(targetNode.groupMemberCount) + : targetNode?.kind === "PICK_GROUP" && ref + ? ref + : targetNode?.kind === "MATERIAL_PICK" && ref + ? ref + : (targetNode?.kind === "OUT" || targetNode?.kind === "DO_OUT") && ref + ? ref + : undefined; + return { + id: `e-${fromId}-${toId}`, + source: fromId, + target: toId, + sourceHandle, + targetHandle, + type: "traceFlow", + data: { + offset, + pathMode, + corridorY, + corridorEntryOffset, + corridorExitOffset, + corridorBranchOffset, + }, + label: flowLabel, + labelStyle: { fontSize: 10, fontWeight: 600, fill: "#5d4037" }, + labelBgStyle: { fill: "#fff8e1", fillOpacity: 0.95 }, + labelBgPadding: [4, 6] as [number, number], + labelBgBorderRadius: 4, + style: { stroke: "#757575", strokeWidth: 1.5, opacity: 0.75 }, + markerEnd: { type: MarkerType.ArrowClosed, color: "#757575", width: 16, height: 16 }, + }; + }); + + return { nodes: nodesWithHandles, edges, graphHeight: totalHeight }; +}; + +export const reactFlowGraphExtent = ( + layout: TraceGraphLayout, + graphHeight?: number, +): [[number, number], [number, number]] => { + const width = PHASE_LABEL_WIDTH + layout.dayColumns.timelineWidth + 32; + const height = graphHeight ?? HEADER_HEIGHT + layout.laneCount * LANE_MIN_HEIGHT + 32; + // Extra bottom room so expanded stock-take lifecycle panels stay pannable. + const padX = 48; + const padTop = 48; + const padBottom = 360; + return [ + [-padX, -padTop], + [width + padX, height + padBottom], + ]; +}; + +export type ReactFlowGraphResult = ReturnType; diff --git a/src/components/ItemTracing/buildTraceGraphNodes.ts b/src/components/ItemTracing/buildTraceGraphNodes.ts new file mode 100644 index 0000000..9ee7d78 --- /dev/null +++ b/src/components/ItemTracing/buildTraceGraphNodes.ts @@ -0,0 +1,1210 @@ +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, + 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 }; diff --git a/src/components/ItemTracing/compileTraceGraph.ts b/src/components/ItemTracing/compileTraceGraph.ts new file mode 100644 index 0000000..60ef281 --- /dev/null +++ b/src/components/ItemTracing/compileTraceGraph.ts @@ -0,0 +1,33 @@ +import { ItemLotTraceResponse } from "@/app/api/itemTracing"; +import { + buildTraceGraphLayout, + TraceGraphLayout, + TraceGraphLayoutNode, +} from "./traceGraphLayout"; +import { resolveTraceFlowEdgePairs } from "./traceGraphSemantics"; +import type { TraceGraphCompileLabels } from "./traceGraphLabels"; + +export type TraceFlowEdgePair = { fromId: string; toId: string }; + +export type CompiledTraceGraph = { + layout: TraceGraphLayout; + edgePairs: TraceFlowEdgePair[]; + nodes: TraceGraphLayoutNode[]; +}; + +export const compileTraceGraph = ( + data: ItemLotTraceResponse, + labels: TraceGraphCompileLabels, +): CompiledTraceGraph => { + const layout = buildTraceGraphLayout(data, labels); + const backendEdges = data.traceGraph?.edges?.map((e) => ({ + fromKey: e.fromKey, + toKey: e.toKey, + })); + const edgePairs = resolveTraceFlowEdgePairs(layout.nodes, layout.phaseOrder, backendEdges); + return { + layout, + edgePairs, + nodes: layout.nodes, + }; +}; diff --git a/src/components/ItemTracing/exportItemLotTraceXlsx.ts b/src/components/ItemTracing/exportItemLotTraceXlsx.ts new file mode 100644 index 0000000..e065953 --- /dev/null +++ b/src/components/ItemTracing/exportItemLotTraceXlsx.ts @@ -0,0 +1,905 @@ +import type { TFunction } from "i18next"; +import type { ItemLotTraceResponse } from "@/app/api/itemTracing"; +import { + exportMultiSheetToXlsx, + type MultiSheetSpec, +} from "@/app/(main)/chart/_components/exportChartToXlsx"; +import type { CompiledTraceGraph } from "./compileTraceGraph"; +import { + mergeScopedAdjustments, + mergeScopedDoDeliveries, + mergeScopedFailEvents, + mergeScopedOpenMovements, + mergeScopedOrigins, + mergeScopedOutboundUsage, + mergeScopedPurchaseEvents, + mergeScopedPutawayEvents, + mergeScopedQcResults, + mergeScopedReplenishmentEvents, + mergeScopedReturnEvents, + mergeScopedStockTakeEvents, + mergeScopedTransfers, +} from "./mergeLocationScopedData"; +import { + resolveStockTakeAcceptedQty, + resolveStockTakeBookQty, +} from "./traceStockTakeUtils"; +import { + createTraceLabelTranslator, + type TraceLabelTranslator, +} from "./traceLabelUtils"; +import { kindLabelKey, phaseLabelKey } from "./traceFlowNodeUtils"; +import { buildTracePresentationRows } from "./tracePresentationAdapter"; +import { isDoGroupChild } from "./traceDoGroupLayout"; + +const NO_DATA = "(無資料 / No records)"; + +const SHEET = { + summary: "摘要 Summary", + timeline: "生命週期時間軸 Timeline", + origins: "來源與採購 Origins", + qc: "品質檢驗 QC", + warehouse: "倉儲作業 Warehouse", + outbound: "出庫 Outbound", + stockTake: "盤點 Stock Take", + production: "生產與BOM Production", +} as const; + +export type ItemLotTraceExportLabels = { + bomDirectionFg: string; + bomDirectionMaterial: string; + bomDirectionUnknown: string; +}; + +const orDash = (v: string | number | null | undefined): string | number => { + if (v == null) return "—"; + if (typeof v === "string" && !v.trim()) return "—"; + return v; +}; + +const bomDirectionLabel = ( + direction: string, + labels: ItemLotTraceExportLabels, +): string => { + const d = direction.trim().toUpperCase(); + if (d === "FINISHED_GOOD") return labels.bomDirectionFg; + if (d === "MATERIAL") return labels.bomDirectionMaterial; + return labels.bomDirectionUnknown; +}; + +const ensureRows = ( + rows: Record[], + emptyTemplate: Record, +): Record[] => { + if (rows.length > 0) return rows; + const firstKey = Object.keys(emptyTemplate)[0]; + return [{ ...emptyTemplate, ...(firstKey ? { [firstKey]: NO_DATA } : {}) }]; +}; + +const todayYmd = (): string => { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}${m}${day}`; +}; + +const safeFilenamePart = (s: string): string => + s.replace(/[\\/:*?"<>|]/g, "_").trim() || "unknown"; + +export const buildItemLotTraceFilename = ( + data: ItemLotTraceResponse, +): string => { + const item = safeFilenamePart(data.lot.itemCode || "item"); + const lot = safeFilenamePart(data.lot.lotNo || "lot"); + return `批號追溯_${item}_${lot}_${todayYmd()}`; +}; + +const summaryEmpty = (): Record => ({ + "Field / 欄位": "", + "Value / 值": "", +}); + +const buildSummarySheet = ( + data: ItemLotTraceResponse, + labels: ItemLotTraceExportLabels, + exportAt: string, +): Record[] => { + const { lot, warehouseLines, joPrelude, alternateLocations, bomTrace } = data; + const totalAvailable = warehouseLines.reduce( + (s, w) => s + (w.availableQty ?? 0), + 0, + ); + const rows: Record[] = [ + { "Field / 欄位": "Lot No. / 批號", "Value / 值": orDash(lot.lotNo) }, + { "Field / 欄位": "Item Code / 貨品編號", "Value / 值": orDash(lot.itemCode) }, + { "Field / 欄位": "Item Name / 品名", "Value / 值": orDash(lot.itemName) }, + { "Field / 欄位": "UOM / 單位", "Value / 值": orDash(lot.uom) }, + { "Field / 欄位": "Expiry / 效期", "Value / 值": orDash(lot.expiryDate) }, + { + "Field / 欄位": "Production Date / 生產日期", + "Value / 值": orDash(lot.productionDate), + }, + { + "Field / 欄位": "Stock In Date / 入庫日期", + "Value / 值": orDash(lot.stockInDate), + }, + { + "Field / 欄位": "Total Available / 總可用量", + "Value / 值": totalAvailable, + }, + { + "Field / 欄位": "BOM Direction / BOM 方向", + "Value / 值": bomDirectionLabel(bomTrace.direction, labels), + }, + ]; + + if (joPrelude) { + const jo = joPrelude.jobOrder; + rows.push( + { + "Field / 欄位": "Job Order / 工單", + "Value / 值": orDash(jo.jobOrderCode), + }, + { + "Field / 欄位": "Plan Start / 計劃開工", + "Value / 值": orDash(jo.planStart), + }, + { "Field / 欄位": "Planned Qty / 計劃產量", "Value / 值": jo.reqQty }, + { + "Field / 欄位": "JO Status / 工單狀態", + "Value / 值": orDash(jo.status), + }, + ); + } + + rows.push( + { "Field / 欄位": "Export Time / 匯出時間", "Value / 值": exportAt }, + + { "Field / 欄位": "", "Value / 值": "" }, + { + "Field / 欄位": "— Warehouse Breakdown / 各倉庫存量 —", + "Value / 值": "", + }, + ); + + warehouseLines.forEach((w) => { + rows.push({ + "Field / 欄位": `Warehouse / 倉位: ${w.warehouseCode}`, + "Value / 值": `In ${w.inQty} · Out ${w.outQty} · Available ${w.availableQty} · ${w.status}`, + }); + }); + + if (alternateLocations.length > 0) { + rows.push( + { "Field / 欄位": "", "Value / 值": "" }, + { + "Field / 欄位": "— Alternate Locations / 其他倉位 —", + "Value / 值": "", + }, + ); + alternateLocations.forEach((loc) => { + rows.push({ + "Field / 欄位": `Inventory Lot ${loc.inventoryLotId}`, + "Value / 值": `${loc.warehouseCode} · Available ${loc.availableQty}`, + }); + }); + } + + return rows.length > 0 ? rows : [summaryEmpty()]; +}; + +const timelineEmpty = (): Record => ({ + "Date/Time / 時間": "", + "Phase / 階段": "", + "Event Type / 事件類型": "", + "Doc No. / 單號": "", + "Ref Type / 來源類型": "", + "Ref Type Code": "", + "Qty / 數量": "", + "UOM / 單位": "", + "Warehouse / 倉位": "", + "Handler / 經手人": "", + "Lot / 批號": "", + "Remarks / 備註": "", +}); + +const buildTimelineSheet = ( + compiledGraph: CompiledTraceGraph, + t: TFunction, + tr: TraceLabelTranslator, +): Record[] => { + const nodes = compiledGraph.layout.nodes + .filter( + (n) => + n.kind !== "DO_GROUP" && n.kind !== "PICK_GROUP" && !isDoGroupChild(n), + ) + .slice() + .sort((a, b) => { + const ta = a.timestamp ?? ""; + const tb = b.timestamp ?? ""; + if (ta !== tb) return ta.localeCompare(tb); + return a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex; + }); + + const rows = nodes.map((n) => { + const handlerDetail = n.details.find( + (d) => + d.label.toLowerCase().includes("handler") || + d.label.includes("經手") || + d.label.includes("核准") || + d.label.includes("盤點"), + ); + return { + "Date/Time / 時間": orDash(n.timestamp), + "Phase / 階段": t(phaseLabelKey(n.phase)), + "Event Type / 事件類型": t(kindLabelKey(n.kind)), + "Doc No. / 單號": orDash(n.refCode), + "Ref Type / 來源類型": tr.refType(n.refType), + "Ref Type Code": orDash(n.refType), + "Qty / 數量": n.qty ?? "", + "UOM / 單位": orDash(n.uom), + "Warehouse / 倉位": orDash(n.warehouseCode), + "Handler / 經手人": orDash( + handlerDetail?.value ?? n.meta?.split(" · ")[0], + ), + "Lot / 批號": orDash(n.traceLotNo), + "Remarks / 備註": orDash(n.subtitle), + }; + }); + return ensureRows(rows, timelineEmpty()); +}; + +const originsEmpty = (): Record => ({ + "Section / 區塊": "", + "Warehouse / 倉位": "", + "Inventory Lot Id": "", + "Type / 類型": "", + "Type Code": "", + "Doc No. / 單號": "", + "Supplier Code / 供應商編號": "", + "Supplier / 供應商": "", + "DN No. / 送貨單號": "", + "Qty / 數量": "", + "Status / 狀態": "", + "Date / 日期": "", +}); + +const buildOriginsSheet = ( + data: ItemLotTraceResponse, + tr: TraceLabelTranslator, +): Record[] => { + const originRows = mergeScopedOrigins(data).map((o) => ({ + "Section / 區塊": "Origin / 來源", + "Warehouse / 倉位": o.scopeWarehouseCode, + "Inventory Lot Id": o.inventoryLotId, + "Type / 類型": tr.refType(o.type), + "Type Code": orDash(o.type), + "Doc No. / 單號": orDash(o.refCode), + "Supplier Code / 供應商編號": orDash(o.supplierCode), + "Supplier / 供應商": orDash(o.supplierName), + "DN No. / 送貨單號": orDash(o.dnNo), + "Qty / 數量": o.acceptedQty, + "Status / 狀態": tr.stockInStatus(o.status), + "Date / 日期": orDash(o.receiptDate), + })); + + const purchaseRows = mergeScopedPurchaseEvents(data).map((p) => ({ + "Section / 區塊": "Purchase / 採購", + "Warehouse / 倉位": p.scopeWarehouseCode, + "Inventory Lot Id": p.inventoryLotId, + "Type / 類型": tr.refType("PO"), + "Type Code": "PO", + "Doc No. / 單號": orDash(p.purchaseOrderCode), + "Supplier Code / 供應商編號": orDash(p.supplierCode), + "Supplier / 供應商": orDash(p.supplierName), + "DN No. / 送貨單號": "", + "Qty / 數量": p.orderQty, + "Status / 狀態": orDash(p.status), + "Date / 日期": orDash(p.orderDate), + })); + + return ensureRows([...originRows, ...purchaseRows], originsEmpty()); +}; + +const qcEmpty = (): Record => ({ + "Warehouse / 倉位": "", + "Inventory Lot Id": "", + "QC Result / 結果": "", + "QC Type / 品檢類型": "", + "QC Type Code": "", + "QC Item Code / 品檢項編號": "", + "QC Item / 品檢項": "", + "Accepted Qty / 抽樣數量": "", + "Fail Qty / 不良數量": "", + "Remarks / 備註": "", + "Handler / 經手人": "", + "Date/Time / 時間": "", + "Stock In Line Id": "", +}); + +const buildQcSheet = ( + data: ItemLotTraceResponse, + tr: TraceLabelTranslator, +): Record[] => { + const rows = mergeScopedQcResults(data).map((q) => ({ + "Warehouse / 倉位": q.scopeWarehouseCode, + "Inventory Lot Id": q.inventoryLotId, + "QC Result / 結果": tr.qcPassed(q.qcPassed), + "QC Type / 品檢類型": tr.qcType(q.qcType), + "QC Type Code": orDash(q.qcType), + "QC Item Code / 品檢項編號": orDash(q.qcItemCode), + "QC Item / 品檢項": orDash(q.qcItemName || q.qcItemDescription), + "Accepted Qty / 抽樣數量": q.acceptedQty, + "Fail Qty / 不良數量": q.failQty, + "Remarks / 備註": orDash(q.remarks), + "Handler / 經手人": orDash(q.handledBy), + "Date/Time / 時間": orDash(q.created), + "Stock In Line Id": q.stockInLineId, + })); + return ensureRows(rows, qcEmpty()); +}; + +const warehouseEmpty = (): Record => ({ + "Section / 區塊": "", + "Warehouse / 倉位": "", + "Inventory Lot Id": "", + "Direction / 方向": "", + "Type / 類型": "", + "Type Code": "", + "Doc No. / 單號": "", + "From / 來源倉": "", + "To / 目標倉": "", + "Qty / 數量": "", + "Handler / 經手人": "", + "Status / 狀態": "", + "Remarks / 備註": "", + "Date/Time / 時間": "", +}); + +const buildWarehouseSheet = ( + data: ItemLotTraceResponse, + tr: TraceLabelTranslator, +): Record[] => { + const putaway = mergeScopedPutawayEvents(data).map((p) => ({ + "Section / 區塊": "Putaway / 上架", + "Warehouse / 倉位": p.scopeWarehouseCode, + "Inventory Lot Id": p.inventoryLotId, + "Direction / 方向": "", + "Type / 類型": tr.refType(p.refType), + "Type Code": orDash(p.refType), + "Doc No. / 單號": orDash(p.refCode), + "From / 來源倉": "", + "To / 目標倉": orDash(p.warehouseCode), + "Qty / 數量": p.qty, + "Handler / 經手人": orDash(p.handledBy), + "Status / 狀態": orDash(p.status), + "Remarks / 備註": "", + "Date/Time / 時間": orDash(p.timestamp), + })); + + const transfers = mergeScopedTransfers(data).map((trRow) => ({ + "Section / 區塊": "Transfer / 轉倉", + "Warehouse / 倉位": trRow.scopeWarehouseCode, + "Inventory Lot Id": trRow.inventoryLotId, + "Direction / 方向": "", + "Type / 類型": tr.refType("TRANSFER"), + "Type Code": "TRANSFER", + "Doc No. / 單號": orDash(trRow.transferCode), + "From / 來源倉": orDash(trRow.fromWarehouse), + "To / 目標倉": orDash(trRow.toWarehouse), + "Qty / 數量": trRow.qty, + "Handler / 經手人": "", + "Status / 狀態": "", + "Remarks / 備註": "", + "Date/Time / 時間": orDash(trRow.timestamp), + })); + + const adjustments = mergeScopedAdjustments(data).map((a) => ({ + "Section / 區塊": "Adjustment / 庫存調整", + "Warehouse / 倉位": a.scopeWarehouseCode, + "Inventory Lot Id": a.inventoryLotId, + "Direction / 方向": tr.direction(a.direction), + "Type / 類型": tr.adjustmentType(a.adjustmentType), + "Type Code": orDash(a.adjustmentType), + "Doc No. / 單號": orDash(a.refCode), + "From / 來源倉": "", + "To / 目標倉": orDash(a.warehouseCode), + "Qty / 數量": a.qty, + "Handler / 經手人": orDash(a.handledBy), + "Status / 狀態": "", + "Remarks / 備註": orDash(a.reason), + "Date/Time / 時間": orDash(a.timestamp), + })); + + const openMoves = mergeScopedOpenMovements(data).map((o) => ({ + "Section / 區塊": "Opening Stock / 開倉入庫", + "Warehouse / 倉位": o.scopeWarehouseCode, + "Inventory Lot Id": o.inventoryLotId, + "Direction / 方向": tr.direction("IN"), + "Type / 類型": tr.refType("OPEN"), + "Type Code": "OPEN", + "Doc No. / 單號": orDash(o.refCode), + "From / 來源倉": "", + "To / 目標倉": orDash(o.warehouseCode), + "Qty / 數量": o.qty, + "Handler / 經手人": orDash(o.handledBy), + "Status / 狀態": "", + "Remarks / 備註": orDash(o.remarks), + "Date/Time / 時間": orDash(o.timestamp), + })); + + return ensureRows( + [...putaway, ...transfers, ...adjustments, ...openMoves], + warehouseEmpty(), + ); +}; + +const outboundEmpty = (): Record => ({ + "Section / 區塊": "", + "Warehouse / 倉位": "", + "Inventory Lot Id": "", + "Usage Type / 類型": "", + "Usage Type Code": "", + "Doc No. / 單號": "", + "Pick Order / 提料單": "", + "Conso / 併單": "", + "Job Order / 工單": "", + "Delivery Order / 送貨單": "", + "Delivery Note / DN": "", + "Ticket / 票號": "", + "Shop / 門市": "", + "Qty / 數量": "", + "Handler / 經手人": "", + "Extra / 加單": "", + "Replenish / 補貨": "", + "Remarks / 備註": "", + "Date/Time / 時間": "", +}); + +const buildOutboundSheet = ( + data: ItemLotTraceResponse, + tr: TraceLabelTranslator, +): Record[] => { + const usage = mergeScopedOutboundUsage(data).map((u) => ({ + "Section / 區塊": "Outbound Usage / 出庫使用", + "Warehouse / 倉位": u.scopeWarehouseCode, + "Inventory Lot Id": u.inventoryLotId, + "Usage Type / 類型": tr.usageType(u.usageType), + "Usage Type Code": orDash(u.usageType), + "Doc No. / 單號": orDash(u.deliveryOrderCode || u.pickOrderCode), + "Pick Order / 提料單": orDash(u.pickOrderCode), + "Conso / 併單": orDash(u.consoCode), + "Job Order / 工單": orDash(u.jobOrderCode), + "Delivery Order / 送貨單": orDash(u.deliveryOrderCode), + "Delivery Note / DN": "", + "Ticket / 票號": "", + "Shop / 門市": "", + "Qty / 數量": u.qty, + "Handler / 經手人": orDash(u.handler), + "Extra / 加單": "", + "Replenish / 補貨": "", + "Remarks / 備註": "", + "Date/Time / 時間": orDash(u.timestamp), + })); + + const doRows = mergeScopedDoDeliveries(data).map((d) => ({ + "Section / 區塊": "DO Delivery / 成品出倉", + "Warehouse / 倉位": d.scopeWarehouseCode, + "Inventory Lot Id": d.inventoryLotId, + "Usage Type / 類型": tr.usageType("FG_DELIVERY"), + "Usage Type Code": "FG_DELIVERY", + "Doc No. / 單號": orDash(d.deliveryOrderCode), + "Pick Order / 提料單": orDash(d.pickOrderCode), + "Conso / 併單": orDash(d.consoCode), + "Job Order / 工單": "", + "Delivery Order / 送貨單": orDash(d.deliveryOrderCode), + "Delivery Note / DN": orDash(d.deliveryNoteCode), + "Ticket / 票號": orDash(d.ticketNo), + "Shop / 門市": [d.shopCode, d.shopName].filter(Boolean).join(" · ") || "—", + "Qty / 數量": d.qty, + "Handler / 經手人": orDash(d.handler), + "Extra / 加單": d.isExtra ? "Y" : "", + "Replenish / 補貨": d.isReplenish ? "Y" : "", + "Remarks / 備註": "", + "Date/Time / 時間": orDash(d.timestamp), + })); + + const replenish = mergeScopedReplenishmentEvents(data).map((r) => ({ + "Section / 區塊": "Replenishment / 補貨", + "Warehouse / 倉位": r.scopeWarehouseCode, + "Inventory Lot Id": r.inventoryLotId, + "Usage Type / 類型": "Replenishment", + "Usage Type Code": "REPLENISH", + "Doc No. / 單號": orDash(r.replenishmentCode), + "Pick Order / 提料單": "", + "Conso / 併單": "", + "Job Order / 工單": "", + "Delivery Order / 送貨單": orDash(r.sourceDoCode), + "Delivery Note / DN": "", + "Ticket / 票號": "", + "Shop / 門市": [r.shopCode, r.shopName].filter(Boolean).join(" · ") || "—", + "Qty / 數量": r.replenishQty, + "Handler / 經手人": orDash(r.handler), + "Extra / 加單": "", + "Replenish / 補貨": "Y", + "Remarks / 備註": orDash(r.reason), + "Date/Time / 時間": orDash(r.timestamp), + })); + + const returns = mergeScopedReturnEvents(data).map((r) => ({ + "Section / 區塊": "Return / 退貨", + "Warehouse / 倉位": r.scopeWarehouseCode, + "Inventory Lot Id": r.inventoryLotId, + "Usage Type / 類型": tr.movementType(r.movementType), + "Usage Type Code": orDash(r.movementType), + "Doc No. / 單號": orDash(r.refCode), + "Pick Order / 提料單": "", + "Conso / 併單": "", + "Job Order / 工單": "", + "Delivery Order / 送貨單": "", + "Delivery Note / DN": "", + "Ticket / 票號": "", + "Shop / 門市": "", + "Qty / 數量": r.qty, + "Handler / 經手人": orDash(r.handler), + "Extra / 加單": "", + "Replenish / 補貨": "", + "Remarks / 備註": orDash(r.remarks), + "Date/Time / 時間": orDash(r.timestamp), + })); + + const fails = mergeScopedFailEvents(data).map((f) => ({ + "Section / 區塊": "Pick Failure / 揀貨異常", + "Warehouse / 倉位": f.scopeWarehouseCode, + "Inventory Lot Id": f.inventoryLotId, + "Usage Type / 類型": tr.failType(f.failType), + "Usage Type Code": orDash(f.failType), + "Doc No. / 單號": orDash(f.pickOrderCode), + "Pick Order / 提料單": orDash(f.pickOrderCode), + "Conso / 併單": "", + "Job Order / 工單": "", + "Delivery Order / 送貨單": "", + "Delivery Note / DN": "", + "Ticket / 票號": "", + "Shop / 門市": "", + "Qty / 數量": f.qty, + "Handler / 經手人": orDash(f.handlerName), + "Extra / 加單": "", + "Replenish / 補貨": "", + "Remarks / 備註": orDash(f.category), + "Date/Time / 時間": orDash(f.recordDate), + })); + + return ensureRows( + [...usage, ...doRows, ...replenish, ...returns, ...fails], + outboundEmpty(), + ); +}; + +const stockTakeEmpty = (): Record => ({ + "Warehouse / 倉位": "", + "Inventory Lot Id": "", + "Stock Take Code / 盤點單號": "", + "Round / 輪次": "", + "Section / 區域": "", + "Lot / 批號": "", + "Book Qty / 帳面數量": "", + "Accepted Qty / 核准數量": "", + "Variance Qty / 差異數量": "", + "Approver / 核准人": "", + "Stock Taker / 初盤人": "", + "First Count Qty / 初盤數量": "", + "Second Count Qty / 複盤數量": "", + "Approver Count Qty / 覆盤數量": "", + "Record Status / 紀錄狀態": "", + "Date/Time / 時間": "", +}); + +const buildStockTakeSheet = ( + data: ItemLotTraceResponse, +): Record[] => { + const rows = mergeScopedStockTakeEvents(data).map((e) => { + const d = e.recordDetail; + return { + "Warehouse / 倉位": e.scopeWarehouseCode, + "Inventory Lot Id": e.inventoryLotId, + "Stock Take Code / 盤點單號": orDash(e.stockTakeCode), + "Round / 輪次": orDash( + e.stockTakeRoundName ?? + (e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : ""), + ), + "Section / 區域": orDash(e.stockTakeSection), + "Lot / 批號": orDash(e.lotNo), + "Book Qty / 帳面數量": resolveStockTakeBookQty(d, e.beforeQty) ?? "", + "Accepted Qty / 核准數量": + resolveStockTakeAcceptedQty(d, e.afterQty) ?? "", + "Variance Qty / 差異數量": + d?.varianceQty != null ? d.varianceQty : e.varianceQty, + "Approver / 核准人": orDash(d?.approverName || e.approver), + "Stock Taker / 初盤人": orDash(d?.stockTakerName), + "First Count Qty / 初盤數量": d?.pickerFirstQty ?? "", + "Second Count Qty / 複盤數量": d?.pickerSecondQty ?? "", + "Approver Count Qty / 覆盤數量": d?.approverQty ?? "", + "Record Status / 紀錄狀態": orDash(d?.recordStatus), + "Date/Time / 時間": orDash(e.timestamp), + }; + }); + return ensureRows(rows, stockTakeEmpty()); +}; + +const productionEmpty = (): Record => ({ + "Section / 區塊": "", + "Job Order / 工單": "", + "Doc No. / 單號": "", + "Item Code / 貨品編號": "", + "Lot / 批號": "", + "Item Name / 品名": "", + "Step / 步驟": "", + "Assigned Step / 對應製程": "", + "Qty / 數量": "", + "Scrap Qty / 損耗數量": "", + "Defect Qty / 不良數量": "", + "UOM / 單位": "", + "Operator / 操作員": "", + "Equipment / 設備": "", + "Status / 狀態": "", + "Date/Time / 時間": "", +}); + +const buildProductionSheet = ( + data: ItemLotTraceResponse, + compiledGraph: CompiledTraceGraph, + tr: TraceLabelTranslator, +): Record[] => { + const rows: Record[] = []; + const presentation = buildTracePresentationRows(compiledGraph.nodes, data); + + const upstreamSource = data.joPrelude?.materialInputs?.length + ? data.joPrelude.materialInputs.map((m) => ({ + jobOrderCode: m.jobOrderCode, + materialItemCode: m.materialItemCode, + materialLotNo: m.materialLotNo, + materialQty: m.materialQty, + materialUom: m.materialUom, + assignedStepName: m.assignedStepName, + pickOrderCode: m.pickOrderCode, + pickedAt: m.pickedAt, + materialItemName: m.materialItemName, + })) + : data.bomTrace.upstream.map((u) => ({ + jobOrderCode: u.jobOrderCode, + materialItemCode: u.materialItemCode, + materialLotNo: u.materialLotNo, + materialQty: u.materialQty, + materialUom: "", + assignedStepName: "", + pickOrderCode: "", + pickedAt: null as string | null, + materialItemName: "", + })); + + upstreamSource.forEach((m) => { + rows.push({ + "Section / 區塊": "BOM Upstream / BOM 上游", + "Job Order / 工單": orDash(m.jobOrderCode), + "Doc No. / 單號": orDash(m.pickOrderCode), + "Item Code / 貨品編號": orDash(m.materialItemCode), + "Lot / 批號": orDash(m.materialLotNo), + "Item Name / 品名": orDash(m.materialItemName), + "Step / 步驟": "", + "Assigned Step / 對應製程": orDash(m.assignedStepName), + "Qty / 數量": m.materialQty, + "Scrap Qty / 損耗數量": "", + "Defect Qty / 不良數量": "", + "UOM / 單位": orDash(m.materialUom), + "Operator / 操作員": "", + "Equipment / 設備": "", + "Status / 狀態": "", + "Date/Time / 時間": orDash(m.pickedAt), + }); + }); + + data.bomTrace.downstream.forEach((d) => { + rows.push({ + "Section / 區塊": "BOM Downstream / BOM 下游", + "Job Order / 工單": orDash(d.jobOrderCode), + "Doc No. / 單號": "", + "Item Code / 貨品編號": orDash(d.finishedItemCode), + "Lot / 批號": orDash(d.finishedLotNo), + "Item Name / 品名": "", + "Step / 步驟": "", + "Assigned Step / 對應製程": "", + "Qty / 數量": d.fgQty, + "Scrap Qty / 損耗數量": "", + "Defect Qty / 不良數量": "", + "UOM / 單位": orDash(d.fgUom), + "Operator / 操作員": "", + "Equipment / 設備": "", + "Status / 狀態": "", + "Date/Time / 時間": "", + }); + }); + + data.bomTrace.bomRecipe.forEach((r) => { + rows.push({ + "Section / 區塊": "BOM Recipe / BOM 配方", + "Job Order / 工單": "", + "Doc No. / 單號": "", + "Item Code / 貨品編號": orDash(r.materialItemCode), + "Lot / 批號": "", + "Item Name / 品名": orDash(r.materialItemName), + "Step / 步驟": "", + "Assigned Step / 對應製程": orDash(r.assignedStepName), + "Qty / 數量": r.qtyPerUnit, + "Scrap Qty / 損耗數量": "", + "Defect Qty / 不良數量": "", + "UOM / 單位": orDash(r.uom), + "Operator / 操作員": "", + "Equipment / 設備": "", + "Status / 狀態": "", + "Date/Time / 時間": "", + }); + }); + + presentation.joPicks + .slice() + .sort((a, b) => (b.pickedAt ?? "").localeCompare(a.pickedAt ?? "")) + .forEach((p) => { + rows.push({ + "Section / 區塊": "JO Pick / 工單提料", + "Job Order / 工單": "", + "Doc No. / 單號": orDash(p.pickOrderCode), + "Item Code / 貨品編號": orDash(p.materialItemCode), + "Lot / 批號": orDash(p.materialLotNo), + "Item Name / 品名": "", + "Step / 步驟": "", + "Assigned Step / 對應製程": orDash(p.assignedStepName), + "Qty / 數量": p.qty, + "Scrap Qty / 損耗數量": "", + "Defect Qty / 不良數量": "", + "UOM / 單位": orDash(p.uom), + "Operator / 操作員": "", + "Equipment / 設備": "", + "Status / 狀態": "", + "Date/Time / 時間": orDash(p.pickedAt), + }); + }); + + const productionRows = + data.productionSteps.length > 0 + ? data.productionSteps + .slice() + .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0)) + .map((s) => ({ + "Section / 區塊": "Production Step / 生產步驟", + "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode), + "Doc No. / 單號": "", + "Item Code / 貨品編號": orDash(data.lot.itemCode), + "Lot / 批號": orDash(data.lot.lotNo), + "Item Name / 品名": orDash(data.lot.itemName), + "Step / 步驟": orDash(s.stepName), + "Assigned Step / 對應製程": orDash(s.description), + "Qty / 數量": s.outputQty, + "Scrap Qty / 損耗數量": s.scrapQty, + "Defect Qty / 不良數量": s.defectQty, + "UOM / 單位": orDash(data.lot.uom), + "Operator / 操作員": orDash(s.operatorName), + "Equipment / 設備": + [s.equipmentCode, s.equipmentName].filter(Boolean).join(" · ") || + "—", + "Status / 狀態": tr.productionStatus(s.status), + "Date/Time / 時間": orDash(s.endTime || s.startTime), + })) + : presentation.production + .slice() + .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0)) + .map((s) => ({ + "Section / 區塊": "Production Step / 生產步驟", + "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode), + "Doc No. / 單號": "", + "Item Code / 貨品編號": orDash(data.lot.itemCode), + "Lot / 批號": orDash(s.traceLotNo || data.lot.lotNo), + "Item Name / 品名": orDash(data.lot.itemName), + "Step / 步驟": orDash(s.stepName), + "Assigned Step / 對應製程": orDash(s.description), + "Qty / 數量": s.outputQty, + "Scrap Qty / 損耗數量": s.scrapQty, + "Defect Qty / 不良數量": s.defectQty, + "UOM / 單位": orDash(data.lot.uom), + "Operator / 操作員": orDash(s.operatorName), + "Equipment / 設備": orDash(s.equipmentName || s.equipmentCode), + "Status / 狀態": orDash(s.status), + "Date/Time / 時間": orDash(s.endTime || s.startTime), + })); + + rows.push(...productionRows); + + data.byproductLots.forEach((b) => { + rows.push({ + "Section / 區塊": "Byproduct / 副產品", + "Job Order / 工單": orDash(b.jobOrderCode), + "Doc No. / 單號": "", + "Item Code / 貨品編號": orDash(b.itemCode), + "Lot / 批號": orDash(b.lotNo), + "Item Name / 品名": orDash(b.itemName), + "Step / 步驟": orDash(b.processStepName), + "Assigned Step / 對應製程": "", + "Qty / 數量": b.qty, + "Scrap Qty / 損耗數量": "", + "Defect Qty / 不良數量": "", + "UOM / 單位": orDash(b.uom), + "Operator / 操作員": "", + "Equipment / 設備": "", + "Status / 狀態": "", + "Date/Time / 時間": orDash(b.producedAt), + }); + }); + + data.lotRelations.forEach((r) => { + rows.push({ + "Section / 區塊": "Lot Relation / 拆批關聯", + "Job Order / 工單": "", + "Doc No. / 單號": orDash(r.relationType), + "Item Code / 貨品編號": orDash(r.itemCode), + "Lot / 批號": orDash(r.lotNo), + "Item Name / 品名": orDash(r.itemName), + "Step / 步驟": "", + "Assigned Step / 對應製程": "", + "Qty / 數量": r.qty, + "Scrap Qty / 損耗數量": "", + "Defect Qty / 不良數量": "", + "UOM / 單位": "", + "Operator / 操作員": "", + "Equipment / 設備": "", + "Status / 狀態": orDash(r.productLotNo), + "Date/Time / 時間": orDash(r.timestamp), + }); + }); + + return ensureRows(rows, productionEmpty()); +}; + +/** Build the 8 classified sheets without triggering download (for tests). */ +export const buildItemLotTraceSheets = ( + data: ItemLotTraceResponse, + compiledGraph: CompiledTraceGraph, + t: TFunction, + exportLabels: ItemLotTraceExportLabels, + exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19), +): MultiSheetSpec[] => { + const tr = createTraceLabelTranslator(t); + return [ + { + name: SHEET.summary, + rows: buildSummarySheet(data, exportLabels, exportAt), + }, + { name: SHEET.timeline, rows: buildTimelineSheet(compiledGraph, t, tr) }, + { name: SHEET.origins, rows: buildOriginsSheet(data, tr) }, + { name: SHEET.qc, rows: buildQcSheet(data, tr) }, + { name: SHEET.warehouse, rows: buildWarehouseSheet(data, tr) }, + { name: SHEET.outbound, rows: buildOutboundSheet(data, tr) }, + { name: SHEET.stockTake, rows: buildStockTakeSheet(data) }, + { + name: SHEET.production, + rows: buildProductionSheet(data, compiledGraph, tr), + }, + ]; +}; + +export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET); + +/** FP-MTMS Version Checklist | Functions Ref. No. 4 | v1.0.0 | 2026-07-15 */ +export const exportItemLotTraceXlsx = ( + data: ItemLotTraceResponse, + compiledGraph: CompiledTraceGraph, + t: TFunction, +): void => { + const exportLabels: ItemLotTraceExportLabels = { + bomDirectionFg: t("bomDirectionFG"), + bomDirectionMaterial: t("bomDirectionMaterial"), + bomDirectionUnknown: t("bomDirectionUnknown"), + }; + const sheets = buildItemLotTraceSheets(data, compiledGraph, t, exportLabels); + exportMultiSheetToXlsx(sheets, buildItemLotTraceFilename(data)); +}; diff --git a/src/components/ItemTracing/index.ts b/src/components/ItemTracing/index.ts new file mode 100644 index 0000000..f1bda39 --- /dev/null +++ b/src/components/ItemTracing/index.ts @@ -0,0 +1,5 @@ +export { default } from "./ItemTracing"; +export { default as ItemTracingScanBar } from "./ItemTracingScanBar"; +export { default as ItemTracingSummary } from "./ItemTracingSummary"; +export { default as ItemTracingFlowGraph } from "./ItemTracingFlowGraph"; +export { default as ItemTracingSections } from "./ItemTracingSections"; diff --git a/src/components/ItemTracing/itemTracingTableFilters.tsx b/src/components/ItemTracing/itemTracingTableFilters.tsx new file mode 100644 index 0000000..599173f --- /dev/null +++ b/src/components/ItemTracing/itemTracingTableFilters.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { type ReactNode } from "react"; + +export type FilterableColumnDef = { + key: string; + label: string; + align?: "left" | "right" | "center"; + value: (row: T) => string | number | null | undefined; + cell?: (row: T) => ReactNode; + /** @deprecated Unused — filters removed. */ + filterable?: boolean; +}; + +type FilterableDataTableProps = { + rows: T[]; + columns: FilterableColumnDef[]; + getRowKey: (row: T, index: number) => string; + emptyLabel: string; + size?: "small" | "medium"; + collapseAfter?: number; + showAll?: boolean; + onToggleShowAll?: () => void; + showAllLabel?: string | ((count: number) => string); + collapseLabel?: string | ((count: number) => string); +}; + +const resolveCountLabel = ( + label: string | ((count: number) => string) | undefined, + count: number, + fallback: string, +) => (typeof label === "function" ? label(count) : (label ?? fallback)); + +/** Plain data table (column filters removed). */ +export function FilterableDataTable({ + rows, + columns, + getRowKey, + emptyLabel, + size = "small", + collapseAfter, + showAll = false, + onToggleShowAll, + showAllLabel, + collapseLabel, +}: FilterableDataTableProps) { + const collapsed = + collapseAfter != null && rows.length > collapseAfter && !showAll; + const visibleRows = collapsed ? rows.slice(0, collapseAfter) : rows; + + return ( + <> + + + + {columns.map((col) => ( + + {col.label} + + ))} + + + + {visibleRows.length === 0 ? ( + + + + {emptyLabel} + + + + ) : ( + visibleRows.map((row, index) => ( + + {columns.map((col) => ( + + {col.cell ? col.cell(row) : (col.value(row) ?? "—")} + + ))} + + )) + )} + +
+ {collapseAfter != null && + rows.length > collapseAfter && + onToggleShowAll && ( + + {showAll + ? resolveCountLabel(collapseLabel, rows.length, `▲ ${rows.length}`) + : resolveCountLabel(showAllLabel, rows.length, `▼ ${rows.length}`)} + + )} + + ); +} diff --git a/src/components/ItemTracing/mergeLocationScopedData.ts b/src/components/ItemTracing/mergeLocationScopedData.ts new file mode 100644 index 0000000..1bab5a1 --- /dev/null +++ b/src/components/ItemTracing/mergeLocationScopedData.ts @@ -0,0 +1,316 @@ +import type { + ItemLotTraceLocationBlock, + ItemLotTraceResponse, +} from "@/app/api/itemTracing"; +import { blockWarehouseCode } from "./buildLocationBlockGraphNodes"; + +export type ScopedRow = T & { + scopeWarehouseCode: string; + inventoryLotId: number; +}; + +export const primaryWarehouseLabel = (data: ItemLotTraceResponse): string => + data.warehouseLines + .map((w) => w.warehouseCode) + .filter(Boolean) + .join(" / ") || "—"; + +const sortByTimestampDesc = ( + rows: T[], +): T[] => + [...rows].sort((a, b) => + (b.timestamp ?? "").localeCompare(a.timestamp ?? ""), + ); + +const sortByReceiptDateDesc = ( + rows: T[], +): T[] => + [...rows].sort((a, b) => + (b.receiptDate ?? "").localeCompare(a.receiptDate ?? ""), + ); + +export const mergeScopedOrigins = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.origins.map((o) => ({ + ...o, + scopeWarehouseCode: primaryWh, + inventoryLotId: data.lot.inventoryLotId, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.origins.map((o) => ({ + ...o, + scopeWarehouseCode: wh, + inventoryLotId: block.inventoryLotId, + })); + }); + return sortByReceiptDateDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedQcResults = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.qcResults.map((q, i) => ({ + ...q, + scopeWarehouseCode: primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-${q.stockInLineId}-${i}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.qcResults.map((q, i) => ({ + ...q, + scopeWarehouseCode: wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-${q.stockInLineId}-${i}`, + })); + }); + return [...primary, ...fromBlocks].sort((a, b) => + (b.created ?? "").localeCompare(a.created ?? ""), + ); +}; + +export const mergeScopedOutboundUsage = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.outboundUsage.map((u) => ({ + ...u, + scopeWarehouseCode: primaryWh, + inventoryLotId: data.lot.inventoryLotId, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.outboundUsage.map((u) => ({ + ...u, + scopeWarehouseCode: wh, + inventoryLotId: block.inventoryLotId, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedStockTakeEvents = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.stockTakeEvents.map((e, i) => ({ + ...e, + scopeWarehouseCode: e.warehouseCode?.trim() || primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-${e.stockTakeCode}-${i}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.stockTakeEvents.map((e, i) => ({ + ...e, + scopeWarehouseCode: e.warehouseCode?.trim() || wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-${e.stockTakeCode}-${i}`, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedAdjustments = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.adjustments.map((a, i) => ({ + ...a, + scopeWarehouseCode: primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-${a.refCode}-${i}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.adjustments.map((a, i) => ({ + ...a, + scopeWarehouseCode: wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-${a.refCode}-${i}`, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedTransfers = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.transfers.map((tr, i) => ({ + ...tr, + scopeWarehouseCode: primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-${tr.transferCode}-${i}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.transfers.map((tr, i) => ({ + ...tr, + scopeWarehouseCode: wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-${tr.transferCode}-${i}`, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedMovements = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.movements.map((m, i) => ({ + ...m, + scopeWarehouseCode: m.warehouseCode?.trim() || primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-mov-${i}-${m.refCode}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.movements.map((m, i) => ({ + ...m, + scopeWarehouseCode: m.warehouseCode?.trim() || wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-mov-${i}-${m.refCode}`, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedPutawayEvents = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.putawayEvents.map((p, i) => ({ + ...p, + scopeWarehouseCode: p.warehouseCode?.trim() || primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-putaway-${i}-${p.refCode}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.putawayEvents.map((p, i) => ({ + ...p, + scopeWarehouseCode: p.warehouseCode?.trim() || wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-putaway-${i}-${p.refCode}`, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedPurchaseEvents = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = (data.purchaseEvents ?? []).map((p, i) => ({ + ...p, + scopeWarehouseCode: primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-po-${i}-${p.purchaseOrderLineId}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return (block.purchaseEvents ?? []).map((p, i) => ({ + ...p, + scopeWarehouseCode: wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-po-${i}-${p.purchaseOrderLineId}`, + })); + }); + return [...primary, ...fromBlocks].sort((a, b) => + (b.orderDate ?? "").localeCompare(a.orderDate ?? ""), + ); +}; + +export const mergeScopedDoDeliveries = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.doDeliveries.map((d, i) => ({ + ...d, + scopeWarehouseCode: primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-do-${i}-${d.stockOutLineId}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.doDeliveries.map((d, i) => ({ + ...d, + scopeWarehouseCode: wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-do-${i}-${d.stockOutLineId}`, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedFailEvents = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.failEvents.map((f, i) => ({ + ...f, + scopeWarehouseCode: primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-fail-${i}-${f.failId}`, + timestamp: f.recordDate, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.failEvents.map((f, i) => ({ + ...f, + scopeWarehouseCode: wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-fail-${i}-${f.failId}`, + timestamp: f.recordDate, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedReturnEvents = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.returnEvents.map((r, i) => ({ + ...r, + scopeWarehouseCode: r.warehouseCode?.trim() || primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-return-${i}-${r.stockOutLineId}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.returnEvents.map((r, i) => ({ + ...r, + scopeWarehouseCode: r.warehouseCode?.trim() || wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-return-${i}-${r.stockOutLineId}`, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedOpenMovements = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = data.openMovements.map((o, i) => ({ + ...o, + scopeWarehouseCode: o.warehouseCode?.trim() || primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-open-${i}-${o.stockInLineId}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return block.openMovements.map((o, i) => ({ + ...o, + scopeWarehouseCode: o.warehouseCode?.trim() || wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-open-${i}-${o.stockInLineId}`, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const mergeScopedReplenishmentEvents = (data: ItemLotTraceResponse) => { + const primaryWh = primaryWarehouseLabel(data); + const primary = (data.replenishmentEvents ?? []).map((r, i) => ({ + ...r, + scopeWarehouseCode: primaryWh, + inventoryLotId: data.lot.inventoryLotId, + rowKey: `primary-replenish-${i}-${r.replenishmentId}`, + })); + const fromBlocks = (data.locationBlocks ?? []).flatMap((block) => { + const wh = blockWarehouseCode(block); + return (block.replenishmentEvents ?? []).map((r, i) => ({ + ...r, + scopeWarehouseCode: wh, + inventoryLotId: block.inventoryLotId, + rowKey: `loc-${block.inventoryLotId}-replenish-${i}-${r.replenishmentId}`, + })); + }); + return sortByTimestampDesc([...primary, ...fromBlocks]); +}; + +export const hasMultipleLocations = (data: ItemLotTraceResponse): boolean => + (data.locationBlocks?.length ?? 0) > 0 || + (data.alternateLocations?.length ?? 0) > 0; diff --git a/src/components/ItemTracing/traceDoGroupLayout.ts b/src/components/ItemTracing/traceDoGroupLayout.ts new file mode 100644 index 0000000..3a6066b --- /dev/null +++ b/src/components/ItemTracing/traceDoGroupLayout.ts @@ -0,0 +1,274 @@ +import { + DO_GROUP_CHILD_WIDTH, + DO_GROUP_HEADER, + DO_GROUP_INNER_COLS_MAX, + DO_GROUP_MIN_SIZE, + DO_GROUP_PAD, + PICK_GROUP_MIN_SIZE, + NODE_GAP, + NODE_HEIGHT, + NODE_WIDTH, +} from "./traceFlowConstants"; +import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout"; +import { TraceGraphNode } from "./buildTraceGraphNodes"; + +export type DoGroupChildPlacement = { x: number; y: number; compact: boolean }; + +/** Fewer columns for large groups — wider cards, no horizontal cramming. */ +const preferredDoGroupCols = (memberCount: number): number => { + if (memberCount <= 1) return 1; + if (memberCount >= 15) return 2; + if (memberCount >= 6) return 3; + return 2; +}; + +const sumDoGroupQty = ( + children: TraceGraphLayoutNode[], +): { totalQty: number; uom?: string } | null => { + let totalQty = 0; + let hasQty = false; + let uom: string | undefined; + children.forEach((child) => { + const qty = child.qty; + if (qty != null && Number.isFinite(Number(qty))) { + totalQty += Number(qty); + hasQty = true; + } + if (!uom && child.uom?.trim()) uom = child.uom.trim(); + }); + return hasQty ? { totalQty, uom } : null; +}; + +export const computeDoGroupBoxLayout = ( + memberCount: number, +): { width: number; height: number; cols: number } => { + const childW = DO_GROUP_CHILD_WIDTH; + const cols = Math.min(DO_GROUP_INNER_COLS_MAX, memberCount, preferredDoGroupCols(memberCount)); + const rows = Math.ceil(memberCount / cols); + const gridW = cols * childW + Math.max(0, cols - 1) * NODE_GAP; + const gridH = rows * NODE_HEIGHT + Math.max(0, rows - 1) * NODE_GAP; + return { + width: gridW + DO_GROUP_PAD * 2, + height: DO_GROUP_HEADER + gridH + DO_GROUP_PAD * 2, + cols, + }; +}; + +export const layoutDoGroupChildPlacements = ( + children: TraceGraphLayoutNode[], +): Map => { + const sorted = [...children].sort(sortNodesInPhase); + const { cols } = computeDoGroupBoxLayout(sorted.length); + const childW = DO_GROUP_CHILD_WIDTH; + const result = new Map(); + + sorted.forEach((child, index) => { + const col = index % cols; + const row = Math.floor(index / cols); + result.set(child.id, { + x: DO_GROUP_PAD + col * (childW + NODE_GAP), + y: DO_GROUP_HEADER + DO_GROUP_PAD + row * (NODE_HEIGHT + NODE_GAP), + compact: false, + }); + }); + + return result; +}; + +export const applyDoOutboundGrouping = ( + nodes: TraceGraphLayoutNode[], + groupTitle: (count: number) => string, + minSize = DO_GROUP_MIN_SIZE, +): TraceGraphLayoutNode[] => { + const doByColumnAndWarehouse = new Map(); + + nodes.forEach((node) => { + if (node.kind !== "DO_OUT" || node.doGroupId) return; + const whKey = (node.warehouseCode ?? "").trim().toUpperCase() || "__none__"; + const key = `${node.column}::${whKey}`; + const list = doByColumnAndWarehouse.get(key) ?? []; + list.push(node); + doByColumnAndWarehouse.set(key, list); + }); + + const groupNodes: TraceGraphLayoutNode[] = []; + const childGroupIds = new Map(); + + doByColumnAndWarehouse.forEach((columnNodes, key) => { + if (columnNodes.length < minSize) return; + + const column = columnNodes[0]!.column; + const warehouseCode = columnNodes[0]!.warehouseCode?.trim() || undefined; + const whSlug = (warehouseCode ?? "none").replace(/[^a-zA-Z0-9_-]/g, "_"); + const groupId = `do-group-col-${column}-${whSlug}`; + const sorted = [...columnNodes].sort(sortNodesInPhase); + const { width, height } = computeDoGroupBoxLayout(sorted.length); + const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey); + const qtySummary = sumDoGroupQty(sorted); + + sorted.forEach((child) => childGroupIds.set(child.id, groupId)); + + const groupNode: TraceGraphLayoutNode = { + id: groupId, + kind: "DO_GROUP", + timestamp: sorted[0]?.timestamp ?? null, + sortKey: minSort, + title: groupTitle(sorted.length), + subtitle: [sorted[0]?.dayKey, warehouseCode].filter(Boolean).join(" · ") || "", + categoryLabel: sorted[0]?.categoryLabel, + warehouseCode, + details: [], + phase: sorted[0]!.phase, + column, + dayKey: sorted[0]!.dayKey, + laneIndex: sorted[0]!.laneIndex, + sequenceIndex: sorted[0]!.sequenceIndex, + branchIndex: 0, + branchSize: 1, + dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex, + groupBoxWidth: width, + groupBoxHeight: height, + groupMemberCount: sorted.length, + groupTotalQty: qtySummary?.totalQty, + groupUom: qtySummary?.uom, + }; + groupNodes.push(groupNode); + }); + + if (!groupNodes.length) return nodes; + + return [ + ...nodes.map((node) => { + const groupId = childGroupIds.get(node.id); + if (!groupId) return node; + return { ...node, doGroupId: groupId }; + }), + ...groupNodes, + ].sort((a, b) => { + if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; + return a.id.localeCompare(b.id); + }); +}; + +export const applyMaterialPickGrouping = ( + nodes: TraceGraphLayoutNode[], + groupTitle: (pickOrderCode: string, count: number) => string, + minSize = PICK_GROUP_MIN_SIZE, +): TraceGraphLayoutNode[] => { + const picksByKey = new Map(); + + nodes.forEach((node) => { + if (node.kind !== "MATERIAL_PICK" || node.doGroupId) return; + const pickCode = node.refCode?.trim(); + if (!pickCode) return; + const key = `${node.column}::${pickCode}`; + const list = picksByKey.get(key) ?? []; + list.push(node); + picksByKey.set(key, list); + }); + + const groupNodes: TraceGraphLayoutNode[] = []; + const childGroupIds = new Map(); + + picksByKey.forEach((columnNodes, key) => { + if (columnNodes.length < minSize) return; + + const pickCode = key.split("::").slice(1).join("::"); + const groupId = `pick-group-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}`; + const sorted = [...columnNodes].sort(sortNodesInPhase); + const { width, height } = computeDoGroupBoxLayout(sorted.length); + const minSort = sorted.reduce((min, n) => Math.min(min, n.sortKey), sorted[0]!.sortKey); + + sorted.forEach((child) => childGroupIds.set(child.id, groupId)); + + const groupNode: TraceGraphLayoutNode = { + id: groupId, + kind: "PICK_GROUP", + timestamp: sorted[0]?.timestamp ?? null, + sortKey: minSort, + title: groupTitle(pickCode, sorted.length), + subtitle: sorted[0]?.dayKey ?? "", + refCode: pickCode, + refId: sorted[0]?.refId, + docLinkKind: "pick", + consoCode: sorted[0]?.consoCode, + jobOrderCode: sorted[0]?.jobOrderCode, + categoryLabel: sorted[0]?.categoryLabel, + details: [], + phase: sorted[0]!.phase, + column: sorted[0]!.column, + dayKey: sorted[0]!.dayKey, + laneIndex: sorted[0]!.laneIndex, + sequenceIndex: sorted[0]!.sequenceIndex, + branchIndex: 0, + branchSize: 1, + dayPhaseStaggerIndex: sorted[0]!.dayPhaseStaggerIndex, + groupBoxWidth: width, + groupBoxHeight: height, + groupMemberCount: sorted.length, + }; + groupNodes.push(groupNode); + }); + + if (!groupNodes.length) return nodes; + + return [ + ...nodes.map((node) => { + const groupId = childGroupIds.get(node.id); + if (!groupId) return node; + return { ...node, doGroupId: groupId }; + }), + ...groupNodes, + ].sort((a, b) => { + if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; + return a.id.localeCompare(b.id); + }); +}; + +export const applyFlowNodeGrouping = ( + nodes: TraceGraphLayoutNode[], + labels: { + flowDoGroupTitle: (count: number) => string; + flowPickGroupTitle: (pickOrderCode: string, count: number) => string; + }, +): TraceGraphLayoutNode[] => + applyMaterialPickGrouping( + applyDoOutboundGrouping(nodes, labels.flowDoGroupTitle), + labels.flowPickGroupTitle, + ); + +export const regroupLayoutCells = ( + nodes: TraceGraphLayoutNode[], +): TraceGraphLayoutNode[][] => { + const visible = nodes.filter((n) => !isDoGroupChild(n)); + const groups = new Map(); + + visible.forEach((node) => { + const key = `${node.column}:${node.laneIndex}`; + const list = groups.get(key) ?? []; + list.push(node); + groups.set(key, list); + }); + + groups.forEach((list) => { + list.sort(sortNodesInPhase); + const size = list.length; + list.forEach((node, idx) => { + node.branchIndex = idx; + node.branchSize = size; + }); + }); + + return Array.from(groups.values()).sort((a, b) => { + const na = a[0]!; + const nb = b[0]!; + if (na.column !== nb.column) return na.column - nb.column; + return na.laneIndex - nb.laneIndex; + }); +}; + +export const isFlowGroupContainer = (node: TraceGraphNode | TraceGraphLayoutNode): boolean => + node.kind === "DO_GROUP" || node.kind === "PICK_GROUP"; + +export const isDoGroupChild = (node: TraceGraphNode | TraceGraphLayoutNode): boolean => + Boolean(node.doGroupId?.trim()); diff --git a/src/components/ItemTracing/traceDocLinkUtils.ts b/src/components/ItemTracing/traceDocLinkUtils.ts new file mode 100644 index 0000000..56a4882 --- /dev/null +++ b/src/components/ItemTracing/traceDocLinkUtils.ts @@ -0,0 +1,155 @@ +import dayjs from "dayjs"; +import { + ItemLotTraceJoPrelude, + ItemLotTraceResponse, +} from "@/app/api/itemTracing"; + +export const isWorkbenchTicketNo = (ticketNo?: string | null): boolean => + (ticketNo ?? "").trim().toUpperCase().startsWith("TI-"); + +/** Normalize any date/datetime string to YYYY-MM-DD for API / URL query params (LocalDate). */ +export const normalizeTargetDateForLink = ( + value?: string | null, +): string | undefined => { + if (value == null) return undefined; + const raw = String(value).trim(); + if (!raw) return undefined; + // Prefer extracting YYYY-MM-DD prefix — dayjs/Date.parse is unreliable for + // "YYYY-MM-DD HH:mm:ss" in some browsers (e.g. Safari). + const match = raw.match(/(\d{4}-\d{2}-\d{2})/); + if (match) return match[1]; + const d = dayjs(raw); + return d.isValid() ? d.format("YYYY-MM-DD") : undefined; +}; + +export const targetDateFromTraceTimestamp = ( + timestamp?: string | null, +): string | undefined => normalizeTargetDateForLink(timestamp); + +export type DoOutboundDocLink = { + kind: "workbench" | "pick" | undefined; + ticketNo?: string; + targetDate?: string; + /** Visible link label (DO code preferred on DO_OUT cards). */ + displayCode: string; + pickOrderId?: number | null; + consoCode?: string; +}; + +export type JoPickDocLink = { + kind: "jodetail"; + pickOrderCode: string; + targetDate?: string; + displayCode: string; + pickOrderId?: number | null; +}; + +const walkJoPreludePickOrders = ( + prelude: ItemLotTraceJoPrelude, + map: Map, +): void => { + prelude.pickOrders.forEach((po) => { + const code = po.pickOrderCode?.trim(); + const date = normalizeTargetDateForLink(po.targetDate); + if (code && date) map.set(code, date); + }); + prelude.materialInputs.forEach((m) => { + if (m.nestedJoPrelude) walkJoPreludePickOrders(m.nestedJoPrelude, map); + }); +}; + +export const buildPickOrderTargetDateMapFromPrelude = ( + prelude: ItemLotTraceJoPrelude, +): Map => { + const map = new Map(); + walkJoPreludePickOrders(prelude, map); + return map; +}; + +export const buildPickOrderTargetDateMap = ( + data: ItemLotTraceResponse, +): Map => + data.joPrelude ? buildPickOrderTargetDateMapFromPrelude(data.joPrelude) : new Map(); + +export const resolvePickOrderTargetDate = ( + map: Map, + pickOrderCode?: string | null, + fallbackTimestamp?: string | null, +): string | undefined => { + const code = pickOrderCode?.trim(); + if (code) { + const fromPrelude = normalizeTargetDateForLink(map.get(code)); + if (fromPrelude) return fromPrelude; + } + return targetDateFromTraceTimestamp(fallbackTimestamp); +}; + +export const resolveJoPickDocLink = (input: { + pickOrderCode?: string; + pickOrderId?: number | null; + timestamp?: string | null; + targetDate?: string | null; +}): JoPickDocLink | null => { + const pickCode = input.pickOrderCode?.trim() || ""; + if (!pickCode) return null; + return { + kind: "jodetail", + pickOrderCode: pickCode, + targetDate: + normalizeTargetDateForLink(input.targetDate) || + targetDateFromTraceTimestamp(input.timestamp), + displayCode: pickCode, + pickOrderId: input.pickOrderId, + }; +}; + +export const resolveDoOutboundDocLink = (input: { + pickOrderCode?: string; + pickOrderId?: number | null; + deliveryOrderCode?: string; + ticketNo?: string; + outboundTicketNo?: string; + consoCode?: string; + timestamp?: string | null; + /** Workbench path only when linked to delivery_order_pick_order. */ + deliveryOrderPickOrderId?: number | null; +}): DoOutboundDocLink => { + const ticket = (input.ticketNo || input.outboundTicketNo || "").trim(); + const conso = (input.consoCode || "").trim(); + const hasWorkbenchLink = + input.deliveryOrderPickOrderId != null && + Number.isFinite(Number(input.deliveryOrderPickOrderId)); + const workbenchTicket = hasWorkbenchLink + ? isWorkbenchTicketNo(ticket) + ? ticket + : isWorkbenchTicketNo(conso) + ? conso + : undefined + : undefined; + const targetDate = targetDateFromTraceTimestamp(input.timestamp); + const pickCode = input.pickOrderCode?.trim() || ""; + const doCode = input.deliveryOrderCode?.trim() || ""; + + if (workbenchTicket) { + return { + kind: "workbench", + ticketNo: workbenchTicket, + targetDate, + displayCode: doCode || pickCode || workbenchTicket, + pickOrderId: input.pickOrderId, + consoCode: conso || pickCode, + }; + } + // Prefer 送貨單號 on the card; /pickOrder/detail is retired so no pick-only link. + // Legacy GoodPick TI-* without deliveryOrderPickOrderId must not deep-link to workbench. + if (doCode || pickCode) { + return { + kind: undefined, + displayCode: doCode || pickCode, + pickOrderId: input.pickOrderId, + consoCode: conso || pickCode, + targetDate, + }; + } + return { kind: undefined, displayCode: "—" }; +}; diff --git a/src/components/ItemTracing/traceFlowConstants.ts b/src/components/ItemTracing/traceFlowConstants.ts new file mode 100644 index 0000000..41ee187 --- /dev/null +++ b/src/components/ItemTracing/traceFlowConstants.ts @@ -0,0 +1,38 @@ +export const LANE_MIN_HEIGHT = 150; +export const LANE_GAP = 32; +/** Minimum width of one phase slot within a calendar day column. */ +export const COLUMN_WIDTH_MIN = 320; +/** @deprecated use COLUMN_WIDTH_MIN — kept for imports that expect COLUMN_WIDTH */ +export const COLUMN_WIDTH = COLUMN_WIDTH_MIN; +export const PHASE_LABEL_WIDTH = 112; +export const HEADER_HEIGHT = 40; +/** Trace event card width — sized for PO codes and supplier names. */ +export const NODE_WIDTH = 268; +export const NODE_WIDTH_BRANCH = 220; +/** Fixed trace event card height — layout spacing must match rendered Paper height. + * Sized for densest cards (MATERIAL_PICK / JO_OUT with item, statuses, timestamp). */ +export const NODE_HEIGHT = 280; +export const NODE_GAP = 16; +/** React Flow canvas height in the life-cycle graph panel. */ +export const VIEWPORT_HEIGHT = 720; +/** Initial fitView will not zoom out below this (keeps nodes readable). */ +export const FIT_VIEW_MIN_ZOOM = 0.55; +export const FIT_VIEW_MAX_ZOOM = 1.35; + +/** + * Min DO_OUT nodes in one day column before collapsing into a group box. + * Higher than pick groups: individual DO tickets stay readable until the column + * gets dense (≥3 same day + warehouse). + */ +export const DO_GROUP_MIN_SIZE = 3; +/** + * Min MATERIAL_PICK lines sharing a pick order before collapsing into a group box. + * Intentional asymmetry vs DO_GROUP_MIN_SIZE: warehouse users think in pick-order + * batches, so even a single material line sits inside its pick-order box. + */ +export const PICK_GROUP_MIN_SIZE = 1; +export const DO_GROUP_PAD = 12; +export const DO_GROUP_HEADER = 32; +export const DO_GROUP_INNER_COLS_MAX = 3; +/** Card width used inside a DO group grid (full-size, not branch compact). */ +export const DO_GROUP_CHILD_WIDTH = NODE_WIDTH; diff --git a/src/components/ItemTracing/traceFlowEdgeLayout.ts b/src/components/ItemTracing/traceFlowEdgeLayout.ts new file mode 100644 index 0000000..c479123 --- /dev/null +++ b/src/components/ItemTracing/traceFlowEdgeLayout.ts @@ -0,0 +1,395 @@ +export type TraceFlowPair = { fromId: string; toId: string }; + +export type RoutedTraceFlowEdge = TraceFlowPair & { + sourceHandle: string; + targetHandle: string; + offset: number; + pathMode?: "smooth" | "corridor"; + corridorY?: number; + /** Added to React Flow sourceX for the first horizontal stub. */ + corridorExitOffset?: number; + /** Added to React Flow targetX for the vertical drop into the target. */ + corridorEntryOffset?: number; + /** + * Added to React Flow sourceX for the late branch point on the corridor. + * Shared-trunk fans travel together until this X, then fork to each target. + */ + corridorBranchOffset?: number; +}; + +export type TraceFlowNodeRect = { + x: number; + y: number; + width: number; + height: number; + laneIndex: number; +}; + +export type TraceFlowLaneLayout = { + laneTops: number[]; + laneHeights: number[]; + laneGap: number; +}; + +const OFFSET_STEP = 18; +const CORRIDOR_STEP = 14; +const HORIZONTAL_STUB = 14; +const MIN_HORIZONTAL_SPAN = 24; + +export const TRACE_FLOW_HANDLE_IN = "in"; +export const TRACE_FLOW_HANDLE_OUT = "out"; + +const stableSortPairs = (list: TraceFlowPair[]): TraceFlowPair[] => + [...list].sort((a, b) => { + const keyA = `${a.fromId}->${a.toId}`; + const keyB = `${b.fromId}->${b.toId}`; + return keyA.localeCompare(keyB); + }); + +const edgeKey = (fromId: string, toId: string) => `${fromId}->${toId}`; + +const targetColumnKey = (rect: TraceFlowNodeRect) => + `${rect.laneIndex}:${Math.round(rect.x)}`; + +const sourceColumnKey = (rect: TraceFlowNodeRect) => + `${rect.laneIndex}:${Math.round(rect.x)}`; + +type CorridorMeta = { + corridorY: number; + corridorExitOffset: number; + corridorEntryOffset: number; + corridorBranchOffset?: number; +}; + +/** Spread multiple edges from the same node via handles + smoothstep offset. */ +export const assignTraceFlowEdgeRouting = ( + pairs: TraceFlowPair[], +): { + routed: RoutedTraceFlowEdge[]; + incomingCount: Map; + outgoingCount: Map; +} => { + const bySource = new Map(); + const byTarget = new Map(); + + pairs.forEach((p) => { + const outList = bySource.get(p.fromId) ?? []; + outList.push(p); + bySource.set(p.fromId, outList); + + const inList = byTarget.get(p.toId) ?? []; + inList.push(p); + byTarget.set(p.toId, inList); + }); + + bySource.forEach((list, id) => bySource.set(id, stableSortPairs(list))); + byTarget.forEach((list, id) => byTarget.set(id, stableSortPairs(list))); + + const incomingCount = new Map(); + const outgoingCount = new Map(); + byTarget.forEach((list, id) => incomingCount.set(id, list.length)); + // 1→N fans use a single source handle (shared trunk); report 1 outbound slot. + bySource.forEach((list, id) => outgoingCount.set(id, list.length > 1 ? 1 : list.length)); + + const routed: RoutedTraceFlowEdge[] = pairs.map((p) => { + const outList = bySource.get(p.fromId) ?? [p]; + const inList = byTarget.get(p.toId) ?? [p]; + const sourceIndex = outList.findIndex((e) => e.fromId === p.fromId && e.toId === p.toId); + const targetIndex = inList.findIndex((e) => e.fromId === p.fromId && e.toId === p.toId); + + // 1→N fans leave from one handle so the stroke starts as a single trunk. + const sourceHandle = + outList.length > 1 + ? `${TRACE_FLOW_HANDLE_OUT}-0` + : `${TRACE_FLOW_HANDLE_OUT}-${Math.max(0, sourceIndex)}`; + const targetHandle = `${TRACE_FLOW_HANDLE_IN}-${Math.max(0, targetIndex)}`; + + let offset = 0; + if (outList.length > 1) { + offset = 0; + } else if (inList.length > 1) { + offset = (targetIndex - (inList.length - 1) / 2) * OFFSET_STEP; + } + + return { ...p, sourceHandle, targetHandle, offset, pathMode: "smooth" }; + }); + + return { routed, incomingCount, outgoingCount }; +}; + +const baseCorridorY = ( + src: TraceFlowNodeRect, + srcLane: number, + tgtLane: number, + lanes: TraceFlowLaneLayout, +): number => { + if (srcLane !== tgtLane) { + const srcLaneBottom = + (lanes.laneTops[srcLane] ?? src.y) + (lanes.laneHeights[srcLane] ?? 0); + return srcLaneBottom + lanes.laneGap * 0.42; + } + return src.y + src.height + 12; +}; + +/** + * Route long horizontal spans through the lane gutter so edges do not cut across + * unrelated nodes in the same swimlane. + * 1→N source fans share one corridor trunk and only branch near targets. + */ +export const enrichTraceFlowCorridorRouting = ( + routed: RoutedTraceFlowEdge[], + rects: Map, + lanes: TraceFlowLaneLayout, +): RoutedTraceFlowEdge[] => { + const outDegree = new Map(); + routed.forEach((edge) => { + outDegree.set(edge.fromId, (outDegree.get(edge.fromId) ?? 0) + 1); + }); + + const corridorCandidates: RoutedTraceFlowEdge[] = []; + + routed.forEach((edge) => { + const src = rects.get(edge.fromId); + const tgt = rects.get(edge.toId); + if (!src || !tgt) return; + + const srcRight = src.x + src.width; + const crossLane = src.laneIndex !== tgt.laneIndex; + const longSpan = tgt.x > srcRight + MIN_HORIZONTAL_SPAN; + const sourceFan = (outDegree.get(edge.fromId) ?? 0) > 1; + if (crossLane || longSpan || sourceFan) { + corridorCandidates.push(edge); + } + }); + + const corridorMetaByKey = new Map(); + const groups = new Map(); + + corridorCandidates.forEach((edge) => { + const src = rects.get(edge.fromId); + const tgt = rects.get(edge.toId); + if (!src || !tgt) return; + const groupKey = `${src.laneIndex}->${tgt.laneIndex}`; + const list = groups.get(groupKey) ?? []; + list.push(edge); + groups.set(groupKey, list); + }); + + groups.forEach((edges, groupKey) => { + const [srcLaneStr, tgtLaneStr] = groupKey.split("->"); + const srcLane = Number(srcLaneStr); + const tgtLane = Number(tgtLaneStr); + + const byFrom = new Map(); + edges.forEach((edge) => { + const list = byFrom.get(edge.fromId) ?? []; + list.push(edge); + byFrom.set(edge.fromId, list); + }); + + // Distinct sources in this lane-pair get separated corridor Y bands. + const sourceIds = Array.from(byFrom.keys()).sort((a, b) => { + const ax = rects.get(a)?.x ?? 0; + const bx = rects.get(b)?.x ?? 0; + if (ax !== bx) return ax - bx; + const ay = rects.get(a)?.y ?? 0; + const by = rects.get(b)?.y ?? 0; + return ay - by || a.localeCompare(b); + }); + + sourceIds.forEach((fromId, sourceIndex) => { + const fan = [...(byFrom.get(fromId) ?? [])].sort((a, b) => { + const aty = rects.get(a.toId)?.y ?? 0; + const bty = rects.get(b.toId)?.y ?? 0; + if (aty !== bty) return aty - bty; + const atx = rects.get(a.toId)?.x ?? 0; + const btx = rects.get(b.toId)?.x ?? 0; + return ( + atx - btx || + edgeKey(a.fromId, a.toId).localeCompare(edgeKey(b.fromId, b.toId)) + ); + }); + const src = rects.get(fromId); + if (!src || !fan.length) return; + + const baseY = baseCorridorY(src, srcLane, tgtLane, lanes); + const corridorY = baseY + sourceIndex * CORRIDOR_STEP; + const sharedExit = HORIZONTAL_STUB; + const srcRight = src.x + src.width; + const isFan = fan.length > 1; + const entrySpread = isFan ? (fan.length - 1) * CORRIDOR_STEP : 0; + + const fanMetas = fan.map((edge, fanIndex) => { + const tgt = rects.get(edge.toId); + const corridorEntryOffset = isFan + ? -(HORIZONTAL_STUB + entrySpread) + fanIndex * CORRIDOR_STEP + : -HORIZONTAL_STUB; + const entryXLayout = (tgt?.x ?? 0) + corridorEntryOffset; + return { edge, corridorEntryOffset, entryXLayout }; + }); + + let sharedBranchOffset: number | undefined; + if (isFan && fanMetas.length) { + const minEntryX = Math.min(...fanMetas.map((m) => m.entryXLayout)); + sharedBranchOffset = minEntryX - srcRight; + } + + fanMetas.forEach(({ edge, corridorEntryOffset }) => { + corridorMetaByKey.set(edgeKey(edge.fromId, edge.toId), { + corridorY, + corridorExitOffset: sharedExit, + corridorEntryOffset, + corridorBranchOffset: sharedBranchOffset, + }); + }); + }); + }); + + // Separate vertical drops into the same target column (stacked nodes), + // without breaking shared-trunk exit / branch for same-source fans. + const byTargetColumn = new Map(); + corridorCandidates.forEach((edge) => { + const tgt = rects.get(edge.toId); + if (!tgt) return; + const key = targetColumnKey(tgt); + const list = byTargetColumn.get(key) ?? []; + list.push(edge); + byTargetColumn.set(key, list); + }); + + byTargetColumn.forEach((edges) => { + if (edges.length <= 1) return; + + const sorted = [...edges].sort((a, b) => { + const aty = rects.get(a.toId)?.y ?? 0; + const bty = rects.get(b.toId)?.y ?? 0; + return aty - bty || edgeKey(a.fromId, a.toId).localeCompare(edgeKey(b.fromId, b.toId)); + }); + + const spread = (sorted.length - 1) * CORRIDOR_STEP; + + sorted.forEach((edge, index) => { + const key = edgeKey(edge.fromId, edge.toId); + const meta = corridorMetaByKey.get(key); + if (!meta) return; + // Same-source fans already have entry offsets relative to the fan. + if ((outDegree.get(edge.fromId) ?? 0) > 1) return; + corridorMetaByKey.set(key, { + ...meta, + corridorEntryOffset: -(HORIZONTAL_STUB + spread) + index * CORRIDOR_STEP, + }); + }); + }); + + // Separate trunks when multiple distinct sources share a column (stacked nodes). + // Same-source fan edges keep a shared exit; different sources get staggered exits. + const bySourceColumn = new Map(); + corridorCandidates.forEach((edge) => { + const src = rects.get(edge.fromId); + if (!src) return; + const key = sourceColumnKey(src); + const list = bySourceColumn.get(key) ?? []; + if (!list.includes(edge.fromId)) list.push(edge.fromId); + bySourceColumn.set(key, list); + }); + + bySourceColumn.forEach((sourceIds) => { + if (sourceIds.length <= 1) return; + + const sortedSources = [...sourceIds].sort((a, b) => { + const asy = rects.get(a)?.y ?? 0; + const bsy = rects.get(b)?.y ?? 0; + return asy - bsy || a.localeCompare(b); + }); + + const spread = (sortedSources.length - 1) * CORRIDOR_STEP; + + sortedSources.forEach((fromId, index) => { + const exitOffset = HORIZONTAL_STUB - spread / 2 + index * CORRIDOR_STEP; + corridorCandidates + .filter((e) => e.fromId === fromId) + .forEach((edge) => { + const key = edgeKey(edge.fromId, edge.toId); + const meta = corridorMetaByKey.get(key); + if (!meta) return; + corridorMetaByKey.set(key, { + ...meta, + corridorExitOffset: exitOffset, + }); + }); + }); + }); + + return routed.map((edge) => { + const meta = corridorMetaByKey.get(edgeKey(edge.fromId, edge.toId)); + if (!meta) return edge; + return { + ...edge, + pathMode: "corridor", + corridorY: meta.corridorY, + corridorExitOffset: meta.corridorExitOffset, + corridorEntryOffset: meta.corridorEntryOffset, + corridorBranchOffset: meta.corridorBranchOffset, + // Shared trunk fans already leave from out-0 with zero smooth offset. + offset: (outDegree.get(edge.fromId) ?? 0) > 1 ? 0 : edge.offset, + sourceHandle: + (outDegree.get(edge.fromId) ?? 0) > 1 + ? `${TRACE_FLOW_HANDLE_OUT}-0` + : edge.sourceHandle, + }; + }); +}; + +/** + * Orthogonal path: exit source → drop to corridor → travel → + * (optional late branch) → rise to target. + */ +export const buildTraceFlowCorridorPath = ( + sourceX: number, + sourceY: number, + targetX: number, + targetY: number, + corridorY: number, + corridorEntryOffset?: number, + corridorExitOffset?: number, + corridorBranchOffset?: number, +): string => { + const exitX = sourceX + (corridorExitOffset ?? HORIZONTAL_STUB); + const entryX = targetX + (corridorEntryOffset ?? -HORIZONTAL_STUB); + const midY = corridorY; + const branchX = + corridorBranchOffset != null ? sourceX + corridorBranchOffset : entryX; + + // Keep branch between exit and entry so the trunk never backtracks oddly. + const clampedBranchX = Math.min( + Math.max(branchX, Math.min(exitX, entryX)), + Math.max(exitX, entryX), + ); + + if (Math.abs(clampedBranchX - entryX) < 0.5) { + return [ + `M ${sourceX} ${sourceY}`, + `L ${exitX} ${sourceY}`, + `L ${exitX} ${midY}`, + `L ${entryX} ${midY}`, + `L ${entryX} ${targetY}`, + `L ${targetX} ${targetY}`, + ].join(" "); + } + + return [ + `M ${sourceX} ${sourceY}`, + `L ${exitX} ${sourceY}`, + `L ${exitX} ${midY}`, + `L ${clampedBranchX} ${midY}`, + `L ${entryX} ${midY}`, + `L ${entryX} ${targetY}`, + `L ${targetX} ${targetY}`, + ].join(" "); +}; + +/** Vertical position (%) for handle index among `count` siblings on one node side. */ +export const traceFlowHandleTopPercent = (index: number, count: number): string => { + if (count <= 1) return "50%"; + return `${((index + 1) / (count + 1)) * 100}%`; +}; diff --git a/src/components/ItemTracing/traceFlowLayout.ts b/src/components/ItemTracing/traceFlowLayout.ts new file mode 100644 index 0000000..9469290 --- /dev/null +++ b/src/components/ItemTracing/traceFlowLayout.ts @@ -0,0 +1,200 @@ +import { + COLUMN_WIDTH_MIN, + HEADER_HEIGHT, + LANE_GAP, + LANE_MIN_HEIGHT, + NODE_GAP, + NODE_HEIGHT, + NODE_WIDTH, + NODE_WIDTH_BRANCH, +} from "./traceFlowConstants"; +import { TraceGraphLayoutNode, sortNodesInPhase } from "./traceGraphLayout"; +import { isFlowGroupContainer } from "./traceDoGroupLayout"; + +export const CELL_PAD_X = 10; +export const CELL_PAD_Y = 10; + +export type CellPlacement = { x: number; y: number; compact: boolean }; + +const horizWidth = (count: number, nodeW: number) => + count * nodeW + Math.max(0, count - 1) * NODE_GAP; + +const defaultInnerCellWidth = () => COLUMN_WIDTH_MIN - CELL_PAD_X * 2; + +const nodeLayoutSize = ( + node: TraceGraphLayoutNode, + compact = false, +): { width: number; height: number } => { + if (isFlowGroupContainer(node)) { + return { + width: node.groupBoxWidth ?? NODE_WIDTH, + height: node.groupBoxHeight ?? NODE_HEIGHT, + }; + } + return { + width: compact ? NODE_WIDTH_BRANCH : NODE_WIDTH, + height: NODE_HEIGHT, + }; +}; + +const stackedCellHeight = (members: TraceGraphLayoutNode[]): number => + members.reduce((sum, node, index) => { + const { height } = nodeLayoutSize(node); + return sum + height + (index > 0 ? NODE_GAP : 0); + }, 0); + +/** Group boxes have variable height — never tile them horizontally with other cards. */ +const requiresStackedLayout = (members: TraceGraphLayoutNode[]): boolean => + members.length > 1 && members.some(isFlowGroupContainer); + +const layoutStackedMembers = ( + sorted: TraceGraphLayoutNode[], + innerW: number, + compact: boolean, +): Map => { + const result = new Map(); + let y = CELL_PAD_Y; + sorted.forEach((node) => { + const { width, height } = nodeLayoutSize(node, compact); + result.set(node.id, { + x: Math.max(0, (innerW - width) / 2), + y, + compact, + }); + y += height + NODE_GAP; + }); + return result; +}; + +/** Minimum phase-slot width for a cell's top-level members (excludes DO group children). */ +export const cellWidthRequirement = (members: TraceGraphLayoutNode[]): number => { + const sorted = [...members].sort(sortNodesInPhase); + const n = sorted.length; + if (n === 0) return COLUMN_WIDTH_MIN; + + if (n === 1 && isFlowGroupContainer(sorted[0]!)) { + return Math.max(COLUMN_WIDTH_MIN, (sorted[0]!.groupBoxWidth ?? NODE_WIDTH) + CELL_PAD_X * 2); + } + + if (n === 1) return COLUMN_WIDTH_MIN; + + if (requiresStackedLayout(sorted)) { + const maxW = Math.max(...sorted.map((node) => nodeLayoutSize(node).width)); + return Math.max(COLUMN_WIDTH_MIN, maxW + CELL_PAD_X * 2); + } + + const innerMin = COLUMN_WIDTH_MIN - CELL_PAD_X * 2; + const branchInner = horizWidth(n, NODE_WIDTH_BRANCH); + const fullInner = horizWidth(n, NODE_WIDTH); + const contentInner = Math.max(branchInner, fullInner, innerMin); + return contentInner + CELL_PAD_X * 2; +}; + +/** Lay out nodes sharing the same date column + phase lane (side-by-side or vertical stack). */ +export const layoutCellMembers = ( + members: TraceGraphLayoutNode[], + innerCellWidth = defaultInnerCellWidth(), +): Map => { + const sorted = [...members].sort(sortNodesInPhase); + const result = new Map(); + const n = sorted.length; + if (n === 0) return result; + + const innerW = innerCellWidth; + + if (n === 1 && isFlowGroupContainer(sorted[0]!)) { + const w = sorted[0]!.groupBoxWidth ?? NODE_WIDTH; + result.set(sorted[0]!.id, { + x: Math.max(0, (innerW - w) / 2), + y: CELL_PAD_Y, + compact: false, + }); + return result; + } + + if (n === 1) { + result.set(sorted[0].id, { + x: (innerW - NODE_WIDTH) / 2, + y: CELL_PAD_Y, + compact: false, + }); + return result; + } + + if (requiresStackedLayout(sorted)) { + return layoutStackedMembers(sorted, innerW, false); + } + + let horizontal = false; + let nodeW = NODE_WIDTH_BRANCH; + if (horizWidth(n, NODE_WIDTH_BRANCH) <= innerW) { + horizontal = true; + nodeW = NODE_WIDTH_BRANCH; + } else if (horizWidth(n, NODE_WIDTH) <= innerW) { + horizontal = true; + nodeW = NODE_WIDTH; + } + + if (horizontal) { + const totalW = horizWidth(n, nodeW); + const startX = (innerW - totalW) / 2; + sorted.forEach((node, i) => { + result.set(node.id, { + x: startX + i * (nodeW + NODE_GAP), + y: CELL_PAD_Y, + compact: true, + }); + }); + return result; + } + + return layoutStackedMembers(sorted, innerW, true); +}; + +export const cellContentHeight = (members: TraceGraphLayoutNode[]): number => { + const sorted = [...members].sort(sortNodesInPhase); + const memberCount = sorted.length; + if (memberCount <= 0) return LANE_MIN_HEIGHT; + if (memberCount === 1) { + const sole = sorted[0]!; + if (isFlowGroupContainer(sole) && sole.groupBoxHeight) { + return CELL_PAD_Y * 2 + sole.groupBoxHeight; + } + return CELL_PAD_Y * 2 + NODE_HEIGHT; + } + + if (requiresStackedLayout(sorted)) { + return CELL_PAD_Y * 2 + stackedCellHeight(sorted); + } + + const innerW = defaultInnerCellWidth(); + const fitsHoriz = + horizWidth(memberCount, NODE_WIDTH_BRANCH) <= innerW || + horizWidth(memberCount, NODE_WIDTH) <= innerW; + + if (fitsHoriz) return CELL_PAD_Y * 2 + NODE_HEIGHT; + return CELL_PAD_Y * 2 + stackedCellHeight(sorted); +}; + +export const computeLaneTops = ( + laneCount: number, + cells: Map, +): { laneTops: number[]; laneHeights: number[]; totalHeight: number } => { + const laneHeights = Array.from({ length: laneCount }, () => LANE_MIN_HEIGHT); + + cells.forEach((members, key) => { + const laneIndex = Number(key.split(":")[0]); + const cellH = cellContentHeight(members); + laneHeights[laneIndex] = Math.max(laneHeights[laneIndex] ?? LANE_MIN_HEIGHT, cellH); + }); + + const laneTops: number[] = []; + let y = HEADER_HEIGHT; + for (let i = 0; i < laneCount; i++) { + laneTops[i] = y; + y += laneHeights[i] ?? LANE_MIN_HEIGHT; + if (i < laneCount - 1) y += LANE_GAP; + } + + return { laneTops, laneHeights, totalHeight: y + 16 }; +}; diff --git a/src/components/ItemTracing/traceFlowNodeUtils.ts b/src/components/ItemTracing/traceFlowNodeUtils.ts new file mode 100644 index 0000000..30a1a1d --- /dev/null +++ b/src/components/ItemTracing/traceFlowNodeUtils.ts @@ -0,0 +1,223 @@ +import { TraceGraphNodeKind } from "./buildTraceGraphNodes"; +import { TraceGraphPhase } from "./traceGraphLayout"; + +/** + * Palette aligned with phase/category so kinds in different lanes do not all + * collapse onto the same chip color (outbound vs pick vs scrap vs fail). + */ +export const kindColor = ( + kind: TraceGraphNodeKind, +): + | "success" + | "warning" + | "info" + | "secondary" + | "default" + | "primary" + | "error" => { + switch (kind) { + case "PURCHASE": + case "RECEIPT": + case "MATERIAL_IN": + case "OPEN": + case "IN": + return "success"; + case "QC": + case "MATERIAL_QC": + return "info"; + case "FAIL": + return "error"; + case "MATERIAL_PICK": + case "PICK_GROUP": + return "secondary"; + case "PRODUCTION_STEP": + case "JO_CREATED": + case "BYPRODUCT": + return "primary"; + case "SCRAP": + return "warning"; + case "DEFECT": + case "EXPIRED": + return "error"; + case "PUTAWAY": + case "TRANSFER": + case "REPACK": + return "primary"; + case "STOCK_TAKE": + return "secondary"; + case "ADJUSTMENT": + case "DEPLETED": + return "default"; + case "DO_OUT": + case "DO_GROUP": + case "REPLENISHMENT_CREATED": + case "OUT": + return "warning"; + case "JO_OUT": + case "PO_OUT": + return "secondary"; + case "RETURN": + return "error"; + default: + return "default"; + } +}; + +export const kindLabelKey = (kind: TraceGraphNodeKind): string => { + switch (kind) { + case "MATERIAL_IN": + return "nodeMaterialIn"; + case "JO_CREATED": + return "nodeJoCreated"; + case "MATERIAL_QC": + return "tabQc"; + case "MATERIAL_PICK": + return "nodeMaterialPick"; + case "PRODUCTION_STEP": + return "nodeProductionStep"; + case "BYPRODUCT": + return "nodeByproduct"; + case "SCRAP": + return "nodeScrap"; + case "DEFECT": + return "nodeDefect"; + case "OPEN": + return "nodeOpen"; + case "FAIL": + return "nodeFail"; + case "DO_OUT": + return "nodeDoOut"; + case "REPLENISHMENT_CREATED": + return "nodeReplenishmentCreated"; + case "JO_OUT": + return "nodeJoOut"; + case "PO_OUT": + return "nodePoOut"; + case "DO_GROUP": + return "nodeDoGroup"; + case "PICK_GROUP": + return "nodePickGroup"; + case "RETURN": + return "nodeReturn"; + case "REPACK": + return "nodeRepack"; + case "PURCHASE": + return "nodePurchase"; + case "RECEIPT": + return "nodeReceipt"; + case "PUTAWAY": + return "nodePutaway"; + case "IN": + return "directionIn"; + case "OUT": + return "directionOut"; + case "QC": + return "tabQc"; + case "STOCK_TAKE": + return "nodeStockTake"; + case "ADJUSTMENT": + return "nodeAdjustment"; + case "TRANSFER": + return "nodeTransfer"; + case "EXPIRED": + return "nodeExpired"; + case "DEPLETED": + return "nodeDepleted"; + default: + return "type"; + } +}; + +export const phaseLabelKey = (phase: TraceGraphPhase): string => { + switch (phase) { + case "MATERIAL_PICK": + return "phaseMaterialPick"; + case "PRODUCTION": + return "phaseProduction"; + case "PURCHASE": + return "phasePurchase"; + case "INBOUND": + return "phaseInbound"; + case "QC": + return "phaseQc"; + case "PUTAWAY": + return "phasePutaway"; + case "WAREHOUSE": + return "phaseWarehouse"; + case "OUTBOUND": + return "phaseOutbound"; + case "STOCK_TAKE": + return "phaseStockTake"; + default: + return "type"; + } +}; + +export const phaseAccent = (phase: TraceGraphPhase): string => { + switch (phase) { + case "MATERIAL_PICK": + return "#f9a825"; + case "PRODUCTION": + return "#6a1b9a"; + case "PURCHASE": + return "#33691e"; + case "INBOUND": + return "#2e7d32"; + case "QC": + return "#0288d1"; + case "PUTAWAY": + return "#00897b"; + case "WAREHOUSE": + return "#7b1fa2"; + case "OUTBOUND": + return "#ed6c02"; + case "STOCK_TAKE": + return "#5c6bc0"; + default: + return "#757575"; + } +}; + +/** MUI Chip color for a phase — matches the node chips typically shown in that lane. */ +export const phaseChipColor = ( + phase: TraceGraphPhase, +): + | "success" + | "warning" + | "info" + | "secondary" + | "default" + | "primary" + | "error" => { + switch (phase) { + case "PURCHASE": + case "INBOUND": + return "success"; + case "QC": + return "info"; + case "PUTAWAY": + case "PRODUCTION": + case "WAREHOUSE": + return "primary"; + case "MATERIAL_PICK": + case "STOCK_TAKE": + return "secondary"; + case "OUTBOUND": + return "warning"; + default: + return "default"; + } +}; + +export const minimapNodeColor = (kind: TraceGraphNodeKind): string => { + const map: Record = { + success: "#2e7d32", + warning: "#ed6c02", + info: "#0288d1", + secondary: "#5c6bc0", + primary: "#7b1fa2", + default: "#9e9e9e", + error: "#d32f2f", + }; + return map[kindColor(kind)] ?? "#9e9e9e"; +}; diff --git a/src/components/ItemTracing/traceGraphLabels.ts b/src/components/ItemTracing/traceGraphLabels.ts new file mode 100644 index 0000000..8f5d762 --- /dev/null +++ b/src/components/ItemTracing/traceGraphLabels.ts @@ -0,0 +1,291 @@ +import { TFunction } from "i18next"; +import { createTraceLabelTranslator } from "./traceLabelUtils"; +import { formatQty } from "./traceQtyUtils"; +import type { JoPreludeGraphLabels } from "./buildJoPreludeGraphNodes"; +import type { ProductionGraphLabels } from "./buildProductionGraphNodes"; +import type { ExtendedTraceGraphLabels } from "./buildExtendedTraceGraphNodes"; +import type { TraceGraphDetailLabels } from "./buildTraceGraphNodes"; + +export type TraceGraphCompileLabels = TraceGraphDetailLabels & + JoPreludeGraphLabels & + ProductionGraphLabels & + ExtendedTraceGraphLabels & { + flowDoGroupTitle?: (count: number) => string; + flowPickGroupTitle?: (pickOrderCode: string, count: number) => string; + }; + +export const buildTraceGraphLabels = ( + t: TFunction, + lotUom: string, +): TraceGraphCompileLabels => { + const tr = createTraceLabelTranslator(t); + return { + tr, + nodeQcPass: t("nodeQcPass"), + nodeQcFail: t("nodeQcFail"), + nodeReceipt: t("nodeReceipt"), + nodePurchase: t("nodePurchase"), + nodePutaway: t("nodePutaway"), + nodePutawayTransfer: t("nodePutawayTransfer"), + putawayTransferDetail: t("putawayTransferInboundDetail"), + nodeStockTake: t("nodeStockTake"), + nodeAdjustment: t("nodeAdjustment"), + nodeTransfer: t("nodeTransfer"), + nodeMaterialIn: t("nodeMaterialIn"), + nodeJoCreated: t("nodeJoCreated"), + nodeMaterialPick: t("nodeMaterialPick"), + nodeProductionStep: t("nodeProductionStep"), + nodeByproduct: t("nodeByproduct"), + nodeScrap: t("nodeScrap"), + nodeDefect: t("nodeDefect"), + detailProcessOutputQty: t("processOutputQty"), + detailProcessScrapQty: t("processScrapQty"), + detailProcessDefectQty: t("processDefectQty"), + nodeOpen: t("nodeOpen"), + nodeFail: t("nodeFail"), + nodeDoOut: t("nodeDoOut"), + nodeReplenishmentCreated: t("nodeReplenishmentCreated"), + nodeJoOut: t("nodeJoOut"), + nodePoOut: t("nodePoOut"), + doOutboundExtra: t("doOutboundExtra"), + doOutboundReplenish: t("doOutboundReplenish"), + detailDoOutboundKind: t("detailDoOutboundKind"), + flowDoGroupTitle: (count: number) => t("flowDoGroupTitle", { count }), + flowPickGroupTitle: (pickOrderCode: string, count: number) => + t("flowPickGroupTitle", { pickOrderCode, count }), + nodeReturn: t("nodeReturn"), + nodeRepack: t("nodeRepack"), + traceRepackLot: t("traceRepackLot"), + nodeExpired: t("nodeExpired"), + nodeDepleted: t("nodeDepleted"), + directionIn: t("directionIn"), + directionOut: t("directionOut"), + formatQcSubtitle: (failQty: number, acceptedQty: number) => + `${t("failQty")}: ${formatQty(failQty, lotUom)} / ${t("acceptedQty")}: ${formatQty(acceptedQty, lotUom)}`, + detailQty: t("qty"), + detailTime: t("timestamp"), + detailHandler: t("handler"), + detailStockTaker: t("stockTaker"), + detailApprover: t("approver"), + detailWarehouse: t("from"), + detailRemarks: t("remarks"), + detailFailCategory: t("detailFailCategory"), + detailProcessDescription: t("detailProcessDescription"), + detailType: t("type"), + detailRef: t("ref"), + detailStockTakeCode: t("stockTakeCode"), + detailAdjustmentRef: t("detailAdjustmentRef"), + detailReplenishmentCode: t("detailReplenishmentCode"), + detailReason: t("detailReason"), + detailReturnRef: t("detailReturnRef"), + detailSourceDoc: t("detailSourceDoc"), + detailPurchaseOrderNo: t("detailPurchaseOrderNo"), + detailInboundRef: t("detailInboundRef"), + detailInboundSiNo: t("detailInboundSiNo"), + detailPutawayBin: t("detailPutawayBin"), + detailDirection: t("detailDirection"), + detailStatus: t("status"), + detailSupplier: t("supplier"), + detailMaterial: t("material"), + detailItemCode: t("detailItemCode"), + detailItemName: t("detailItemName"), + detailLot: t("lotNo"), + detailAcceptedQty: t("acceptedQty"), + detailFailQty: t("failQty"), + detailQcCriteria: t("detailQcCriteria"), + detailQcType: t("detailQcType"), + detailQcUnknownItem: t("detailQcUnknownItem"), + detailFrom: t("from"), + detailTo: t("to"), + detailVariance: t("variance"), + detailBefore: t("before"), + detailAfter: t("after"), + detailStockTakeFirstCount: t("stockTakeStageFirstCount"), + detailStockTakeSecondCount: t("stockTakeStageSecondCount"), + detailStockTakeApproverCount: t("stockTakeStageApproverCount"), + detailScrapQty: t("scrapQty"), + detailDefectQty: t("defectQty"), + detailEquipment: t("equipment"), + detailProcessStep: t("processStep"), + detailStepMaterials: t("detailStepMaterials"), + detailAssignedStep: t("detailAssignedStep"), + detailPickTargetDate: t("detailPickTargetDate"), + detailProductLotNo: t("productLotNo"), + deliveryOrder: t("deliveryOrder"), + deliveryNoteCode: t("deliveryNoteCode"), + ticketNo: t("ticketNo"), + pickOrder: t("pickOrder"), + jobOrder: t("jobOrder"), + expiryDate: t("expiryDate"), + totalAvailable: t("totalAvailable"), + itemCode: t("itemCode"), + detailOrderQty: t("detailOrderQty"), + detailPutAwayQty: t("detailPutAwayQty"), + detailPurchaseUnit: t("detailPurchaseUnit"), + detailSupplyTo: t("detailSupplyTo"), + detailStockTakeRound: t("detailStockTakeRound"), + detailStockTakeSection: t("detailStockTakeSection"), + detailLocation: t("detailLocation"), + categoryPurchase: t("categoryPurchase"), + categoryProduction: t("categoryProduction"), + categoryInbound: t("phaseInbound"), + categoryReceipt: t("categoryReceipt"), + categoryPutaway: t("phasePutaway"), + categoryTransfer: t("phaseWarehouse"), + categoryStockTake: t("phaseStockTake"), + categoryOpen: t("categoryOpen"), + categoryAdjustment: t("nodeAdjustment"), + categoryPick: t("phaseMaterialPick"), + categoryQc: t("phaseQc"), + categoryOutbound: t("phaseOutbound"), + categoryTerminal: t("categoryTerminal"), + processingStatus: t("processingStatus"), + matchStatus: t("matchStatus"), + }; +}; + +/** Minimal labels for unit tests (no i18n). */ +export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { + const identity = (s: string | null | undefined) => s?.trim() || "—"; + const tr = { + refType: identity, + movementType: identity, + direction: (c: string | null | undefined) => + c?.toUpperCase() === "OUT" ? "Out" : c?.toUpperCase() === "IN" ? "In" : identity(c), + stockInStatus: identity, + adjustmentType: identity, + usageType: identity, + failType: identity, + productionStatus: identity, + pickStatus: identity, + joStatus: identity, + qcType: () => "IQC", + qcPassed: (p: boolean) => (p ? "Pass" : "Fail"), + putawayShelfStatus: (phase: "pending" | "completed") => + phase === "completed" ? "Put away done" : "Pending put-away", + processingStatus: identity, + matchStatus: identity, + lotLineStatus: identity, + }; + return { + tr, + nodeQcPass: "QC Pass", + nodeQcFail: "QC Fail", + nodeReceipt: "Receipt", + nodePurchase: "Purchase", + nodePutaway: "Putaway", + nodePutawayTransfer: "Transfer inbound", + putawayTransferDetail: "Inbound at target warehouse after transfer (auto-completed, not PO put-away queue)", + nodeStockTake: "Stock take", + nodeAdjustment: "Adjustment", + nodeTransfer: "Transfer", + nodeMaterialIn: "Material in", + nodeJoCreated: "JO created", + nodeMaterialPick: "Material pick", + nodeProductionStep: "Production step", + nodeByproduct: "Byproduct", + nodeScrap: "Scrap", + nodeDefect: "Defect", + detailProcessOutputQty: "Process output", + detailProcessScrapQty: "Scrap quantity", + detailProcessDefectQty: "Defect quantity", + nodeOpen: "Opening", + nodeFail: "Pick fail", + nodeDoOut: "DO PO", + nodeReplenishmentCreated: "Replenishment created", + nodeJoOut: "JO PO", + nodePoOut: "PO pick", + doOutboundExtra: "Add-on", + doOutboundReplenish: "Replenishment", + detailDoOutboundKind: "Outbound type", + flowDoGroupTitle: (count) => `DO × ${count}`, + flowPickGroupTitle: (code, count) => `Pick · ${code} × ${count}`, + nodeReturn: "Return", + nodeRepack: "Repack", + traceRepackLot: "Trace lot", + nodeExpired: "Expired", + nodeDepleted: "Depleted", + directionIn: "In", + directionOut: "Out", + formatQcSubtitle: (f, a) => `Fail ${f} / Accepted ${a}`, + detailQty: "Qty", + detailTime: "Time", + detailHandler: "Handler", + detailStockTaker: "First counter", + detailApprover: "Approver", + detailWarehouse: "Warehouse", + detailRemarks: "Remarks", + detailFailCategory: "Fail category", + detailProcessDescription: "Step description", + detailType: "Type", + detailRef: "Doc no.", + detailStockTakeCode: "Stock Take #", + detailAdjustmentRef: "Adjustment #", + detailReplenishmentCode: "Replenishment #", + detailReason: "Reason", + detailReturnRef: "Return #", + detailSourceDoc: "Source doc #", + detailPurchaseOrderNo: "PO no.", + detailInboundRef: "Inbound SI", + detailInboundSiNo: "Inbound SI no.", + detailPutawayBin: "Bin", + detailDirection: "Direction", + detailStatus: "Status", + detailSupplier: "Supplier", + detailMaterial: "Material", + detailItemCode: "Item code", + detailItemName: "Item name", + detailLot: "Lot", + detailAcceptedQty: "Accepted", + detailFailQty: "Fail", + detailQcCriteria: "Criteria", + detailQcType: "QC type", + detailQcUnknownItem: "Unknown", + detailFrom: "From", + detailTo: "To", + detailVariance: "Variance", + detailBefore: "Book qty", + detailAfter: "Accepted qty", + detailStockTakeFirstCount: "First count", + detailStockTakeSecondCount: "Re-count", + detailStockTakeApproverCount: "Approver count", + detailScrapQty: "Scrap", + detailDefectQty: "Defect", + detailEquipment: "Equipment", + detailProcessStep: "Step", + detailStepMaterials: "Step materials", + detailAssignedStep: "Assigned step", + detailPickTargetDate: "Target date", + detailProductLotNo: "Product lot", + deliveryOrder: "DO", + deliveryNoteCode: "DN", + ticketNo: "Ticket", + pickOrder: "Pick", + jobOrder: "Job order", + expiryDate: "Expiry", + totalAvailable: "Available", + itemCode: "Item code", + detailOrderQty: "Order qty", + detailPutAwayQty: "Putaway qty", + detailPurchaseUnit: "Purchase unit", + detailSupplyTo: "Supply to", + detailStockTakeRound: "Stock take round", + detailStockTakeSection: "Stock take section", + detailLocation: "Location", + categoryPurchase: "Purchase", + categoryProduction: "Production", + categoryInbound: "Inbound", + categoryReceipt: "Receipt", + categoryPutaway: "Putaway", + categoryTransfer: "Warehouse", + categoryStockTake: "Stock take", + categoryOpen: "Opening", + categoryAdjustment: "Adjustment", + categoryPick: "Pick", + categoryQc: "QC", + categoryOutbound: "Outbound", + categoryTerminal: "Terminal", + processingStatus: "Processing status", + matchStatus: "Match status", + }; +}; diff --git a/src/components/ItemTracing/traceGraphLayout.ts b/src/components/ItemTracing/traceGraphLayout.ts new file mode 100644 index 0000000..1e2a703 --- /dev/null +++ b/src/components/ItemTracing/traceGraphLayout.ts @@ -0,0 +1,402 @@ +import dayjs from "dayjs"; +import { ItemLotTraceResponse } from "@/app/api/itemTracing"; +import { COLUMN_WIDTH_MIN } from "./traceFlowConstants"; +import { + buildTraceGraphNodes, + TraceGraphDetailLabels, + TraceGraphNode, +} from "./buildTraceGraphNodes"; +import { buildJoPreludeGraphNodes, JoPreludeGraphLabels } from "./buildJoPreludeGraphNodes"; +import { + buildProductionGraphNodes, + ProductionGraphLabels, +} from "./buildProductionGraphNodes"; +import { + buildExtendedTraceGraphNodes, + ExtendedTraceGraphLabels, +} from "./buildExtendedTraceGraphNodes"; +import { buildAllLocationBlockGraphNodes } from "./buildLocationBlockGraphNodes"; +import { applyFlowNodeGrouping, isDoGroupChild, regroupLayoutCells } from "./traceDoGroupLayout"; +import { cellWidthRequirement } from "./traceFlowLayout"; +import { + materialInputsHaveProduction, + phaseFromKind, + sortNodesInPhase, + sortPhasesByEarliestEvent, +} from "./traceGraphSemantics"; + +export { + buildTraceFlowEdgePairs, + materialInputsHaveProduction, + phaseFromKind, + sortNodesInPhase, + sortPhasesByEarliestEvent, +} from "./traceGraphSemantics"; + +export type PreProductionPhase = "MATERIAL_PICK"; + +export type ProductionPhase = "PRODUCTION"; + +export type FgTraceGraphPhase = + | "PURCHASE" + | "INBOUND" + | "QC" + | "PUTAWAY" + | "WAREHOUSE" + | "OUTBOUND" + | "STOCK_TAKE"; + +/** @deprecated MATERIAL_QC merged into QC lane when prelude is shown */ +export type LegacyMaterialQcPhase = "MATERIAL_QC"; + +export type TraceGraphPhase = PreProductionPhase | ProductionPhase | FgTraceGraphPhase; + +export const PRE_PRODUCTION_PHASES: PreProductionPhase[] = ["MATERIAL_PICK"]; + +export const FG_PHASE_ORDER: FgTraceGraphPhase[] = [ + "PURCHASE", + "INBOUND", + "QC", + "PUTAWAY", + "WAREHOUSE", + "STOCK_TAKE", + "OUTBOUND", +]; + +/** Prelude rows use one shared QC lane (原料 + 成品品檢). */ +export const PRELUDE_PHASE_ORDER = ( + hasProduction: boolean, +): TraceGraphPhase[] => { + const phases: TraceGraphPhase[] = [ + "PURCHASE", + "INBOUND", + "QC", + "PUTAWAY", + "MATERIAL_PICK", + ]; + if (hasProduction) phases.push("PRODUCTION"); + phases.push("WAREHOUSE", "STOCK_TAKE", "OUTBOUND"); + return phases; +}; + +export const getPhaseOrder = (hasPrelude: boolean, hasProduction: boolean): TraceGraphPhase[] => { + if (hasPrelude) return PRELUDE_PHASE_ORDER(hasProduction); + const phases: TraceGraphPhase[] = [...FG_PHASE_ORDER]; + if (hasProduction) phases.splice(1, 0, "PRODUCTION"); + return phases; +}; + +/** @deprecated use layout.phaseOrder */ +export const PHASE_ORDER: TraceGraphPhase[] = FG_PHASE_ORDER; + +/** @deprecated use layout.laneCount */ +export const LANE_COUNT = FG_PHASE_ORDER.length; + +export interface TraceGraphLayoutNode extends TraceGraphNode { + phase: TraceGraphPhase; + column: number; + dayKey: string; + laneIndex: number; + sequenceIndex: number; + branchIndex: number; + branchSize: number; + /** Slot index within the day column (one dynamic phase slot per phase on that day). */ + dayPhaseStaggerIndex: number; +} + +export interface DayColumnLayout { + columnStarts: number[]; + columnWidths: number[]; + /** Width of each phase slot within a calendar day (same order as phases on that day). */ + phaseSlotWidths: number[][]; + /** Start X offset of each phase slot within a calendar day. */ + phaseSlotStarts: number[][]; + timelineWidth: number; +} + +export interface TraceGraphLayout { + nodes: TraceGraphLayoutNode[]; + columnCount: number; + columnDates: string[]; + dayColumns: DayColumnLayout; + cellGroups: TraceGraphLayoutNode[][]; + phaseOrder: TraceGraphPhase[]; + laneCount: number; + hasPrelude: boolean; + hasProduction: boolean; +} + +export const laneIndexFromPhase = (phase: TraceGraphPhase, phaseOrder: TraceGraphPhase[]): number => + phaseOrder.indexOf(phase); + +const cellKey = (column: number, laneIndex: number) => `${column}:${laneIndex}`; + +export const dayKeyFromTimestamp = (timestamp: string | null): string => { + if (!timestamp?.trim()) return "—"; + const d = dayjs(timestamp); + if (!d.isValid()) return timestamp.slice(0, 10) || "—"; + return d.format("YYYY-MM-DD"); +}; + +export const phasesOnDay = ( + nodes: TraceGraphLayoutNode[], + dayKey: string, + phaseOrder: TraceGraphPhase[], +): TraceGraphPhase[] => { + const present = new Set(); + nodes.forEach((n) => { + if (n.dayKey === dayKey) present.add(n.phase); + }); + return sortPhasesByEarliestEvent(nodes, Array.from(present), phaseOrder, dayKey); +}; + +/** Widen each calendar day by chronological time slots (not phase-packed slots). */ +export const computeDayColumnLayout = ( + columnDates: string[], + nodes: TraceGraphLayoutNode[], + _phaseOrder: TraceGraphPhase[], +): DayColumnLayout => { + const visible = nodes.filter((n) => !isDoGroupChild(n)); + const phaseSlotWidths: number[][] = []; + const phaseSlotStarts: number[][] = []; + const columnWidths: number[] = []; + + columnDates.forEach((dayKey, colIndex) => { + const dayNodes = visible.filter((n) => n.column === colIndex && n.dayKey === dayKey); + const maxSlot = dayNodes.reduce((m, n) => Math.max(m, n.dayPhaseStaggerIndex), 0); + const slotCount = dayNodes.length === 0 ? 1 : maxSlot + 1; + + const slotWidths: number[] = []; + for (let slot = 0; slot < slotCount; slot++) { + const members = dayNodes.filter((n) => n.dayPhaseStaggerIndex === slot); + slotWidths.push(cellWidthRequirement(members)); + } + + const starts: number[] = []; + let dayWidth = 0; + slotWidths.forEach((w) => { + starts.push(dayWidth); + dayWidth += w; + }); + + phaseSlotWidths.push(slotWidths); + phaseSlotStarts.push(starts); + columnWidths.push(Math.max(COLUMN_WIDTH_MIN, dayWidth)); + }); + + const columnStarts: number[] = []; + let x = 0; + for (const w of columnWidths) { + columnStarts.push(x); + x += w; + } + return { columnStarts, columnWidths, phaseSlotWidths, phaseSlotStarts, timelineWidth: x }; +}; + +/** + * Within each calendar day, assign left→right slots by event time (sortKey). + * Avoids packing an entire phase to the left of another phase when later events + * in the early phase happen after earlier events in a later phase. + */ +export const assignChronologicalDaySlots = (nodes: TraceGraphLayoutNode[]): void => { + const byDay = new Map(); + nodes.forEach((n) => { + if (isDoGroupChild(n)) return; + const list = byDay.get(n.dayKey) ?? []; + list.push(n); + byDay.set(n.dayKey, list); + }); + + byDay.forEach((dayNodes) => { + dayNodes.sort((a, b) => { + if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; + // Same timestamp: putaway before material ADJUSTMENT only (上架 → 庫存調整). + if ( + (a.kind === "PUTAWAY" || a.kind === "ADJUSTMENT") && + (b.kind === "PUTAWAY" || b.kind === "ADJUSTMENT") && + a.kind !== b.kind + ) { + return a.kind === "PUTAWAY" ? -1 : 1; + } + // 建立工單 before 工單提料 group / lines in the same pick phase. + const pickLaneOrder = (n: TraceGraphLayoutNode) => + n.kind === "JO_CREATED" ? 0 : n.kind === "PICK_GROUP" ? 1 : n.kind === "MATERIAL_PICK" ? 2 : 3; + const pickA = pickLaneOrder(a); + const pickB = pickLaneOrder(b); + if (pickA !== pickB) return pickA - pickB; + // Then canonical phase / lane order (e.g. QC before putaway). + if (a.laneIndex !== b.laneIndex) return a.laneIndex - b.laneIndex; + if (a.sequenceIndex !== b.sequenceIndex) return a.sequenceIndex - b.sequenceIndex; + return a.id.localeCompare(b.id); + }); + dayNodes.forEach((n, i) => { + n.dayPhaseStaggerIndex = i; + n.branchIndex = 0; + n.branchSize = 1; + }); + }); + + // Group children share the container's horizontal time slot. + const parentById = new Map(nodes.map((n) => [n.id, n])); + nodes.forEach((n) => { + if (!n.doGroupId) return; + const parent = parentById.get(n.doGroupId); + if (parent) n.dayPhaseStaggerIndex = parent.dayPhaseStaggerIndex; + }); +}; + +const assignColumns = ( + sorted: TraceGraphNode[], + phaseOrder: TraceGraphPhase[], +): { columnDates: string[]; nodes: TraceGraphLayoutNode[]; cellGroups: TraceGraphLayoutNode[][] } => { + const dayOrder: string[] = []; + const columnByDayKey = new Map(); + + const withMeta: TraceGraphLayoutNode[] = sorted.map((node, sequenceIndex) => { + const dayKey = dayKeyFromTimestamp(node.timestamp); + let column = columnByDayKey.get(dayKey); + if (column === undefined) { + column = dayOrder.length; + dayOrder.push(dayKey); + columnByDayKey.set(dayKey, column); + } + const phase = phaseFromKind(node.kind, phaseOrder, node.traceLotNo, node.refType); + return { + ...node, + phase, + dayKey, + column, + laneIndex: laneIndexFromPhase(phase, phaseOrder), + sequenceIndex, + branchIndex: 0, + branchSize: 1, + dayPhaseStaggerIndex: 0, + }; + }); + + // Provisional phase-based stagger (overwritten after grouping by chronological slots). + const staggerByDay = new Map(); + withMeta.forEach((node) => { + if (!staggerByDay.has(node.dayKey)) { + staggerByDay.set(node.dayKey, phasesOnDay(withMeta, node.dayKey, phaseOrder)); + } + const ordered = staggerByDay.get(node.dayKey)!; + node.dayPhaseStaggerIndex = Math.max(0, ordered.indexOf(node.phase)); + }); + + const groups = new Map(); + withMeta.forEach((node) => { + const key = cellKey(node.column, node.laneIndex); + const list = groups.get(key) ?? []; + list.push(node); + groups.set(key, list); + }); + + groups.forEach((list) => { + list.sort(sortNodesInPhase); + const size = list.length; + list.forEach((node, idx) => { + node.branchIndex = idx; + node.branchSize = size; + }); + }); + + const cellGroups = Array.from(groups.values()).sort((a, b) => { + const na = a[0]; + const nb = b[0]; + if (na.column !== nb.column) return na.column - nb.column; + return na.laneIndex - nb.laneIndex; + }); + + return { columnDates: dayOrder, nodes: withMeta, cellGroups }; +}; + +export const buildTraceGraphLayout = ( + data: ItemLotTraceResponse, + labels: TraceGraphDetailLabels & + Partial & + Partial & + Partial & { + flowDoGroupTitle?: (count: number) => string; + flowPickGroupTitle?: (pickOrderCode: string, count: number) => string; + }, +): TraceGraphLayout => { + const hasPrelude = data.joPrelude != null; + const hasScrap = (data.productionSteps ?? []).some( + (s) => (s.scrapQty ?? 0) + (s.defectQty ?? 0) > 0, + ); + const hasMaterialProduction = + data.joPrelude != null && materialInputsHaveProduction(data.joPrelude.materialInputs); + const hasProduction = + (data.productionSteps?.length ?? 0) > 0 || + (data.byproductLots?.length ?? 0) > 0 || + hasScrap || + hasMaterialProduction; + const phaseOrder = getPhaseOrder(hasPrelude, hasProduction); + const mergedMultiLocation = (data.locationBlocks?.length ?? 0) > 0; + const primaryWh = + data.warehouseLines.map((w) => w.warehouseCode).filter(Boolean).join(" / ") || undefined; + const primaryScope = { + defaultWarehouseCode: primaryWh, + inventoryLotId: data.lot.inventoryLotId, + mergedMultiLocation, + }; + const fgNodes = buildTraceGraphNodes(data, labels, primaryScope); + const preludeNodes = + data.joPrelude && labels.nodeMaterialIn && labels.nodeMaterialPick && labels.nodePurchase + ? buildJoPreludeGraphNodes(data.joPrelude, labels as JoPreludeGraphLabels) + : []; + const productionNodes = + hasProduction && labels.nodeProductionStep && labels.nodeScrap && labels.nodeDefect + ? buildProductionGraphNodes( + data.productionSteps ?? [], + data.byproductLots ?? [], + labels as ProductionGraphLabels, + data.lot.uom, + ) + : []; + const extendedNodes = + labels.nodeOpen && labels.nodeFail && labels.nodeDoOut + ? buildExtendedTraceGraphNodes(data, labels as ExtendedTraceGraphLabels, primaryScope) + : []; + const locationNodes = + (data.locationBlocks?.length ?? 0) > 0 && + labels.nodeOpen && + labels.nodeFail && + labels.nodeDoOut + ? buildAllLocationBlockGraphNodes(data.locationBlocks ?? [], labels as ExtendedTraceGraphLabels) + : []; + const merged = [...preludeNodes, ...productionNodes, ...extendedNodes, ...fgNodes, ...locationNodes].sort((a, b) => { + if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; + return a.id.localeCompare(b.id); + }); + const { columnDates, nodes: assigned, cellGroups: _initialCellGroups } = assignColumns( + merged, + phaseOrder, + ); + const groupTitleDo = + labels.flowDoGroupTitle ?? ((count: number) => `DO × ${count}`); + const groupTitlePick = + labels.flowPickGroupTitle ?? + ((pickOrderCode: string, count: number) => `${pickOrderCode} × ${count}`); + const nodes = applyFlowNodeGrouping(assigned, { + flowDoGroupTitle: groupTitleDo, + flowPickGroupTitle: groupTitlePick, + }); + assignChronologicalDaySlots(nodes); + const cellGroups = regroupLayoutCells(nodes); + const columnCount = Math.max(columnDates.length, 1); + const dayColumns = computeDayColumnLayout(columnDates, nodes, phaseOrder); + + return { + nodes, + columnCount, + columnDates, + dayColumns, + cellGroups, + phaseOrder, + laneCount: phaseOrder.length, + hasPrelude, + hasProduction, + }; +}; diff --git a/src/components/ItemTracing/traceGraphSearch.ts b/src/components/ItemTracing/traceGraphSearch.ts new file mode 100644 index 0000000..62b6352 --- /dev/null +++ b/src/components/ItemTracing/traceGraphSearch.ts @@ -0,0 +1,47 @@ +import { TraceGraphNodeKind } from "./buildTraceGraphNodes"; +import { TraceGraphLayoutNode } from "./traceGraphLayout"; + +const normalize = (value: string) => value.trim().toLowerCase(); + +const nodeSearchHaystack = ( + node: TraceGraphLayoutNode, + kindLabel: string, +): string => { + const parts: string[] = [ + node.title, + node.subtitle, + node.refCode ?? "", + node.traceLotNo ?? "", + node.traceItemCode ?? "", + node.consoCode ?? "", + node.meta ?? "", + node.timestamp ?? "", + node.categoryLabel ?? "", + node.refType ?? "", + node.warehouseCode ?? "", + node.transferFromWarehouse ?? "", + node.transferToWarehouse ?? "", + kindLabel, + node.qty != null ? String(node.qty) : "", + ...node.details.flatMap((field) => + [field.label, field.value, field.linkCode ?? ""].filter(Boolean), + ), + ]; + return normalize(parts.join(" ")); +}; + +export const searchTraceGraphNodes = ( + nodes: TraceGraphLayoutNode[], + query: string, + kindLabel: (kind: TraceGraphNodeKind) => string, +): string[] => { + const normalizedQuery = normalize(query); + if (!normalizedQuery) return []; + + return nodes + .filter((node) => + nodeSearchHaystack(node, kindLabel(node.kind)).includes(normalizedQuery), + ) + .sort((a, b) => a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex) + .map((node) => node.id); +}; diff --git a/src/components/ItemTracing/traceGraphSemantics.ts b/src/components/ItemTracing/traceGraphSemantics.ts new file mode 100644 index 0000000..d864838 --- /dev/null +++ b/src/components/ItemTracing/traceGraphSemantics.ts @@ -0,0 +1,1169 @@ +import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; +import { TraceGraphNodeKind } from "./buildTraceGraphNodes"; +import { isDoGroupChild } from "./traceDoGroupLayout"; +import { isTransferInboundPutaway, warehouseCodesMatch } from "./tracePutawayUtils"; +import type { TraceGraphLayoutNode, TraceGraphPhase } from "./traceGraphLayout"; + +export const sortNodesInPhase = (a: TraceGraphLayoutNode, b: TraceGraphLayoutNode): number => { + if (a.sortKey !== b.sortKey) return a.sortKey - b.sortKey; + return a.sequenceIndex - b.sequenceIndex; +}; + +const earliestNodeInPhase = ( + nodes: TraceGraphLayoutNode[], + phase: TraceGraphPhase, + dayKey?: string, +): TraceGraphLayoutNode | null => { + let best: TraceGraphLayoutNode | null = null; + nodes.forEach((n) => { + if (n.phase !== phase) return; + if (dayKey != null && n.dayKey !== dayKey) return; + if (!best || n.sortKey < best.sortKey) best = n; + }); + return best; +}; + +const earliestSortKey = ( + nodes: TraceGraphLayoutNode[], + phase: TraceGraphPhase, + dayKey?: string, +): number => earliestNodeInPhase(nodes, phase, dayKey)?.sortKey ?? Number.MAX_SAFE_INTEGER; + +/** Sort phases by earliest event time; tie-break with canonical phase order. */ +const sortPhasesByTimestamp = ( + nodes: TraceGraphLayoutNode[], + phases: TraceGraphPhase[], + phaseOrder: TraceGraphPhase[], + dayKey?: string, +): TraceGraphPhase[] => + [...phases].sort((a, b) => { + const ta = earliestSortKey(nodes, a, dayKey); + const tb = earliestSortKey(nodes, b, dayKey); + if (ta !== tb) return ta - tb; + return phaseOrder.indexOf(a) - phaseOrder.indexOf(b); + }); + +export const sortPhasesByEarliestEvent = ( + nodes: TraceGraphLayoutNode[], + phases: TraceGraphPhase[], + phaseOrder: TraceGraphPhase[], + dayKey?: string, +): TraceGraphPhase[] => sortPhasesByTimestamp(nodes, phases, phaseOrder, dayKey); + +/** FG cross-day flow: chronological across days; same calendar day keeps canonical phase order for edges. */ +const sortFgPhasesForCrossDayFlow = ( + nodes: TraceGraphLayoutNode[], + phases: TraceGraphPhase[], + phaseOrder: TraceGraphPhase[], +): TraceGraphPhase[] => + [...phases].sort((a, b) => { + const nodeA = earliestNodeInPhase(nodes, a); + const nodeB = earliestNodeInPhase(nodes, b); + const ta = nodeA?.sortKey ?? Number.MAX_SAFE_INTEGER; + const tb = nodeB?.sortKey ?? Number.MAX_SAFE_INTEGER; + const sameDay = + nodeA != null && + nodeB != null && + nodeA.dayKey !== "—" && + nodeA.dayKey === nodeB.dayKey; + if (sameDay) { + return phaseOrder.indexOf(a) - phaseOrder.indexOf(b); + } + if (ta !== tb) return ta - tb; + return phaseOrder.indexOf(a) - phaseOrder.indexOf(b); + }); + +export const phaseFromKind = ( + kind: TraceGraphNodeKind, + phaseOrder: TraceGraphPhase[], + traceLotNo?: string, + refType?: string | null, +): TraceGraphPhase => { + const materialPrelude = Boolean(traceLotNo?.trim()); + switch (kind) { + case "MATERIAL_IN": + return "INBOUND"; + case "JO_CREATED": + // Sit with 工單提料 so 建立工單 → 工單提料 group stay in one lane. + if (phaseOrder.includes("MATERIAL_PICK")) return "MATERIAL_PICK"; + return "INBOUND"; + case "MATERIAL_QC": + case "QC": + case "FAIL": + return "QC"; + case "MATERIAL_PICK": + return "MATERIAL_PICK"; + case "PICK_GROUP": + return "MATERIAL_PICK"; + case "PRODUCTION_STEP": + case "BYPRODUCT": + case "SCRAP": + case "DEFECT": + return "PRODUCTION"; + case "OPEN": + return "INBOUND"; + case "DO_OUT": + case "REPLENISHMENT_CREATED": + case "JO_OUT": + case "PO_OUT": + case "DO_GROUP": + case "RETURN": + return "OUTBOUND"; + case "REPACK": + return "WAREHOUSE"; + case "PURCHASE": + return "PURCHASE"; + case "RECEIPT": + case "IN": + return "INBOUND"; + case "PUTAWAY": + if (isTransferInboundPutaway(refType)) return "WAREHOUSE"; + return "PUTAWAY"; + case "TRANSFER": + return "WAREHOUSE"; + case "ADJUSTMENT": + // Material BOM ADJ stock-in lives next to material putaway (上架 → 庫存調整). + return materialPrelude ? "PUTAWAY" : "WAREHOUSE"; + case "OUT": + return "OUTBOUND"; + case "STOCK_TAKE": + return "STOCK_TAKE"; + case "EXPIRED": + case "DEPLETED": + return "OUTBOUND"; + default: + return phaseOrder.includes("WAREHOUSE") ? "WAREHOUSE" : phaseOrder[0]; + } +}; + +export const materialInputsHaveProduction = (inputs: ItemLotTraceMaterialInput[]): boolean => + inputs.some( + (m) => + (m.productionSteps?.length ?? 0) > 0 || + (m.nestedJoPrelude?.materialInputs.length + ? materialInputsHaveProduction(m.nestedJoPrelude.materialInputs) + : false), + ); + +const NO_FLOW_EDGE_SOURCE_KINDS = new Set(["EXPIRED", "DEPLETED"]); + +const NO_FLOW_EDGE_KINDS = new Set([ + "REPACK", + "BYPRODUCT", +]); + +const TERMINAL_TARGET_KINDS = new Set(["EXPIRED", "DEPLETED"]); + +const TERMINAL_PREDECESSOR_KINDS = new Set([ + "PUTAWAY", + "TRANSFER", + "ADJUSTMENT", + "STOCK_TAKE", + "REPACK", + "DO_OUT", + "DO_GROUP", + "JO_OUT", + "PO_OUT", + "OUT", + "RETURN", +]); + +const nodeScopePrefix = (node: TraceGraphLayoutNode): string => { + const match = /^loc-(\d+)-/.exec(node.id); + return match ? `loc-${match[1]}-` : ""; +}; + +const buildTerminalStateEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +) => { + const terminals = nodes.filter((n) => TERMINAL_TARGET_KINDS.has(n.kind)); + terminals.forEach((terminal) => { + const scope = nodeScopePrefix(terminal); + const scoped = nodes.filter((n) => nodeScopePrefix(n) === scope); + const predecessors = scoped + .filter((n) => TERMINAL_PREDECESSOR_KINDS.has(n.kind) && n.id !== terminal.id) + .sort(sortNodesInPhase); + const from = predecessors[predecessors.length - 1]; + if (from) add(from, terminal); + }); +}; + +const WAREHOUSE_FLOW_KINDS = new Set(["TRANSFER", "ADJUSTMENT"]); + +/** Predecessors that represent on-hand inventory state (skip QC / production). */ +const INVENTORY_STATE_KINDS = new Set([ + "PUTAWAY", + "TRANSFER", + "ADJUSTMENT", + "OUT", + "DO_OUT", + "JO_OUT", + "PO_OUT", + "DO_GROUP", + "STOCK_TAKE", + "RETURN", + "RECEIPT", + "IN", + "OPEN", +]); + +export const isMaterialPreludeNode = (n: TraceGraphLayoutNode): boolean => + (["MATERIAL_IN", "MATERIAL_QC", "MATERIAL_PICK"] as TraceGraphNodeKind[]).includes(n.kind) || + (n.kind === "JO_CREATED" && Boolean(n.traceLotNo?.trim())) || + (n.kind === "PUTAWAY" && Boolean(n.traceLotNo?.trim())) || + (n.kind === "ADJUSTMENT" && Boolean(n.traceLotNo?.trim())) || + (n.kind === "PRODUCTION_STEP" && Boolean(n.traceLotNo?.trim())) || + ((n.kind === "SCRAP" || n.kind === "DEFECT") && Boolean(n.traceLotNo?.trim())) || + ((n.kind === "PURCHASE" || n.kind === "RECEIPT") && Boolean(n.traceLotNo?.trim())); + +const materialLotKey = (n: TraceGraphLayoutNode): string | null => { + const lot = n.traceLotNo?.trim(); + if (!lot) return null; + const item = n.traceItemCode?.trim() ?? ""; + return `${item}::${lot}`; +}; + +const resolvePickFlowTarget = ( + pick: TraceGraphLayoutNode, + nodes: TraceGraphLayoutNode[], +): TraceGraphLayoutNode => { + const groupId = pick.doGroupId?.trim(); + if (!groupId) return pick; + return nodes.find((n) => n.id === groupId) ?? pick; +}; + +const PICK_OR_OUTBOUND_TARGET_KINDS = new Set([ + "MATERIAL_PICK", + "PICK_GROUP", + "DO_OUT", + "DO_GROUP", + "JO_OUT", + "PO_OUT", + "OUT", +]); + +export const shouldSkipTraceFlowEdge = ( + from: TraceGraphLayoutNode, + to: TraceGraphLayoutNode, +): boolean => { + if (from.kind === "JO_CREATED" && !isMaterialPreludeNode(from)) { + if (to.kind === "MATERIAL_PICK" || to.kind === "PICK_GROUP") return false; + return true; + } + // Allow PRODUCTION_STEP → PRODUCTION_STEP (process chain). Block other sources into steps + // except material picks (handled by dedicated pick→step edges). + if ( + to.kind === "PRODUCTION_STEP" && + from.kind !== "MATERIAL_PICK" && + from.kind !== "PICK_GROUP" && + from.kind !== "PRODUCTION_STEP" + ) { + return true; + } + if ( + (from.kind === "QC" || from.kind === "FAIL" || from.kind === "MATERIAL_QC") && + to.kind === "PRODUCTION_STEP" + ) { + return true; + } + if (from.kind === "PRODUCTION_STEP" && to.kind === "JO_CREATED") { + return true; + } + if (from.kind === "PUTAWAY" && (to.kind === "QC" || to.kind === "FAIL")) { + return true; + } + if (from.kind === "PRODUCTION_STEP" && PICK_OR_OUTBOUND_TARGET_KINDS.has(to.kind)) { + return true; + } + if (to.kind === "REPLENISHMENT_CREATED") { + // Inventory / warehouse → 建立補貨; block generic phase noise. + return !( + from.kind === "PUTAWAY" || + from.kind === "TRANSFER" || + from.kind === "ADJUSTMENT" || + from.kind === "OPEN" || + from.kind === "RECEIPT" || + from.kind === "IN" + ); + } + if (from.kind === "REPLENISHMENT_CREATED") { + if (to.kind === "DO_GROUP") return false; + if (to.kind !== "DO_OUT") return true; + const targetSolId = from.replenishmentStockOutLineId; + if (targetSolId != null && doOutNodeMatchesStockOutLineId(to.id, targetSolId)) { + return false; + } + const doCode = from.refCode?.trim(); + if (doCode && doOutMatchesDeliveryCode(to, doCode)) return false; + return true; + } + return false; +}; + +const doOutNodeMatchesStockOutLineId = (nodeId: string, stockOutLineId: number): boolean => + nodeId === `do-${stockOutLineId}` || nodeId.endsWith(`-${stockOutLineId}`); + +const doOutMatchesDeliveryCode = (doOut: TraceGraphLayoutNode, doCode: string): boolean => { + const code = doCode.trim(); + if (!code) return false; + if (doOut.refCode?.trim() === code) return true; + if (doOut.title?.includes(code)) return true; + if (doOut.subtitle?.includes(code)) return true; + return (doOut.details ?? []).some((d) => d.value?.trim() === code); +}; + +const shouldSkipGenericPhaseEdge = shouldSkipTraceFlowEdge; + +const productionScopeKey = (n: TraceGraphLayoutNode): string => + n.traceLotNo?.trim() || "__fg__"; + +const normWh = (w?: string | null): string => (w ?? "").trim().toUpperCase(); + +const normRefType = (refType?: string | null): string => (refType ?? "").trim().toUpperCase(); + +const pickSourcePutawayForTransfer = ( + putaways: TraceGraphLayoutNode[], + transfer: TraceGraphLayoutNode, +): TraceGraphLayoutNode | null => { + const fromWh = normWh(transfer.transferFromWarehouse); + const candidates = putaways.filter((p) => p.sortKey <= transfer.sortKey); + const whMatched = + fromWh.length > 0 + ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, fromWh)) + : candidates; + if (!whMatched.length) return null; + const nonTransfer = whMatched.filter((p) => normRefType(p.refType) !== "TRANSFER"); + const pool = nonTransfer.length > 0 ? nonTransfer : whMatched; + return [...pool].sort(sortNodesInPhase).at(-1) ?? null; +}; + +const pickDestPutawayForTransfer = ( + putaways: TraceGraphLayoutNode[], + transfer: TraceGraphLayoutNode, +): TraceGraphLayoutNode | null => { + const toWh = normWh(transfer.transferToWarehouse); + const candidates = putaways.filter((p) => p.sortKey >= transfer.sortKey); + const whMatched = + toWh.length > 0 + ? candidates.filter((p) => warehouseCodesMatch(p.warehouseCode, toWh)) + : candidates; + if (!whMatched.length) return null; + const transferPutaways = whMatched.filter((p) => normRefType(p.refType) === "TRANSFER"); + const pool = transferPutaways.length > 0 ? transferPutaways : whMatched; + return [...pool].sort(sortNodesInPhase)[0] ?? null; +}; + +const connectPutawayToWarehousePhase = ( + fromList: TraceGraphLayoutNode[], + toList: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const transfers = toList.filter((n) => n.kind === "TRANSFER"); + transfers.forEach((tr) => { + const src = pickSourcePutawayForTransfer(fromList, tr); + if (src) add(src, tr); + }); + + const transferInbounds = toList.filter( + (n) => n.kind === "PUTAWAY" && normRefType(n.refType) === "TRANSFER", + ); + transferInbounds.forEach((dest) => { + const src = pickSourcePutawayForTransfer(fromList, dest); + if (src) add(src, dest); + }); + + if (transfers.length > 0 || transferInbounds.length > 0) { + const linked = new Set([ + ...transfers.map((n) => n.id), + ...transferInbounds.map((n) => n.id), + ]); + const remaining = toList.filter((n) => !linked.has(n.id)); + remaining.forEach((to) => { + // ADJUSTMENT / other warehouse events must link from same warehouse only. + const from = pickUpstreamForTarget(fromList, to); + if (from) add(from, to); + }); + return; + } + + toList.forEach((to) => { + const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!; + add(from, to); + }); +}; + +const buildTransferPutawayEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const putaways = nodes.filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n)); + const transfers = nodes.filter((n) => n.kind === "TRANSFER"); + transfers.forEach((tr) => { + const src = pickSourcePutawayForTransfer(putaways, tr); + if (src) add(src, tr); + const dest = pickDestPutawayForTransfer(putaways, tr); + if (dest) add(tr, dest); + }); + + // Every 轉倉入庫 card needs an inbound edge (not only the first WAREHOUSE node). + const destInbound = putaways.filter((p) => normRefType(p.refType) === "TRANSFER"); + destInbound.forEach((dest) => { + const src = pickSourcePutawayForTransfer(putaways, dest); + if (src && src.id !== dest.id) add(src, dest); + }); +}; + +const PRELUDE_MATERIAL_PHASES: TraceGraphPhase[] = [ + "PURCHASE", + "INBOUND", + "PRODUCTION", + "QC", + "PUTAWAY", + "MATERIAL_PICK", +]; + +const buildMaterialLotFlowEdgePairs = ( + nodes: TraceGraphLayoutNode[], + phaseOrder: TraceGraphPhase[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + if (!phaseOrder.includes("MATERIAL_PICK")) return; + + const preludePhases = PRELUDE_MATERIAL_PHASES.filter((p) => phaseOrder.includes(p)); + const byLot = new Map(); + + nodes.forEach((n) => { + if (!isMaterialPreludeNode(n)) return; + const key = materialLotKey(n); + if (!key) return; + const list = byLot.get(key) ?? []; + list.push(n); + byLot.set(key, list); + }); + + const nodesInPhase = (lotNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) => + lotNodes + .filter((n) => n.phase === phase && !NO_FLOW_EDGE_KINDS.has(n.kind)) + .sort(sortNodesInPhase); + + byLot.forEach((lotNodes) => { + const phasesPresent = sortPhasesByEarliestEvent( + lotNodes, + preludePhases.filter((p) => nodesInPhase(lotNodes, p).length > 0), + phaseOrder, + ); + + for (let i = 0; i < phasesPresent.length - 1; i++) { + const fromPhase = phasesPresent[i]; + const toPhase = phasesPresent[i + 1]; + const fromList = nodesInPhase(lotNodes, fromPhase); + const toList = nodesInPhase(lotNodes, toPhase); + if (!fromList.length || !toList.length) continue; + + if (toPhase === "MATERIAL_PICK") { + const upstream = fromList[fromList.length - 1]!; + const seenPickTargets = new Set(); + toList.forEach((pick) => { + const target = resolvePickFlowTarget(pick, nodes); + if (seenPickTargets.has(target.id)) return; + seenPickTargets.add(target.id); + add(upstream, target); + }); + } else { + add(fromList[fromList.length - 1]!, toList[0]!); + } + } + + // Same PUTAWAY lane: material 上架 → 庫存調整 (FG warehouse pattern). + const putaways = lotNodes + .filter((n) => n.kind === "PUTAWAY" && !NO_FLOW_EDGE_KINDS.has(n.kind)) + .sort(sortNodesInPhase); + const adjustments = lotNodes + .filter((n) => n.kind === "ADJUSTMENT" && !NO_FLOW_EDGE_KINDS.has(n.kind)) + .sort(sortNodesInPhase); + if (putaways.length && adjustments.length) { + adjustments.forEach((adj) => { + const targetWh = adj.warehouseCode?.trim(); + const matched = targetWh + ? putaways.filter((p) => warehouseCodesMatch(p.warehouseCode, targetWh)) + : putaways; + const from = (matched.length ? matched : putaways).at(-1); + if (from) add(from, adj); + }); + } + }); +}; + +const buildPutawayToOutboundEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const inventorySources = nodes + .filter( + (n) => + !isMaterialPreludeNode(n) && + (n.kind === "PUTAWAY" || + n.kind === "TRANSFER" || + n.kind === "ADJUSTMENT" || + n.kind === "OPEN" || + n.kind === "RECEIPT" || + n.kind === "IN"), + ) + .sort(sortNodesInPhase); + if (!inventorySources.length) return; + + const outboundTargets = nodes.filter( + (n) => + !isDoGroupChild(n) && + (n.kind === "DO_OUT" || + n.kind === "DO_GROUP" || + n.kind === "JO_OUT" || + n.kind === "PO_OUT" || + n.kind === "OUT" || + n.kind === "REPLENISHMENT_CREATED"), + ); + if (!outboundTargets.length) return; + + outboundTargets.forEach((target) => { + const from = pickUpstreamForTarget(inventorySources, target); + if (from && !shouldSkipGenericPhaseEdge(from, target)) add(from, target); + }); +}; + +const buildPutawayToStockTakeEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const putaways = nodes + .filter((n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n)) + .sort(sortNodesInPhase); + if (!putaways.length) return; + + nodes + .filter((n) => n.kind === "STOCK_TAKE" && !isDoGroupChild(n)) + .forEach((stockTake) => { + const scope = nodeScopePrefix(stockTake); + // Stock take must stay within the same inventory-lot scope (loc-* / primary). + const scopedPutaways = putaways.filter((n) => nodeScopePrefix(n) === scope); + const from = pickUpstreamForTarget(scopedPutaways, stockTake); + if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake); + }); +}; + +const inventoryWarehouseOf = (n: TraceGraphLayoutNode): string | undefined => { + if (n.kind === "TRANSFER") { + return n.transferToWarehouse?.trim() || n.warehouseCode?.trim(); + } + return n.warehouseCode?.trim(); +}; + +const pickUpstreamForTarget = ( + fromList: TraceGraphLayoutNode[], + target: TraceGraphLayoutNode, +): TraceGraphLayoutNode | null => { + if (!fromList.length) return null; + + const byTime = fromList.filter( + (n) => n.column <= target.column || n.sortKey <= target.sortKey, + ); + const pool = byTime.length > 0 ? byTime : fromList; + + const targetWh = target.warehouseCode?.trim(); + if (targetWh) { + const matched = pool.filter((n) => + warehouseCodesMatch(inventoryWarehouseOf(n), targetWh), + ); + if (matched.length) return matched[matched.length - 1]!; + // No warehouse match in time window — do not link across warehouses. + const anyMatched = fromList.filter((n) => + warehouseCodesMatch(inventoryWarehouseOf(n), targetWh), + ); + return anyMatched.length ? anyMatched[anyMatched.length - 1]! : null; + } + + const scope = nodeScopePrefix(target); + const scoped = pool.filter((n) => nodeScopePrefix(n) === scope); + if (scoped.length) return scoped[scoped.length - 1]!; + + const upstreamWhs = new Set( + pool + .map((n) => (inventoryWarehouseOf(n) ?? "").trim().toUpperCase()) + .filter(Boolean), + ); + if (upstreamWhs.size <= 1) return pool[pool.length - 1]!; + return null; +}; + +const buildFgLotFlowEdgePairs = ( + nodes: TraceGraphLayoutNode[], + phaseOrder: TraceGraphPhase[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const fgPhases = phaseOrder.filter((p) => p !== "MATERIAL_PICK"); + const fgNodes = nodes.filter( + (n) => + !isMaterialPreludeNode(n) && + !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) && + !NO_FLOW_EDGE_KINDS.has(n.kind) && + !isDoGroupChild(n), + ); + if (fgNodes.length < 2) return; + + const nodesInPhase = (phase: TraceGraphPhase) => + fgNodes.filter((n) => n.phase === phase).sort(sortNodesInPhase); + + const phasesPresent = sortFgPhasesForCrossDayFlow( + fgNodes, + fgPhases.filter((p) => nodesInPhase(p).length > 0), + phaseOrder, + ); + + for (let i = 0; i < phasesPresent.length - 1; i++) { + const fromPhase = phasesPresent[i]; + const toPhase = phasesPresent[i + 1]; + const fromList = nodesInPhase(fromPhase); + const toList = nodesInPhase(toPhase); + if (!fromList.length || !toList.length) continue; + + if (fromPhase === "OUTBOUND" && toPhase === "STOCK_TAKE") continue; + + if ( + fromPhase === "OUTBOUND" && + (toPhase === "WAREHOUSE" || toPhase === "PUTAWAY" || toPhase === "STOCK_TAKE") + ) { + continue; + } + + if (fromPhase === "PUTAWAY" && toPhase === "WAREHOUSE") { + connectPutawayToWarehousePhase(fromList, toList, add); + continue; + } + + if (toPhase === "OUTBOUND") { + toList.forEach((out) => { + const scope = nodeScopePrefix(out); + const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope); + const from = pickUpstreamForTarget(scopedFrom, out); + if (from && !shouldSkipGenericPhaseEdge(from, out)) add(from, out); + }); + } else if (toPhase === "STOCK_TAKE") { + toList.forEach((stockTake) => { + const scope = nodeScopePrefix(stockTake); + const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope); + const from = pickUpstreamForTarget(scopedFrom, stockTake); + if (from && !shouldSkipGenericPhaseEdge(from, stockTake)) add(from, stockTake); + }); + } else if (toList.length > 1) { + const targets = + toPhase === "PUTAWAY" + ? (() => { + const nonTransfer = toList.filter((n) => normRefType(n.refType) !== "TRANSFER"); + return nonTransfer.length > 0 ? nonTransfer : toList; + })() + : toList; + targets.forEach((to) => { + const from = pickUpstreamForTarget(fromList, to); + if (from && !shouldSkipGenericPhaseEdge(from, to)) add(from, to); + }); + } else { + const to = toList[0]!; + const from = pickUpstreamForTarget(fromList, to) ?? fromList[fromList.length - 1]!; + if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to); + } + } +}; + +const sortProductionStepsForChain = ( + a: TraceGraphLayoutNode, + b: TraceGraphLayoutNode, +): number => { + const sa = a.bomProcessSeqNo; + const sb = b.bomProcessSeqNo; + if (sa != null && sb != null && sa !== sb) return sa - sb; + if (sa != null && sb == null) return -1; + if (sa == null && sb != null) return 1; + return sortNodesInPhase(a, b); +}; + +const buildMaterialPickToProductionStepEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const prodSteps = nodes.filter( + (n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n), + ); + if (!prodSteps.length) return; + + const productionByScopeAndProcess = new Map(); + prodSteps.forEach((n) => { + if (n.bomProcessId == null) return; + const key = `${productionScopeKey(n)}::${n.bomProcessId}`; + const list = productionByScopeAndProcess.get(key) ?? []; + list.push(n); + productionByScopeAndProcess.set(key, list); + }); + + const resolveProductionTarget = ( + scope: string, + pick: TraceGraphLayoutNode, + ): TraceGraphLayoutNode | null => { + const inScope = (n: TraceGraphLayoutNode) => productionScopeKey(n) === scope; + + if (pick.bomProcessId != null) { + const byId = productionByScopeAndProcess.get(`${scope}::${pick.bomProcessId}`); + if (byId?.length) return [...byId].sort(sortProductionStepsForChain)[0]!; + } + + if (pick.bomProcessSeqNo != null) { + const bySeq = prodSteps.filter( + (n) => inScope(n) && n.bomProcessSeqNo === pick.bomProcessSeqNo, + ); + if (bySeq.length) return [...bySeq].sort(sortProductionStepsForChain)[0]!; + } + + const itemCode = pick.traceItemCode?.trim().toUpperCase(); + if (itemCode) { + const byItem = prodSteps.filter( + (n) => + inScope(n) && + (n.stepMaterialItemCodes ?? []).some( + (code) => code.trim().toUpperCase() === itemCode, + ), + ); + if (byItem.length) return [...byItem].sort(sortProductionStepsForChain)[0]!; + } + + return null; + }; + + const connectPickOrGroup = ( + source: TraceGraphLayoutNode, + pick: TraceGraphLayoutNode, + seenTargets: Set, + ) => { + const scope = pick.feedsProductionScopeLotNo?.trim() || source.feedsProductionScopeLotNo?.trim() || "__fg__"; + const target = resolveProductionTarget(scope, pick); + if (!target || seenTargets.has(target.id)) return; + seenTargets.add(target.id); + add( + { + ...source, + feedsProductionScopeLotNo: pick.feedsProductionScopeLotNo ?? source.feedsProductionScopeLotNo, + }, + target, + ); + }; + + nodes + .filter((n) => n.kind === "PICK_GROUP") + .forEach((group) => { + const children = nodes.filter( + (n) => n.doGroupId === group.id && n.kind === "MATERIAL_PICK", + ); + const seenTargets = new Set(); + children.forEach((child) => connectPickOrGroup(group, child, seenTargets)); + }); + + nodes + .filter((n) => n.kind === "MATERIAL_PICK" && !n.doGroupId) + .forEach((pick) => connectPickOrGroup(pick, pick, new Set())); +}; + +const buildProductionToFgQcEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const prodSteps = nodes.filter( + (n) => n.kind === "PRODUCTION_STEP" && !isMaterialPreludeNode(n) && !isDoGroupChild(n), + ); + const qcNodes = nodes.filter((n) => n.kind === "QC" && !isMaterialPreludeNode(n)); + if (!prodSteps.length || !qcNodes.length) return; + + const lastProd = [...prodSteps].sort(sortNodesInPhase).at(-1)!; + const firstQc = [...qcNodes].sort(sortNodesInPhase)[0]!; + if (firstQc.sortKey >= lastProd.sortKey) add(lastProd, firstQc); +}; + +const buildProductionStepChainEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const byScope = new Map(); + nodes + .filter((n) => n.kind === "PRODUCTION_STEP" && !isDoGroupChild(n)) + .forEach((n) => { + const key = productionScopeKey(n); + const list = byScope.get(key) ?? []; + list.push(n); + byScope.set(key, list); + }); + + byScope.forEach((list) => { + const sorted = [...list].sort(sortProductionStepsForChain); + for (let i = 0; i < sorted.length - 1; i++) { + add(sorted[i]!, sorted[i + 1]!); + } + }); +}; + +const joCodeForPickTarget = ( + target: TraceGraphLayoutNode, + nodes: TraceGraphLayoutNode[], +): string | null => { + // Prefer jobOrderCode — po.consoCode is often a TI ticket or blank, and refCode is PI-*. + const fromNode = (n: TraceGraphLayoutNode): string | null => { + const jo = n.jobOrderCode?.trim(); + if (jo) return jo; + const conso = n.consoCode?.trim(); + if (conso && /^JO[-_]/i.test(conso)) return conso; + return null; + }; + + if (target.kind === "PICK_GROUP") { + const direct = fromNode(target); + if (direct) return direct; + const child = nodes.find((n) => n.doGroupId === target.id && fromNode(n)); + return child ? fromNode(child) : null; + } + if (target.kind === "MATERIAL_PICK") { + return fromNode(target); + } + return null; +}; + +const buildJoCreatedToMaterialPickEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const joCreatedNodes = nodes.filter((n) => n.kind === "JO_CREATED"); + if (!joCreatedNodes.length) return; + + const pickTargets = nodes.filter( + (n) => + !isDoGroupChild(n) && + (n.kind === "PICK_GROUP" || + (n.kind === "MATERIAL_PICK" && !n.doGroupId?.trim())), + ); + if (!pickTargets.length) return; + + const feedLotForTarget = (target: TraceGraphLayoutNode): string => { + if (target.kind === "PICK_GROUP") { + return ( + nodes + .find( + (n) => + n.doGroupId === target.id && Boolean(n.feedsProductionScopeLotNo?.trim()), + ) + ?.feedsProductionScopeLotNo?.trim() ?? "" + ); + } + return target.feedsProductionScopeLotNo?.trim() ?? ""; + }; + + pickTargets.forEach((target) => { + const pickJoCode = joCodeForPickTarget(target, nodes); + const matches = joCreatedNodes.filter((jo) => { + const joCode = jo.refCode?.trim(); + return Boolean(joCode && pickJoCode && joCode === pickJoCode); + }); + if (!matches.length) return; + + const feedLot = feedLotForTarget(target); + let pool: TraceGraphLayoutNode[]; + if (feedLot) { + const scoped = matches.filter((jo) => jo.traceLotNo?.trim() === feedLot); + pool = scoped.length ? scoped : matches; + } else { + // FG 工單提料: prefer the FG 建立工單 card (no material lot), not nested JO output. + const fg = matches.filter((jo) => !jo.traceLotNo?.trim()); + pool = fg.length ? fg : matches; + } + const joCreated = [...pool].sort(sortNodesInPhase)[0]!; + add(joCreated, target); + }); +}; + +const resolveDoOutFlowTarget = ( + doOut: TraceGraphLayoutNode, + nodes: TraceGraphLayoutNode[], +): TraceGraphLayoutNode => { + const groupId = doOut.doGroupId?.trim(); + if (!groupId) return doOut; + return nodes.find((n) => n.id === groupId) ?? doOut; +}; + +const findDoOutForReplenishment = ( + replenishment: TraceGraphLayoutNode, + nodes: TraceGraphLayoutNode[], +): TraceGraphLayoutNode | null => { + const stockOutLineId = replenishment.replenishmentStockOutLineId; + const doOuts = nodes.filter((n) => n.kind === "DO_OUT"); + if (stockOutLineId != null) { + const bySol = doOuts.find((n) => doOutNodeMatchesStockOutLineId(n.id, stockOutLineId)); + if (bySol) return bySol; + } + const doCode = replenishment.refCode?.trim(); + if (!doCode) return null; + const byCode = doOuts.filter((n) => doOutMatchesDeliveryCode(n, doCode)); + if (!byCode.length) return null; + const replenishFlagged = byCode.filter((n) => n.doOutboundIsReplenish); + const pool = replenishFlagged.length ? replenishFlagged : byCode; + return [...pool].sort(sortNodesInPhase)[0] ?? null; +}; + +const buildReplenishmentToDoOutEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + nodes + .filter((n) => n.kind === "REPLENISHMENT_CREATED") + .forEach((replenishment) => { + const doOut = findDoOutForReplenishment(replenishment, nodes); + if (!doOut) return; + add(replenishment, resolveDoOutFlowTarget(doOut, nodes)); + }); +}; + +const connectWarehousePhaseChainEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const chainNodes = nodes + .filter( + (n) => + !isMaterialPreludeNode(n) && + !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) && + !NO_FLOW_EDGE_KINDS.has(n.kind) && + !isDoGroupChild(n), + ) + .sort(sortNodesInPhase); + + chainNodes + .filter((n) => WAREHOUSE_FLOW_KINDS.has(n.kind)) + .forEach((warehouseNode) => { + if (warehouseNode.kind === "TRANSFER") { + const putaways = chainNodes.filter( + (n) => n.kind === "PUTAWAY" && !isMaterialPreludeNode(n), + ); + const src = pickSourcePutawayForTransfer(putaways, warehouseNode); + if (src) { + add(src, warehouseNode); + return; + } + } + + const predecessors = chainNodes.filter((n) => n.sortKey < warehouseNode.sortKey); + const targetWh = warehouseNode.warehouseCode?.trim(); + const sameWarehouse = (n: TraceGraphLayoutNode) => + !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh); + + const warehousePredecessors = predecessors.filter( + (n) => WAREHOUSE_FLOW_KINDS.has(n.kind) && sameWarehouse(n), + ); + if (warehousePredecessors.length > 0) { + add(warehousePredecessors[warehousePredecessors.length - 1]!, warehouseNode); + return; + } + + const inventoryPredecessors = predecessors.filter( + (n) => INVENTORY_STATE_KINDS.has(n.kind) && sameWarehouse(n), + ); + if (inventoryPredecessors.length > 0) { + add(inventoryPredecessors[inventoryPredecessors.length - 1]!, warehouseNode); + return; + } + + // ADJUSTMENT with a known warehouse must not fall back to a different warehouse. + if (warehouseNode.kind === "ADJUSTMENT" && targetWh) return; + + const lastBefore = predecessors[predecessors.length - 1]; + if (lastBefore) add(lastBefore, warehouseNode); + }); +}; + +const connectStockTakeInboundEdges = ( + nodes: TraceGraphLayoutNode[], + add: (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => void, +): void => { + const chainNodes = nodes + .filter( + (n) => + !isMaterialPreludeNode(n) && + !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) && + !NO_FLOW_EDGE_KINDS.has(n.kind) && + !isDoGroupChild(n), + ) + .sort(sortNodesInPhase); + + const inventoryPredecessorKinds = new Set([ + "PUTAWAY", + "TRANSFER", + "ADJUSTMENT", + "REPACK", + "RECEIPT", + "IN", + "OPEN", + ]); + + chainNodes + .filter((n) => n.kind === "STOCK_TAKE") + .forEach((stockTake) => { + const targetScope = nodeScopePrefix(stockTake); + const targetWh = stockTake.warehouseCode?.trim(); + const targetLot = stockTake.traceLotNo?.trim(); + const sameLot = (n: TraceGraphLayoutNode) => { + if (nodeScopePrefix(n) !== targetScope) return false; + if (!targetLot) return true; + const fromLot = n.traceLotNo?.trim(); + return !fromLot || fromLot === targetLot; + }; + const sameWarehouse = (n: TraceGraphLayoutNode) => + !targetWh || warehouseCodesMatch(inventoryWarehouseOf(n), targetWh); + + const predecessors = chainNodes.filter( + (n) => + n.kind !== "STOCK_TAKE" && + n.sortKey < stockTake.sortKey && + sameLot(n), + ); + if (!predecessors.length) return; + + const putawayPredecessors = predecessors.filter( + (n) => + n.kind === "PUTAWAY" && !isMaterialPreludeNode(n) && sameWarehouse(n), + ); + const inventoryPredecessors = predecessors.filter( + (n) => inventoryPredecessorKinds.has(n.kind) && sameWarehouse(n), + ); + const from = + putawayPredecessors[putawayPredecessors.length - 1] ?? + inventoryPredecessors[inventoryPredecessors.length - 1] ?? + // Known warehouse: never fall back across warehouses / other lots. + (targetWh ? undefined : predecessors[predecessors.length - 1]); + if (from) add(from, stockTake); + }); +}; + +export const buildTraceFlowEdgePairs = ( + nodes: TraceGraphLayoutNode[], + phaseOrder: TraceGraphPhase[], +): Array<{ fromId: string; toId: string }> => { + const pairs: Array<{ fromId: string; toId: string }> = []; + const seen = new Set(); + + const add = (from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) => { + if (NO_FLOW_EDGE_SOURCE_KINDS.has(from.kind) || NO_FLOW_EDGE_KINDS.has(from.kind)) return; + if (NO_FLOW_EDGE_KINDS.has(to.kind)) return; + const key = `${from.id}->${to.id}`; + if (seen.has(key)) return; + seen.add(key); + pairs.push({ fromId: from.id, toId: to.id }); + }; + + const byDay = new Map(); + nodes.forEach((n) => { + const list = byDay.get(n.dayKey) ?? []; + list.push(n); + byDay.set(n.dayKey, list); + }); + + const sortedDays = Array.from(byDay.keys()).sort((a, b) => { + if (a === "—") return 1; + if (b === "—") return -1; + return a.localeCompare(b); + }); + + const nodesInPhase = (dayNodes: TraceGraphLayoutNode[], phase: TraceGraphPhase) => + dayNodes + .filter( + (n) => + n.phase === phase && + !NO_FLOW_EDGE_SOURCE_KINDS.has(n.kind) && + !NO_FLOW_EDGE_KINDS.has(n.kind) && + !isDoGroupChild(n), + ) + .sort(sortNodesInPhase); + + for (const dayKey of sortedDays) { + const dayNodes = byDay.get(dayKey)!; + const phasesPresent = sortPhasesByEarliestEvent( + dayNodes, + phaseOrder.filter((p) => nodesInPhase(dayNodes, p).length > 0), + phaseOrder, + dayKey, + ); + + for (let i = 0; i < phasesPresent.length - 1; i++) { + const fromList = nodesInPhase(dayNodes, phasesPresent[i]); + const toList = nodesInPhase(dayNodes, phasesPresent[i + 1]); + if (!fromList.length || !toList.length) continue; + const from = fromList[fromList.length - 1]!; + const to = toList[0]!; + if (isMaterialPreludeNode(from) || isMaterialPreludeNode(to)) continue; + + if (phasesPresent[i] === "OUTBOUND" && phasesPresent[i + 1] === "STOCK_TAKE") { + continue; + } + + if (phasesPresent[i] === "PUTAWAY" && phasesPresent[i + 1] === "WAREHOUSE") { + connectPutawayToWarehousePhase(fromList, toList, add); + continue; + } + + if (phasesPresent[i + 1] === "OUTBOUND" || phasesPresent[i + 1] === "STOCK_TAKE") { + toList.forEach((target) => { + const scope = nodeScopePrefix(target); + const scopedFrom = fromList.filter((n) => nodeScopePrefix(n) === scope); + const matched = pickUpstreamForTarget(scopedFrom, target); + if (matched && !shouldSkipGenericPhaseEdge(matched, target)) add(matched, target); + }); + continue; + } + + if (!shouldSkipGenericPhaseEdge(from, to)) add(from, to); + } + } + + buildMaterialLotFlowEdgePairs(nodes, phaseOrder, add); + buildProductionStepChainEdges(nodes, add); + buildProductionToFgQcEdges(nodes, add); + buildJoCreatedToMaterialPickEdges(nodes, add); + buildReplenishmentToDoOutEdges(nodes, add); + buildMaterialPickToProductionStepEdges(nodes, add); + buildFgLotFlowEdgePairs(nodes, phaseOrder, add); + buildPutawayToOutboundEdges(nodes, add); + buildPutawayToStockTakeEdges(nodes, add); + buildTransferPutawayEdges(nodes, add); + connectWarehousePhaseChainEdges(nodes, add); + connectStockTakeInboundEdges(nodes, add); + buildTerminalStateEdges(nodes, add); + + return pairs; +}; + +export type TraceFlowEdgePair = { fromId: string; toId: string }; + +/** Build edges from backend traceGraph when present; merge with local semantics. */ +export const resolveTraceFlowEdgePairs = ( + nodes: TraceGraphLayoutNode[], + phaseOrder: TraceGraphPhase[], + backendEdges?: Array<{ fromKey: string; toKey: string }> | null, +): TraceFlowEdgePair[] => { + const local = buildTraceFlowEdgePairs(nodes, phaseOrder); + if (!backendEdges?.length) return local; + + const nodeByKey = new Map(nodes.map((n) => [n.id, n])); + const merged = new Map(); + const addPair = (fromId: string, toId: string) => { + const from = nodeByKey.get(fromId); + const to = nodeByKey.get(toId); + if (!from || !to) return; + if (shouldSkipTraceFlowEdge(from, to)) return; + merged.set(`${fromId}->${toId}`, { fromId, toId }); + }; + + backendEdges.forEach((e) => addPair(e.fromKey, e.toKey)); + local.forEach((e) => addPair(e.fromId, e.toId)); + return Array.from(merged.values()); +}; diff --git a/src/components/ItemTracing/traceLabelUtils.ts b/src/components/ItemTracing/traceLabelUtils.ts new file mode 100644 index 0000000..577d5ea --- /dev/null +++ b/src/components/ItemTracing/traceLabelUtils.ts @@ -0,0 +1,178 @@ +import { TFunction } from "i18next"; +import { ItemLotTraceQcResult } from "@/app/api/itemTracing"; + +const normCode = (code: string | null | undefined) => (code ?? "").trim(); + +const lookupCode = (t: TFunction, prefix: string, code: string | null | undefined): string => { + const raw = normCode(code); + if (!raw) return "—"; + const variants = [raw, raw.toUpperCase(), raw.toLowerCase()]; + for (const v of variants) { + const key = `${prefix}.${v}`; + const translated = t(key, { defaultValue: "" }); + if (translated && translated !== key) return translated; + } + return raw.replace(/_/g, " "); +}; + +export type TraceLabelTranslator = { + refType: (code: string | null | undefined) => string; + movementType: (code: string | null | undefined) => string; + direction: (code: string | null | undefined) => string; + stockInStatus: (code: string | null | undefined) => string; + adjustmentType: (code: string | null | undefined) => string; + usageType: (code: string | null | undefined) => string; + failType: (code: string | null | undefined) => string; + productionStatus: (code: string | null | undefined) => string; + pickStatus: (code: string | null | undefined) => string; + processingStatus: (code: string | null | undefined) => string; + matchStatus: (code: string | null | undefined) => string; + lotLineStatus: (code: string | null | undefined) => string; + joStatus: (code: string | null | undefined) => string; + qcType: (code: string | null | undefined) => string; + qcPassed: (passed: boolean) => string; + putawayShelfStatus: (phase: "pending" | "completed") => string; +}; + +export const createTraceLabelTranslator = (t: TFunction): TraceLabelTranslator => ({ + refType: (code) => lookupCode(t, "code.refType", code), + movementType: (code) => lookupCode(t, "code.movementType", code), + direction: (code) => { + const u = normCode(code).toUpperCase(); + if (u === "IN") return t("directionIn"); + if (u === "OUT") return t("directionOut"); + return lookupCode(t, "code.direction", code); + }, + stockInStatus: (code) => lookupCode(t, "code.stockInStatus", code), + adjustmentType: (code) => lookupCode(t, "code.adjustmentType", code), + usageType: (code) => lookupCode(t, "code.usageType", code), + failType: (code) => lookupCode(t, "code.failType", code), + productionStatus: (code) => lookupCode(t, "code.productionStatus", code), + pickStatus: (code) => lookupCode(t, "code.pickStatus", code), + processingStatus: (code) => lookupCode(t, "code.processingStatus", code), + matchStatus: (code) => lookupCode(t, "code.matchStatus", code), + lotLineStatus: (code) => lookupCode(t, "code.lotLineStatus", code), + joStatus: (code) => lookupCode(t, "code.joStatus", code), + qcType: (code) => { + const raw = normCode(code); + const u = raw.toUpperCase(); + if (!raw || u === "QC") return t("code.qcType.IQC"); + return lookupCode(t, "code.qcType", code); + }, + qcPassed: (passed) => (passed ? t("qcPassed") : t("qcFailed")), + putawayShelfStatus: (phase) => + phase === "completed" ? t("putawayStatusCompleted") : t("putawayStatusPending"), +}); + +/** Color for 處理狀態 / 對料狀態 values on flow cards and detail panel. */ +export type PickStatusValueColor = "success" | "error" | "warning" | "info" | "default"; + +export const pickStatusValueColor = ( + code: string | null | undefined, +): PickStatusValueColor => { + switch (normCode(code).toLowerCase()) { + case "completed": + return "success"; + case "rejected": + return "error"; + case "scanned": + return "info"; + case "pending": + case "created": + return "warning"; + default: + return "default"; + } +}; + +export const qcItemShortLabel = (q: ItemLotTraceQcResult): string => + q.qcItemName?.trim() || q.qcItemCode?.trim() || ""; + +export const qcItemLabel = (q: ItemLotTraceQcResult): string => { + const name = qcItemShortLabel(q); + const desc = q.qcItemDescription?.trim(); + if (name && desc) return `${name}(${desc})`; + return name || desc || ""; +}; + +export const groupQcResultsBySession = ( + results: ItemLotTraceQcResult[], +): ItemLotTraceQcResult[][] => { + const map = new Map(); + results.forEach((q) => { + const key = `${q.stockInLineId}:${q.created ?? ""}`; + const list = map.get(key) ?? []; + list.push(q); + map.set(key, list); + }); + return Array.from(map.values()); +}; + +export const buildQcCriteriaLines = ( + results: ItemLotTraceQcResult[], + tr: TraceLabelTranslator, + unknownItemLabel: string, + failQtyLabel: string, +): string => { + if (results.length === 0) return "—"; + return results + .map((q) => { + const item = qcItemLabel(q) || unknownItemLabel; + const status = q.qcPassed + ? tr.qcPassed(true) + : `${tr.qcPassed(false)}${q.failQty > 0 ? `(${failQtyLabel}: ${q.failQty})` : ""}`; + return `${item}:${status}`; + }) + .join("\n"); +}; + +export const buildQcSubtitle = ( + results: ItemLotTraceQcResult[], + formatQcSubtitle: (failQty: number, acceptedQty: number) => string, + extra?: string, +): string => { + const first = results[0]; + const qtyLine = first ? formatQcSubtitle(first.failQty, first.acceptedQty) : ""; + const criteria = results + .map((q) => qcItemShortLabel(q)) + .filter(Boolean) + .slice(0, 3) + .join(" · "); + const parts = [extra, criteria, qtyLine].filter(Boolean); + return parts.join(" · ") || qtyLine || "—"; +}; + +export type DoOutboundKindLabels = { + extra: string; + replenish: string; +}; + +/** Detail label for 加單·補貨. */ +export const formatDoOutboundKindLabel = ( + isExtra?: boolean, + isReplenish?: boolean, + labels?: DoOutboundKindLabels, +): string | undefined => { + if (!labels) return undefined; + const parts: string[] = []; + if (isExtra) parts.push(labels.extra); + if (isReplenish) parts.push(labels.replenish); + return parts.length > 0 ? parts.join(" · ") : undefined; +}; + +import { + resolveTraceDoOutboundIsExtra, + type TraceDoOutboundExtraSource, +} from "@/utils/traceDoOutboundExtra"; + +export type DoOutboundChipSource = TraceDoOutboundExtraSource & { + isReplenish?: boolean; +}; + +/** Resolve chip flags from API fields, with ticket-type fallback (TI-E / TI-M rules). */ +export const resolveDoOutboundChipFlags = ( + source: DoOutboundChipSource, +): { isExtra: boolean; isReplenish: boolean } => ({ + isExtra: resolveTraceDoOutboundIsExtra(source), + isReplenish: source.isReplenish === true, +}); diff --git a/src/components/ItemTracing/traceNavigationUtils.ts b/src/components/ItemTracing/traceNavigationUtils.ts new file mode 100644 index 0000000..238e39a --- /dev/null +++ b/src/components/ItemTracing/traceNavigationUtils.ts @@ -0,0 +1,14 @@ +export type TraceNavigateParams = { + stockInLineId?: number; + lotNo?: string; + itemCode?: string; +}; + +export const buildItemTracingHref = (params: TraceNavigateParams): string => { + const q = new URLSearchParams(); + if (params.stockInLineId != null) q.set("stockInLineId", String(params.stockInLineId)); + if (params.lotNo?.trim()) q.set("lotNo", params.lotNo.trim()); + if (params.itemCode?.trim()) q.set("itemCode", params.itemCode.trim()); + const qs = q.toString(); + return qs ? `/itemTracing?${qs}` : "/itemTracing"; +}; diff --git a/src/components/ItemTracing/traceNodeFactory.ts b/src/components/ItemTracing/traceNodeFactory.ts new file mode 100644 index 0000000..2446c94 --- /dev/null +++ b/src/components/ItemTracing/traceNodeFactory.ts @@ -0,0 +1,213 @@ +import dayjs from "dayjs"; +import { ItemLotTraceMaterialInput } from "@/app/api/itemTracing"; +import { + TraceGraphDetailField, + TraceGraphDocLinkKind, + TraceGraphNode, +} from "./buildTraceGraphNodes"; +import { formatQty } from "./traceQtyUtils"; +import { pickStatusValueColor } from "./traceLabelUtils"; +import { normalizeTargetDateForLink } from "./traceDocLinkUtils"; + +export type MaterialPickLabels = { + nodeMaterialPick: string; + categoryPick: string; + pickOrder: string; + jobOrder: string; + detailMaterial: string; + detailLot: string; + detailQty: string; + detailType: string; + detailAssignedStep: string; + detailPickTargetDate: string; + detailTime: string; + processingStatus: string; + matchStatus: string; + tr: { + processingStatus: (code: string | null | undefined) => string; + matchStatus: (code: string | null | undefined) => string; + }; +}; + +export const pickStatusDetailFields = ( + labels: Pick, + processingStatus?: string | null, + matchStatus?: string | null, + opts?: { always?: boolean }, +): TraceGraphDetailField[] => { + const always = opts?.always === true; + const rows: TraceGraphDetailField[] = []; + if (always || processingStatus?.trim()) { + rows.push( + field(labels.processingStatus, labels.tr.processingStatus(processingStatus), { + valueColor: pickStatusValueColor(processingStatus), + }), + ); + } + if (always || matchStatus?.trim()) { + rows.push( + field(labels.matchStatus, labels.tr.matchStatus(matchStatus), { + valueColor: pickStatusValueColor(matchStatus), + }), + ); + } + return rows; +}; + +export const pickStatusNodeLabels = ( + labels: Pick, + processingStatus?: string | null, + matchStatus?: string | null, + opts?: { always?: boolean }, +): Pick< + TraceGraphNode, + "processingStatusLabel" | "matchStatusLabel" | "processingStatus" | "matchStatus" +> => { + const always = opts?.always === true; + const showProcessing = always || Boolean(processingStatus?.trim()); + const showMatch = always || Boolean(matchStatus?.trim()); + return { + processingStatusLabel: showProcessing + ? labels.tr.processingStatus(processingStatus) + : undefined, + matchStatusLabel: showMatch ? labels.tr.matchStatus(matchStatus) : undefined, + processingStatus: showProcessing ? processingStatus?.trim() || undefined : undefined, + matchStatus: showMatch ? matchStatus?.trim() || undefined : undefined, + }; +}; + +export const fmt = (v: string | null | undefined) => (v?.trim() ? v : "—"); + +export const field = ( + label: string, + value: string | null | undefined, + extra?: Partial, +): TraceGraphDetailField => ({ + label, + value: fmt(value), + ...extra, +}); + +/** Omit detail row when value is blank (used for optional remarks, etc.). */ +export const fieldIf = ( + label: string, + value: string | number | null | undefined, + extra?: Partial, +): TraceGraphDetailField | null => { + const raw = value == null ? "" : String(value).trim(); + if (!raw) return null; + return field(label, raw, extra); +}; + +export const detailsOf = ( + ...rows: Array +): TraceGraphDetailField[] => rows.filter((row): row is TraceGraphDetailField => Boolean(row)); + +export const parseSortKey = (ts: string | null | undefined, seq: number): number => { + if (!ts?.trim()) return seq; + const d = dayjs(ts); + return d.isValid() ? d.valueOf() : seq; +}; + +/** Expiry is date-only in master data; sort at end of that calendar day (23:59:59). */ +export const parseExpirySortKey = (expiry: string | null | undefined, seq: number): number => { + if (!expiry?.trim()) return seq; + const d = dayjs(expiry.trim().slice(0, 10)); + return d.isValid() ? d.endOf("day").valueOf() : seq; +}; + +export const docLinkFromOriginType = (type: string): TraceGraphDocLinkKind | undefined => { + const t = type.toUpperCase(); + if (t === "PO") return "po"; + if (t === "JO") return "jo"; + return undefined; +}; + +export const docLinkFromRefType = (refType: string): TraceGraphDocLinkKind | undefined => { + const t = refType.toUpperCase(); + if (t === "PO") return "po"; + if (t === "JO") return "jo"; + if (t === "PICK" || t === "DO") return "pick"; + if (t === "JO_PICK") return "jodetail"; + return undefined; +}; + +/** Semi-finished / nested JO output used as a material for another JO. */ +export const isJoProducedMaterial = (m: ItemLotTraceMaterialInput): boolean => + (m.productionSteps?.length ?? 0) > 0 || + Boolean(m.nestedJoPrelude) || + m.stockInOrigin?.type?.trim().toUpperCase() === "JO"; + +export type MaterialPickNodeContext = { + nextSeq: () => number; + pickOrderTargetDate?: (pickOrderCode: string) => string | undefined; +}; + +export const createMaterialPickNode = ( + m: ItemLotTraceMaterialInput, + index: number, + labels: MaterialPickLabels, + ctx: MaterialPickNodeContext, + feedsProductionScopeLotNo?: string, +): TraceGraphNode => { + const matUom = m.materialUom?.trim() || ""; + const pickTitle = m.pickOrderCode || labels.nodeMaterialPick; + const stepLabel = m.assignedStepName?.trim() + ? m.bomProcessSeqNo != null + ? `${m.bomProcessSeqNo}. ${m.assignedStepName}` + : m.assignedStepName + : ""; + const itemLabel = [m.materialItemCode, m.materialItemName].filter(Boolean).join(" · "); + const pickTargetDate = normalizeTargetDateForLink( + ctx.pickOrderTargetDate?.(m.pickOrderCode ?? ""), + ); + const pickedDate = normalizeTargetDateForLink(m.pickedAt); + const linkTargetDate = pickTargetDate || pickedDate; + return { + id: `mat-pick-${index}-${m.pickOrderId}-${m.materialLotNo}-${m.pickedAt}`, + kind: "MATERIAL_PICK", + timestamp: m.pickedAt, + sortKey: parseSortKey(m.pickedAt, ctx.nextSeq()), + title: pickTitle, + subtitle: [m.materialLotNo, stepLabel, m.consoCode].filter(Boolean).join(" · "), + qty: m.materialQty, + uom: matUom, + meta: itemLabel || undefined, + refCode: m.pickOrderCode, + refId: m.pickOrderId, + consoCode: m.consoCode, + jobOrderCode: m.jobOrderCode?.trim() || undefined, + docLinkKind: m.pickOrderCode ? "jodetail" : undefined, + docLinkTargetDate: linkTargetDate, + traceLotNo: m.materialLotNo, + traceItemCode: m.materialItemCode, + bomProcessId: m.bomProcessId, + bomProcessSeqNo: m.bomProcessSeqNo, + assignedStepName: m.assignedStepName, + feedsProductionScopeLotNo, + categoryLabel: labels.categoryPick, + details: [ + field(labels.pickOrder, m.pickOrderCode, { + linkKind: "jodetail", + linkCode: m.pickOrderCode, + linkId: m.pickOrderId, + consoCode: m.consoCode, + linkTargetDate, + }), + field(labels.detailPickTargetDate, pickTargetDate || pickedDate), + field(labels.detailMaterial, `${m.materialItemCode} · ${m.materialItemName}`), + field(labels.detailLot, m.materialLotNo), + field(labels.detailQty, formatQty(m.materialQty, matUom)), + field(labels.detailType, labels.nodeMaterialPick), + field(labels.detailAssignedStep, stepLabel || "—"), + field(labels.jobOrder, m.jobOrderCode, { + linkKind: "jo", + linkCode: m.jobOrderCode, + linkId: m.jobOrderId, + }), + ...pickStatusDetailFields(labels, m.processingStatus, m.matchStatus), + field(labels.detailTime, m.pickedAt), + ], + ...pickStatusNodeLabels(labels, m.processingStatus, m.matchStatus), + }; +}; diff --git a/src/components/ItemTracing/tracePresentationAdapter.ts b/src/components/ItemTracing/tracePresentationAdapter.ts new file mode 100644 index 0000000..9d19b08 --- /dev/null +++ b/src/components/ItemTracing/tracePresentationAdapter.ts @@ -0,0 +1,200 @@ +import type { ItemLotTraceResponse } from "@/app/api/itemTracing"; +import type { TraceGraphLayoutNode } from "./traceGraphLayout"; +import type { TraceGraphNodeKind } from "./buildTraceGraphNodes"; +import { mergeScopedMovements } from "./mergeLocationScopedData"; + +export type TraceMovementRow = { + id: string; + direction: string; + qty: number; + movementType: string; + refType: string; + refCode: string; + refId: number | null; + warehouseCode: string; + handledBy: string; + timestamp: string | null; + remarks: string; +}; + +export type TraceJoPickRow = { + id: string; + pickOrderCode: string; + pickOrderId: number | null; + consoCode: string; + materialItemCode: string; + materialLotNo: string; + qty: number; + uom: string; + pickedAt: string | null; + bomProcessId: number | null; + assignedStepName: string; + /** YYYY-MM-DD for jodetail deep links. */ + targetDate?: string; +}; + +export type TraceProductionRow = { + id: string; + stepName: string; + description: string; + equipmentCode: string; + equipmentName: string; + operatorName: string; + startTime: string | null; + endTime: string | null; + outputQty: number; + scrapQty: number; + defectQty: number; + status: string; + seqNo: number | null; + bomProcessId: number | null; + traceLotNo: string | null; +}; + +const detailValue = (node: TraceGraphLayoutNode, labelIncludes: string): string => { + const row = node.details.find((d) => d.label.includes(labelIncludes)); + return row?.value?.trim() || "—"; +}; + +const movementKinds: TraceGraphNodeKind[] = [ + "IN", + "OUT", + "RECEIPT", + "OPEN", + "RETURN", + "REPACK", +]; + +export const deriveMovementRowsFromGraph = ( + nodes: TraceGraphLayoutNode[], + data: ItemLotTraceResponse, +): TraceMovementRow[] => { + const fromGraph = nodes + .filter((n) => movementKinds.includes(n.kind)) + .map((n) => ({ + id: n.id, + direction: n.kind === "OUT" || n.kind === "RETURN" ? "OUT" : "IN", + qty: n.qty ?? 0, + movementType: n.subtitle?.trim() || n.kind, + refType: n.refType?.trim() || "", + refCode: n.refCode?.trim() || "", + refId: n.refId ?? null, + warehouseCode: n.warehouseCode?.trim() || (detailValue(n, "Warehouse") !== "—" ? detailValue(n, "Warehouse") : n.meta?.split(" · ")[1] ?? ""), + handledBy: detailValue(n, "Handler") !== "—" ? detailValue(n, "Handler") : n.meta?.split(" · ")[0] ?? "", + timestamp: n.timestamp, + remarks: detailValue(n, "Remarks"), + })); + + if (fromGraph.length > 0) return fromGraph; + + return mergeScopedMovements(data).map((m) => ({ + id: m.rowKey, + direction: m.direction, + qty: m.qty, + movementType: m.movementType, + refType: m.refType, + refCode: m.refCode, + refId: m.refId, + warehouseCode: m.scopeWarehouseCode, + handledBy: m.handledBy, + timestamp: m.timestamp, + remarks: m.remarks, + })); +}; + +export const deriveJoPickRowsFromGraph = ( + nodes: TraceGraphLayoutNode[], + fallbackInputs: NonNullable["materialInputs"] | undefined, +): TraceJoPickRow[] => { + const picks = nodes.filter((n) => n.kind === "MATERIAL_PICK"); + if (picks.length > 0) { + return picks.map((n) => ({ + id: n.id, + pickOrderCode: n.refCode?.trim() || n.title?.trim() || "", + pickOrderId: n.refId ?? null, + consoCode: n.consoCode?.trim() || "", + materialItemCode: n.traceItemCode?.trim() || "", + materialLotNo: n.traceLotNo?.trim() || "", + qty: n.qty ?? 0, + uom: n.uom?.trim() || "", + pickedAt: n.timestamp, + bomProcessId: n.bomProcessId ?? null, + assignedStepName: n.assignedStepName?.trim() || "", + targetDate: n.docLinkTargetDate?.trim() || undefined, + })); + } + + return (fallbackInputs ?? []).map((m, i) => ({ + id: `pick-api-${i}-${m.pickOrderCode}-${m.materialLotNo}`, + pickOrderCode: m.pickOrderCode, + pickOrderId: m.pickOrderId, + consoCode: m.consoCode, + materialItemCode: m.materialItemCode, + materialLotNo: m.materialLotNo, + qty: m.materialQty, + uom: m.materialUom ?? "", + pickedAt: m.pickedAt, + bomProcessId: m.bomProcessId ?? null, + assignedStepName: m.assignedStepName ?? "", + targetDate: undefined, + })); +}; + +export const deriveProductionRowsFromGraph = ( + nodes: TraceGraphLayoutNode[], + fallback: ItemLotTraceResponse["productionSteps"], +): TraceProductionRow[] => { + const steps = nodes.filter((n) => n.kind === "PRODUCTION_STEP"); + if (steps.length > 0) { + return steps.map((n) => ({ + id: n.id, + stepName: n.title?.trim() || detailValue(n, "Step"), + description: detailValue(n, "Remarks"), + equipmentCode: "", + equipmentName: detailValue(n, "Equipment"), + operatorName: detailValue(n, "Handler"), + startTime: null, + endTime: n.timestamp, + outputQty: n.qty ?? 0, + scrapQty: 0, + defectQty: 0, + status: detailValue(n, "Status"), + seqNo: n.bomProcessSeqNo ?? null, + bomProcessId: n.bomProcessId ?? null, + traceLotNo: n.traceLotNo ?? null, + })); + } + + return fallback.map((s) => ({ + id: `prod-api-${s.processLineId}`, + stepName: s.stepName, + description: s.description, + equipmentCode: s.equipmentCode, + equipmentName: s.equipmentName, + operatorName: s.operatorName, + startTime: s.startTime, + endTime: s.endTime, + outputQty: s.outputQty, + scrapQty: s.scrapQty, + defectQty: s.defectQty, + status: s.status, + seqNo: s.seqNo, + bomProcessId: s.bomProcessId ?? null, + traceLotNo: null, + })); +}; + +export type TracePresentationRows = { + movements: TraceMovementRow[]; + joPicks: TraceJoPickRow[]; + production: TraceProductionRow[]; +}; + +export const buildTracePresentationRows = ( + nodes: TraceGraphLayoutNode[], + data: ItemLotTraceResponse, +): TracePresentationRows => ({ + movements: deriveMovementRowsFromGraph(nodes, data), + joPicks: deriveJoPickRowsFromGraph(nodes, data.joPrelude?.materialInputs), + production: deriveProductionRowsFromGraph(nodes, data.productionSteps), +}); diff --git a/src/components/ItemTracing/tracePutawayUtils.ts b/src/components/ItemTracing/tracePutawayUtils.ts new file mode 100644 index 0000000..687a4cc --- /dev/null +++ b/src/components/ItemTracing/tracePutawayUtils.ts @@ -0,0 +1,174 @@ +import { TraceLabelTranslator } from "./traceLabelUtils"; + +/** Matches PO receiving UI: only `completed` means 已上架. */ +const COMPLETED_PUTAWAY_STATUSES = new Set(["completed", "complete"]); + +export const normRefType = (refType: string | null | undefined): string => + (refType ?? "").trim().toUpperCase(); + +const normWhCode = (code: string | null | undefined): string => + (code ?? "").trim().toUpperCase(); + +/** Match exact warehouse codes or floor vs bin (e.g. 4F ↔ 4F-W401). */ +export const warehouseCodesMatch = ( + a: string | null | undefined, + b: string | null | undefined, +): boolean => { + const x = normWhCode(a); + const y = normWhCode(b); + if (!x || !y) return false; + if (x === y) return true; + return x.startsWith(`${y}-`) || y.startsWith(`${x}-`); +}; + +/** Transfer destination stock-in is shown on the TRANSFER node, not as a separate putaway card. */ +export const isTransferInboundPutaway = (refType: string | null | undefined): boolean => + normRefType(refType) === "TRANSFER"; + +export type TraceTransferMatchInput = { + fromWarehouse?: string | null; + toWarehouse?: string | null; + qty?: number | null; + transferCode?: string | null; + timestamp?: string | null; +}; + +/** + * Putaway refCode is often SI-* while transferCode is TR-*; match by code, then to-warehouse + qty/time. + */ +export const matchTransferForInboundPutaway = ( + putaway: { + warehouseCode?: string | null; + qty?: number | null; + refCode?: string | null; + timestamp?: string | null; + }, + transfers: TraceTransferMatchInput[], +): TraceTransferMatchInput | undefined => { + if (!transfers.length) return undefined; + const ref = (putaway.refCode ?? "").trim(); + if (ref) { + const byCode = transfers.find((tr) => (tr.transferCode ?? "").trim() === ref); + if (byCode) return byCode; + } + + const toMatched = transfers.filter((tr) => + warehouseCodesMatch(tr.toWarehouse, putaway.warehouseCode), + ); + if (!toMatched.length) return undefined; + if (toMatched.length === 1) return toMatched[0]; + + const putawayQty = putaway.qty; + const qtyMatched = + putawayQty != null + ? toMatched.filter((tr) => tr.qty != null && Number(tr.qty) === Number(putawayQty)) + : []; + const pool = qtyMatched.length > 0 ? qtyMatched : toMatched; + + const putawayTs = Date.parse(putaway.timestamp ?? ""); + if (!Number.isNaN(putawayTs)) { + const ranked = pool + .map((tr) => { + const ts = Date.parse(tr.timestamp ?? ""); + return { tr, dist: Number.isNaN(ts) ? Number.POSITIVE_INFINITY : Math.abs(ts - putawayTs) }; + }) + .sort((a, b) => a.dist - b.dist); + if (ranked[0] && Number.isFinite(ranked[0].dist)) return ranked[0].tr; + } + return pool[0]; +}; + +/** + * Emit a TRANSFER card for each transfer on the primary lot. + * Location-block scopes skip transfers to avoid duplicating the same TR-* rows. + */ +export const shouldEmitTransferForScope = (locationBlockKeys?: boolean): boolean => + !locationBlockKeys; + +/** + * Transfer inbound putaways are represented by TRANSFER cards (one per TR-*). + * Keep 轉倉入庫 putaway cards off so multi-transfer lots do not collapse to one inbound. + */ +export const shouldShowTransferInboundPutawayCard = ( + _scopeWarehouse?: string | null, + _putawayWarehouse?: string | null, + _transfers?: Array<{ fromWarehouse?: string | null; toWarehouse?: string | null }>, + _mergedMultiLocation?: boolean, +): boolean => false; + +/** + * In a merged multi-warehouse graph, skip 已用完 when this location was emptied by transfer + * to another location still shown on the same graph. Remaining DO picks belong to the destination. + */ +export const shouldEmitDepletedForScope = ( + availableQty: number, + scopeWarehouse: string | null | undefined, + transfers: Array<{ fromWarehouse?: string | null; toWarehouse?: string | null }>, + mergedMultiLocation: boolean, +): boolean => { + if (availableQty > 0) return false; + if (!mergedMultiLocation) return true; + const scope = scopeWarehouse?.trim(); + if (!scope) return true; + const emptiedByTransfer = transfers.some((tr) => + warehouseCodesMatch(scope, tr.fromWarehouse), + ); + return !emptiedByTransfer; +}; + +export type PutawayPresentationLabels = { + nodePutaway: string; + nodePutawayTransfer: string; + putawayTransferDetail: string; +}; + +export type PutawayPresentation = { + /** Chip on flow card (top badge). */ + chipLabel: string; + /** Status row in node detail panel. */ + statusDetail: string; + /** Card title (replaces generic「上架」for transfer inbound). */ + title: string; + /** Type row in node detail panel. */ + detailType: string; +}; + +export const isPendingPutawayOriginStatus = (status: string | null | undefined): boolean => { + const normalized = (status ?? "").trim().toLowerCase(); + if (COMPLETED_PUTAWAY_STATUSES.has(normalized)) return false; + return true; +}; + +export const resolvePutawayStatusLabel = ( + tr: TraceLabelTranslator, + status: string | null | undefined, +): string => { + const normalized = (status ?? "").trim().toLowerCase(); + if (COMPLETED_PUTAWAY_STATUSES.has(normalized)) { + return tr.putawayShelfStatus("completed"); + } + return tr.putawayShelfStatus("pending"); +}; + +export const resolvePutawayPresentation = ( + tr: TraceLabelTranslator, + labels: PutawayPresentationLabels, + status: string | null | undefined, + refType: string | null | undefined, +): PutawayPresentation => { + if (normRefType(refType) === "TRANSFER") { + return { + chipLabel: labels.nodePutawayTransfer, + statusDetail: labels.putawayTransferDetail, + title: labels.nodePutawayTransfer, + detailType: labels.nodePutawayTransfer, + }; + } + const shelf = resolvePutawayStatusLabel(tr, status); + return { + chipLabel: shelf, + statusDetail: shelf, + title: labels.nodePutaway, + detailType: labels.nodePutaway, + }; +}; diff --git a/src/components/ItemTracing/traceQtyUtils.ts b/src/components/ItemTracing/traceQtyUtils.ts new file mode 100644 index 0000000..35092d0 --- /dev/null +++ b/src/components/ItemTracing/traceQtyUtils.ts @@ -0,0 +1,38 @@ +export const formatNum = (n: number) => + Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 4 }) : "—"; + +/** Packaging / compound stock unit (e.g. 1包X25千克) — not a simple unit like 斤 or kg. */ +export const isPackagingUom = (uom?: string | null): boolean => { + const unit = uom?.trim(); + if (!unit) return false; + if (/^\d/.test(unit)) return true; + if (/[xX×]/.test(unit) && /\d/.test(unit)) return true; + return false; +}; + +/** Join qty with UOM; packaging units use parentheses to avoid "6 1包…" ambiguity. */ +export const joinQtyUom = (num: string, unit: string): string => + isPackagingUom(unit) ? `${num}(${unit})` : `${num} ${unit}`; + +/** Format a quantity with its unit when available. */ +export const formatQty = (qty: number | null | undefined, uom?: string | null): string => { + if (qty == null || !Number.isFinite(Number(qty))) return "—"; + const num = formatNum(Number(qty)); + const unit = uom?.trim(); + return unit ? joinQtyUom(num, unit) : num; +}; + +/** Signed variance for stock adjustments: IN → +qty, OUT → −qty. */ +export const formatSignedQty = ( + qty: number | null | undefined, + direction: string | null | undefined, + uom?: string | null, +): string => { + if (qty == null || !Number.isFinite(Number(qty))) return "—"; + const abs = Math.abs(Number(qty)); + const sign = (direction ?? "").trim().toUpperCase() === "OUT" ? "-" : "+"; + const num = formatNum(abs); + const unit = uom?.trim(); + const signed = `${sign}${num}`; + return unit ? joinQtyUom(signed, unit) : signed; +}; diff --git a/src/components/ItemTracing/traceStockTakeUtils.ts b/src/components/ItemTracing/traceStockTakeUtils.ts new file mode 100644 index 0000000..ef2cc1f --- /dev/null +++ b/src/components/ItemTracing/traceStockTakeUtils.ts @@ -0,0 +1,166 @@ +import type { ItemLotTraceStockTakeEvent, ItemLotTraceStockTakeRecordDetail } from "@/app/api/itemTracing"; + +export const formatStockTakeRoundLabel = (event: ItemLotTraceStockTakeEvent): string => { + const name = event.stockTakeRoundName?.trim(); + if (name) return name; + if (event.stockTakeRoundId != null) return `#${event.stockTakeRoundId}`; + return ""; +}; + +export const stockTakeEventSubtitle = (event: ItemLotTraceStockTakeEvent): string => { + const parts = [ + formatStockTakeRoundLabel(event), + event.stockTakeSection?.trim(), + event.warehouseCode?.trim(), + event.lotNo?.trim() || undefined, + ].filter(Boolean); + return parts.length > 0 ? parts.join(" · ") : event.stockTakeCode || "—"; +}; + +/** Book (system) qty at count time — prefer record snapshot over line initialQty. */ +export const resolveStockTakeBookQty = ( + detail: ItemLotTraceStockTakeRecordDetail | null | undefined, + fallbackBeforeQty?: number | null, +): number | null => { + if (detail?.bookQty != null && !Number.isNaN(Number(detail.bookQty))) { + return Number(detail.bookQty); + } + if (fallbackBeforeQty != null && !Number.isNaN(Number(fallbackBeforeQty))) { + return Number(fallbackBeforeQty); + } + return null; +}; + +/** + * Accepted physical qty used for variance / posting decision. + * Prefer lastSelect (1=first, 2=second, 3=approver), then fallback chain, then line finalQty. + */ +export const resolveStockTakeAcceptedQty = ( + detail: ItemLotTraceStockTakeRecordDetail | null | undefined, + fallbackAfterQty?: number | null, +): number | null => { + if (detail) { + const pick = (n: number | null | undefined) => + n != null && !Number.isNaN(Number(n)) ? Number(n) : null; + switch (detail.lastSelect) { + case 3: { + const q = pick(detail.approverQty); + if (q != null) return q; + break; + } + case 2: { + const q = pick(detail.pickerSecondQty); + if (q != null) return q; + break; + } + case 1: { + const q = pick(detail.pickerFirstQty); + if (q != null) return q; + break; + } + default: + break; + } + const fromApprover = pick(detail.approverQty); + if (fromApprover != null) return fromApprover; + const fromSecond = pick(detail.pickerSecondQty); + if (fromSecond != null) return fromSecond; + const fromFirst = pick(detail.pickerFirstQty); + if (fromFirst != null) return fromFirst; + } + if (fallbackAfterQty != null && !Number.isNaN(Number(fallbackAfterQty))) { + return Number(fallbackAfterQty); + } + return null; +}; + +export interface StockTakeLifecycleStage { + key: string; + label: string; + qty?: number | null; + badQty?: number | null; + handler?: string | null; + timestamp?: string | null; + isActive: boolean; +} + +/** Build ordered lifecycle stages from stocktake record detail. */ +export const buildStockTakeLifecycleStages = ( + detail: ItemLotTraceStockTakeRecordDetail | null | undefined, +): StockTakeLifecycleStage[] => { + if (!detail) return []; + const stages: StockTakeLifecycleStage[] = []; + + // Stage 1: Round created — show book (帳面) qty for context + stages.push({ + key: "round-created", + label: "stockTakeStageCreated", + qty: detail.bookQty, + handler: null, + timestamp: detail.stockTakeStartTime ?? null, + isActive: true, + }); + + // Stage 2: Picker first count + const hasFirstCount = + detail.pickerFirstQty != null || + detail.pickerFirstBadQty != null || + Boolean(detail.stockTakerName?.trim()); + stages.push({ + key: "first-count", + label: "stockTakeStageFirstCount", + qty: detail.pickerFirstQty, + badQty: detail.pickerFirstBadQty, + handler: detail.stockTakerName, + timestamp: detail.stockTakeStartTime ?? null, + isActive: hasFirstCount, + }); + + // Stage 3: Picker second count (re-count) — only if present + const hasSecondCount = + detail.pickerSecondQty != null || detail.pickerSecondBadQty != null; + if (hasSecondCount) { + stages.push({ + key: "second-count", + label: "stockTakeStageSecondCount", + qty: detail.pickerSecondQty, + badQty: detail.pickerSecondBadQty, + handler: detail.stockTakerName, + timestamp: detail.stockTakeEndTime ?? null, + isActive: true, + }); + } + + // Stage 4: Approver manual input — only when lastSelect === 3 (admin entered own count) + const hasApproverCount = + detail.approverQty != null || detail.approverBadQty != null; + if (detail.lastSelect === 3 && hasApproverCount) { + stages.push({ + key: "approver-count", + label: "stockTakeStageApproverCount", + qty: detail.approverQty, + badQty: detail.approverBadQty, + handler: detail.approverName, + timestamp: detail.approverTime ?? null, + isActive: true, + }); + } + + // Stage 5: Accepted — show accepted physical qty; variance via badQty for secondary line + const isAccepted = + detail.recordStatus?.toUpperCase() === "ACCEPTED" || + detail.recordStatus?.toUpperCase() === "COMPLETED" || + detail.recordStatus?.toUpperCase() === "COMPLETE"; + const acceptedQty = resolveStockTakeAcceptedQty(detail, null); + stages.push({ + key: "accepted", + label: isAccepted ? "stockTakeStageAccepted" : "stockTakeStagePending", + qty: acceptedQty, + badQty: detail.varianceQty, + handler: detail.approverName, + timestamp: detail.approverTime ?? detail.stockTakeEndTime ?? null, + isActive: isAccepted || Boolean(detail.approverName?.trim()), + }); + + return stages; +}; diff --git a/src/components/Jodetail/JodetailSearch.tsx b/src/components/Jodetail/JodetailSearch.tsx index ad07fe6..5a410b6 100644 --- a/src/components/Jodetail/JodetailSearch.tsx +++ b/src/components/Jodetail/JodetailSearch.tsx @@ -1,6 +1,7 @@ "use client"; import { PickOrderResult } from "@/app/api/pickOrder"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; import SearchBox, { Criterion } from "../SearchBox"; import { @@ -52,6 +53,20 @@ type SearchParamNames = keyof SearchQuery; const JodetailSearch: React.FC = ({ printerCombo }) => { const { t } = useTranslation("jo"); + const searchParams = useSearchParams(); + const urlTabRaw = searchParams.get("tab"); + const urlPickOrderCode = searchParams.get("pickOrderCode")?.trim() || undefined; + const urlTargetDateRaw = searchParams.get("targetDate")?.trim() || undefined; + const urlTargetDate = (() => { + if (!urlTargetDateRaw) return undefined; + const match = urlTargetDateRaw.match(/(\d{4}-\d{2}-\d{2})/); + return match ? match[1] : undefined; + })(); + const urlTabIndex = useMemo(() => { + if (urlTabRaw == null) return undefined; + const n = parseInt(urlTabRaw, 10); + return !Number.isNaN(n) && n >= 0 && n <= 3 ? n : undefined; + }, [urlTabRaw]); const { data: session } = useSession() as { data: SessionWithTokens | null }; const currentUserId = session?.id ? parseInt(session.id) : undefined; @@ -61,7 +76,7 @@ const JodetailSearch: React.FC = ({ printerCombo }) => { //const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders); const [filterArgs, setFilterArgs] = useState>({}); const [searchQuery, setSearchQuery] = useState>({}); - const [tabIndex, setTabIndex] = useState(0); + const [tabIndex, setTabIndex] = useState(urlTabIndex ?? 0); const [totalCount, setTotalCount] = useState(); const [isAssigning, setIsAssigning] = useState(false); const [unassignedOrders, setUnassignedOrders] = useState([]); @@ -70,6 +85,10 @@ const JodetailSearch: React.FC = ({ printerCombo }) => { const [hasDataTab0, setHasDataTab0] = useState(false); const [hasDataTab1, setHasDataTab1] = useState(false); const hasAnyAssignedData = hasDataTab0 || hasDataTab1; + + useEffect(() => { + if (urlTabIndex != null) setTabIndex(urlTabIndex); + }, [urlTabIndex]); // Add printer selection state const [selectedPrinter, setSelectedPrinter] = useState( @@ -492,6 +511,8 @@ const JodetailSearch: React.FC = ({ printerCombo }) => { printerCombo={printerCombo} selectedPrinter={selectedPrinter} printQty={printQty} + initialPickOrderCode={urlPickOrderCode} + initialTargetDate={urlTargetDate} /> )} {tabIndex === 2 && } diff --git a/src/components/Jodetail/completeJobOrderRecord.tsx b/src/components/Jodetail/completeJobOrderRecord.tsx index 77ef206..b67f9eb 100644 --- a/src/components/Jodetail/completeJobOrderRecord.tsx +++ b/src/components/Jodetail/completeJobOrderRecord.tsx @@ -58,6 +58,8 @@ interface Props { printerCombo: PrinterCombo[]; selectedPrinter?: PrinterCombo | null; printQty?: number; + initialPickOrderCode?: string; + initialTargetDate?: string; } // 修改:已完成的 Job Order Pick Order 接口 @@ -112,11 +114,13 @@ interface LotDetail { match_status: string | null; } -const CompleteJobOrderRecord: React.FC = ({ +const CompleteJobOrderRecord: React.FC = ({ filterArgs, printerCombo, selectedPrinter: selectedPrinterProp, - printQty: printQtyProp + printQty: printQtyProp, + initialPickOrderCode, + initialTargetDate, }) => { const { t } = useTranslation("jo"); const router = useRouter(); @@ -135,15 +139,23 @@ const CompleteJobOrderRecord: React.FC = ({ const [detailLotDataLoading, setDetailLotDataLoading] = useState(false); // 修改:搜索状态 - const [searchQuery, setSearchQuery] = useState>(() => ({ - completedDate: dayjs().format("YYYY-MM-DD"), - })); + const [searchQuery, setSearchQuery] = useState>(() => { + const raw = initialTargetDate?.trim() || ""; + const match = raw.match(/(\d{4}-\d{2}-\d{2})/); + return { + completedDate: match?.[1] || dayjs().format("YYYY-MM-DD"), + ...(initialPickOrderCode?.trim() + ? { pickOrderCode: initialPickOrderCode.trim() } + : {}), + }; + }); const [filteredJobOrderPickOrders, setFilteredJobOrderPickOrders] = useState([]); // Use props with fallback const selectedPrinter = selectedPrinterProp ?? (printerCombo && printerCombo.length > 0 ? printerCombo[0] : null); const printQty = printQtyProp ?? 1; const pickRecordPrintInFlightRef = useRef(false); + const initialDetailOpenedRef = useRef(false); // 修改:分页状态 const [paginationController, setPaginationController] = useState({ @@ -370,6 +382,25 @@ const CompleteJobOrderRecord: React.FC = ({ }, [fetchLotDetailsData]); + useEffect(() => { + if (initialDetailOpenedRef.current || completedJobOrderPickOrdersLoading) return; + const code = initialPickOrderCode?.trim(); + if (!code) return; + const match = completedJobOrderPickOrders.find( + (row) => + row.pickOrderCode?.trim() === code || + row.pickOrderConsoCode?.trim() === code, + ); + if (!match) return; + initialDetailOpenedRef.current = true; + void handleDetailClick(match); + }, [ + completedJobOrderPickOrders, + completedJobOrderPickOrdersLoading, + initialPickOrderCode, + handleDetailClick, + ]); + // 修改:返回列表视图 const handleBackToList = useCallback(() => { setShowDetailView(false); diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 426db03..314e431 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -86,7 +86,7 @@ const NavigationContent: React.FC = () => { icon: , labelKey: "nav.storeManagement", path: "", - requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ADMIN], + requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ITEM_TRACING, AUTH.ADMIN], children: [ { id: "nav.store.purchaseOrder", @@ -109,6 +109,13 @@ const NavigationContent: React.FC = () => { requiredAbility: [AUTH.STOCK, AUTH.ADMIN], path: "/inventory", }, + { + id: "nav.store.itemTracing", + icon: , + labelKey: "nav.store.itemTracing", + requiredAbility: [AUTH.ITEM_TRACING], + path: "/itemTracing", + }, { id: "nav.store.stockTake", icon: , diff --git a/src/i18n/en/itemTracing.json b/src/i18n/en/itemTracing.json new file mode 100644 index 0000000..1763fc7 --- /dev/null +++ b/src/i18n/en/itemTracing.json @@ -0,0 +1,312 @@ +{ + "title": "Item Tracing", + "subtitle": "Scan a lot QR code to trace the full lifecycle — stock in/out, QC, pick orders, job orders, stock take, transfers, and BOM links.", + "scanReady": "Scanner ready — scan lot label QR", + "scanning": "Scanning…", + "scanAgain": "Scan again", + "manualSearch": "Manual search", + "itemCode": "Item code", + "lotNo": "Lot no.", + "search": "Trace", + "searching": "Tracing…", + "noResult": "No trace data. Scan a lot QR or search by item code and lot number.", + "notFound": "Lot not found or no permission to view.", + "traceError": "Unable to load trace data. Please try again or contact support.", + "scanError": "Invalid QR code. Expected lot label JSON with itemId and stockInLineId.", + "summary": "Lot summary", + "expiryDate": "Expiry", + "productionDate": "Production", + "stockInDate": "Stock in", + "totalAvailable": "Total available", + "warehouseBreakdown": "Warehouse breakdown", + "alternateLocationsTitle": "Same lot in other locations", + "alternateLocationsHint": "This lot may exist in multiple inventory records (e.g. after transfer). All locations are merged in the lifecycle graph above — click a row below to focus that warehouse in the graph.", + "traceAlternateLocation": "Focus in graph", + "focusWarehouseInGraph": "Focus in graph", + "sectionsMultiLocationHint": "Tables below merge events from the traced location and other warehouses. The warehouse column shows which inventory record each row belongs to.", + "action": "Action", + "timeline": "Movement timeline", + "flowGraph": "Lifecycle flow", + "flowLegendTime": "→ Time (later to the right)", + "flowLegendPhase": "↓ Phase (later stages below)", + "flowLegendBranch": "↔ Same day = left to right by time", + "flowLegendArrow": "Arrows only between phases", + "flowLegendPrelude": "↑ Pre-production at top", + "flowLegendTimeTip": "Horizontal axis = calendar dates, left to right", + "flowLegendPhaseTip": "Vertical axis = process phases, top to bottom in order", + "flowLegendBranchTip": "Events on the same calendar day are ordered left to right by timestamp; vertical lane still shows the process phase", + "flowLegendArrowTip": "Arrows link events in different phases to show flow", + "flowLegendPreludeTip": "FG lots: material inbound, QC (material + finished good), job pick at top", + "flowGraphPathJo": "Process order: material in → QC → putaway → pick → production / byproducts → FG in → warehouse → out → stock take", + "flowZoomIn": "Zoom in", + "flowZoomOut": "Zoom out", + "flowZoomFit": "Fit view", + "flowMinimapHide": "Hide minimap", + "flowMinimapShow": "Show minimap", + "flowZoomPanHint": "Scroll to zoom · drag to pan · click a node for details on the left · search nodes at top right", + "flowNodeDetailHint": "Click a node in the graph to view event details", + "flowGraphSearchPlaceholder": "Search nodes (doc no., lot, warehouse…)", + "flowGraphSearchNoMatch": "No matching nodes", + "flowGraphSearchMatch": "{{current}} / {{total}}", + "flowGraphSearchPrev": "Previous", + "flowGraphSearchNext": "Next", + "flowGraphSearchClear": "Clear", + "nodeDetailTitle": "Event details", + "nodeDetailClose": "Close", + "nodeExpired": "Expired", + "nodeDepleted": "Depleted", + "detailDirection": "Direction", + "categoryPurchase": "Purchase", + "categoryProduction": "Production", + "categoryOpen": "Lot open", + "categoryTerminal": "Terminal state", + "flowGraphHint": "Phases on the left, dates left to right. Same-day events are ordered left to right by time; arrows indicate cross-phase flow.", + "flowGraphHintJo": "Includes pre-production (material inbound, QC, job pick) and finished-good phases. One QC lane covers material and FG IQC.", + "phaseMaterialInbound": "Material inbound", + "phaseMaterialQc": "Material QC", + "phaseMaterialPick": "Job pick", + "phaseProduction": "Production / byproducts", + "nodeProductionStep": "Production step", + "nodeByproduct": "Byproduct lot", + "scrapQty": "Scrap qty", + "defectQty": "Defect qty", + "processOutputQty": "Process output", + "processScrapQty": "Scrap quantity", + "processDefectQty": "Defect quantity", + "equipment": "Equipment", + "processStep": "Process step", + "detailStepMaterials": "Step materials", + "detailAssignedStep": "Assigned process", + "traceByproductLot": "Trace byproduct lot", + "nodeScrap": "Scrap", + "nodeDefect": "Defect", + "nodeOpen": "Opening stock", + "nodeFail": "Pick failure", + "nodeDoOut": "Delivery outbound", + "nodeReplenishmentCreated": "Replenishment created", + "doOutboundExtra": "Add-on", + "doOutboundReplenish": "Replenishment", + "detailDoOutboundKind": "Outbound type", + "nodeDoGroup": "Delivery outbound group", + "flowDoGroupTitle": "Delivery outbound ({{count}})", + "flowDoGroupTotalQty": "Total outbound: {{qtyLabel}}", + "flowGroupCollapse": "Collapse group", + "flowGroupExpand": "Expand group", + "nodePickGroup": "Material pick group", + "flowPickGroupTitle": "Material pick · {{pickOrderCode}} ({{count}})", + "nodeReturn": "Return", + "nodeRepack": "Repack / split lot", + "traceRepackLot": "Trace related lot", + "productLotNo": "Product lot no.", + "nodeMaterialIn": "Material receipt", + "nodeJoCreated": "Job order created", + "nodeMaterialPick": "Material pick", + "nodePurchase": "Purchase", + "nodeReceipt": "Goods receipt", + "nodePutaway": "Putaway", + "nodePutawayTransfer": "Transfer inbound", + "flowLegendTitle": "How to read the graph", + "flowLegendOpen": "Graph legend", + "flowPhaseFilter": "Show phases", + "flowPhaseFilterAll": "All phases", + "flowPhaseFilterReset": "Reset", + "locationsShowAll": "▼ Show all {{count}} rows", + "locationsCollapse": "▲ Collapse (showing all {{count}})", + "exportExcel": "Export Excel", + "exportExcelTooltip": "Download a classified multi-sheet workbook for this lot genealogy", + "putawayTransferInboundDetail": "Received at target warehouse after transfer (system auto-completed; not the PO pending put-away queue)", + "putawayStatusPending": "Pending put-away", + "putawayStatusCompleted": "Put away done", + "phaseInbound": "Inbound", + "phasePurchase": "Purchase", + "phaseQc": "Quality inspection", + "phasePutaway": "Putaway", + "categoryReceipt": "Receipt", + "phaseWarehouse": "Warehouse operations", + "phaseOutbound": "Outbound / consumption", + "phaseStockTake": "Stock take", + "noTimestamp": "No date", + "nodeQcPass": "IQC passed", + "nodeQcFail": "IQC failed", + "nodeStockTake": "Stock take", + "nodeAdjustment": "Adjustment", + "nodeTransfer": "Transfer", + "tabOrigins": "Origins & receipt", + "tabQc": "Quality (IQC)", + "tabOutbound": "Outbound usage", + "tabStockTake": "Stock take", + "tabAdjustments": "Adjustments", + "tabTransfers": "Transfers", + "tabBom": "BOM trace", + "tabJoPick": "Job pick orders", + "joContext": "Job order context", + "planStart": "Planned start", + "plannedQty": "Planned qty", + "pickedQty": "Actual pick qty", + "requiredQty": "Required qty", + "pickedAt": "Picked at", + "traceMaterialLot": "Trace material lot", + "targetDate": "Target date", + "detailPickTargetDate": "Target date", + "completeDate": "Complete date", + "directionIn": "In", + "directionOut": "Out", + "bomDirection": "Trace direction", + "bomDirectionFG": "Finished good lot", + "bomDirectionMaterial": "Material lot", + "bomDirectionUnknown": "General inventory lot", + "bomUpstream": "Materials consumed (actual)", + "bomDownstream": "Finished goods produced", + "bomRecipe": "BOM recipe (theoretical)", + "noRecords": "No records", + "type": "Type", + "ref": "Doc no.", + "detailInboundRef": "Inbound SI", + "detailInboundSiNo": "Inbound SI no.", + "detailAdjustmentRef": "Adjustment #", + "detailReplenishmentCode": "Replenishment #", + "detailReason": "Reason", + "detailReturnRef": "Return #", + "detailSourceDoc": "Source doc #", + "detailPutawayBin": "Bin", + "supplier": "Supplier", + "dnNo": "DN no.", + "qty": "Qty", + "detailOrderQty": "Order qty", + "detailPutAwayQty": "Put away qty", + "detailRemainingQty": "Remaining qty", + "detailPurchaseUnit": "Purchase unit", + "detailPurchaseOrderNo": "PO number", + "detailSupplyTo": "Supply to", + "detailStockTakeRound": "Stock take round", + "detailStockTakeSection": "Stock take section", + "detailLocation": "Location", + "status": "Status", + "handler": "Handler", + "stockTaker": "First counter", + "timestamp": "Time", + "pickOrder": "Pick order", + "jobOrder": "Job order", + "deliveryOrder": "Delivery order", + "deliveryNoteCode": "Delivery note (DN)", + "ticketNo": "Ticket no.", + "variance": "Variance", + "before": "Book qty", + "after": "Accepted qty", + "approver": "Approver", + "from": "From", + "to": "To", + "material": "Material", + "detailItemCode": "Item code", + "detailItemName": "Item name", + "finishedItem": "Finished item", + "finishedLot": "FG lot", + "materialLot": "Material lot", + "materialQty": "Actual pick qty", + "processingStatus": "Processing status", + "matchStatus": "Match status", + "fgQty": "FG qty", + "qtyPerUnit": "Required qty", + "uom": "UOM", + "remarks": "Remarks", + "detailFailCategory": "Fail category", + "detailProcessDescription": "Step description", + "qcPassed": "Passed", + "qcFailed": "Failed", + "acceptedQty": "Sample qty", + "failQty": "Defect qty", + "unqualifiedQty": "Reject qty", + "usageType": "Type", + "detailQcCriteria": "QC criteria", + "detailQcType": "QC type", + "detailQcUnknownItem": "Unnamed QC item", + "code.refType.PO": "Purchase order", + "code.refType.JO": "Job order", + "code.refType.DO": "Delivery order", + "code.refType.TKE": "Stock take", + "code.refType.TRANSFER": "Transfer", + "code.refType.ADJ": "Adjustment", + "code.refType.OPEN": "Lot open", + "code.refType.OTHER": "Other", + "code.movementType.STOCK_IN": "Stock in", + "code.movementType.STOCK_OUT": "Stock out", + "code.movementType.CONSUMABLE": "Consumption", + "code.movementType.RETURN": "Return", + "code.movementType.ADJ": "Adjustment", + "code.movementType.TKE": "Stock take", + "code.movementType.OPEN": "Lot open", + "code.movementType.IN": "In", + "code.movementType.OUT": "Out", + "code.stockInStatus.pending": "Pending", + "code.stockInStatus.qc": "Under QC", + "code.stockInStatus.escalated": "Escalated", + "code.stockInStatus.determine1": "1st determination", + "code.stockInStatus.determine2": "2nd determination", + "code.stockInStatus.determine3": "3rd determination", + "code.stockInStatus.receiving": "Receiving", + "code.stockInStatus.received": "Received, pending putaway", + "code.stockInStatus.completed": "Completed", + "code.stockInStatus.complete": "Completed", + "code.stockInStatus.partially_completed": "Partially completed", + "code.stockInStatus.rejected": "Rejected", + "code.qcType.IQC": "Incoming QC (IQC)", + "code.qcType.IPQC": "In-process QC (IPQC)", + "code.qcType.EPQC": "End-product QC (EPQC)", + "code.qcType.FQC": "Finished goods QC (FQC)", + "code.adjustmentType.ADJ": "Inventory adjustment", + "code.usageType.CONSUMABLE": "Consumption", + "code.usageType.PRODUCTION": "Production use", + "code.usageType.FG_DELIVERY": "FG delivery", + "code.usageType.MATERIAL": "Material issue", + "code.processingStatus.pending": "Pending", + "code.processingStatus.completed": "Completed", + "code.processingStatus.rejected": "Rejected", + "code.matchStatus.pending": "Pending", + "code.matchStatus.scanned": "Scanned", + "code.matchStatus.completed": "Completed", + "code.lotLineStatus.available": "Available", + "code.lotLineStatus.unavailable": "Unavailable", + "code.joStatus.pending": "Pending", + "code.joStatus.completed": "Completed", + "code.joStatus.cancelled": "Cancelled", + "code.joStatus.in_progress": "In progress", + "code.joStatus.planning": "Planning", + "code.joStatus.packaging": "Packaging", + "code.joStatus.processing": "Processing", + "code.joStatus.pendingQC": "Pending QC", + "code.joStatus.storing": "Storing", + "code.joStatus.PARTIAL": "Partial", + "code.joStatus.partial": "Partial", + "continuousScanBlocked": "Finish current scan first", + "nodeJoOut": "Job order material issue", + "nodePoOut": "Purchase pick", + "code.refType.JO_PICK": "JO pick order", + "code.refType.PO_PICK": "Pick order", + "stockTakeStageCreated": "Round created", + "stockTakeStageFirstCount": "First count", + "stockTakeStageSecondCount": "Re-count", + "stockTakeStageApproverCount": "Approver count", + "stockTakeStageAccepted": "Accepted", + "stockTakeStagePending": "Pending review", + "stockTakeStageBookQty": "Book qty", + "stockTakeStageExpand": "Show lifecycle", + "stockTakeStageCollapse": "Hide lifecycle", + "stockTakeStageDetail": "{{count}} stages", + "locationHeader": "This lot exists in other locations ({{count}} total)", + "available": "Available", + "inQty": "Qty In", + "outQty": "Qty Out", + "warehouseLines": "Warehouse Lines", + "movements": "Movements", + "stockTake": "Stock Take", + "stockTakeCode": "Stock Take #", + "section": "Section", + "round": "Round", + "outbound": "Outbound", + "date": "Date", + "warehouse": "Warehouse", + "direction": "Direction", + "origins": "Origins", + "refCode": "Ref.", + "lastMove": "Last move" +} diff --git a/src/i18n/en/navigation.json b/src/i18n/en/navigation.json index 61a67c7..7d852f0 100644 --- a/src/i18n/en/navigation.json +++ b/src/i18n/en/navigation.json @@ -5,6 +5,7 @@ "nav.store.purchaseOrder": "Purchase Order", "nav.store.pickOrder": "Pick Order", "nav.store.inventoryLedger": "View item In-out And inventory Ledger", + "nav.store.itemTracing": "Item Tracing", "nav.store.stockTake": "Stock Take Management", "nav.store.stockIssue": "Stock Issue", "nav.store.putAwayScan": "Put Away Scan", @@ -81,6 +82,7 @@ "nav.breadcrumb.schedulingDetailed": "Detail Scheduling", "nav.breadcrumb.schedulingDetailedEdit": "FG Production Schedule", "nav.breadcrumb.inventory": "Inventory", + "nav.breadcrumb.itemTracing": "Item Tracing", "nav.breadcrumb.importTesting": "Import Testing", "nav.breadcrumb.doWorkbenchSearch": "DO Workbench Search", "nav.breadcrumb.doWorkbenchPick": "DO Workbench Pick", diff --git a/src/i18n/zh/itemTracing.json b/src/i18n/zh/itemTracing.json new file mode 100644 index 0000000..6aefb9f --- /dev/null +++ b/src/i18n/zh/itemTracing.json @@ -0,0 +1,313 @@ +{ + "title": "批號追溯", + "subtitle": "掃描批號 QR 即可追溯完整生命週期:入庫、出庫、品檢、提料單、工單、盤點、轉倉及 BOM 關聯。", + "scanReady": "掃碼槍就緒 — 請掃描批號標籤 QR", + "scanning": "掃描中…", + "scanAgain": "重新掃描", + "manualSearch": "手動查詢", + "itemCode": "貨品編號", + "lotNo": "批號", + "search": "追溯", + "searching": "查詢中…", + "noResult": "尚無追溯資料。請掃描批號 QR,或輸入貨品編號與批號查詢。", + "notFound": "找不到批號或無權限查看。", + "traceError": "無法載入追溯資料,請稍後再試或聯絡管理員。", + "scanError": "QR 格式無效。批號標籤應包含 itemId 與 stockInLineId。", + "summary": "批號摘要", + "expiryDate": "效期", + "productionDate": "生產日期", + "stockInDate": "入庫日期", + "totalAvailable": "總可用量", + "warehouseBreakdown": "各倉庫存量", + "alternateLocationsTitle": "此批號亦存於其他位置", + "alternateLocationsHint": "同一批號可能因轉倉或拆批而有多筆庫存紀錄;各倉生命週期已合併顯示於上方流程圖,可點擊下方列聚焦該倉節點。", + "traceAlternateLocation": "於流程圖聚焦", + "focusWarehouseInGraph": "於流程圖聚焦", + "sectionsMultiLocationHint": "以下表格合併顯示目前追溯位置與其他倉位事件;倉位欄標示事件所屬庫存紀錄。", + "action": "操作", + "timeline": "異動時間軸", + "flowGraph": "生命週期流程", + "flowLegendTime": "→ 時間(愈右愈晚)", + "flowLegendPhase": "↓ 階段(愈下愈後續)", + "flowLegendBranch": "↔ 同日由左至右依時間", + "flowLegendArrow": "箭頭僅跨階段", + "flowLegendPrelude": "↑ 上方為製造前", + "flowLegendTimeTip": "橫軸 = 日期,由左至右時間推進", + "flowLegendPhaseTip": "縱軸 = 製程階段,由上至下依作業順序", + "flowLegendBranchTip": "同一天的事件依時間戳由左至右排列;縱向列仍表示製程階段", + "flowLegendArrowTip": "箭頭連接不同階段的事件,表示流程走向", + "flowLegendPreludeTip": "成品批上方為原料入庫、品檢(原料與成品共用)、工單提料", + "flowGraphPathJo": "製程順序:原料入庫 → 品檢 → 上架 → 提料 → 生產/副產品 → 成品入庫 → 倉儲 → 出庫 → 盤點", + "flowZoomIn": "放大", + "flowZoomOut": "縮小", + "flowZoomFit": "顯示全圖", + "flowMinimapHide": "隱藏縮圖", + "flowMinimapShow": "顯示縮圖", + "flowZoomPanHint": "滾輪縮放 · 拖曳平移 · 點擊節點於左側看詳情 · 右上角可搜尋節點", + "flowNodeDetailHint": "點擊圖中節點以查看事件詳情", + "flowGraphSearchPlaceholder": "搜尋節點(單號、批號、倉位…)", + "flowGraphSearchNoMatch": "無符合節點", + "flowGraphSearchMatch": "{{current}} / {{total}}", + "flowGraphSearchPrev": "上一個", + "flowGraphSearchNext": "下一個", + "flowGraphSearchClear": "清除", + "nodeDetailTitle": "事件詳情", + "nodeDetailClose": "關閉", + "nodeExpired": "已過期", + "nodeDepleted": "已用完", + "detailDirection": "方向", + "categoryPurchase": "採購", + "categoryProduction": "生產", + "categoryOpen": "開倉", + "categoryTerminal": "終態", + "flowGraphHint": "左側為生命週期階段,由左至右依日期排列;同一天的事件依時間由左至右排列,箭頭表示跨階段流程。", + "flowGraphHintJo": "成品批含製造前階段(原料入庫、品檢、工單提料)與成品階段;品檢列合併原料與成品 IQC。", + "phaseMaterialInbound": "原料入庫", + "phaseMaterialQc": "原料品檢", + "phaseMaterialPick": "工單提料", + "phaseProduction": "生產/副產品", + "nodeProductionStep": "生產步驟", + "nodeByproduct": "副產品批", + "scrapQty": "損耗量", + "defectQty": "不良量", + "processOutputQty": "工序產出", + "processScrapQty": "損耗數量", + "processDefectQty": "不良品數量", + "equipment": "設備", + "processStep": "製程步驟", + "detailStepMaterials": "步驟用料", + "detailAssignedStep": "對應製程", + "traceByproductLot": "追溯副產品批", + "nodeScrap": "損耗", + "nodeDefect": "不良品", + "nodeOpen": "開倉入庫", + "nodeFail": "揀貨異常", + "nodeDoOut": "成品出倉", + "nodeReplenishmentCreated": "建立補貨", + "doOutboundExtra": "加單", + "doOutboundReplenish": "補貨", + "detailDoOutboundKind": "出庫類型", + "nodeDoGroup": "成品出倉群組", + "flowDoGroupTitle": "成品出倉({{count}} 筆)", + "flowDoGroupTotalQty": "出倉數量共:{{qtyLabel}}", + "flowGroupCollapse": "收合群組", + "flowGroupExpand": "展開群組", + "nodePickGroup": "工單提料群組", + "flowPickGroupTitle": "工單提料 · {{pickOrderCode}}({{count}} 筆)", + "nodeReturn": "退貨", + "nodeRepack": "拆批/重包", + "traceRepackLot": "追溯關聯批", + "productLotNo": "生產批號", + "nodeMaterialIn": "原料入庫", + "nodeJoCreated": "工單建立", + "nodeMaterialPick": "工單提料", + "nodePurchase": "採購", + "nodeReceipt": "收貨", + "nodePutaway": "上架", + "nodePutawayTransfer": "轉倉入庫", + "flowLegendTitle": "如何閱讀流程圖", + "flowLegendOpen": "流程圖圖例", + "flowPhaseFilter": "顯示階段", + "flowPhaseFilterAll": "全部階段", + "flowPhaseFilterReset": "重置", + "locationsShowAll": "▼ 顯示全部 {{count}} 列", + "locationsCollapse": "▲ 收合(目前顯示全部 {{count}})", + "exportExcel": "匯出 Excel", + "exportExcelTooltip": "下載此批號分類整理後的多工作表 Excel", + "putawayTransferInboundDetail": "轉倉至目標倉完成入庫(系統自動完成,非採購「待上架」流程)", + "putawayStatusPending": "待上架", + "putawayStatusCompleted": "已上架", + "phaseInbound": "入庫階段", + "phasePurchase": "採購階段", + "phaseQc": "品檢階段", + "phasePutaway": "上架階段", + "categoryReceipt": "收貨", + "phaseWarehouse": "倉儲作業", + "phaseOutbound": "出庫耗用", + "phaseStockTake": "盤點階段", + "noTimestamp": "無日期", + "nodeQcPass": "品檢合格", + "nodeQcFail": "品檢不合格", + "nodeStockTake": "盤點", + "nodeAdjustment": "庫存調整", + "nodeTransfer": "轉倉", + "tabOrigins": "來源與入庫", + "tabQc": "品質檢驗 (IQC)", + "tabOutbound": "出庫使用", + "tabStockTake": "盤點", + "tabAdjustments": "庫存調整", + "tabTransfers": "轉倉", + "tabBom": "BOM 追溯", + "tabJoPick": "工單提料", + "joContext": "工單脈絡", + "planStart": "計劃開工", + "plannedQty": "計劃產量", + "pickedQty": "實際提料數量", + "requiredQty": "需求數量", + "pickedAt": "提料時間", + "traceMaterialLot": "追溯原材料/半成品", + "targetDate": "目標日期", + "detailPickTargetDate": "需求日期", + "completeDate": "完成日期", + "directionIn": "入", + "directionOut": "出", + "bomDirection": "追溯方向", + "bomDirectionFG": "成品批", + "bomDirectionMaterial": "原料批", + "bomDirectionUnknown": "一般庫存批", + "bomUpstream": "實際耗用原料", + "bomDownstream": "關聯成品產出", + "bomRecipe": "BOM", + "noRecords": "無紀錄", + "type": "類型", + "ref": "單號", + "detailInboundRef": "入庫單 (SI)", + "detailInboundSiNo": "入庫單編號", + "detailAdjustmentRef": "調整單號", + "detailReplenishmentCode": "補貨編號", + "detailReason": "原因", + "detailReturnRef": "退貨單號", + "detailSourceDoc": "來源單號", + "detailPutawayBin": "倉位", + "supplier": "供應商", + "dnNo": "送貨單號", + "qty": "數量", + "detailOrderQty": "訂單數量", + "detailPutAwayQty": "已上架數量", + "detailRemainingQty": "剩餘數量", + "detailPurchaseUnit": "採購單位", + "detailPurchaseOrderNo": "採購單編號", + "detailSupplyTo": "供應至", + "detailStockTakeRound": "盤點輪次", + "detailStockTakeSection": "盤點區域", + "detailLocation": "庫位", + "status": "狀態", + "handler": "經手人", + "stockTaker": "初盤人", + "timestamp": "時間", + "pickOrder": "提料單", + "jobOrder": "工單", + "deliveryOrder": "送貨單", + "deliveryNoteCode": "送貨單據號 (DN)", + "ticketNo": "票號", + "variance": "差異", + "before": "帳面數量", + "after": "核准數量", + "approver": "核准人", + "from": "來源倉", + "to": "目標倉", + "material": "原料", + "Item": "貨品", + "detailItemCode": "貨品編號", + "detailItemName": "貨品名稱", + "finishedItem": "成品編號", + "finishedLot": "成品批號", + "materialLot": "原料批號", + "materialQty": "實際提料數量", + "processingStatus": "處理狀態", + "matchStatus": "對料狀態", + "fgQty": "成品入庫量", + "qtyPerUnit": "需求數量", + "uom": "單位", + "remarks": "備註", + "detailFailCategory": "異常類別", + "detailProcessDescription": "工序說明", + "qcPassed": "合格", + "qcFailed": "不合格", + "acceptedQty": "上架量", + "failQty": "不良量", + "unqualifiedQty": "不合格數", + "usageType": "類型", + "detailQcCriteria": "品檢項目", + "detailQcType": "品檢類型", + "detailQcUnknownItem": "未命名品檢項", + "code.refType.PO": "採購單", + "code.refType.JO": "工單", + "code.refType.DO": "送貨單", + "code.refType.TKE": "盤點", + "code.refType.TRANSFER": "轉倉", + "code.refType.ADJ": "調整", + "code.refType.OPEN": "開倉", + "code.refType.OTHER": "其他", + "code.movementType.STOCK_IN": "入庫", + "code.movementType.STOCK_OUT": "出庫", + "code.movementType.CONSUMABLE": "耗用出庫", + "code.movementType.RETURN": "退貨", + "code.movementType.ADJ": "調整", + "code.movementType.TKE": "盤點", + "code.movementType.OPEN": "開倉", + "code.movementType.IN": "入庫", + "code.movementType.OUT": "出庫", + "code.stockInStatus.pending": "待處理", + "code.stockInStatus.qc": "品檢中", + "code.stockInStatus.escalated": "已升級", + "code.stockInStatus.determine1": "一階判定", + "code.stockInStatus.determine2": "二階判定", + "code.stockInStatus.determine3": "三階判定", + "code.stockInStatus.receiving": "收貨中", + "code.stockInStatus.received": "已收貨待入庫", + "code.stockInStatus.completed": "已完成", + "code.stockInStatus.complete": "已完成", + "code.stockInStatus.partially_completed": "部分完成", + "code.stockInStatus.rejected": "已拒收", + "code.qcType.IQC": "來貨品檢 (IQC)", + "code.qcType.IPQC": "製程品檢 (IPQC)", + "code.qcType.EPQC": "生產後品檢 (EPQC)", + "code.qcType.FQC": "成品品檢 (FQC)", + "code.adjustmentType.ADJ": "庫存調整", + "code.usageType.CONSUMABLE": "耗用", + "code.usageType.PRODUCTION": "生產耗用", + "code.usageType.FG_DELIVERY": "成品出貨", + "code.usageType.MATERIAL": "原料提料", + "code.processingStatus.pending": "待處理", + "code.processingStatus.completed": "已完成", + "code.processingStatus.rejected": "已拒收", + "code.matchStatus.pending": "待對料", + "code.matchStatus.scanned": "已掃描", + "code.matchStatus.completed": "已完成", + "code.lotLineStatus.available": "可用", + "code.lotLineStatus.unavailable": "不可用", + "code.joStatus.pending": "待處理", + "code.joStatus.completed": "已完成", + "code.joStatus.cancelled": "已取消", + "code.joStatus.in_progress": "進行中", + "code.joStatus.planning": "規劃中", + "code.joStatus.packaging": "提料中", + "code.joStatus.processing": "生產中", + "code.joStatus.pendingQC": "待品檢", + "code.joStatus.storing": "待品檢入倉", + "code.joStatus.PARTIAL": "部分完成", + "code.joStatus.partial": "部分完成", + "continuousScanBlocked": "請先完成目前掃描", + "nodeJoOut": "工單提料", + "nodePoOut": "採購提料", + "code.refType.JO_PICK": "工單提料單", + "code.refType.PO_PICK": "提料單", + "stockTakeStageCreated": "建立盤點輪次", + "stockTakeStageFirstCount": "盤點人初盤", + "stockTakeStageSecondCount": "盤點人複盤", + "stockTakeStageApproverCount": "管理員覆盤", + "stockTakeStageAccepted": "盤點核准", + "stockTakeStagePending": "待審核", + "stockTakeStageBookQty": "帳面數量", + "stockTakeStageExpand": "展開生命週期", + "stockTakeStageCollapse": "收合生命週期", + "stockTakeStageDetail": "{{count}} 個階段", + "locationHeader": "此批號亦存於其他位置(共 {{count}} 處)", + "available": "可用量", + "inQty": "入庫量", + "outQty": "出庫量", + "warehouseLines": "各倉庫存量", + "movements": "異動記錄", + "stockTake": "盤點", + "stockTakeCode": "盤點單號", + "section": "區域", + "round": "輪次", + "outbound": "出庫使用", + "date": "日期", + "warehouse": "倉庫", + "direction": "方向", + "origins": "來源", + "refCode": "單號", + "lastMove": "最近異動" +} diff --git a/src/i18n/zh/navigation.json b/src/i18n/zh/navigation.json index 4d56587..cd01b47 100644 --- a/src/i18n/zh/navigation.json +++ b/src/i18n/zh/navigation.json @@ -21,6 +21,7 @@ "nav.breadcrumb.home": "總覽", "nav.breadcrumb.importTesting": "匯入測試", "nav.breadcrumb.inventory": "存貨", + "nav.breadcrumb.itemTracing": "批號追溯", "nav.breadcrumb.joEdit": "工單詳情", "nav.breadcrumb.joTesting": "工單測試", "nav.breadcrumb.joWorkbench": "工單工作台", @@ -91,6 +92,7 @@ "nav.store.doWorkbench": "成品出倉", "nav.store.finishedGoodManagement": "成品出倉管理", "nav.store.inventoryLedger": "查看物品出入庫及庫存日誌", + "nav.store.itemTracing": "批號追溯", "nav.store.pickOrder": "提料單", "nav.store.purchaseOrder": "採購單", "nav.store.putAwayScan": "上架掃碼", diff --git a/src/utils/traceDoOutboundExtra.ts b/src/utils/traceDoOutboundExtra.ts new file mode 100644 index 0000000..54711ac --- /dev/null +++ b/src/utils/traceDoOutboundExtra.ts @@ -0,0 +1,57 @@ +const parseTicketTypeLetter = (ticketNo?: string | null): string | undefined => { + const parts = (ticketNo ?? "").trim().split("-"); + return parts[1]?.toUpperCase(); +}; + +export const isTiETicketNo = (ticketNo?: string | null): boolean => { + const tn = (ticketNo ?? "").trim().toUpperCase(); + return parseTicketTypeLetter(tn) === "E" || tn.startsWith("TI-E-"); +}; + +export const isTiMTicketNo = (ticketNo?: string | null): boolean => + (ticketNo ?? "").trim().toUpperCase().startsWith("TI-M-"); + +const isExtraReleaseType = (releaseType?: string | null): boolean => { + const rt = (releaseType ?? "").trim().toLowerCase(); + return rt === "isextra" || rt === "isextrabatch" || rt === "isextrasingle"; +}; + +export type TraceDoOutboundExtraSource = { + isExtra?: boolean; + ticketNo?: string | null; + consoCode?: string | null; + releaseType?: string | null; + deliveryOrderIsExtra?: boolean; + deliveryOrderPickOrderId?: number | null; + relationshipId?: number | null; +}; + +/** + * Trace 加單 chip rules (aligned with DeliveryOrderService.isExtraDeliveryTicket): + * - TI-E / conso TI-E- → extra + * - TI-M → per DO isExtra, conso TI-E-, or relationshipId lineage (≠ own dop id) + * - Other → releaseType / API isExtra + */ +export const resolveTraceDoOutboundIsExtra = ( + source: TraceDoOutboundExtraSource, +): boolean => { + if (source.isExtra === true) return true; + + const ticket = source.ticketNo?.trim() || ""; + const conso = source.consoCode?.trim() || ""; + const consoIsTiE = isTiETicketNo(conso); + const deliveryOrderIsExtra = source.deliveryOrderIsExtra === true; + + if (isTiMTicketNo(ticket)) { + if (deliveryOrderIsExtra) return true; + if (consoIsTiE) return true; + const dopId = source.deliveryOrderPickOrderId ?? null; + const relationshipId = source.relationshipId ?? null; + if (dopId != null && relationshipId != null && relationshipId !== dopId) return true; + return false; + } + + if (isTiETicketNo(ticket) || consoIsTiE) return true; + if (isExtraReleaseType(source.releaseType) && !isTiMTicketNo(ticket)) return true; + return deliveryOrderIsExtra; +};