FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

272 rivejä
9.1 KiB

  1. "use client";
  2. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  3. import { buildItemCodePasteRequestBody } from './parseItemCodeTokens';
  4. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  5. import {
  6. exportChartToXlsx,
  7. exportMultiSheetToXlsx,
  8. } from "@/app/(main)/chart/_components/exportChartToXlsx";
  9. import { reportExcelT as tx } from "./reportI18n";
  10. import type { TFunction } from "i18next";
  11. export interface GrnReportRow {
  12. poCode?: string;
  13. deliveryNoteNo?: string;
  14. receiptDate?: string;
  15. itemCode?: string;
  16. itemName?: string;
  17. acceptedQty?: number;
  18. receivedQty?: number;
  19. demandQty?: number;
  20. uom?: string;
  21. purchaseUomDesc?: string;
  22. stockUomDesc?: string;
  23. productLotNo?: string;
  24. expiryDate?: string;
  25. supplierCode?: string;
  26. supplier?: string;
  27. status?: string;
  28. /** PO line unit price (purchase_order_line.up) */
  29. unitPrice?: number;
  30. /** unitPrice × acceptedQty */
  31. lineAmount?: number;
  32. /** PO currency code (currency.code) */
  33. currencyCode?: string;
  34. /** M18 AN document code from m18_goods_receipt_note_log.grn_code */
  35. grnCode?: string;
  36. /** M18 record id (m18_record_id) */
  37. grnId?: number | string;
  38. /** From purchase_order.m18CreatedUId; e.g. "2569 (legato)" */
  39. poM18CreatorDisplay?: string;
  40. [key: string]: unknown;
  41. }
  42. /** Sheet "已上架PO金額": totals grouped by receipt date + currency / PO (ADMIN-only data from API). */
  43. export interface ListedPoAmounts {
  44. currencyTotals: {
  45. receiptDate?: string;
  46. currencyCode?: string;
  47. totalAmount?: number;
  48. }[];
  49. byPurchaseOrder: {
  50. receiptDate?: string;
  51. poCode?: string;
  52. currencyCode?: string;
  53. totalAmount?: number;
  54. grnCodes?: string;
  55. }[];
  56. }
  57. export interface GrnReportResponse {
  58. rows: GrnReportRow[];
  59. listedPoAmounts?: ListedPoAmounts;
  60. }
  61. /**
  62. * Fetch GRN (Goods Received Note) report data by date range.
  63. * Backend: POST /report/grn-report { receiptDateStart, receiptDateEnd, itemCodes }
  64. */
  65. export async function fetchGrnReportData(
  66. criteria: Record<string, string>
  67. ): Promise<{ rows: GrnReportRow[]; listedPoAmounts?: ListedPoAmounts }> {
  68. const url = `${NEXT_PUBLIC_API_URL}/report/grn-report`;
  69. const response = await clientAuthFetch(url, {
  70. method: "POST",
  71. headers: {
  72. Accept: "application/json",
  73. "Content-Type": "application/json",
  74. },
  75. body: JSON.stringify(buildItemCodePasteRequestBody(criteria, "itemCode")),
  76. });
  77. if (response.status === 401 || response.status === 403)
  78. throw new Error("Unauthorized");
  79. if (!response.ok)
  80. throw new Error(`HTTP error! status: ${response.status}`);
  81. const data = (await response.json()) as GrnReportResponse | GrnReportRow[];
  82. if (Array.isArray(data)) {
  83. return { rows: data };
  84. }
  85. const body = data as GrnReportResponse;
  86. return {
  87. rows: body.rows ?? [],
  88. listedPoAmounts: body.listedPoAmounts,
  89. };
  90. }
  91. /** Coerce API JSON (number or numeric string) to a finite number. */
  92. function coerceToFiniteNumber(value: unknown): number | null {
  93. if (value === null || value === undefined) return null;
  94. if (typeof value === "number" && Number.isFinite(value)) return value;
  95. if (typeof value === "string") {
  96. const t = value.trim();
  97. if (t === "") return null;
  98. const n = Number(t);
  99. return Number.isFinite(n) ? n : null;
  100. }
  101. return null;
  102. }
  103. /**
  104. * Cell value for money columns: numeric when possible so Excel export can apply `#,##0.00` (see exportChartToXlsx).
  105. */
  106. function moneyCellValue(v: unknown): number | string {
  107. const n = coerceToFiniteNumber(v);
  108. if (n === null) return "";
  109. return n;
  110. }
  111. /** Thousands separator for quantities (up to 4 decimal places, trims trailing zeros). */
  112. const formatQty = (n: number | undefined | null): string => {
  113. if (n === undefined || n === null || Number.isNaN(Number(n))) return "";
  114. return new Intl.NumberFormat("en-US", {
  115. minimumFractionDigits: 0,
  116. maximumFractionDigits: 4,
  117. }).format(Number(n));
  118. };
  119. function grnLabels(t?: TFunction) {
  120. return {
  121. sheetDetail: tx(t, "excel.grn.sheetDetail", "PO入倉記錄"),
  122. sheetListedPo: tx(t, "excel.grn.sheetListedPo", "已上架PO金額"),
  123. poNo: tx(t, "excel.grn.poNo", "訂單編號"),
  124. deliveryNoteNo: tx(t, "excel.grn.deliveryNoteNo", "送貨單編號"),
  125. receiptDate: tx(t, "excel.grn.receiptDate", "收貨日期"),
  126. itemCode: tx(t, "excel.grn.itemCode", "物料編號"),
  127. itemName: tx(t, "excel.grn.itemName", "物料名稱"),
  128. qty: tx(t, "excel.grn.qty", "數量"),
  129. demandQty: tx(t, "excel.grn.demandQty", "訂單數量"),
  130. uom: tx(t, "excel.grn.uom", "單位"),
  131. supplierLotNo: tx(t, "excel.grn.supplierLotNo", "供應商批次"),
  132. expiryDate: tx(t, "excel.grn.expiryDate", "到期日"),
  133. supplierCode: tx(t, "excel.grn.supplierCode", "供應商編號"),
  134. supplier: tx(t, "excel.grn.supplier", "供應商"),
  135. status: tx(t, "excel.grn.status", "入倉狀態"),
  136. unitPrice: tx(t, "excel.grn.unitPrice", "單價"),
  137. currency: tx(t, "excel.grn.currency", "貨幣"),
  138. amount: tx(t, "excel.grn.amount", "金額"),
  139. grnCode: tx(t, "excel.grn.grnCode", "M18 入倉單號"),
  140. grnId: tx(t, "excel.grn.grnId", "M18 記錄編號"),
  141. poCreator: tx(t, "excel.grn.poCreator", "PO建立者(M18)"),
  142. note: tx(t, "excel.grn.note", "備註"),
  143. category: tx(t, "excel.grn.category", "類別"),
  144. totalAmount: tx(t, "excel.grn.totalAmount", "金額"),
  145. grnCodes: tx(t, "excel.grn.grnCodes", "M18 入倉單號"),
  146. noCompletedPo: tx(t, "excel.grn.noCompletedPo", "(篩選範圍內無已完成之 PO 行)"),
  147. categoryCurrencyTotal: tx(t, "excel.grn.categoryCurrencyTotal", "貨幣小計"),
  148. categoryPo: tx(t, "excel.grn.categoryPo", "訂單"),
  149. };
  150. }
  151. function toExcelRow(
  152. r: GrnReportRow,
  153. includeFinancialColumns: boolean,
  154. t?: TFunction,
  155. ): Record<string, string | number | undefined> {
  156. const L = grnLabels(t);
  157. const base: Record<string, string | number | undefined> = {
  158. [L.poNo]: r.poCode ?? "",
  159. [L.deliveryNoteNo]: r.deliveryNoteNo ?? "",
  160. [L.receiptDate]: r.receiptDate ?? "",
  161. [L.itemCode]: r.itemCode ?? "",
  162. [L.itemName]: r.itemName ?? "",
  163. [L.qty]: formatQty(r.acceptedQty ?? r.receivedQty ?? undefined),
  164. [L.demandQty]: formatQty(r.demandQty),
  165. [L.uom]: r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "",
  166. [L.supplierLotNo]: r.productLotNo ?? "",
  167. [L.expiryDate]: r.expiryDate ?? "",
  168. [L.supplierCode]: r.supplierCode ?? "",
  169. [L.supplier]: r.supplier ?? "",
  170. [L.status]: r.status ?? "",
  171. };
  172. if (includeFinancialColumns) {
  173. base[L.unitPrice] = moneyCellValue(r.unitPrice);
  174. base[L.currency] = r.currencyCode ?? "";
  175. base[L.amount] = moneyCellValue(r.lineAmount);
  176. }
  177. base[L.grnCode] = r.grnCode ?? "";
  178. base[L.grnId] = r.grnId ?? "";
  179. base[L.poCreator] = r.poM18CreatorDisplay ?? "";
  180. return base;
  181. }
  182. function buildListedPoAmountSheetRows(
  183. listed: ListedPoAmounts | undefined,
  184. t?: TFunction,
  185. ): Record<string, string | number | undefined>[] {
  186. const L = grnLabels(t);
  187. if (
  188. !listed ||
  189. (listed.currencyTotals.length === 0 &&
  190. listed.byPurchaseOrder.length === 0)
  191. ) {
  192. return [{ [L.note]: L.noCompletedPo }];
  193. }
  194. const out: Record<string, string | number | undefined>[] = [];
  195. for (const c of listed.currencyTotals) {
  196. out.push({
  197. [L.category]: L.categoryCurrencyTotal,
  198. [L.receiptDate]: c.receiptDate ?? "",
  199. [L.poNo]: "",
  200. [L.currency]: c.currencyCode ?? "",
  201. [L.totalAmount]: moneyCellValue(c.totalAmount),
  202. [L.grnCodes]: "",
  203. });
  204. }
  205. for (const p of listed.byPurchaseOrder) {
  206. out.push({
  207. [L.category]: L.categoryPo,
  208. [L.receiptDate]: p.receiptDate ?? "",
  209. [L.poNo]: p.poCode ?? "",
  210. [L.currency]: p.currencyCode ?? "",
  211. [L.totalAmount]: moneyCellValue(p.totalAmount),
  212. [L.grnCodes]: p.grnCodes ?? "",
  213. });
  214. }
  215. return out;
  216. }
  217. /**
  218. * Generate and download GRN report as Excel.
  219. * Sheet "已上架PO金額" is included only when `includeFinancialColumns` is true (ADMIN).
  220. */
  221. export async function generateGrnReportExcel(
  222. criteria: Record<string, string>,
  223. reportTitle: string = "PO 入倉記錄",
  224. /** Only users with ADMIN authority should pass true (must match backend). */
  225. includeFinancialColumns: boolean = false,
  226. t?: TFunction,
  227. ): Promise<void> {
  228. const { rows, listedPoAmounts } = await fetchGrnReportData(criteria);
  229. const excelRows = rows.map((r) => toExcelRow(r, includeFinancialColumns, t));
  230. const start = criteria.receiptDateStart;
  231. const end = criteria.receiptDateEnd;
  232. let datePart: string;
  233. if (start && end && start === end) {
  234. datePart = start;
  235. } else if (start || end) {
  236. datePart = `${start || ""}_to_${end || ""}`;
  237. } else {
  238. datePart = new Date().toISOString().slice(0, 10);
  239. }
  240. const safeDatePart = datePart.replace(/[^\d\-_/]/g, "");
  241. const filename = `${reportTitle}_${safeDatePart}`;
  242. const L = grnLabels(t);
  243. if (includeFinancialColumns) {
  244. const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts, t);
  245. exportMultiSheetToXlsx(
  246. [
  247. { name: L.sheetDetail, rows: excelRows as Record<string, unknown>[] },
  248. { name: L.sheetListedPo, rows: sheet2 as Record<string, unknown>[] },
  249. ],
  250. filename
  251. );
  252. } else {
  253. exportChartToXlsx(excelRows as Record<string, unknown>[], filename, L.sheetDetail);
  254. }
  255. }