FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 

1219 行
43 KiB

  1. import type { TFunction } from "i18next";
  2. import type { ItemLotTraceResponse } from "@/app/api/itemTracing";
  3. import {
  4. exportMultiSheetToXlsx,
  5. type MultiSheetSpec,
  6. } from "@/app/(main)/chart/_components/exportChartToXlsx";
  7. import type { CompiledTraceGraph } from "./compileTraceGraph";
  8. import {
  9. mergeScopedAdjustments,
  10. mergeScopedDoDeliveries,
  11. mergeScopedFailEvents,
  12. mergeScopedOpenMovements,
  13. mergeScopedOrigins,
  14. mergeScopedOutboundUsage,
  15. mergeScopedPurchaseEvents,
  16. mergeScopedPutawayEvents,
  17. mergeScopedQcResults,
  18. mergeScopedReplenishmentEvents,
  19. mergeScopedReturnEvents,
  20. mergeScopedStockTakeEvents,
  21. mergeScopedTransfers,
  22. } from "./mergeLocationScopedData";
  23. import {
  24. resolveStockTakeAcceptedQty,
  25. resolveStockTakeBookQty,
  26. } from "./traceStockTakeUtils";
  27. import {
  28. createTraceLabelTranslator,
  29. type TraceLabelTranslator,
  30. } from "./traceLabelUtils";
  31. import { kindLabelKey, phaseLabelKey } from "./traceFlowNodeUtils";
  32. import { buildTracePresentationRows } from "./tracePresentationAdapter";
  33. import { isDoGroupChild } from "./traceDoGroupLayout";
  34. import {
  35. buildIssueIngredientRows,
  36. buildIssueStoreRows,
  37. buildIssueWhereUsedRows,
  38. formatShopPhone,
  39. issueStoreItemsLabel,
  40. issueStoreReason,
  41. shopNameWithoutCode,
  42. } from "./buildIssueTraceRows";
  43. const NO_DATA = "(無資料 / No records)";
  44. const HISTORY_SHEET_KEYS = [
  45. "summary",
  46. "timeline",
  47. "origins",
  48. "qc",
  49. "warehouse",
  50. "outbound",
  51. "stockTake",
  52. "production",
  53. ] as const;
  54. const ISSUE_SHEET_KEYS = ["stores", "whereUsed", "ingredients"] as const;
  55. const SHEET_KEYS = [...HISTORY_SHEET_KEYS, ...ISSUE_SHEET_KEYS] as const;
  56. export type ItemLotTraceWorkbook = "history" | "issue";
  57. /** Bilingual workbook text, rewritten to the active site language at export time. */
  58. const EXCEL_PHRASE: Record<string, string> = {
  59. [NO_DATA]: "excel.noData",
  60. "Field / 欄位": "excel.col.field",
  61. "Value / 值": "excel.col.value",
  62. "Lot No. / 批號": "excel.summary.lotNo",
  63. "Item Code / 貨品編號": "excel.col.itemCode",
  64. "Item Name / 品名": "excel.col.itemName",
  65. "UOM / 單位": "excel.col.uom",
  66. "Expiry / 效期": "excel.summary.expiry",
  67. "Production Date / 生產日期": "excel.summary.productionDate",
  68. "Stock In Date / 入庫日期": "excel.summary.stockInDate",
  69. "Total Available / 總可用量": "excel.summary.totalAvailable",
  70. "BOM Direction / BOM 方向": "excel.summary.bomDirection",
  71. "Issue Excel / 問題清單": "excel.summary.issueExcel",
  72. "Job Order / 工單": "excel.col.jobOrder",
  73. "Plan Start / 計劃開工": "excel.summary.planStart",
  74. "Planned Qty / 計劃產量": "excel.summary.plannedQty",
  75. "JO Status / 工單狀態": "excel.summary.joStatus",
  76. "Export Time / 匯出時間": "excel.summary.exportTime",
  77. "— Warehouse Breakdown / 各倉庫存量 —": "excel.summary.warehouseBreakdown",
  78. "— Alternate Locations / 其他倉位 —": "excel.summary.alternateLocations",
  79. "Why / 原因": "excel.col.why",
  80. "Times Received / 收貨次數": "excel.col.timesReceived",
  81. "Items / 收到的貨": "excel.col.itemsReceived",
  82. "Latest / 最近一次": "excel.col.latest",
  83. "Level / 層級": "excel.col.level",
  84. "Shop Code / 門市編號": "excel.col.shopCode",
  85. "Shop Name / 門市名稱": "excel.col.shopName",
  86. "Contact / 聯絡人": "excel.col.contact",
  87. "Phone / 電話": "excel.col.phone",
  88. "Email / 電郵": "excel.col.email",
  89. "Address / 地址": "excel.col.address",
  90. "District / 地區": "excel.col.district",
  91. "Lot / 批號": "excel.col.lot",
  92. "Qty / 數量": "excel.col.qty",
  93. "Delivery Order / 送貨單": "excel.col.deliveryOrder",
  94. "Delivery Note / DN": "excel.col.deliveryNote",
  95. "Ticket / 票號": "excel.col.ticket",
  96. "Date/Time / 時間": "excel.col.dateTime",
  97. "Handler / 經手人": "excel.col.handler",
  98. "Consumed Item Code / 被用貨品編號": "excel.col.consumedItemCode",
  99. "Consumed Item / 被用品名": "excel.col.consumedItem",
  100. "Consumed Lot / 被用批號": "excel.col.consumedLot",
  101. "Qty Used / 用量": "excel.col.qtyUsed",
  102. "Produced Item Code / 製成品編號": "excel.col.producedItemCode",
  103. "Produced Item / 製成品名": "excel.col.producedItem",
  104. "Produced Lot / 製成批號": "excel.col.producedLot",
  105. "Produced Qty / 製成數量": "excel.col.producedQty",
  106. "Kind / 類型": "excel.col.kind",
  107. "Qty Per Unit / 每單位用量": "excel.col.qtyPerUnit",
  108. "Pick Order / 提料單": "excel.col.pickOrder",
  109. "Assigned Step / 對應製程": "excel.col.assignedStep",
  110. "Picked At / 提料時間": "excel.col.pickedAt",
  111. "Phase / 階段": "excel.col.phase",
  112. "Event Type / 事件類型": "excel.col.eventType",
  113. "Doc No. / 單號": "excel.col.docNo",
  114. "Ref Type / 來源類型": "excel.col.refType",
  115. "Ref Type Code": "excel.col.refTypeCode",
  116. "Warehouse / 倉位": "excel.col.warehouse",
  117. "Remarks / 備註": "excel.col.remarks",
  118. "Section / 區塊": "excel.col.section",
  119. "Inventory Lot Id": "excel.col.inventoryLotId",
  120. "Type / 類型": "excel.col.type",
  121. "Type Code": "excel.col.typeCode",
  122. "Supplier Code / 供應商編號": "excel.col.supplierCode",
  123. "Supplier / 供應商": "excel.col.supplier",
  124. "DN No. / 送貨單號": "excel.col.dnNo",
  125. "Status / 狀態": "excel.col.status",
  126. "Date / 日期": "excel.col.date",
  127. "QC Result / 結果": "excel.col.qcResult",
  128. "QC Type / 品檢類型": "excel.col.qcType",
  129. "QC Type Code": "excel.col.qcTypeCode",
  130. "QC Item Code / 品檢項編號": "excel.col.qcItemCode",
  131. "QC Item / 品檢項": "excel.col.qcItem",
  132. "Accepted Qty / 抽樣數量": "excel.col.sampleQty",
  133. "Fail Qty / 不良數量": "excel.col.failQty",
  134. "Stock In Line Id": "excel.col.stockInLineId",
  135. "Direction / 方向": "excel.col.direction",
  136. "From / 來源倉": "excel.col.from",
  137. "To / 目標倉": "excel.col.to",
  138. "Usage Type / 類型": "excel.col.usageType",
  139. "Usage Type Code": "excel.col.usageTypeCode",
  140. "Conso / 併單": "excel.col.conso",
  141. "Shop / 門市": "excel.col.shop",
  142. "Extra / 加單": "excel.col.extra",
  143. "Replenish / 補貨": "excel.col.replenish",
  144. "Stock Take Code / 盤點單號": "excel.col.stockTakeCode",
  145. "Round / 輪次": "excel.col.round",
  146. "Section / 區域": "excel.col.area",
  147. "Book Qty / 帳面數量": "excel.col.bookQty",
  148. "Accepted Qty / 核准數量": "excel.col.approvedQty",
  149. "Variance Qty / 差異數量": "excel.col.varianceQty",
  150. "Approver / 核准人": "excel.col.approver",
  151. "Stock Taker / 初盤人": "excel.col.stockTaker",
  152. "First Count Qty / 初盤數量": "excel.col.firstCountQty",
  153. "Second Count Qty / 複盤數量": "excel.col.secondCountQty",
  154. "Approver Count Qty / 覆盤數量": "excel.col.approverCountQty",
  155. "Record Status / 紀錄狀態": "excel.col.recordStatus",
  156. "Step / 步驟": "excel.col.step",
  157. "Scrap Qty / 損耗數量": "excel.col.scrapQty",
  158. "Defect Qty / 不良數量": "excel.col.defectQty",
  159. "Operator / 操作員": "excel.col.operator",
  160. "Equipment / 設備": "excel.col.equipment",
  161. "Origin / 來源": "excel.section.origin",
  162. "Purchase / 採購": "excel.section.purchase",
  163. "Putaway / 上架": "excel.section.putaway",
  164. "Transfer / 轉倉": "excel.section.transfer",
  165. "Adjustment / 庫存調整": "excel.section.adjustment",
  166. "Opening Stock / 開倉入庫": "excel.section.openingStock",
  167. "Outbound Usage / 出庫使用": "excel.section.outboundUsage",
  168. "DO Delivery / 成品出倉": "excel.section.doDelivery",
  169. "Replenishment / 補貨": "excel.section.replenishment",
  170. Replenishment: "excel.section.replenishment",
  171. "Return / 退貨": "excel.section.return",
  172. "Pick Failure / 揀貨異常": "excel.section.pickFailure",
  173. "BOM Upstream / BOM 上游": "excel.section.bomUpstream",
  174. "BOM Downstream / BOM 下游": "excel.section.bomDownstream",
  175. "BOM Recipe / BOM 配方": "excel.section.bomRecipe",
  176. "JO Pick / 工單提料": "excel.section.joPick",
  177. "Production Step / 生產步驟": "excel.section.productionStep",
  178. "Byproduct / 副產品": "excel.section.byproduct",
  179. "Lot Relation / 拆批關聯": "excel.section.lotRelation",
  180. };
  181. const sheetTitle = (t: TFunction, key: (typeof SHEET_KEYS)[number]): string =>
  182. t(`excel.sheet.${key}`).replace(/[\\/*?:[\]]/g, "").trim().slice(0, 31);
  183. const localizeCell = (value: unknown, t: TFunction): unknown => {
  184. if (typeof value !== "string") return value;
  185. const phrase = EXCEL_PHRASE[value];
  186. return phrase ? t(phrase) : value;
  187. };
  188. const localizeRows = (
  189. rows: Record<string, unknown>[],
  190. t: TFunction,
  191. ): Record<string, unknown>[] =>
  192. rows.map((row) => {
  193. const next: Record<string, unknown> = {};
  194. Object.entries(row).forEach(([key, value]) => {
  195. const headerKey = EXCEL_PHRASE[key];
  196. next[headerKey ? t(headerKey) : key] = localizeCell(value, t);
  197. });
  198. return next;
  199. });
  200. export type ItemLotTraceExportLabels = {
  201. bomDirectionFg: string;
  202. bomDirectionMaterial: string;
  203. bomDirectionUnknown: string;
  204. };
  205. const orDash = (v: string | number | null | undefined): string | number => {
  206. if (v == null) return "—";
  207. if (typeof v === "string" && !v.trim()) return "—";
  208. return v;
  209. };
  210. const bomDirectionLabel = (
  211. direction: string,
  212. labels: ItemLotTraceExportLabels,
  213. ): string => {
  214. const d = direction.trim().toUpperCase();
  215. if (d === "FINISHED_GOOD") return labels.bomDirectionFg;
  216. if (d === "MATERIAL") return labels.bomDirectionMaterial;
  217. return labels.bomDirectionUnknown;
  218. };
  219. const ensureRows = (
  220. rows: Record<string, unknown>[],
  221. emptyTemplate: Record<string, unknown>,
  222. ): Record<string, unknown>[] => {
  223. if (rows.length > 0) return rows;
  224. const firstKey = Object.keys(emptyTemplate)[0];
  225. return [{ ...emptyTemplate, ...(firstKey ? { [firstKey]: NO_DATA } : {}) }];
  226. };
  227. const todayYmd = (): string => {
  228. const d = new Date();
  229. const y = d.getFullYear();
  230. const m = String(d.getMonth() + 1).padStart(2, "0");
  231. const day = String(d.getDate()).padStart(2, "0");
  232. return `${y}${m}${day}`;
  233. };
  234. const safeFilenamePart = (s: string): string =>
  235. s.replace(/[\\/:*?"<>|]/g, "_").trim() || "unknown";
  236. export const buildItemLotTraceFilename = (
  237. data: ItemLotTraceResponse,
  238. t: TFunction,
  239. workbook: ItemLotTraceWorkbook = "history",
  240. ): string => {
  241. const item = safeFilenamePart(data.lot.itemCode || "item");
  242. const lot = safeFilenamePart(data.lot.lotNo || "lot");
  243. const prefix = safeFilenamePart(
  244. t(workbook === "issue" ? "excel.issueFilePrefix" : "excel.filePrefix"),
  245. );
  246. return `${prefix}_${item}_${lot}_${todayYmd()}`;
  247. };
  248. const summaryEmpty = (): Record<string, unknown> => ({
  249. "Field / 欄位": "",
  250. "Value / 值": "",
  251. });
  252. const buildSummarySheet = (
  253. data: ItemLotTraceResponse,
  254. labels: ItemLotTraceExportLabels,
  255. exportAt: string,
  256. t: TFunction,
  257. ): Record<string, unknown>[] => {
  258. const tr = createTraceLabelTranslator(t);
  259. const { lot, warehouseLines, joPrelude, alternateLocations, bomTrace } = data;
  260. const totalAvailable = warehouseLines.reduce(
  261. (s, w) => s + (w.availableQty ?? 0),
  262. 0,
  263. );
  264. const rows: Record<string, unknown>[] = [
  265. { "Field / 欄位": "Lot No. / 批號", "Value / 值": orDash(lot.lotNo) },
  266. { "Field / 欄位": "Item Code / 貨品編號", "Value / 值": orDash(lot.itemCode) },
  267. { "Field / 欄位": "Item Name / 品名", "Value / 值": orDash(lot.itemName) },
  268. { "Field / 欄位": "UOM / 單位", "Value / 值": orDash(lot.uom) },
  269. { "Field / 欄位": "Expiry / 效期", "Value / 值": orDash(lot.expiryDate) },
  270. {
  271. "Field / 欄位": "Production Date / 生產日期",
  272. "Value / 值": orDash(lot.productionDate),
  273. },
  274. {
  275. "Field / 欄位": "Stock In Date / 入庫日期",
  276. "Value / 值": orDash(lot.stockInDate),
  277. },
  278. {
  279. "Field / 欄位": "Total Available / 總可用量",
  280. "Value / 值": totalAvailable,
  281. },
  282. {
  283. "Field / 欄位": "BOM Direction / BOM 方向",
  284. "Value / 值": bomDirectionLabel(bomTrace.direction, labels),
  285. },
  286. {
  287. "Field / 欄位": "Issue Excel / 問題清單",
  288. "Value / 值": t("excel.summary.issueExcelNote"),
  289. },
  290. ];
  291. if (joPrelude) {
  292. const jo = joPrelude.jobOrder;
  293. rows.push(
  294. {
  295. "Field / 欄位": "Job Order / 工單",
  296. "Value / 值": orDash(jo.jobOrderCode),
  297. },
  298. {
  299. "Field / 欄位": "Plan Start / 計劃開工",
  300. "Value / 值": orDash(jo.planStart),
  301. },
  302. { "Field / 欄位": "Planned Qty / 計劃產量", "Value / 值": jo.reqQty },
  303. {
  304. "Field / 欄位": "JO Status / 工單狀態",
  305. "Value / 值": orDash(tr.joStatus(jo.status)),
  306. },
  307. );
  308. }
  309. rows.push(
  310. { "Field / 欄位": "Export Time / 匯出時間", "Value / 值": exportAt },
  311. { "Field / 欄位": "", "Value / 值": "" },
  312. {
  313. "Field / 欄位": "— Warehouse Breakdown / 各倉庫存量 —",
  314. "Value / 值": "",
  315. },
  316. );
  317. warehouseLines.forEach((w) => {
  318. rows.push({
  319. "Field / 欄位": t("excel.summary.warehouseLine", { code: w.warehouseCode }),
  320. "Value / 值": t("excel.summary.warehouseQty", {
  321. inQty: w.inQty,
  322. outQty: w.outQty,
  323. available: w.availableQty,
  324. status: w.status ? tr.lotLineStatus(w.status) : "—",
  325. }),
  326. });
  327. });
  328. if (alternateLocations.length > 0) {
  329. rows.push(
  330. { "Field / 欄位": "", "Value / 值": "" },
  331. {
  332. "Field / 欄位": "— Alternate Locations / 其他倉位 —",
  333. "Value / 值": "",
  334. },
  335. );
  336. alternateLocations.forEach((loc) => {
  337. rows.push({
  338. "Field / 欄位": t("excel.summary.inventoryLot", { id: loc.inventoryLotId }),
  339. "Value / 值": t("excel.summary.locationQty", {
  340. code: loc.warehouseCode,
  341. qty: loc.availableQty,
  342. }),
  343. });
  344. });
  345. }
  346. return rows.length > 0 ? rows : [summaryEmpty()];
  347. };
  348. const storesEmpty = (): Record<string, unknown> => ({
  349. "Shop Code / 門市編號": "",
  350. "Shop Name / 門市名稱": "",
  351. "Phone / 電話": "",
  352. "Address / 地址": "",
  353. "District / 地區": "",
  354. "Times Received / 收貨次數": "",
  355. "Qty / 數量": "",
  356. "UOM / 單位": "",
  357. "Why / 原因": "",
  358. "Items / 收到的貨": "",
  359. "Delivery Order / 送貨單": "",
  360. "Latest / 最近一次": "",
  361. });
  362. const buildStoresSheet = (
  363. data: ItemLotTraceResponse,
  364. t: TFunction,
  365. ): Record<string, unknown>[] => {
  366. const rows = buildIssueStoreRows(data).map((row) => {
  367. const singleQty = row.qtyByUom.length === 1 ? row.qtyByUom[0] : null;
  368. return {
  369. "Shop Code / 門市編號": orDash(row.shopCode),
  370. "Shop Name / 門市名稱": orDash(shopNameWithoutCode(row.shopCode, row.shopName)),
  371. "Phone / 電話": formatShopPhone(row.contactNo),
  372. "Address / 地址": orDash(row.address),
  373. "District / 地區": orDash(row.district),
  374. "Times Received / 收貨次數": row.receiptCount,
  375. // One line per unit so "11" and "1包x2磅" stay paired instead of colliding.
  376. "Qty / 數量": singleQty
  377. ? singleQty.qty
  378. : row.qtyByUom.map((part) => part.qty).join("\n") || "—",
  379. "UOM / 單位": singleQty
  380. ? orDash(singleQty.uom)
  381. : row.qtyByUom.map((part) => part.uom || "—").join("\n") || "—",
  382. "Why / 原因": issueStoreReason(row, t),
  383. "Items / 收到的貨": issueStoreItemsLabel(row),
  384. "Delivery Order / 送貨單": orDash(row.deliveryOrderCodes.join("\n")),
  385. "Latest / 最近一次": orDash(row.latestTimestamp),
  386. };
  387. });
  388. return ensureRows(rows, storesEmpty());
  389. };
  390. const whereUsedEmpty = (): Record<string, unknown> => ({
  391. "Level / 層級": "",
  392. "Consumed Item Code / 被用貨品編號": "",
  393. "Consumed Item / 被用品名": "",
  394. "Consumed Lot / 被用批號": "",
  395. "Qty Used / 用量": "",
  396. "Job Order / 工單": "",
  397. "Produced Item Code / 製成品編號": "",
  398. "Produced Item / 製成品名": "",
  399. "Produced Lot / 製成批號": "",
  400. "Produced Qty / 製成數量": "",
  401. "UOM / 單位": "",
  402. });
  403. const buildWhereUsedSheet = (
  404. data: ItemLotTraceResponse,
  405. ): Record<string, unknown>[] => {
  406. const rows = buildIssueWhereUsedRows(data).map((row) => ({
  407. "Level / 層級": row.depth,
  408. "Consumed Item Code / 被用貨品編號": orDash(row.consumedItemCode),
  409. "Consumed Item / 被用品名": orDash(row.consumedItemName),
  410. "Consumed Lot / 被用批號": orDash(row.consumedLotNo),
  411. "Qty Used / 用量": row.materialQtyUsed,
  412. "Job Order / 工單": orDash(row.jobOrderCode),
  413. "Produced Item Code / 製成品編號": orDash(row.producedItemCode),
  414. "Produced Item / 製成品名": orDash(row.producedItemName),
  415. "Produced Lot / 製成批號": orDash(row.producedLotNo),
  416. "Produced Qty / 製成數量": row.producedQty,
  417. "UOM / 單位": orDash(row.producedUom),
  418. }));
  419. return ensureRows(rows, whereUsedEmpty());
  420. };
  421. const ingredientsEmpty = (): Record<string, unknown> => ({
  422. "Kind / 類型": "",
  423. "Item Code / 貨品編號": "",
  424. "Item Name / 品名": "",
  425. "Lot / 批號": "",
  426. "Qty / 數量": "",
  427. "UOM / 單位": "",
  428. "Qty Per Unit / 每單位用量": "",
  429. "Job Order / 工單": "",
  430. "Pick Order / 提料單": "",
  431. "Assigned Step / 對應製程": "",
  432. "Picked At / 提料時間": "",
  433. });
  434. const buildIngredientsSheet = (
  435. data: ItemLotTraceResponse,
  436. t: TFunction,
  437. ): Record<string, unknown>[] => {
  438. const rows = buildIssueIngredientRows(data).map((row) => ({
  439. "Kind / 類型":
  440. row.kind === "RECIPE" ? t("issueIngredientRecipe") : t("issueIngredientActual"),
  441. "Item Code / 貨品編號": orDash(row.itemCode),
  442. "Item Name / 品名": orDash(row.itemName),
  443. "Lot / 批號": orDash(row.lotNo),
  444. "Qty / 數量": row.qty ?? "",
  445. "UOM / 單位": orDash(row.uom),
  446. "Qty Per Unit / 每單位用量": row.qtyPerUnit ?? "",
  447. "Job Order / 工單": orDash(row.jobOrderCode),
  448. "Pick Order / 提料單": orDash(row.pickOrderCode),
  449. "Assigned Step / 對應製程": orDash(row.assignedStepName),
  450. "Picked At / 提料時間": orDash(row.pickedAt),
  451. }));
  452. return ensureRows(rows, ingredientsEmpty());
  453. };
  454. const timelineEmpty = (): Record<string, unknown> => ({
  455. "Date/Time / 時間": "",
  456. "Phase / 階段": "",
  457. "Event Type / 事件類型": "",
  458. "Doc No. / 單號": "",
  459. "Ref Type / 來源類型": "",
  460. "Ref Type Code": "",
  461. "Qty / 數量": "",
  462. "UOM / 單位": "",
  463. "Warehouse / 倉位": "",
  464. "Handler / 經手人": "",
  465. "Lot / 批號": "",
  466. "Remarks / 備註": "",
  467. });
  468. const buildTimelineSheet = (
  469. compiledGraph: CompiledTraceGraph,
  470. t: TFunction,
  471. tr: TraceLabelTranslator,
  472. ): Record<string, unknown>[] => {
  473. const nodes = compiledGraph.layout.nodes
  474. .filter(
  475. (n) =>
  476. n.kind !== "DO_GROUP" && n.kind !== "PICK_GROUP" && !isDoGroupChild(n),
  477. )
  478. .slice()
  479. .sort((a, b) => {
  480. const ta = a.timestamp ?? "";
  481. const tb = b.timestamp ?? "";
  482. if (ta !== tb) return ta.localeCompare(tb);
  483. return a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex;
  484. });
  485. const rows = nodes.map((n) => {
  486. const handlerDetail = n.details.find(
  487. (d) =>
  488. d.label.toLowerCase().includes("handler") ||
  489. d.label.includes("經手") ||
  490. d.label.includes("核准") ||
  491. d.label.includes("盤點"),
  492. );
  493. return {
  494. "Date/Time / 時間": orDash(n.timestamp),
  495. "Phase / 階段": t(phaseLabelKey(n.phase)),
  496. "Event Type / 事件類型": t(kindLabelKey(n.kind)),
  497. "Doc No. / 單號": orDash(n.refCode),
  498. "Ref Type / 來源類型": tr.refType(n.refType),
  499. "Ref Type Code": orDash(n.refType),
  500. "Qty / 數量": n.qty ?? "",
  501. "UOM / 單位": orDash(n.uom),
  502. "Warehouse / 倉位": orDash(n.warehouseCode),
  503. "Handler / 經手人": orDash(
  504. handlerDetail?.value ?? n.meta?.split(" · ")[0],
  505. ),
  506. "Lot / 批號": orDash(n.traceLotNo),
  507. "Remarks / 備註": orDash(n.subtitle),
  508. };
  509. });
  510. return ensureRows(rows, timelineEmpty());
  511. };
  512. const originsEmpty = (): Record<string, unknown> => ({
  513. "Section / 區塊": "",
  514. "Warehouse / 倉位": "",
  515. "Inventory Lot Id": "",
  516. "Type / 類型": "",
  517. "Type Code": "",
  518. "Doc No. / 單號": "",
  519. "Supplier Code / 供應商編號": "",
  520. "Supplier / 供應商": "",
  521. "DN No. / 送貨單號": "",
  522. "Qty / 數量": "",
  523. "Status / 狀態": "",
  524. "Date / 日期": "",
  525. });
  526. const buildOriginsSheet = (
  527. data: ItemLotTraceResponse,
  528. tr: TraceLabelTranslator,
  529. ): Record<string, unknown>[] => {
  530. const originRows = mergeScopedOrigins(data).map((o) => ({
  531. "Section / 區塊": "Origin / 來源",
  532. "Warehouse / 倉位": o.scopeWarehouseCode,
  533. "Inventory Lot Id": o.inventoryLotId,
  534. "Type / 類型": tr.refType(o.type),
  535. "Type Code": orDash(o.type),
  536. "Doc No. / 單號": orDash(o.refCode),
  537. "Supplier Code / 供應商編號": orDash(o.supplierCode),
  538. "Supplier / 供應商": orDash(o.supplierName),
  539. "DN No. / 送貨單號": orDash(o.dnNo),
  540. "Qty / 數量": o.acceptedQty,
  541. "Status / 狀態": tr.stockInStatus(o.status),
  542. "Date / 日期": orDash(o.receiptDate),
  543. }));
  544. const purchaseRows = mergeScopedPurchaseEvents(data).map((p) => ({
  545. "Section / 區塊": "Purchase / 採購",
  546. "Warehouse / 倉位": p.scopeWarehouseCode,
  547. "Inventory Lot Id": p.inventoryLotId,
  548. "Type / 類型": tr.refType("PO"),
  549. "Type Code": "PO",
  550. "Doc No. / 單號": orDash(p.purchaseOrderCode),
  551. "Supplier Code / 供應商編號": orDash(p.supplierCode),
  552. "Supplier / 供應商": orDash(p.supplierName),
  553. "DN No. / 送貨單號": "",
  554. "Qty / 數量": p.orderQty,
  555. "Status / 狀態": orDash(p.status),
  556. "Date / 日期": orDash(p.orderDate),
  557. }));
  558. return ensureRows([...originRows, ...purchaseRows], originsEmpty());
  559. };
  560. const qcEmpty = (): Record<string, unknown> => ({
  561. "Warehouse / 倉位": "",
  562. "Inventory Lot Id": "",
  563. "QC Result / 結果": "",
  564. "QC Type / 品檢類型": "",
  565. "QC Type Code": "",
  566. "QC Item Code / 品檢項編號": "",
  567. "QC Item / 品檢項": "",
  568. "Accepted Qty / 抽樣數量": "",
  569. "Fail Qty / 不良數量": "",
  570. "Remarks / 備註": "",
  571. "Handler / 經手人": "",
  572. "Date/Time / 時間": "",
  573. "Stock In Line Id": "",
  574. });
  575. const buildQcSheet = (
  576. data: ItemLotTraceResponse,
  577. tr: TraceLabelTranslator,
  578. ): Record<string, unknown>[] => {
  579. const rows = mergeScopedQcResults(data).map((q) => ({
  580. "Warehouse / 倉位": q.scopeWarehouseCode,
  581. "Inventory Lot Id": q.inventoryLotId,
  582. "QC Result / 結果": tr.qcPassed(q.qcPassed),
  583. "QC Type / 品檢類型": tr.qcType(q.qcType),
  584. "QC Type Code": orDash(q.qcType),
  585. "QC Item Code / 品檢項編號": orDash(q.qcItemCode),
  586. "QC Item / 品檢項": orDash(q.qcItemName || q.qcItemDescription),
  587. "Accepted Qty / 抽樣數量": q.acceptedQty,
  588. "Fail Qty / 不良數量": q.failQty,
  589. "Remarks / 備註": orDash(q.remarks),
  590. "Handler / 經手人": orDash(q.handledBy),
  591. "Date/Time / 時間": orDash(q.created),
  592. "Stock In Line Id": q.stockInLineId,
  593. }));
  594. return ensureRows(rows, qcEmpty());
  595. };
  596. const warehouseEmpty = (): Record<string, unknown> => ({
  597. "Section / 區塊": "",
  598. "Warehouse / 倉位": "",
  599. "Inventory Lot Id": "",
  600. "Direction / 方向": "",
  601. "Type / 類型": "",
  602. "Type Code": "",
  603. "Doc No. / 單號": "",
  604. "From / 來源倉": "",
  605. "To / 目標倉": "",
  606. "Qty / 數量": "",
  607. "Handler / 經手人": "",
  608. "Status / 狀態": "",
  609. "Remarks / 備註": "",
  610. "Date/Time / 時間": "",
  611. });
  612. const buildWarehouseSheet = (
  613. data: ItemLotTraceResponse,
  614. tr: TraceLabelTranslator,
  615. ): Record<string, unknown>[] => {
  616. const putaway = mergeScopedPutawayEvents(data).map((p) => ({
  617. "Section / 區塊": "Putaway / 上架",
  618. "Warehouse / 倉位": p.scopeWarehouseCode,
  619. "Inventory Lot Id": p.inventoryLotId,
  620. "Direction / 方向": "",
  621. "Type / 類型": tr.refType(p.refType),
  622. "Type Code": orDash(p.refType),
  623. "Doc No. / 單號": orDash(p.refCode),
  624. "From / 來源倉": "",
  625. "To / 目標倉": orDash(p.warehouseCode),
  626. "Qty / 數量": p.qty,
  627. "Handler / 經手人": orDash(p.handledBy),
  628. "Status / 狀態": orDash(p.status),
  629. "Remarks / 備註": "",
  630. "Date/Time / 時間": orDash(p.timestamp),
  631. }));
  632. const transfers = mergeScopedTransfers(data).map((trRow) => ({
  633. "Section / 區塊": "Transfer / 轉倉",
  634. "Warehouse / 倉位": trRow.scopeWarehouseCode,
  635. "Inventory Lot Id": trRow.inventoryLotId,
  636. "Direction / 方向": "",
  637. "Type / 類型": tr.refType("TRANSFER"),
  638. "Type Code": "TRANSFER",
  639. "Doc No. / 單號": orDash(trRow.transferCode),
  640. "From / 來源倉": orDash(trRow.fromWarehouse),
  641. "To / 目標倉": orDash(trRow.toWarehouse),
  642. "Qty / 數量": trRow.qty,
  643. "Handler / 經手人": "",
  644. "Status / 狀態": "",
  645. "Remarks / 備註": "",
  646. "Date/Time / 時間": orDash(trRow.timestamp),
  647. }));
  648. const adjustments = mergeScopedAdjustments(data).map((a) => ({
  649. "Section / 區塊": "Adjustment / 庫存調整",
  650. "Warehouse / 倉位": a.scopeWarehouseCode,
  651. "Inventory Lot Id": a.inventoryLotId,
  652. "Direction / 方向": tr.direction(a.direction),
  653. "Type / 類型": tr.adjustmentType(a.adjustmentType),
  654. "Type Code": orDash(a.adjustmentType),
  655. "Doc No. / 單號": orDash(a.refCode),
  656. "From / 來源倉": "",
  657. "To / 目標倉": orDash(a.warehouseCode),
  658. "Qty / 數量": a.qty,
  659. "Handler / 經手人": orDash(a.handledBy),
  660. "Status / 狀態": "",
  661. "Remarks / 備註": orDash(a.reason),
  662. "Date/Time / 時間": orDash(a.timestamp),
  663. }));
  664. const openMoves = mergeScopedOpenMovements(data).map((o) => ({
  665. "Section / 區塊": "Opening Stock / 開倉入庫",
  666. "Warehouse / 倉位": o.scopeWarehouseCode,
  667. "Inventory Lot Id": o.inventoryLotId,
  668. "Direction / 方向": tr.direction("IN"),
  669. "Type / 類型": tr.refType("OPEN"),
  670. "Type Code": "OPEN",
  671. "Doc No. / 單號": orDash(o.refCode),
  672. "From / 來源倉": "",
  673. "To / 目標倉": orDash(o.warehouseCode),
  674. "Qty / 數量": o.qty,
  675. "Handler / 經手人": orDash(o.handledBy),
  676. "Status / 狀態": "",
  677. "Remarks / 備註": orDash(o.remarks),
  678. "Date/Time / 時間": orDash(o.timestamp),
  679. }));
  680. return ensureRows(
  681. [...putaway, ...transfers, ...adjustments, ...openMoves],
  682. warehouseEmpty(),
  683. );
  684. };
  685. const outboundEmpty = (): Record<string, unknown> => ({
  686. "Section / 區塊": "",
  687. "Warehouse / 倉位": "",
  688. "Inventory Lot Id": "",
  689. "Usage Type / 類型": "",
  690. "Usage Type Code": "",
  691. "Doc No. / 單號": "",
  692. "Pick Order / 提料單": "",
  693. "Conso / 併單": "",
  694. "Job Order / 工單": "",
  695. "Delivery Order / 送貨單": "",
  696. "Delivery Note / DN": "",
  697. "Ticket / 票號": "",
  698. "Shop / 門市": "",
  699. "Qty / 數量": "",
  700. "Handler / 經手人": "",
  701. "Extra / 加單": "",
  702. "Replenish / 補貨": "",
  703. "Remarks / 備註": "",
  704. "Date/Time / 時間": "",
  705. });
  706. const buildOutboundSheet = (
  707. data: ItemLotTraceResponse,
  708. tr: TraceLabelTranslator,
  709. t: TFunction,
  710. ): Record<string, unknown>[] => {
  711. const usage = mergeScopedOutboundUsage(data).map((u) => ({
  712. "Section / 區塊": "Outbound Usage / 出庫使用",
  713. "Warehouse / 倉位": u.scopeWarehouseCode,
  714. "Inventory Lot Id": u.inventoryLotId,
  715. "Usage Type / 類型": tr.usageType(u.usageType),
  716. "Usage Type Code": orDash(u.usageType),
  717. "Doc No. / 單號": orDash(u.deliveryOrderCode || u.pickOrderCode),
  718. "Pick Order / 提料單": orDash(u.pickOrderCode),
  719. "Conso / 併單": orDash(u.consoCode),
  720. "Job Order / 工單": orDash(u.jobOrderCode),
  721. "Delivery Order / 送貨單": orDash(u.deliveryOrderCode),
  722. "Delivery Note / DN": "",
  723. "Ticket / 票號": "",
  724. "Shop / 門市": "",
  725. "Qty / 數量": u.qty,
  726. "Handler / 經手人": orDash(u.handler),
  727. "Extra / 加單": "",
  728. "Replenish / 補貨": "",
  729. "Remarks / 備註": "",
  730. "Date/Time / 時間": orDash(u.timestamp),
  731. }));
  732. const doRows = mergeScopedDoDeliveries(data).map((d) => ({
  733. "Section / 區塊": "DO Delivery / 成品出倉",
  734. "Warehouse / 倉位": d.scopeWarehouseCode,
  735. "Inventory Lot Id": d.inventoryLotId,
  736. "Usage Type / 類型": tr.usageType("FG_DELIVERY"),
  737. "Usage Type Code": "FG_DELIVERY",
  738. "Doc No. / 單號": orDash(d.deliveryOrderCode),
  739. "Pick Order / 提料單": orDash(d.pickOrderCode),
  740. "Conso / 併單": orDash(d.consoCode),
  741. "Job Order / 工單": "",
  742. "Delivery Order / 送貨單": orDash(d.deliveryOrderCode),
  743. "Delivery Note / DN": orDash(d.deliveryNoteCode),
  744. "Ticket / 票號": orDash(d.ticketNo),
  745. "Shop / 門市": [d.shopCode, d.shopName].filter(Boolean).join(" · ") || "—",
  746. "Qty / 數量": d.qty,
  747. "Handler / 經手人": orDash(d.handler),
  748. "Extra / 加單": d.isExtra ? t("excel.yes") : "",
  749. "Replenish / 補貨": d.isReplenish ? t("excel.yes") : "",
  750. "Remarks / 備註": "",
  751. "Date/Time / 時間": orDash(d.timestamp),
  752. }));
  753. const replenish = mergeScopedReplenishmentEvents(data).map((r) => ({
  754. "Section / 區塊": "Replenishment / 補貨",
  755. "Warehouse / 倉位": r.scopeWarehouseCode,
  756. "Inventory Lot Id": r.inventoryLotId,
  757. "Usage Type / 類型": "Replenishment",
  758. "Usage Type Code": "REPLENISH",
  759. "Doc No. / 單號": orDash(r.replenishmentCode),
  760. "Pick Order / 提料單": "",
  761. "Conso / 併單": "",
  762. "Job Order / 工單": "",
  763. "Delivery Order / 送貨單": orDash(r.sourceDoCode),
  764. "Delivery Note / DN": "",
  765. "Ticket / 票號": "",
  766. "Shop / 門市": [r.shopCode, r.shopName].filter(Boolean).join(" · ") || "—",
  767. "Qty / 數量": r.replenishQty,
  768. "Handler / 經手人": orDash(r.handler),
  769. "Extra / 加單": "",
  770. "Replenish / 補貨": t("excel.yes"),
  771. "Remarks / 備註": orDash(r.reason),
  772. "Date/Time / 時間": orDash(r.timestamp),
  773. }));
  774. const returns = mergeScopedReturnEvents(data).map((r) => ({
  775. "Section / 區塊": "Return / 退貨",
  776. "Warehouse / 倉位": r.scopeWarehouseCode,
  777. "Inventory Lot Id": r.inventoryLotId,
  778. "Usage Type / 類型": tr.movementType(r.movementType),
  779. "Usage Type Code": orDash(r.movementType),
  780. "Doc No. / 單號": orDash(r.refCode),
  781. "Pick Order / 提料單": "",
  782. "Conso / 併單": "",
  783. "Job Order / 工單": "",
  784. "Delivery Order / 送貨單": "",
  785. "Delivery Note / DN": "",
  786. "Ticket / 票號": "",
  787. "Shop / 門市": "",
  788. "Qty / 數量": r.qty,
  789. "Handler / 經手人": orDash(r.handler),
  790. "Extra / 加單": "",
  791. "Replenish / 補貨": "",
  792. "Remarks / 備註": orDash(r.remarks),
  793. "Date/Time / 時間": orDash(r.timestamp),
  794. }));
  795. const fails = mergeScopedFailEvents(data).map((f) => ({
  796. "Section / 區塊": "Pick Failure / 揀貨異常",
  797. "Warehouse / 倉位": f.scopeWarehouseCode,
  798. "Inventory Lot Id": f.inventoryLotId,
  799. "Usage Type / 類型": tr.failType(f.failType),
  800. "Usage Type Code": orDash(f.failType),
  801. "Doc No. / 單號": orDash(f.pickOrderCode),
  802. "Pick Order / 提料單": orDash(f.pickOrderCode),
  803. "Conso / 併單": "",
  804. "Job Order / 工單": "",
  805. "Delivery Order / 送貨單": "",
  806. "Delivery Note / DN": "",
  807. "Ticket / 票號": "",
  808. "Shop / 門市": "",
  809. "Qty / 數量": f.qty,
  810. "Handler / 經手人": orDash(f.handlerName),
  811. "Extra / 加單": "",
  812. "Replenish / 補貨": "",
  813. "Remarks / 備註": orDash(f.category),
  814. "Date/Time / 時間": orDash(f.recordDate),
  815. }));
  816. return ensureRows(
  817. [...usage, ...doRows, ...replenish, ...returns, ...fails],
  818. outboundEmpty(),
  819. );
  820. };
  821. const stockTakeEmpty = (): Record<string, unknown> => ({
  822. "Warehouse / 倉位": "",
  823. "Inventory Lot Id": "",
  824. "Stock Take Code / 盤點單號": "",
  825. "Round / 輪次": "",
  826. "Section / 區域": "",
  827. "Lot / 批號": "",
  828. "Book Qty / 帳面數量": "",
  829. "Accepted Qty / 核准數量": "",
  830. "Variance Qty / 差異數量": "",
  831. "Approver / 核准人": "",
  832. "Stock Taker / 初盤人": "",
  833. "First Count Qty / 初盤數量": "",
  834. "Second Count Qty / 複盤數量": "",
  835. "Approver Count Qty / 覆盤數量": "",
  836. "Record Status / 紀錄狀態": "",
  837. "Date/Time / 時間": "",
  838. });
  839. const buildStockTakeSheet = (
  840. data: ItemLotTraceResponse,
  841. ): Record<string, unknown>[] => {
  842. const rows = mergeScopedStockTakeEvents(data).map((e) => {
  843. const d = e.recordDetail;
  844. return {
  845. "Warehouse / 倉位": e.scopeWarehouseCode,
  846. "Inventory Lot Id": e.inventoryLotId,
  847. "Stock Take Code / 盤點單號": orDash(e.stockTakeCode),
  848. "Round / 輪次": orDash(
  849. e.stockTakeRoundName ??
  850. (e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : ""),
  851. ),
  852. "Section / 區域": orDash(e.stockTakeSection),
  853. "Lot / 批號": orDash(e.lotNo),
  854. "Book Qty / 帳面數量": resolveStockTakeBookQty(d, e.beforeQty) ?? "",
  855. "Accepted Qty / 核准數量":
  856. resolveStockTakeAcceptedQty(d, e.afterQty) ?? "",
  857. "Variance Qty / 差異數量":
  858. d?.varianceQty != null ? d.varianceQty : e.varianceQty,
  859. "Approver / 核准人": orDash(d?.approverName || e.approver),
  860. "Stock Taker / 初盤人": orDash(d?.stockTakerName),
  861. "First Count Qty / 初盤數量": d?.pickerFirstQty ?? "",
  862. "Second Count Qty / 複盤數量": d?.pickerSecondQty ?? "",
  863. "Approver Count Qty / 覆盤數量": d?.approverQty ?? "",
  864. "Record Status / 紀錄狀態": orDash(d?.recordStatus),
  865. "Date/Time / 時間": orDash(e.timestamp),
  866. };
  867. });
  868. return ensureRows(rows, stockTakeEmpty());
  869. };
  870. const productionEmpty = (): Record<string, unknown> => ({
  871. "Section / 區塊": "",
  872. "Job Order / 工單": "",
  873. "Doc No. / 單號": "",
  874. "Item Code / 貨品編號": "",
  875. "Lot / 批號": "",
  876. "Item Name / 品名": "",
  877. "Step / 步驟": "",
  878. "Assigned Step / 對應製程": "",
  879. "Qty / 數量": "",
  880. "Scrap Qty / 損耗數量": "",
  881. "Defect Qty / 不良數量": "",
  882. "UOM / 單位": "",
  883. "Operator / 操作員": "",
  884. "Equipment / 設備": "",
  885. "Status / 狀態": "",
  886. "Date/Time / 時間": "",
  887. });
  888. const buildProductionSheet = (
  889. data: ItemLotTraceResponse,
  890. compiledGraph: CompiledTraceGraph,
  891. tr: TraceLabelTranslator,
  892. ): Record<string, unknown>[] => {
  893. const rows: Record<string, unknown>[] = [];
  894. const presentation = buildTracePresentationRows(compiledGraph.nodes, data);
  895. const upstreamSource = data.joPrelude?.materialInputs?.length
  896. ? data.joPrelude.materialInputs.map((m) => ({
  897. jobOrderCode: m.jobOrderCode,
  898. materialItemCode: m.materialItemCode,
  899. materialLotNo: m.materialLotNo,
  900. materialQty: m.materialQty,
  901. materialUom: m.materialUom,
  902. assignedStepName: m.assignedStepName,
  903. pickOrderCode: m.pickOrderCode,
  904. pickedAt: m.pickedAt,
  905. materialItemName: m.materialItemName,
  906. }))
  907. : data.bomTrace.upstream.map((u) => ({
  908. jobOrderCode: u.jobOrderCode,
  909. materialItemCode: u.materialItemCode,
  910. materialLotNo: u.materialLotNo,
  911. materialQty: u.materialQty,
  912. materialUom: "",
  913. assignedStepName: "",
  914. pickOrderCode: "",
  915. pickedAt: null as string | null,
  916. materialItemName: "",
  917. }));
  918. upstreamSource.forEach((m) => {
  919. rows.push({
  920. "Section / 區塊": "BOM Upstream / BOM 上游",
  921. "Job Order / 工單": orDash(m.jobOrderCode),
  922. "Doc No. / 單號": orDash(m.pickOrderCode),
  923. "Item Code / 貨品編號": orDash(m.materialItemCode),
  924. "Lot / 批號": orDash(m.materialLotNo),
  925. "Item Name / 品名": orDash(m.materialItemName),
  926. "Step / 步驟": "",
  927. "Assigned Step / 對應製程": orDash(m.assignedStepName),
  928. "Qty / 數量": m.materialQty,
  929. "Scrap Qty / 損耗數量": "",
  930. "Defect Qty / 不良數量": "",
  931. "UOM / 單位": orDash(m.materialUom),
  932. "Operator / 操作員": "",
  933. "Equipment / 設備": "",
  934. "Status / 狀態": "",
  935. "Date/Time / 時間": orDash(m.pickedAt),
  936. });
  937. });
  938. data.bomTrace.downstream.forEach((d) => {
  939. rows.push({
  940. "Section / 區塊": "BOM Downstream / BOM 下游",
  941. "Job Order / 工單": orDash(d.jobOrderCode),
  942. "Doc No. / 單號": "",
  943. "Item Code / 貨品編號": orDash(d.finishedItemCode),
  944. "Lot / 批號": orDash(d.finishedLotNo),
  945. "Item Name / 品名": "",
  946. "Step / 步驟": "",
  947. "Assigned Step / 對應製程": "",
  948. "Qty / 數量": d.fgQty,
  949. "Scrap Qty / 損耗數量": "",
  950. "Defect Qty / 不良數量": "",
  951. "UOM / 單位": orDash(d.fgUom),
  952. "Operator / 操作員": "",
  953. "Equipment / 設備": "",
  954. "Status / 狀態": "",
  955. "Date/Time / 時間": "",
  956. });
  957. });
  958. data.bomTrace.bomRecipe.forEach((r) => {
  959. rows.push({
  960. "Section / 區塊": "BOM Recipe / BOM 配方",
  961. "Job Order / 工單": "",
  962. "Doc No. / 單號": "",
  963. "Item Code / 貨品編號": orDash(r.materialItemCode),
  964. "Lot / 批號": "",
  965. "Item Name / 品名": orDash(r.materialItemName),
  966. "Step / 步驟": "",
  967. "Assigned Step / 對應製程": orDash(r.assignedStepName),
  968. "Qty / 數量": r.qtyPerUnit,
  969. "Scrap Qty / 損耗數量": "",
  970. "Defect Qty / 不良數量": "",
  971. "UOM / 單位": orDash(r.uom),
  972. "Operator / 操作員": "",
  973. "Equipment / 設備": "",
  974. "Status / 狀態": "",
  975. "Date/Time / 時間": "",
  976. });
  977. });
  978. presentation.joPicks
  979. .slice()
  980. .sort((a, b) => (b.pickedAt ?? "").localeCompare(a.pickedAt ?? ""))
  981. .forEach((p) => {
  982. rows.push({
  983. "Section / 區塊": "JO Pick / 工單提料",
  984. "Job Order / 工單": "",
  985. "Doc No. / 單號": orDash(p.pickOrderCode),
  986. "Item Code / 貨品編號": orDash(p.materialItemCode),
  987. "Lot / 批號": orDash(p.materialLotNo),
  988. "Item Name / 品名": "",
  989. "Step / 步驟": "",
  990. "Assigned Step / 對應製程": orDash(p.assignedStepName),
  991. "Qty / 數量": p.qty,
  992. "Scrap Qty / 損耗數量": "",
  993. "Defect Qty / 不良數量": "",
  994. "UOM / 單位": orDash(p.uom),
  995. "Operator / 操作員": "",
  996. "Equipment / 設備": "",
  997. "Status / 狀態": "",
  998. "Date/Time / 時間": orDash(p.pickedAt),
  999. });
  1000. });
  1001. const productionRows =
  1002. data.productionSteps.length > 0
  1003. ? data.productionSteps
  1004. .slice()
  1005. .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0))
  1006. .map((s) => ({
  1007. "Section / 區塊": "Production Step / 生產步驟",
  1008. "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode),
  1009. "Doc No. / 單號": "",
  1010. "Item Code / 貨品編號": orDash(data.lot.itemCode),
  1011. "Lot / 批號": orDash(data.lot.lotNo),
  1012. "Item Name / 品名": orDash(data.lot.itemName),
  1013. "Step / 步驟": orDash(s.stepName),
  1014. "Assigned Step / 對應製程": orDash(s.description),
  1015. "Qty / 數量": s.outputQty,
  1016. "Scrap Qty / 損耗數量": s.scrapQty,
  1017. "Defect Qty / 不良數量": s.defectQty,
  1018. "UOM / 單位": orDash(data.lot.uom),
  1019. "Operator / 操作員": orDash(s.operatorName),
  1020. "Equipment / 設備":
  1021. [s.equipmentCode, s.equipmentName].filter(Boolean).join(" · ") ||
  1022. "—",
  1023. "Status / 狀態": tr.productionStatus(s.status),
  1024. "Date/Time / 時間": orDash(s.endTime || s.startTime),
  1025. }))
  1026. : presentation.production
  1027. .slice()
  1028. .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0))
  1029. .map((s) => ({
  1030. "Section / 區塊": "Production Step / 生產步驟",
  1031. "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode),
  1032. "Doc No. / 單號": "",
  1033. "Item Code / 貨品編號": orDash(data.lot.itemCode),
  1034. "Lot / 批號": orDash(s.traceLotNo || data.lot.lotNo),
  1035. "Item Name / 品名": orDash(data.lot.itemName),
  1036. "Step / 步驟": orDash(s.stepName),
  1037. "Assigned Step / 對應製程": orDash(s.description),
  1038. "Qty / 數量": s.outputQty,
  1039. "Scrap Qty / 損耗數量": s.scrapQty,
  1040. "Defect Qty / 不良數量": s.defectQty,
  1041. "UOM / 單位": orDash(data.lot.uom),
  1042. "Operator / 操作員": orDash(s.operatorName),
  1043. "Equipment / 設備": orDash(s.equipmentName || s.equipmentCode),
  1044. "Status / 狀態": orDash(s.status),
  1045. "Date/Time / 時間": orDash(s.endTime || s.startTime),
  1046. }));
  1047. rows.push(...productionRows);
  1048. data.byproductLots.forEach((b) => {
  1049. rows.push({
  1050. "Section / 區塊": "Byproduct / 副產品",
  1051. "Job Order / 工單": orDash(b.jobOrderCode),
  1052. "Doc No. / 單號": "",
  1053. "Item Code / 貨品編號": orDash(b.itemCode),
  1054. "Lot / 批號": orDash(b.lotNo),
  1055. "Item Name / 品名": orDash(b.itemName),
  1056. "Step / 步驟": orDash(b.processStepName),
  1057. "Assigned Step / 對應製程": "",
  1058. "Qty / 數量": b.qty,
  1059. "Scrap Qty / 損耗數量": "",
  1060. "Defect Qty / 不良數量": "",
  1061. "UOM / 單位": orDash(b.uom),
  1062. "Operator / 操作員": "",
  1063. "Equipment / 設備": "",
  1064. "Status / 狀態": "",
  1065. "Date/Time / 時間": orDash(b.producedAt),
  1066. });
  1067. });
  1068. data.lotRelations.forEach((r) => {
  1069. rows.push({
  1070. "Section / 區塊": "Lot Relation / 拆批關聯",
  1071. "Job Order / 工單": "",
  1072. "Doc No. / 單號": orDash(r.relationType),
  1073. "Item Code / 貨品編號": orDash(r.itemCode),
  1074. "Lot / 批號": orDash(r.lotNo),
  1075. "Item Name / 品名": orDash(r.itemName),
  1076. "Step / 步驟": "",
  1077. "Assigned Step / 對應製程": "",
  1078. "Qty / 數量": r.qty,
  1079. "Scrap Qty / 損耗數量": "",
  1080. "Defect Qty / 不良數量": "",
  1081. "UOM / 單位": "",
  1082. "Operator / 操作員": "",
  1083. "Equipment / 設備": "",
  1084. "Status / 狀態": orDash(r.productLotNo),
  1085. "Date/Time / 時間": orDash(r.timestamp),
  1086. });
  1087. });
  1088. return ensureRows(rows, productionEmpty());
  1089. };
  1090. /** Build one workbook without triggering download (for tests). */
  1091. export const buildItemLotTraceSheets = (
  1092. data: ItemLotTraceResponse,
  1093. compiledGraph: CompiledTraceGraph,
  1094. t: TFunction,
  1095. exportLabels: ItemLotTraceExportLabels,
  1096. workbook: ItemLotTraceWorkbook = "history",
  1097. exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19),
  1098. ): MultiSheetSpec[] => {
  1099. const tr = createTraceLabelTranslator(t);
  1100. const built: Record<(typeof SHEET_KEYS)[number], Record<string, unknown>[]> = {
  1101. summary: buildSummarySheet(data, exportLabels, exportAt, t),
  1102. stores: buildStoresSheet(data, t),
  1103. whereUsed: buildWhereUsedSheet(data),
  1104. ingredients: buildIngredientsSheet(data, t),
  1105. timeline: buildTimelineSheet(compiledGraph, t, tr),
  1106. origins: buildOriginsSheet(data, tr),
  1107. qc: buildQcSheet(data, tr),
  1108. warehouse: buildWarehouseSheet(data, tr),
  1109. outbound: buildOutboundSheet(data, tr, t),
  1110. stockTake: buildStockTakeSheet(data),
  1111. production: buildProductionSheet(data, compiledGraph, tr),
  1112. };
  1113. const keys = workbook === "issue" ? ISSUE_SHEET_KEYS : HISTORY_SHEET_KEYS;
  1114. return keys.map((key) => ({
  1115. name: sheetTitle(t, key),
  1116. rows: localizeRows(built[key], t),
  1117. }));
  1118. };
  1119. export const itemLotTraceSheetNames = (
  1120. t: TFunction,
  1121. workbook: ItemLotTraceWorkbook = "history",
  1122. ): string[] =>
  1123. (workbook === "issue" ? ISSUE_SHEET_KEYS : HISTORY_SHEET_KEYS).map((key) =>
  1124. sheetTitle(t, key),
  1125. );
  1126. /** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */
  1127. export const exportItemLotTraceXlsx = (
  1128. data: ItemLotTraceResponse,
  1129. compiledGraph: CompiledTraceGraph,
  1130. t: TFunction,
  1131. workbook: ItemLotTraceWorkbook = "history",
  1132. ): void => {
  1133. const exportLabels: ItemLotTraceExportLabels = {
  1134. bomDirectionFg: t("bomDirectionFG"),
  1135. bomDirectionMaterial: t("bomDirectionMaterial"),
  1136. bomDirectionUnknown: t("bomDirectionUnknown"),
  1137. };
  1138. const sheets = buildItemLotTraceSheets(
  1139. data,
  1140. compiledGraph,
  1141. t,
  1142. exportLabels,
  1143. workbook,
  1144. );
  1145. exportMultiSheetToXlsx(
  1146. sheets,
  1147. buildItemLotTraceFilename(data, t, workbook),
  1148. );
  1149. };