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. 15 | v1.0.1 | 2026-07-20 */ 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)); };