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.
 
 

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