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.
 
 

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