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.
 
 

268 lines
9.0 KiB

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