FPSMS-frontend
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

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