"use client"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { buildItemCodePasteRequestBody } from './parseItemCodeTokens'; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { exportChartToXlsx, exportMultiSheetToXlsx, } from "@/app/(main)/chart/_components/exportChartToXlsx"; import { reportExcelT as tx } from "./reportI18n"; import type { TFunction } from "i18next"; export interface GrnReportRow { poCode?: string; deliveryNoteNo?: string; receiptDate?: string; itemCode?: string; itemName?: string; acceptedQty?: number; receivedQty?: number; demandQty?: number; uom?: string; purchaseUomDesc?: string; stockUomDesc?: string; productLotNo?: string; expiryDate?: string; supplierCode?: string; supplier?: string; status?: string; /** PO line unit price (purchase_order_line.up) */ unitPrice?: number; /** unitPrice × acceptedQty */ lineAmount?: number; /** PO currency code (currency.code) */ currencyCode?: string; /** M18 AN document code from m18_goods_receipt_note_log.grn_code */ grnCode?: string; /** M18 record id (m18_record_id) */ grnId?: number | string; /** From purchase_order.m18CreatedUId; e.g. "2569 (legato)" */ poM18CreatorDisplay?: string; [key: string]: unknown; } /** Sheet "已上架PO金額": totals grouped by receipt date + currency / PO (ADMIN-only data from API). */ export interface ListedPoAmounts { currencyTotals: { receiptDate?: string; currencyCode?: string; totalAmount?: number; }[]; byPurchaseOrder: { receiptDate?: string; poCode?: string; currencyCode?: string; totalAmount?: number; grnCodes?: string; }[]; } export interface GrnReportResponse { rows: GrnReportRow[]; listedPoAmounts?: ListedPoAmounts; } /** * Fetch GRN (Goods Received Note) report data by date range. * Backend: POST /report/grn-report { receiptDateStart, receiptDateEnd, itemCodes } */ export async function fetchGrnReportData( criteria: Record ): Promise<{ rows: GrnReportRow[]; listedPoAmounts?: ListedPoAmounts }> { const url = `${NEXT_PUBLIC_API_URL}/report/grn-report`; const response = await clientAuthFetch(url, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(buildItemCodePasteRequestBody(criteria, "itemCode")), }); if (response.status === 401 || response.status === 403) throw new Error("Unauthorized"); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const data = (await response.json()) as GrnReportResponse | GrnReportRow[]; if (Array.isArray(data)) { return { rows: data }; } const body = data as GrnReportResponse; return { rows: body.rows ?? [], listedPoAmounts: body.listedPoAmounts, }; } /** Coerce API JSON (number or numeric string) to a finite number. */ function coerceToFiniteNumber(value: unknown): number | null { if (value === null || value === undefined) return null; if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const t = value.trim(); if (t === "") return null; const n = Number(t); return Number.isFinite(n) ? n : null; } return null; } /** * Cell value for money columns: numeric when possible so Excel export can apply `#,##0.00` (see exportChartToXlsx). */ function moneyCellValue(v: unknown): number | string { const n = coerceToFiniteNumber(v); if (n === null) return ""; return n; } /** Thousands separator for quantities (up to 4 decimal places, trims trailing zeros). */ const formatQty = (n: number | undefined | null): string => { if (n === undefined || n === null || Number.isNaN(Number(n))) return ""; return new Intl.NumberFormat("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 4, }).format(Number(n)); }; function grnLabels(t?: TFunction) { return { sheetDetail: tx(t, "excel.grn.sheetDetail", "PO入倉記錄"), sheetListedPo: tx(t, "excel.grn.sheetListedPo", "已上架PO金額"), poNo: tx(t, "excel.grn.poNo", "訂單編號"), deliveryNoteNo: tx(t, "excel.grn.deliveryNoteNo", "送貨單編號"), receiptDate: tx(t, "excel.grn.receiptDate", "收貨日期"), itemCode: tx(t, "excel.grn.itemCode", "物料編號"), itemName: tx(t, "excel.grn.itemName", "物料名稱"), qty: tx(t, "excel.grn.qty", "數量"), demandQty: tx(t, "excel.grn.demandQty", "訂單數量"), uom: tx(t, "excel.grn.uom", "單位"), supplierLotNo: tx(t, "excel.grn.supplierLotNo", "供應商批次"), expiryDate: tx(t, "excel.grn.expiryDate", "到期日"), supplierCode: tx(t, "excel.grn.supplierCode", "供應商編號"), supplier: tx(t, "excel.grn.supplier", "供應商"), status: tx(t, "excel.grn.status", "入倉狀態"), unitPrice: tx(t, "excel.grn.unitPrice", "單價"), currency: tx(t, "excel.grn.currency", "貨幣"), amount: tx(t, "excel.grn.amount", "金額"), grnCode: tx(t, "excel.grn.grnCode", "M18 入倉單號"), grnId: tx(t, "excel.grn.grnId", "M18 記錄編號"), poCreator: tx(t, "excel.grn.poCreator", "PO建立者(M18)"), note: tx(t, "excel.grn.note", "備註"), category: tx(t, "excel.grn.category", "類別"), totalAmount: tx(t, "excel.grn.totalAmount", "金額"), grnCodes: tx(t, "excel.grn.grnCodes", "M18 入倉單號"), noCompletedPo: tx(t, "excel.grn.noCompletedPo", "(篩選範圍內無已完成之 PO 行)"), categoryCurrencyTotal: tx(t, "excel.grn.categoryCurrencyTotal", "貨幣小計"), categoryPo: tx(t, "excel.grn.categoryPo", "訂單"), }; } function toExcelRow( r: GrnReportRow, includeFinancialColumns: boolean, t?: TFunction, ): Record { const L = grnLabels(t); const base: Record = { [L.poNo]: r.poCode ?? "", [L.deliveryNoteNo]: r.deliveryNoteNo ?? "", [L.receiptDate]: r.receiptDate ?? "", [L.itemCode]: r.itemCode ?? "", [L.itemName]: r.itemName ?? "", [L.qty]: formatQty(r.acceptedQty ?? r.receivedQty ?? undefined), [L.demandQty]: formatQty(r.demandQty), [L.uom]: r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "", [L.supplierLotNo]: r.productLotNo ?? "", [L.expiryDate]: r.expiryDate ?? "", [L.supplierCode]: r.supplierCode ?? "", [L.supplier]: r.supplier ?? "", [L.status]: r.status ?? "", }; if (includeFinancialColumns) { base[L.unitPrice] = moneyCellValue(r.unitPrice); base[L.currency] = r.currencyCode ?? ""; base[L.amount] = moneyCellValue(r.lineAmount); } base[L.grnCode] = r.grnCode ?? ""; base[L.grnId] = r.grnId ?? ""; base[L.poCreator] = r.poM18CreatorDisplay ?? ""; return base; } function buildListedPoAmountSheetRows( listed: ListedPoAmounts | undefined, t?: TFunction, ): Record[] { const L = grnLabels(t); if ( !listed || (listed.currencyTotals.length === 0 && listed.byPurchaseOrder.length === 0) ) { return [{ [L.note]: L.noCompletedPo }]; } const out: Record[] = []; for (const c of listed.currencyTotals) { out.push({ [L.category]: L.categoryCurrencyTotal, [L.receiptDate]: c.receiptDate ?? "", [L.poNo]: "", [L.currency]: c.currencyCode ?? "", [L.totalAmount]: moneyCellValue(c.totalAmount), [L.grnCodes]: "", }); } for (const p of listed.byPurchaseOrder) { out.push({ [L.category]: L.categoryPo, [L.receiptDate]: p.receiptDate ?? "", [L.poNo]: p.poCode ?? "", [L.currency]: p.currencyCode ?? "", [L.totalAmount]: moneyCellValue(p.totalAmount), [L.grnCodes]: p.grnCodes ?? "", }); } return out; } /** * Generate and download GRN report as Excel. * Sheet "已上架PO金額" is included only when `includeFinancialColumns` is true (ADMIN). */ export async function generateGrnReportExcel( criteria: Record, reportTitle: string = "PO 入倉記錄", /** Only users with ADMIN authority should pass true (must match backend). */ includeFinancialColumns: boolean = false, t?: TFunction, ): Promise { const { rows, listedPoAmounts } = await fetchGrnReportData(criteria); const excelRows = rows.map((r) => toExcelRow(r, includeFinancialColumns, t)); const start = criteria.receiptDateStart; const end = criteria.receiptDateEnd; let datePart: string; if (start && end && start === end) { datePart = start; } else if (start || end) { datePart = `${start || ""}_to_${end || ""}`; } else { datePart = new Date().toISOString().slice(0, 10); } const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); const filename = `${reportTitle}_${safeDatePart}`; const L = grnLabels(t); if (includeFinancialColumns) { const sheet2 = buildListedPoAmountSheetRows(listedPoAmounts, t); exportMultiSheetToXlsx( [ { name: L.sheetDetail, rows: excelRows as Record[] }, { name: L.sheetListedPo, rows: sheet2 as Record[] }, ], filename ); } else { exportChartToXlsx(excelRows as Record[], filename, L.sheetDetail); } }