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"; import { buildIssueIngredientRows, buildIssueStoreRows, buildIssueWhereUsedRows, formatShopPhone, issueStoreItemsLabel, issueStoreReason, shopNameWithoutCode, } from "./buildIssueTraceRows"; const NO_DATA = "(無資料 / No records)"; const HISTORY_SHEET_KEYS = [ "summary", "timeline", "origins", "qc", "warehouse", "outbound", "stockTake", "production", ] as const; const ISSUE_SHEET_KEYS = ["stores", "whereUsed", "ingredients"] as const; const SHEET_KEYS = [...HISTORY_SHEET_KEYS, ...ISSUE_SHEET_KEYS] as const; export type ItemLotTraceWorkbook = "history" | "issue"; /** Bilingual workbook text, rewritten to the active site language at export time. */ const EXCEL_PHRASE: Record = { [NO_DATA]: "excel.noData", "Field / 欄位": "excel.col.field", "Value / 值": "excel.col.value", "Lot No. / 批號": "excel.summary.lotNo", "Item Code / 貨品編號": "excel.col.itemCode", "Item Name / 品名": "excel.col.itemName", "UOM / 單位": "excel.col.uom", "Expiry / 效期": "excel.summary.expiry", "Production Date / 生產日期": "excel.summary.productionDate", "Stock In Date / 入庫日期": "excel.summary.stockInDate", "Total Available / 總可用量": "excel.summary.totalAvailable", "BOM Direction / BOM 方向": "excel.summary.bomDirection", "Issue Excel / 問題清單": "excel.summary.issueExcel", "Job Order / 工單": "excel.col.jobOrder", "Plan Start / 計劃開工": "excel.summary.planStart", "Planned Qty / 計劃產量": "excel.summary.plannedQty", "JO Status / 工單狀態": "excel.summary.joStatus", "Export Time / 匯出時間": "excel.summary.exportTime", "— Warehouse Breakdown / 各倉庫存量 —": "excel.summary.warehouseBreakdown", "— Alternate Locations / 其他倉位 —": "excel.summary.alternateLocations", "Why / 原因": "excel.col.why", "Times Received / 收貨次數": "excel.col.timesReceived", "Items / 收到的貨": "excel.col.itemsReceived", "Latest / 最近一次": "excel.col.latest", "Level / 層級": "excel.col.level", "Shop Code / 門市編號": "excel.col.shopCode", "Shop Name / 門市名稱": "excel.col.shopName", "Contact / 聯絡人": "excel.col.contact", "Phone / 電話": "excel.col.phone", "Email / 電郵": "excel.col.email", "Address / 地址": "excel.col.address", "District / 地區": "excel.col.district", "Lot / 批號": "excel.col.lot", "Qty / 數量": "excel.col.qty", "Delivery Order / 送貨單": "excel.col.deliveryOrder", "Delivery Note / DN": "excel.col.deliveryNote", "Ticket / 票號": "excel.col.ticket", "Date/Time / 時間": "excel.col.dateTime", "Handler / 經手人": "excel.col.handler", "Consumed Item Code / 被用貨品編號": "excel.col.consumedItemCode", "Consumed Item / 被用品名": "excel.col.consumedItem", "Consumed Lot / 被用批號": "excel.col.consumedLot", "Qty Used / 用量": "excel.col.qtyUsed", "Produced Item Code / 製成品編號": "excel.col.producedItemCode", "Produced Item / 製成品名": "excel.col.producedItem", "Produced Lot / 製成批號": "excel.col.producedLot", "Produced Qty / 製成數量": "excel.col.producedQty", "Kind / 類型": "excel.col.kind", "Qty Per Unit / 每單位用量": "excel.col.qtyPerUnit", "Pick Order / 提料單": "excel.col.pickOrder", "Assigned Step / 對應製程": "excel.col.assignedStep", "Picked At / 提料時間": "excel.col.pickedAt", "Phase / 階段": "excel.col.phase", "Event Type / 事件類型": "excel.col.eventType", "Doc No. / 單號": "excel.col.docNo", "Ref Type / 來源類型": "excel.col.refType", "Ref Type Code": "excel.col.refTypeCode", "Warehouse / 倉位": "excel.col.warehouse", "Remarks / 備註": "excel.col.remarks", "Section / 區塊": "excel.col.section", "Inventory Lot Id": "excel.col.inventoryLotId", "Type / 類型": "excel.col.type", "Type Code": "excel.col.typeCode", "Supplier Code / 供應商編號": "excel.col.supplierCode", "Supplier / 供應商": "excel.col.supplier", "DN No. / 送貨單號": "excel.col.dnNo", "Status / 狀態": "excel.col.status", "Date / 日期": "excel.col.date", "QC Result / 結果": "excel.col.qcResult", "QC Type / 品檢類型": "excel.col.qcType", "QC Type Code": "excel.col.qcTypeCode", "QC Item Code / 品檢項編號": "excel.col.qcItemCode", "QC Item / 品檢項": "excel.col.qcItem", "Accepted Qty / 抽樣數量": "excel.col.sampleQty", "Fail Qty / 不良數量": "excel.col.failQty", "Stock In Line Id": "excel.col.stockInLineId", "Direction / 方向": "excel.col.direction", "From / 來源倉": "excel.col.from", "To / 目標倉": "excel.col.to", "Usage Type / 類型": "excel.col.usageType", "Usage Type Code": "excel.col.usageTypeCode", "Conso / 併單": "excel.col.conso", "Shop / 門市": "excel.col.shop", "Extra / 加單": "excel.col.extra", "Replenish / 補貨": "excel.col.replenish", "Stock Take Code / 盤點單號": "excel.col.stockTakeCode", "Round / 輪次": "excel.col.round", "Section / 區域": "excel.col.area", "Book Qty / 帳面數量": "excel.col.bookQty", "Accepted Qty / 核准數量": "excel.col.approvedQty", "Variance Qty / 差異數量": "excel.col.varianceQty", "Approver / 核准人": "excel.col.approver", "Stock Taker / 初盤人": "excel.col.stockTaker", "First Count Qty / 初盤數量": "excel.col.firstCountQty", "Second Count Qty / 複盤數量": "excel.col.secondCountQty", "Approver Count Qty / 覆盤數量": "excel.col.approverCountQty", "Record Status / 紀錄狀態": "excel.col.recordStatus", "Step / 步驟": "excel.col.step", "Scrap Qty / 損耗數量": "excel.col.scrapQty", "Defect Qty / 不良數量": "excel.col.defectQty", "Operator / 操作員": "excel.col.operator", "Equipment / 設備": "excel.col.equipment", "Origin / 來源": "excel.section.origin", "Purchase / 採購": "excel.section.purchase", "Putaway / 上架": "excel.section.putaway", "Transfer / 轉倉": "excel.section.transfer", "Adjustment / 庫存調整": "excel.section.adjustment", "Opening Stock / 開倉入庫": "excel.section.openingStock", "Outbound Usage / 出庫使用": "excel.section.outboundUsage", "DO Delivery / 成品出倉": "excel.section.doDelivery", "Replenishment / 補貨": "excel.section.replenishment", Replenishment: "excel.section.replenishment", "Return / 退貨": "excel.section.return", "Pick Failure / 揀貨異常": "excel.section.pickFailure", "BOM Upstream / BOM 上游": "excel.section.bomUpstream", "BOM Downstream / BOM 下游": "excel.section.bomDownstream", "BOM Recipe / BOM 配方": "excel.section.bomRecipe", "JO Pick / 工單提料": "excel.section.joPick", "Production Step / 生產步驟": "excel.section.productionStep", "Byproduct / 副產品": "excel.section.byproduct", "Lot Relation / 拆批關聯": "excel.section.lotRelation", }; const sheetTitle = (t: TFunction, key: (typeof SHEET_KEYS)[number]): string => t(`excel.sheet.${key}`).replace(/[\\/*?:[\]]/g, "").trim().slice(0, 31); const localizeCell = (value: unknown, t: TFunction): unknown => { if (typeof value !== "string") return value; const phrase = EXCEL_PHRASE[value]; return phrase ? t(phrase) : value; }; const localizeRows = ( rows: Record[], t: TFunction, ): Record[] => rows.map((row) => { const next: Record = {}; Object.entries(row).forEach(([key, value]) => { const headerKey = EXCEL_PHRASE[key]; next[headerKey ? t(headerKey) : key] = localizeCell(value, t); }); return next; }); 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, t: TFunction, workbook: ItemLotTraceWorkbook = "history", ): string => { const item = safeFilenamePart(data.lot.itemCode || "item"); const lot = safeFilenamePart(data.lot.lotNo || "lot"); const prefix = safeFilenamePart( t(workbook === "issue" ? "excel.issueFilePrefix" : "excel.filePrefix"), ); return `${prefix}_${item}_${lot}_${todayYmd()}`; }; const summaryEmpty = (): Record => ({ "Field / 欄位": "", "Value / 值": "", }); const buildSummarySheet = ( data: ItemLotTraceResponse, labels: ItemLotTraceExportLabels, exportAt: string, t: TFunction, ): Record[] => { const tr = createTraceLabelTranslator(t); 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), }, { "Field / 欄位": "Issue Excel / 問題清單", "Value / 值": t("excel.summary.issueExcelNote"), }, ]; 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(tr.joStatus(jo.status)), }, ); } rows.push( { "Field / 欄位": "Export Time / 匯出時間", "Value / 值": exportAt }, { "Field / 欄位": "", "Value / 值": "" }, { "Field / 欄位": "— Warehouse Breakdown / 各倉庫存量 —", "Value / 值": "", }, ); warehouseLines.forEach((w) => { rows.push({ "Field / 欄位": t("excel.summary.warehouseLine", { code: w.warehouseCode }), "Value / 值": t("excel.summary.warehouseQty", { inQty: w.inQty, outQty: w.outQty, available: w.availableQty, status: w.status ? tr.lotLineStatus(w.status) : "—", }), }); }); if (alternateLocations.length > 0) { rows.push( { "Field / 欄位": "", "Value / 值": "" }, { "Field / 欄位": "— Alternate Locations / 其他倉位 —", "Value / 值": "", }, ); alternateLocations.forEach((loc) => { rows.push({ "Field / 欄位": t("excel.summary.inventoryLot", { id: loc.inventoryLotId }), "Value / 值": t("excel.summary.locationQty", { code: loc.warehouseCode, qty: loc.availableQty, }), }); }); } return rows.length > 0 ? rows : [summaryEmpty()]; }; const storesEmpty = (): Record => ({ "Shop Code / 門市編號": "", "Shop Name / 門市名稱": "", "Phone / 電話": "", "Address / 地址": "", "District / 地區": "", "Times Received / 收貨次數": "", "Qty / 數量": "", "UOM / 單位": "", "Why / 原因": "", "Items / 收到的貨": "", "Delivery Order / 送貨單": "", "Latest / 最近一次": "", }); const buildStoresSheet = ( data: ItemLotTraceResponse, t: TFunction, ): Record[] => { const rows = buildIssueStoreRows(data).map((row) => { const singleQty = row.qtyByUom.length === 1 ? row.qtyByUom[0] : null; return { "Shop Code / 門市編號": orDash(row.shopCode), "Shop Name / 門市名稱": orDash(shopNameWithoutCode(row.shopCode, row.shopName)), "Phone / 電話": formatShopPhone(row.contactNo), "Address / 地址": orDash(row.address), "District / 地區": orDash(row.district), "Times Received / 收貨次數": row.receiptCount, // One line per unit so "11" and "1包x2磅" stay paired instead of colliding. "Qty / 數量": singleQty ? singleQty.qty : row.qtyByUom.map((part) => part.qty).join("\n") || "—", "UOM / 單位": singleQty ? orDash(singleQty.uom) : row.qtyByUom.map((part) => part.uom || "—").join("\n") || "—", "Why / 原因": issueStoreReason(row, t), "Items / 收到的貨": issueStoreItemsLabel(row), "Delivery Order / 送貨單": orDash(row.deliveryOrderCodes.join("\n")), "Latest / 最近一次": orDash(row.latestTimestamp), }; }); return ensureRows(rows, storesEmpty()); }; const whereUsedEmpty = (): Record => ({ "Level / 層級": "", "Consumed Item Code / 被用貨品編號": "", "Consumed Item / 被用品名": "", "Consumed Lot / 被用批號": "", "Qty Used / 用量": "", "Job Order / 工單": "", "Produced Item Code / 製成品編號": "", "Produced Item / 製成品名": "", "Produced Lot / 製成批號": "", "Produced Qty / 製成數量": "", "UOM / 單位": "", }); const buildWhereUsedSheet = ( data: ItemLotTraceResponse, ): Record[] => { const rows = buildIssueWhereUsedRows(data).map((row) => ({ "Level / 層級": row.depth, "Consumed Item Code / 被用貨品編號": orDash(row.consumedItemCode), "Consumed Item / 被用品名": orDash(row.consumedItemName), "Consumed Lot / 被用批號": orDash(row.consumedLotNo), "Qty Used / 用量": row.materialQtyUsed, "Job Order / 工單": orDash(row.jobOrderCode), "Produced Item Code / 製成品編號": orDash(row.producedItemCode), "Produced Item / 製成品名": orDash(row.producedItemName), "Produced Lot / 製成批號": orDash(row.producedLotNo), "Produced Qty / 製成數量": row.producedQty, "UOM / 單位": orDash(row.producedUom), })); return ensureRows(rows, whereUsedEmpty()); }; const ingredientsEmpty = (): Record => ({ "Kind / 類型": "", "Item Code / 貨品編號": "", "Item Name / 品名": "", "Lot / 批號": "", "Qty / 數量": "", "UOM / 單位": "", "Qty Per Unit / 每單位用量": "", "Job Order / 工單": "", "Pick Order / 提料單": "", "Assigned Step / 對應製程": "", "Picked At / 提料時間": "", }); const buildIngredientsSheet = ( data: ItemLotTraceResponse, t: TFunction, ): Record[] => { const rows = buildIssueIngredientRows(data).map((row) => ({ "Kind / 類型": row.kind === "RECIPE" ? t("issueIngredientRecipe") : t("issueIngredientActual"), "Item Code / 貨品編號": orDash(row.itemCode), "Item Name / 品名": orDash(row.itemName), "Lot / 批號": orDash(row.lotNo), "Qty / 數量": row.qty ?? "", "UOM / 單位": orDash(row.uom), "Qty Per Unit / 每單位用量": row.qtyPerUnit ?? "", "Job Order / 工單": orDash(row.jobOrderCode), "Pick Order / 提料單": orDash(row.pickOrderCode), "Assigned Step / 對應製程": orDash(row.assignedStepName), "Picked At / 提料時間": orDash(row.pickedAt), })); return ensureRows(rows, ingredientsEmpty()); }; 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, t: TFunction, ): 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 ? t("excel.yes") : "", "Replenish / 補貨": d.isReplenish ? t("excel.yes") : "", "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 / 補貨": t("excel.yes"), "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 one workbook without triggering download (for tests). */ export const buildItemLotTraceSheets = ( data: ItemLotTraceResponse, compiledGraph: CompiledTraceGraph, t: TFunction, exportLabels: ItemLotTraceExportLabels, workbook: ItemLotTraceWorkbook = "history", exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19), ): MultiSheetSpec[] => { const tr = createTraceLabelTranslator(t); const built: Record<(typeof SHEET_KEYS)[number], Record[]> = { summary: buildSummarySheet(data, exportLabels, exportAt, t), stores: buildStoresSheet(data, t), whereUsed: buildWhereUsedSheet(data), ingredients: buildIngredientsSheet(data, t), timeline: buildTimelineSheet(compiledGraph, t, tr), origins: buildOriginsSheet(data, tr), qc: buildQcSheet(data, tr), warehouse: buildWarehouseSheet(data, tr), outbound: buildOutboundSheet(data, tr, t), stockTake: buildStockTakeSheet(data), production: buildProductionSheet(data, compiledGraph, tr), }; const keys = workbook === "issue" ? ISSUE_SHEET_KEYS : HISTORY_SHEET_KEYS; return keys.map((key) => ({ name: sheetTitle(t, key), rows: localizeRows(built[key], t), })); }; export const itemLotTraceSheetNames = ( t: TFunction, workbook: ItemLotTraceWorkbook = "history", ): string[] => (workbook === "issue" ? ISSUE_SHEET_KEYS : HISTORY_SHEET_KEYS).map((key) => sheetTitle(t, key), ); /** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */ export const exportItemLotTraceXlsx = ( data: ItemLotTraceResponse, compiledGraph: CompiledTraceGraph, t: TFunction, workbook: ItemLotTraceWorkbook = "history", ): void => { const exportLabels: ItemLotTraceExportLabels = { bomDirectionFg: t("bomDirectionFG"), bomDirectionMaterial: t("bomDirectionMaterial"), bomDirectionUnknown: t("bomDirectionUnknown"), }; const sheets = buildItemLotTraceSheets( data, compiledGraph, t, exportLabels, workbook, ); exportMultiSheetToXlsx( sheets, buildItemLotTraceFilename(data, t, workbook), ); };