FPSMS-frontend
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 

906 řádky
30 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. const NO_DATA = "(無資料 / No records)";
  35. const SHEET = {
  36. summary: "摘要 Summary",
  37. timeline: "生命週期時間軸 Timeline",
  38. origins: "來源與採購 Origins",
  39. qc: "品質檢驗 QC",
  40. warehouse: "倉儲作業 Warehouse",
  41. outbound: "出庫 Outbound",
  42. stockTake: "盤點 Stock Take",
  43. production: "生產與BOM Production",
  44. } as const;
  45. export type ItemLotTraceExportLabels = {
  46. bomDirectionFg: string;
  47. bomDirectionMaterial: string;
  48. bomDirectionUnknown: string;
  49. };
  50. const orDash = (v: string | number | null | undefined): string | number => {
  51. if (v == null) return "—";
  52. if (typeof v === "string" && !v.trim()) return "—";
  53. return v;
  54. };
  55. const bomDirectionLabel = (
  56. direction: string,
  57. labels: ItemLotTraceExportLabels,
  58. ): string => {
  59. const d = direction.trim().toUpperCase();
  60. if (d === "FINISHED_GOOD") return labels.bomDirectionFg;
  61. if (d === "MATERIAL") return labels.bomDirectionMaterial;
  62. return labels.bomDirectionUnknown;
  63. };
  64. const ensureRows = (
  65. rows: Record<string, unknown>[],
  66. emptyTemplate: Record<string, unknown>,
  67. ): Record<string, unknown>[] => {
  68. if (rows.length > 0) return rows;
  69. const firstKey = Object.keys(emptyTemplate)[0];
  70. return [{ ...emptyTemplate, ...(firstKey ? { [firstKey]: NO_DATA } : {}) }];
  71. };
  72. const todayYmd = (): string => {
  73. const d = new Date();
  74. const y = d.getFullYear();
  75. const m = String(d.getMonth() + 1).padStart(2, "0");
  76. const day = String(d.getDate()).padStart(2, "0");
  77. return `${y}${m}${day}`;
  78. };
  79. const safeFilenamePart = (s: string): string =>
  80. s.replace(/[\\/:*?"<>|]/g, "_").trim() || "unknown";
  81. export const buildItemLotTraceFilename = (
  82. data: ItemLotTraceResponse,
  83. ): string => {
  84. const item = safeFilenamePart(data.lot.itemCode || "item");
  85. const lot = safeFilenamePart(data.lot.lotNo || "lot");
  86. return `批號追溯_${item}_${lot}_${todayYmd()}`;
  87. };
  88. const summaryEmpty = (): Record<string, unknown> => ({
  89. "Field / 欄位": "",
  90. "Value / 值": "",
  91. });
  92. const buildSummarySheet = (
  93. data: ItemLotTraceResponse,
  94. labels: ItemLotTraceExportLabels,
  95. exportAt: string,
  96. ): Record<string, unknown>[] => {
  97. const { lot, warehouseLines, joPrelude, alternateLocations, bomTrace } = data;
  98. const totalAvailable = warehouseLines.reduce(
  99. (s, w) => s + (w.availableQty ?? 0),
  100. 0,
  101. );
  102. const rows: Record<string, unknown>[] = [
  103. { "Field / 欄位": "Lot No. / 批號", "Value / 值": orDash(lot.lotNo) },
  104. { "Field / 欄位": "Item Code / 貨品編號", "Value / 值": orDash(lot.itemCode) },
  105. { "Field / 欄位": "Item Name / 品名", "Value / 值": orDash(lot.itemName) },
  106. { "Field / 欄位": "UOM / 單位", "Value / 值": orDash(lot.uom) },
  107. { "Field / 欄位": "Expiry / 效期", "Value / 值": orDash(lot.expiryDate) },
  108. {
  109. "Field / 欄位": "Production Date / 生產日期",
  110. "Value / 值": orDash(lot.productionDate),
  111. },
  112. {
  113. "Field / 欄位": "Stock In Date / 入庫日期",
  114. "Value / 值": orDash(lot.stockInDate),
  115. },
  116. {
  117. "Field / 欄位": "Total Available / 總可用量",
  118. "Value / 值": totalAvailable,
  119. },
  120. {
  121. "Field / 欄位": "BOM Direction / BOM 方向",
  122. "Value / 值": bomDirectionLabel(bomTrace.direction, labels),
  123. },
  124. ];
  125. if (joPrelude) {
  126. const jo = joPrelude.jobOrder;
  127. rows.push(
  128. {
  129. "Field / 欄位": "Job Order / 工單",
  130. "Value / 值": orDash(jo.jobOrderCode),
  131. },
  132. {
  133. "Field / 欄位": "Plan Start / 計劃開工",
  134. "Value / 值": orDash(jo.planStart),
  135. },
  136. { "Field / 欄位": "Planned Qty / 計劃產量", "Value / 值": jo.reqQty },
  137. {
  138. "Field / 欄位": "JO Status / 工單狀態",
  139. "Value / 值": orDash(jo.status),
  140. },
  141. );
  142. }
  143. rows.push(
  144. { "Field / 欄位": "Export Time / 匯出時間", "Value / 值": exportAt },
  145. { "Field / 欄位": "", "Value / 值": "" },
  146. {
  147. "Field / 欄位": "— Warehouse Breakdown / 各倉庫存量 —",
  148. "Value / 值": "",
  149. },
  150. );
  151. warehouseLines.forEach((w) => {
  152. rows.push({
  153. "Field / 欄位": `Warehouse / 倉位: ${w.warehouseCode}`,
  154. "Value / 值": `In ${w.inQty} · Out ${w.outQty} · Available ${w.availableQty} · ${w.status}`,
  155. });
  156. });
  157. if (alternateLocations.length > 0) {
  158. rows.push(
  159. { "Field / 欄位": "", "Value / 值": "" },
  160. {
  161. "Field / 欄位": "— Alternate Locations / 其他倉位 —",
  162. "Value / 值": "",
  163. },
  164. );
  165. alternateLocations.forEach((loc) => {
  166. rows.push({
  167. "Field / 欄位": `Inventory Lot ${loc.inventoryLotId}`,
  168. "Value / 值": `${loc.warehouseCode} · Available ${loc.availableQty}`,
  169. });
  170. });
  171. }
  172. return rows.length > 0 ? rows : [summaryEmpty()];
  173. };
  174. const timelineEmpty = (): Record<string, unknown> => ({
  175. "Date/Time / 時間": "",
  176. "Phase / 階段": "",
  177. "Event Type / 事件類型": "",
  178. "Doc No. / 單號": "",
  179. "Ref Type / 來源類型": "",
  180. "Ref Type Code": "",
  181. "Qty / 數量": "",
  182. "UOM / 單位": "",
  183. "Warehouse / 倉位": "",
  184. "Handler / 經手人": "",
  185. "Lot / 批號": "",
  186. "Remarks / 備註": "",
  187. });
  188. const buildTimelineSheet = (
  189. compiledGraph: CompiledTraceGraph,
  190. t: TFunction,
  191. tr: TraceLabelTranslator,
  192. ): Record<string, unknown>[] => {
  193. const nodes = compiledGraph.layout.nodes
  194. .filter(
  195. (n) =>
  196. n.kind !== "DO_GROUP" && n.kind !== "PICK_GROUP" && !isDoGroupChild(n),
  197. )
  198. .slice()
  199. .sort((a, b) => {
  200. const ta = a.timestamp ?? "";
  201. const tb = b.timestamp ?? "";
  202. if (ta !== tb) return ta.localeCompare(tb);
  203. return a.sortKey - b.sortKey || a.sequenceIndex - b.sequenceIndex;
  204. });
  205. const rows = nodes.map((n) => {
  206. const handlerDetail = n.details.find(
  207. (d) =>
  208. d.label.toLowerCase().includes("handler") ||
  209. d.label.includes("經手") ||
  210. d.label.includes("核准") ||
  211. d.label.includes("盤點"),
  212. );
  213. return {
  214. "Date/Time / 時間": orDash(n.timestamp),
  215. "Phase / 階段": t(phaseLabelKey(n.phase)),
  216. "Event Type / 事件類型": t(kindLabelKey(n.kind)),
  217. "Doc No. / 單號": orDash(n.refCode),
  218. "Ref Type / 來源類型": tr.refType(n.refType),
  219. "Ref Type Code": orDash(n.refType),
  220. "Qty / 數量": n.qty ?? "",
  221. "UOM / 單位": orDash(n.uom),
  222. "Warehouse / 倉位": orDash(n.warehouseCode),
  223. "Handler / 經手人": orDash(
  224. handlerDetail?.value ?? n.meta?.split(" · ")[0],
  225. ),
  226. "Lot / 批號": orDash(n.traceLotNo),
  227. "Remarks / 備註": orDash(n.subtitle),
  228. };
  229. });
  230. return ensureRows(rows, timelineEmpty());
  231. };
  232. const originsEmpty = (): Record<string, unknown> => ({
  233. "Section / 區塊": "",
  234. "Warehouse / 倉位": "",
  235. "Inventory Lot Id": "",
  236. "Type / 類型": "",
  237. "Type Code": "",
  238. "Doc No. / 單號": "",
  239. "Supplier Code / 供應商編號": "",
  240. "Supplier / 供應商": "",
  241. "DN No. / 送貨單號": "",
  242. "Qty / 數量": "",
  243. "Status / 狀態": "",
  244. "Date / 日期": "",
  245. });
  246. const buildOriginsSheet = (
  247. data: ItemLotTraceResponse,
  248. tr: TraceLabelTranslator,
  249. ): Record<string, unknown>[] => {
  250. const originRows = mergeScopedOrigins(data).map((o) => ({
  251. "Section / 區塊": "Origin / 來源",
  252. "Warehouse / 倉位": o.scopeWarehouseCode,
  253. "Inventory Lot Id": o.inventoryLotId,
  254. "Type / 類型": tr.refType(o.type),
  255. "Type Code": orDash(o.type),
  256. "Doc No. / 單號": orDash(o.refCode),
  257. "Supplier Code / 供應商編號": orDash(o.supplierCode),
  258. "Supplier / 供應商": orDash(o.supplierName),
  259. "DN No. / 送貨單號": orDash(o.dnNo),
  260. "Qty / 數量": o.acceptedQty,
  261. "Status / 狀態": tr.stockInStatus(o.status),
  262. "Date / 日期": orDash(o.receiptDate),
  263. }));
  264. const purchaseRows = mergeScopedPurchaseEvents(data).map((p) => ({
  265. "Section / 區塊": "Purchase / 採購",
  266. "Warehouse / 倉位": p.scopeWarehouseCode,
  267. "Inventory Lot Id": p.inventoryLotId,
  268. "Type / 類型": tr.refType("PO"),
  269. "Type Code": "PO",
  270. "Doc No. / 單號": orDash(p.purchaseOrderCode),
  271. "Supplier Code / 供應商編號": orDash(p.supplierCode),
  272. "Supplier / 供應商": orDash(p.supplierName),
  273. "DN No. / 送貨單號": "",
  274. "Qty / 數量": p.orderQty,
  275. "Status / 狀態": orDash(p.status),
  276. "Date / 日期": orDash(p.orderDate),
  277. }));
  278. return ensureRows([...originRows, ...purchaseRows], originsEmpty());
  279. };
  280. const qcEmpty = (): Record<string, unknown> => ({
  281. "Warehouse / 倉位": "",
  282. "Inventory Lot Id": "",
  283. "QC Result / 結果": "",
  284. "QC Type / 品檢類型": "",
  285. "QC Type Code": "",
  286. "QC Item Code / 品檢項編號": "",
  287. "QC Item / 品檢項": "",
  288. "Accepted Qty / 抽樣數量": "",
  289. "Fail Qty / 不良數量": "",
  290. "Remarks / 備註": "",
  291. "Handler / 經手人": "",
  292. "Date/Time / 時間": "",
  293. "Stock In Line Id": "",
  294. });
  295. const buildQcSheet = (
  296. data: ItemLotTraceResponse,
  297. tr: TraceLabelTranslator,
  298. ): Record<string, unknown>[] => {
  299. const rows = mergeScopedQcResults(data).map((q) => ({
  300. "Warehouse / 倉位": q.scopeWarehouseCode,
  301. "Inventory Lot Id": q.inventoryLotId,
  302. "QC Result / 結果": tr.qcPassed(q.qcPassed),
  303. "QC Type / 品檢類型": tr.qcType(q.qcType),
  304. "QC Type Code": orDash(q.qcType),
  305. "QC Item Code / 品檢項編號": orDash(q.qcItemCode),
  306. "QC Item / 品檢項": orDash(q.qcItemName || q.qcItemDescription),
  307. "Accepted Qty / 抽樣數量": q.acceptedQty,
  308. "Fail Qty / 不良數量": q.failQty,
  309. "Remarks / 備註": orDash(q.remarks),
  310. "Handler / 經手人": orDash(q.handledBy),
  311. "Date/Time / 時間": orDash(q.created),
  312. "Stock In Line Id": q.stockInLineId,
  313. }));
  314. return ensureRows(rows, qcEmpty());
  315. };
  316. const warehouseEmpty = (): Record<string, unknown> => ({
  317. "Section / 區塊": "",
  318. "Warehouse / 倉位": "",
  319. "Inventory Lot Id": "",
  320. "Direction / 方向": "",
  321. "Type / 類型": "",
  322. "Type Code": "",
  323. "Doc No. / 單號": "",
  324. "From / 來源倉": "",
  325. "To / 目標倉": "",
  326. "Qty / 數量": "",
  327. "Handler / 經手人": "",
  328. "Status / 狀態": "",
  329. "Remarks / 備註": "",
  330. "Date/Time / 時間": "",
  331. });
  332. const buildWarehouseSheet = (
  333. data: ItemLotTraceResponse,
  334. tr: TraceLabelTranslator,
  335. ): Record<string, unknown>[] => {
  336. const putaway = mergeScopedPutawayEvents(data).map((p) => ({
  337. "Section / 區塊": "Putaway / 上架",
  338. "Warehouse / 倉位": p.scopeWarehouseCode,
  339. "Inventory Lot Id": p.inventoryLotId,
  340. "Direction / 方向": "",
  341. "Type / 類型": tr.refType(p.refType),
  342. "Type Code": orDash(p.refType),
  343. "Doc No. / 單號": orDash(p.refCode),
  344. "From / 來源倉": "",
  345. "To / 目標倉": orDash(p.warehouseCode),
  346. "Qty / 數量": p.qty,
  347. "Handler / 經手人": orDash(p.handledBy),
  348. "Status / 狀態": orDash(p.status),
  349. "Remarks / 備註": "",
  350. "Date/Time / 時間": orDash(p.timestamp),
  351. }));
  352. const transfers = mergeScopedTransfers(data).map((trRow) => ({
  353. "Section / 區塊": "Transfer / 轉倉",
  354. "Warehouse / 倉位": trRow.scopeWarehouseCode,
  355. "Inventory Lot Id": trRow.inventoryLotId,
  356. "Direction / 方向": "",
  357. "Type / 類型": tr.refType("TRANSFER"),
  358. "Type Code": "TRANSFER",
  359. "Doc No. / 單號": orDash(trRow.transferCode),
  360. "From / 來源倉": orDash(trRow.fromWarehouse),
  361. "To / 目標倉": orDash(trRow.toWarehouse),
  362. "Qty / 數量": trRow.qty,
  363. "Handler / 經手人": "",
  364. "Status / 狀態": "",
  365. "Remarks / 備註": "",
  366. "Date/Time / 時間": orDash(trRow.timestamp),
  367. }));
  368. const adjustments = mergeScopedAdjustments(data).map((a) => ({
  369. "Section / 區塊": "Adjustment / 庫存調整",
  370. "Warehouse / 倉位": a.scopeWarehouseCode,
  371. "Inventory Lot Id": a.inventoryLotId,
  372. "Direction / 方向": tr.direction(a.direction),
  373. "Type / 類型": tr.adjustmentType(a.adjustmentType),
  374. "Type Code": orDash(a.adjustmentType),
  375. "Doc No. / 單號": orDash(a.refCode),
  376. "From / 來源倉": "",
  377. "To / 目標倉": orDash(a.warehouseCode),
  378. "Qty / 數量": a.qty,
  379. "Handler / 經手人": orDash(a.handledBy),
  380. "Status / 狀態": "",
  381. "Remarks / 備註": orDash(a.reason),
  382. "Date/Time / 時間": orDash(a.timestamp),
  383. }));
  384. const openMoves = mergeScopedOpenMovements(data).map((o) => ({
  385. "Section / 區塊": "Opening Stock / 開倉入庫",
  386. "Warehouse / 倉位": o.scopeWarehouseCode,
  387. "Inventory Lot Id": o.inventoryLotId,
  388. "Direction / 方向": tr.direction("IN"),
  389. "Type / 類型": tr.refType("OPEN"),
  390. "Type Code": "OPEN",
  391. "Doc No. / 單號": orDash(o.refCode),
  392. "From / 來源倉": "",
  393. "To / 目標倉": orDash(o.warehouseCode),
  394. "Qty / 數量": o.qty,
  395. "Handler / 經手人": orDash(o.handledBy),
  396. "Status / 狀態": "",
  397. "Remarks / 備註": orDash(o.remarks),
  398. "Date/Time / 時間": orDash(o.timestamp),
  399. }));
  400. return ensureRows(
  401. [...putaway, ...transfers, ...adjustments, ...openMoves],
  402. warehouseEmpty(),
  403. );
  404. };
  405. const outboundEmpty = (): Record<string, unknown> => ({
  406. "Section / 區塊": "",
  407. "Warehouse / 倉位": "",
  408. "Inventory Lot Id": "",
  409. "Usage Type / 類型": "",
  410. "Usage Type Code": "",
  411. "Doc No. / 單號": "",
  412. "Pick Order / 提料單": "",
  413. "Conso / 併單": "",
  414. "Job Order / 工單": "",
  415. "Delivery Order / 送貨單": "",
  416. "Delivery Note / DN": "",
  417. "Ticket / 票號": "",
  418. "Shop / 門市": "",
  419. "Qty / 數量": "",
  420. "Handler / 經手人": "",
  421. "Extra / 加單": "",
  422. "Replenish / 補貨": "",
  423. "Remarks / 備註": "",
  424. "Date/Time / 時間": "",
  425. });
  426. const buildOutboundSheet = (
  427. data: ItemLotTraceResponse,
  428. tr: TraceLabelTranslator,
  429. ): Record<string, unknown>[] => {
  430. const usage = mergeScopedOutboundUsage(data).map((u) => ({
  431. "Section / 區塊": "Outbound Usage / 出庫使用",
  432. "Warehouse / 倉位": u.scopeWarehouseCode,
  433. "Inventory Lot Id": u.inventoryLotId,
  434. "Usage Type / 類型": tr.usageType(u.usageType),
  435. "Usage Type Code": orDash(u.usageType),
  436. "Doc No. / 單號": orDash(u.deliveryOrderCode || u.pickOrderCode),
  437. "Pick Order / 提料單": orDash(u.pickOrderCode),
  438. "Conso / 併單": orDash(u.consoCode),
  439. "Job Order / 工單": orDash(u.jobOrderCode),
  440. "Delivery Order / 送貨單": orDash(u.deliveryOrderCode),
  441. "Delivery Note / DN": "",
  442. "Ticket / 票號": "",
  443. "Shop / 門市": "",
  444. "Qty / 數量": u.qty,
  445. "Handler / 經手人": orDash(u.handler),
  446. "Extra / 加單": "",
  447. "Replenish / 補貨": "",
  448. "Remarks / 備註": "",
  449. "Date/Time / 時間": orDash(u.timestamp),
  450. }));
  451. const doRows = mergeScopedDoDeliveries(data).map((d) => ({
  452. "Section / 區塊": "DO Delivery / 成品出倉",
  453. "Warehouse / 倉位": d.scopeWarehouseCode,
  454. "Inventory Lot Id": d.inventoryLotId,
  455. "Usage Type / 類型": tr.usageType("FG_DELIVERY"),
  456. "Usage Type Code": "FG_DELIVERY",
  457. "Doc No. / 單號": orDash(d.deliveryOrderCode),
  458. "Pick Order / 提料單": orDash(d.pickOrderCode),
  459. "Conso / 併單": orDash(d.consoCode),
  460. "Job Order / 工單": "",
  461. "Delivery Order / 送貨單": orDash(d.deliveryOrderCode),
  462. "Delivery Note / DN": orDash(d.deliveryNoteCode),
  463. "Ticket / 票號": orDash(d.ticketNo),
  464. "Shop / 門市": [d.shopCode, d.shopName].filter(Boolean).join(" · ") || "—",
  465. "Qty / 數量": d.qty,
  466. "Handler / 經手人": orDash(d.handler),
  467. "Extra / 加單": d.isExtra ? "Y" : "",
  468. "Replenish / 補貨": d.isReplenish ? "Y" : "",
  469. "Remarks / 備註": "",
  470. "Date/Time / 時間": orDash(d.timestamp),
  471. }));
  472. const replenish = mergeScopedReplenishmentEvents(data).map((r) => ({
  473. "Section / 區塊": "Replenishment / 補貨",
  474. "Warehouse / 倉位": r.scopeWarehouseCode,
  475. "Inventory Lot Id": r.inventoryLotId,
  476. "Usage Type / 類型": "Replenishment",
  477. "Usage Type Code": "REPLENISH",
  478. "Doc No. / 單號": orDash(r.replenishmentCode),
  479. "Pick Order / 提料單": "",
  480. "Conso / 併單": "",
  481. "Job Order / 工單": "",
  482. "Delivery Order / 送貨單": orDash(r.sourceDoCode),
  483. "Delivery Note / DN": "",
  484. "Ticket / 票號": "",
  485. "Shop / 門市": [r.shopCode, r.shopName].filter(Boolean).join(" · ") || "—",
  486. "Qty / 數量": r.replenishQty,
  487. "Handler / 經手人": orDash(r.handler),
  488. "Extra / 加單": "",
  489. "Replenish / 補貨": "Y",
  490. "Remarks / 備註": orDash(r.reason),
  491. "Date/Time / 時間": orDash(r.timestamp),
  492. }));
  493. const returns = mergeScopedReturnEvents(data).map((r) => ({
  494. "Section / 區塊": "Return / 退貨",
  495. "Warehouse / 倉位": r.scopeWarehouseCode,
  496. "Inventory Lot Id": r.inventoryLotId,
  497. "Usage Type / 類型": tr.movementType(r.movementType),
  498. "Usage Type Code": orDash(r.movementType),
  499. "Doc No. / 單號": orDash(r.refCode),
  500. "Pick Order / 提料單": "",
  501. "Conso / 併單": "",
  502. "Job Order / 工單": "",
  503. "Delivery Order / 送貨單": "",
  504. "Delivery Note / DN": "",
  505. "Ticket / 票號": "",
  506. "Shop / 門市": "",
  507. "Qty / 數量": r.qty,
  508. "Handler / 經手人": orDash(r.handler),
  509. "Extra / 加單": "",
  510. "Replenish / 補貨": "",
  511. "Remarks / 備註": orDash(r.remarks),
  512. "Date/Time / 時間": orDash(r.timestamp),
  513. }));
  514. const fails = mergeScopedFailEvents(data).map((f) => ({
  515. "Section / 區塊": "Pick Failure / 揀貨異常",
  516. "Warehouse / 倉位": f.scopeWarehouseCode,
  517. "Inventory Lot Id": f.inventoryLotId,
  518. "Usage Type / 類型": tr.failType(f.failType),
  519. "Usage Type Code": orDash(f.failType),
  520. "Doc No. / 單號": orDash(f.pickOrderCode),
  521. "Pick Order / 提料單": orDash(f.pickOrderCode),
  522. "Conso / 併單": "",
  523. "Job Order / 工單": "",
  524. "Delivery Order / 送貨單": "",
  525. "Delivery Note / DN": "",
  526. "Ticket / 票號": "",
  527. "Shop / 門市": "",
  528. "Qty / 數量": f.qty,
  529. "Handler / 經手人": orDash(f.handlerName),
  530. "Extra / 加單": "",
  531. "Replenish / 補貨": "",
  532. "Remarks / 備註": orDash(f.category),
  533. "Date/Time / 時間": orDash(f.recordDate),
  534. }));
  535. return ensureRows(
  536. [...usage, ...doRows, ...replenish, ...returns, ...fails],
  537. outboundEmpty(),
  538. );
  539. };
  540. const stockTakeEmpty = (): Record<string, unknown> => ({
  541. "Warehouse / 倉位": "",
  542. "Inventory Lot Id": "",
  543. "Stock Take Code / 盤點單號": "",
  544. "Round / 輪次": "",
  545. "Section / 區域": "",
  546. "Lot / 批號": "",
  547. "Book Qty / 帳面數量": "",
  548. "Accepted Qty / 核准數量": "",
  549. "Variance Qty / 差異數量": "",
  550. "Approver / 核准人": "",
  551. "Stock Taker / 初盤人": "",
  552. "First Count Qty / 初盤數量": "",
  553. "Second Count Qty / 複盤數量": "",
  554. "Approver Count Qty / 覆盤數量": "",
  555. "Record Status / 紀錄狀態": "",
  556. "Date/Time / 時間": "",
  557. });
  558. const buildStockTakeSheet = (
  559. data: ItemLotTraceResponse,
  560. ): Record<string, unknown>[] => {
  561. const rows = mergeScopedStockTakeEvents(data).map((e) => {
  562. const d = e.recordDetail;
  563. return {
  564. "Warehouse / 倉位": e.scopeWarehouseCode,
  565. "Inventory Lot Id": e.inventoryLotId,
  566. "Stock Take Code / 盤點單號": orDash(e.stockTakeCode),
  567. "Round / 輪次": orDash(
  568. e.stockTakeRoundName ??
  569. (e.stockTakeRoundId != null ? `#${e.stockTakeRoundId}` : ""),
  570. ),
  571. "Section / 區域": orDash(e.stockTakeSection),
  572. "Lot / 批號": orDash(e.lotNo),
  573. "Book Qty / 帳面數量": resolveStockTakeBookQty(d, e.beforeQty) ?? "",
  574. "Accepted Qty / 核准數量":
  575. resolveStockTakeAcceptedQty(d, e.afterQty) ?? "",
  576. "Variance Qty / 差異數量":
  577. d?.varianceQty != null ? d.varianceQty : e.varianceQty,
  578. "Approver / 核准人": orDash(d?.approverName || e.approver),
  579. "Stock Taker / 初盤人": orDash(d?.stockTakerName),
  580. "First Count Qty / 初盤數量": d?.pickerFirstQty ?? "",
  581. "Second Count Qty / 複盤數量": d?.pickerSecondQty ?? "",
  582. "Approver Count Qty / 覆盤數量": d?.approverQty ?? "",
  583. "Record Status / 紀錄狀態": orDash(d?.recordStatus),
  584. "Date/Time / 時間": orDash(e.timestamp),
  585. };
  586. });
  587. return ensureRows(rows, stockTakeEmpty());
  588. };
  589. const productionEmpty = (): Record<string, unknown> => ({
  590. "Section / 區塊": "",
  591. "Job Order / 工單": "",
  592. "Doc No. / 單號": "",
  593. "Item Code / 貨品編號": "",
  594. "Lot / 批號": "",
  595. "Item Name / 品名": "",
  596. "Step / 步驟": "",
  597. "Assigned Step / 對應製程": "",
  598. "Qty / 數量": "",
  599. "Scrap Qty / 損耗數量": "",
  600. "Defect Qty / 不良數量": "",
  601. "UOM / 單位": "",
  602. "Operator / 操作員": "",
  603. "Equipment / 設備": "",
  604. "Status / 狀態": "",
  605. "Date/Time / 時間": "",
  606. });
  607. const buildProductionSheet = (
  608. data: ItemLotTraceResponse,
  609. compiledGraph: CompiledTraceGraph,
  610. tr: TraceLabelTranslator,
  611. ): Record<string, unknown>[] => {
  612. const rows: Record<string, unknown>[] = [];
  613. const presentation = buildTracePresentationRows(compiledGraph.nodes, data);
  614. const upstreamSource = data.joPrelude?.materialInputs?.length
  615. ? data.joPrelude.materialInputs.map((m) => ({
  616. jobOrderCode: m.jobOrderCode,
  617. materialItemCode: m.materialItemCode,
  618. materialLotNo: m.materialLotNo,
  619. materialQty: m.materialQty,
  620. materialUom: m.materialUom,
  621. assignedStepName: m.assignedStepName,
  622. pickOrderCode: m.pickOrderCode,
  623. pickedAt: m.pickedAt,
  624. materialItemName: m.materialItemName,
  625. }))
  626. : data.bomTrace.upstream.map((u) => ({
  627. jobOrderCode: u.jobOrderCode,
  628. materialItemCode: u.materialItemCode,
  629. materialLotNo: u.materialLotNo,
  630. materialQty: u.materialQty,
  631. materialUom: "",
  632. assignedStepName: "",
  633. pickOrderCode: "",
  634. pickedAt: null as string | null,
  635. materialItemName: "",
  636. }));
  637. upstreamSource.forEach((m) => {
  638. rows.push({
  639. "Section / 區塊": "BOM Upstream / BOM 上游",
  640. "Job Order / 工單": orDash(m.jobOrderCode),
  641. "Doc No. / 單號": orDash(m.pickOrderCode),
  642. "Item Code / 貨品編號": orDash(m.materialItemCode),
  643. "Lot / 批號": orDash(m.materialLotNo),
  644. "Item Name / 品名": orDash(m.materialItemName),
  645. "Step / 步驟": "",
  646. "Assigned Step / 對應製程": orDash(m.assignedStepName),
  647. "Qty / 數量": m.materialQty,
  648. "Scrap Qty / 損耗數量": "",
  649. "Defect Qty / 不良數量": "",
  650. "UOM / 單位": orDash(m.materialUom),
  651. "Operator / 操作員": "",
  652. "Equipment / 設備": "",
  653. "Status / 狀態": "",
  654. "Date/Time / 時間": orDash(m.pickedAt),
  655. });
  656. });
  657. data.bomTrace.downstream.forEach((d) => {
  658. rows.push({
  659. "Section / 區塊": "BOM Downstream / BOM 下游",
  660. "Job Order / 工單": orDash(d.jobOrderCode),
  661. "Doc No. / 單號": "",
  662. "Item Code / 貨品編號": orDash(d.finishedItemCode),
  663. "Lot / 批號": orDash(d.finishedLotNo),
  664. "Item Name / 品名": "",
  665. "Step / 步驟": "",
  666. "Assigned Step / 對應製程": "",
  667. "Qty / 數量": d.fgQty,
  668. "Scrap Qty / 損耗數量": "",
  669. "Defect Qty / 不良數量": "",
  670. "UOM / 單位": orDash(d.fgUom),
  671. "Operator / 操作員": "",
  672. "Equipment / 設備": "",
  673. "Status / 狀態": "",
  674. "Date/Time / 時間": "",
  675. });
  676. });
  677. data.bomTrace.bomRecipe.forEach((r) => {
  678. rows.push({
  679. "Section / 區塊": "BOM Recipe / BOM 配方",
  680. "Job Order / 工單": "",
  681. "Doc No. / 單號": "",
  682. "Item Code / 貨品編號": orDash(r.materialItemCode),
  683. "Lot / 批號": "",
  684. "Item Name / 品名": orDash(r.materialItemName),
  685. "Step / 步驟": "",
  686. "Assigned Step / 對應製程": orDash(r.assignedStepName),
  687. "Qty / 數量": r.qtyPerUnit,
  688. "Scrap Qty / 損耗數量": "",
  689. "Defect Qty / 不良數量": "",
  690. "UOM / 單位": orDash(r.uom),
  691. "Operator / 操作員": "",
  692. "Equipment / 設備": "",
  693. "Status / 狀態": "",
  694. "Date/Time / 時間": "",
  695. });
  696. });
  697. presentation.joPicks
  698. .slice()
  699. .sort((a, b) => (b.pickedAt ?? "").localeCompare(a.pickedAt ?? ""))
  700. .forEach((p) => {
  701. rows.push({
  702. "Section / 區塊": "JO Pick / 工單提料",
  703. "Job Order / 工單": "",
  704. "Doc No. / 單號": orDash(p.pickOrderCode),
  705. "Item Code / 貨品編號": orDash(p.materialItemCode),
  706. "Lot / 批號": orDash(p.materialLotNo),
  707. "Item Name / 品名": "",
  708. "Step / 步驟": "",
  709. "Assigned Step / 對應製程": orDash(p.assignedStepName),
  710. "Qty / 數量": p.qty,
  711. "Scrap Qty / 損耗數量": "",
  712. "Defect Qty / 不良數量": "",
  713. "UOM / 單位": orDash(p.uom),
  714. "Operator / 操作員": "",
  715. "Equipment / 設備": "",
  716. "Status / 狀態": "",
  717. "Date/Time / 時間": orDash(p.pickedAt),
  718. });
  719. });
  720. const productionRows =
  721. data.productionSteps.length > 0
  722. ? data.productionSteps
  723. .slice()
  724. .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0))
  725. .map((s) => ({
  726. "Section / 區塊": "Production Step / 生產步驟",
  727. "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode),
  728. "Doc No. / 單號": "",
  729. "Item Code / 貨品編號": orDash(data.lot.itemCode),
  730. "Lot / 批號": orDash(data.lot.lotNo),
  731. "Item Name / 品名": orDash(data.lot.itemName),
  732. "Step / 步驟": orDash(s.stepName),
  733. "Assigned Step / 對應製程": orDash(s.description),
  734. "Qty / 數量": s.outputQty,
  735. "Scrap Qty / 損耗數量": s.scrapQty,
  736. "Defect Qty / 不良數量": s.defectQty,
  737. "UOM / 單位": orDash(data.lot.uom),
  738. "Operator / 操作員": orDash(s.operatorName),
  739. "Equipment / 設備":
  740. [s.equipmentCode, s.equipmentName].filter(Boolean).join(" · ") ||
  741. "—",
  742. "Status / 狀態": tr.productionStatus(s.status),
  743. "Date/Time / 時間": orDash(s.endTime || s.startTime),
  744. }))
  745. : presentation.production
  746. .slice()
  747. .sort((a, b) => (a.seqNo ?? 0) - (b.seqNo ?? 0))
  748. .map((s) => ({
  749. "Section / 區塊": "Production Step / 生產步驟",
  750. "Job Order / 工單": orDash(data.joPrelude?.jobOrder.jobOrderCode),
  751. "Doc No. / 單號": "",
  752. "Item Code / 貨品編號": orDash(data.lot.itemCode),
  753. "Lot / 批號": orDash(s.traceLotNo || data.lot.lotNo),
  754. "Item Name / 品名": orDash(data.lot.itemName),
  755. "Step / 步驟": orDash(s.stepName),
  756. "Assigned Step / 對應製程": orDash(s.description),
  757. "Qty / 數量": s.outputQty,
  758. "Scrap Qty / 損耗數量": s.scrapQty,
  759. "Defect Qty / 不良數量": s.defectQty,
  760. "UOM / 單位": orDash(data.lot.uom),
  761. "Operator / 操作員": orDash(s.operatorName),
  762. "Equipment / 設備": orDash(s.equipmentName || s.equipmentCode),
  763. "Status / 狀態": orDash(s.status),
  764. "Date/Time / 時間": orDash(s.endTime || s.startTime),
  765. }));
  766. rows.push(...productionRows);
  767. data.byproductLots.forEach((b) => {
  768. rows.push({
  769. "Section / 區塊": "Byproduct / 副產品",
  770. "Job Order / 工單": orDash(b.jobOrderCode),
  771. "Doc No. / 單號": "",
  772. "Item Code / 貨品編號": orDash(b.itemCode),
  773. "Lot / 批號": orDash(b.lotNo),
  774. "Item Name / 品名": orDash(b.itemName),
  775. "Step / 步驟": orDash(b.processStepName),
  776. "Assigned Step / 對應製程": "",
  777. "Qty / 數量": b.qty,
  778. "Scrap Qty / 損耗數量": "",
  779. "Defect Qty / 不良數量": "",
  780. "UOM / 單位": orDash(b.uom),
  781. "Operator / 操作員": "",
  782. "Equipment / 設備": "",
  783. "Status / 狀態": "",
  784. "Date/Time / 時間": orDash(b.producedAt),
  785. });
  786. });
  787. data.lotRelations.forEach((r) => {
  788. rows.push({
  789. "Section / 區塊": "Lot Relation / 拆批關聯",
  790. "Job Order / 工單": "",
  791. "Doc No. / 單號": orDash(r.relationType),
  792. "Item Code / 貨品編號": orDash(r.itemCode),
  793. "Lot / 批號": orDash(r.lotNo),
  794. "Item Name / 品名": orDash(r.itemName),
  795. "Step / 步驟": "",
  796. "Assigned Step / 對應製程": "",
  797. "Qty / 數量": r.qty,
  798. "Scrap Qty / 損耗數量": "",
  799. "Defect Qty / 不良數量": "",
  800. "UOM / 單位": "",
  801. "Operator / 操作員": "",
  802. "Equipment / 設備": "",
  803. "Status / 狀態": orDash(r.productLotNo),
  804. "Date/Time / 時間": orDash(r.timestamp),
  805. });
  806. });
  807. return ensureRows(rows, productionEmpty());
  808. };
  809. /** Build the 8 classified sheets without triggering download (for tests). */
  810. export const buildItemLotTraceSheets = (
  811. data: ItemLotTraceResponse,
  812. compiledGraph: CompiledTraceGraph,
  813. t: TFunction,
  814. exportLabels: ItemLotTraceExportLabels,
  815. exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19),
  816. ): MultiSheetSpec[] => {
  817. const tr = createTraceLabelTranslator(t);
  818. return [
  819. {
  820. name: SHEET.summary,
  821. rows: buildSummarySheet(data, exportLabels, exportAt),
  822. },
  823. { name: SHEET.timeline, rows: buildTimelineSheet(compiledGraph, t, tr) },
  824. { name: SHEET.origins, rows: buildOriginsSheet(data, tr) },
  825. { name: SHEET.qc, rows: buildQcSheet(data, tr) },
  826. { name: SHEET.warehouse, rows: buildWarehouseSheet(data, tr) },
  827. { name: SHEET.outbound, rows: buildOutboundSheet(data, tr) },
  828. { name: SHEET.stockTake, rows: buildStockTakeSheet(data) },
  829. {
  830. name: SHEET.production,
  831. rows: buildProductionSheet(data, compiledGraph, tr),
  832. },
  833. ];
  834. };
  835. export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET);
  836. /** FP-MTMS Version Checklist | Functions Ref. No. 15 | v1.0.1 | 2026-07-20 */
  837. export const exportItemLotTraceXlsx = (
  838. data: ItemLotTraceResponse,
  839. compiledGraph: CompiledTraceGraph,
  840. t: TFunction,
  841. ): void => {
  842. const exportLabels: ItemLotTraceExportLabels = {
  843. bomDirectionFg: t("bomDirectionFG"),
  844. bomDirectionMaterial: t("bomDirectionMaterial"),
  845. bomDirectionUnknown: t("bomDirectionUnknown"),
  846. };
  847. const sheets = buildItemLotTraceSheets(data, compiledGraph, t, exportLabels);
  848. exportMultiSheetToXlsx(sheets, buildItemLotTraceFilename(data));
  849. };