diff --git a/src/app/(main)/report/ReportSelectionDashboard.tsx b/src/app/(main)/report/ReportSelectionDashboard.tsx index 3e1ff3d..bc256f7 100644 --- a/src/app/(main)/report/ReportSelectionDashboard.tsx +++ b/src/app/(main)/report/ReportSelectionDashboard.tsx @@ -32,6 +32,7 @@ const REPORT_ICON_MAP: Record = { "rep-013": LocalShippingOutlinedIcon, "rep-006": BarChartOutlinedIcon, "rep-005": PieChartOutlineOutlinedIcon, + "rep-015": LayersOutlinedIcon, }; const reportById = Object.fromEntries(REPORTS.map((r) => [r.id, r])); diff --git a/src/app/(main)/report/bomShopSyncReportApi.ts b/src/app/(main)/report/bomShopSyncReportApi.ts new file mode 100644 index 0000000..2af5688 --- /dev/null +++ b/src/app/(main)/report/bomShopSyncReportApi.ts @@ -0,0 +1,205 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { + exportMultiSheetToXlsx, +} from "@/app/(main)/chart/_components/exportChartToXlsx"; + +export interface BomShopSyncReportSummary { + totalAttempts?: number; + success?: number; + skippedUnchanged?: number; + failed?: number; + syncDateStart?: string; + syncDateEnd?: string; +} + +export interface BomShopSyncRow { + syncLogId?: number; + syncDateTime?: string; + bomId?: number; + bomRoutingCode?: string; + finishedItemCode?: string; + finishedItemName?: string; + m18HeaderCode?: string; + version?: string; + m18RecordId?: number; + syncStatus?: string; + synced?: boolean; + m18ApiStatus?: boolean; + failureReason?: string; + message?: string; +} + +export interface BomShopSyncMaterialRow { + syncLogId?: number; + syncDateTime?: string; + bomId?: number; + finishedItemCode?: string; + m18HeaderCode?: string; + version?: string; + syncStatus?: string; + lineNo?: string; + materialName?: string; + udfProductM18Id?: number; + udfBaseUnit?: string; + udfQty?: number; + udfSupplierM18Id?: number; + udfPurchaseUnitM18Id?: number; +} + +export interface BomShopSyncReportResponse { + summary?: BomShopSyncReportSummary; + syncRows?: BomShopSyncRow[]; + materialRows?: BomShopSyncMaterialRow[]; +} + +const SHEET_SYNC = "BOM同步記錄"; +const SHEET_MATERIALS = "BOM物料明細"; + +const NO_DATA_NOTE = + "(篩選範圍內無資料 / No records in the selected range)"; + +/** Column keys for sheet 1 — used for headers when there are no data rows. */ +function emptySyncSheetRow(note: string = NO_DATA_NOTE): Record { + return { + 同步時間: note, + 成品貨號: "", + 成品名稱: "", + BOM路由編號: "", + "M18 BOM Code": "", + 版本: "", + "M18 Record Id": "", + 狀態: "", + 失敗原因: "", + 訊息: "", + "BOM Id": "", + "Sync Log Id": "", + }; +} + +/** Column keys for sheet 2 — used for headers when there are no data rows. */ +function emptyMaterialSheetRow(note: string = NO_DATA_NOTE): Record { + return { + 同步時間: note, + 成品貨號: "", + "M18 BOM Code": "", + 版本: "", + 狀態: "", + 行號: "", + 物料名稱: "", + "M18 Product Id": "", + 單位: "", + 用量: "", + "M18 Supplier Id": "", + "M18 Purchase Unit Id": "", + "Sync Log Id": "", + }; +} + +export async function fetchBomShopSyncReportData( + criteria: Record, +): Promise { + const queryParams = new URLSearchParams(criteria).toString(); + const url = `${NEXT_PUBLIC_API_URL}/report/bom-shop-sync-history?${queryParams}`; + + const response = await clientAuthFetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + }); + + if (response.status === 401 || response.status === 403) + throw new Error("Unauthorized"); + if (!response.ok) + throw new Error(`HTTP error! status: ${response.status}`); + + return (await response.json()) as BomShopSyncReportResponse; +} + +function syncStatusLabel(status: string | undefined): string { + switch (status) { + case "SUCCESS": + return "成功"; + case "SKIPPED_UNCHANGED": + return "略過(內容未變)"; + case "FAILED": + return "失敗"; + default: + return status ?? ""; + } +} + +function toSyncExcelRow(r: BomShopSyncRow): Record { + const base = emptySyncSheetRow(""); + return { + ...base, + 同步時間: r.syncDateTime ?? "", + 成品貨號: r.finishedItemCode ?? "", + 成品名稱: r.finishedItemName ?? "", + BOM路由編號: r.bomRoutingCode ?? "", + "M18 BOM Code": r.m18HeaderCode ?? "", + 版本: r.version ?? "", + "M18 Record Id": r.m18RecordId ?? "", + 狀態: syncStatusLabel(r.syncStatus), + 失敗原因: r.failureReason ?? "", + 訊息: r.message ?? "", + "BOM Id": r.bomId ?? "", + "Sync Log Id": r.syncLogId ?? "", + }; +} + +function toMaterialExcelRow(r: BomShopSyncMaterialRow): Record { + const base = emptyMaterialSheetRow(""); + return { + ...base, + 同步時間: r.syncDateTime ?? "", + 成品貨號: r.finishedItemCode ?? "", + "M18 BOM Code": r.m18HeaderCode ?? "", + 版本: r.version ?? "", + 狀態: syncStatusLabel(r.syncStatus), + 行號: r.lineNo ?? "", + 物料名稱: r.materialName ?? "", + "M18 Product Id": r.udfProductM18Id ?? "", + 單位: r.udfBaseUnit ?? "", + 用量: r.udfQty ?? "", + "M18 Supplier Id": r.udfSupplierM18Id ?? "", + "M18 Purchase Unit Id": r.udfPurchaseUnitM18Id ?? "", + "Sync Log Id": r.syncLogId ?? "", + }; +} + +export async function generateBomShopSyncReportExcel( + criteria: Record, + reportTitle: string = "M18 BOM Shop 同步記錄", +): Promise { + const data = await fetchBomShopSyncReportData(criteria); + const syncRows = + (data.syncRows ?? []).length > 0 + ? (data.syncRows ?? []).map(toSyncExcelRow) + : [emptySyncSheetRow()]; + const materialRows = + (data.materialRows ?? []).length > 0 + ? (data.materialRows ?? []).map(toMaterialExcelRow) + : [emptyMaterialSheetRow()]; + + const start = criteria.syncDateStart; + const end = criteria.syncDateEnd; + 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 filename = `${reportTitle}_${datePart.replace(/[^\d\-_/]/g, "")}`; + + exportMultiSheetToXlsx( + [ + { name: SHEET_SYNC, rows: syncRows }, + { name: SHEET_MATERIALS, rows: materialRows }, + ], + filename, + ); +} diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index cf16570..a6b32ce 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -30,6 +30,7 @@ import { fetchSemiFGItemCodesWithCategory } from './semiFGProductionAnalysisApi'; import { generateGrnReportExcel } from './grnReportApi'; +import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi'; import { FEATURE_USAGE, FEATURE_USAGE_ACTION, @@ -261,6 +262,8 @@ export default function ReportPage() { currentReport.title, includeGrnFinancialColumns ); + } else if (currentReport.id === 'rep-015') { + await generateBomShopSyncReportExcel(criteria, currentReport.title); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. const queryParams = diff --git a/src/app/(main)/report/reportCategories.ts b/src/app/(main)/report/reportCategories.ts index b869064..7c6cced 100644 --- a/src/app/(main)/report/reportCategories.ts +++ b/src/app/(main)/report/reportCategories.ts @@ -33,6 +33,6 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ headerBg: "#f5d4a8", bodyBg: "#fdf6ec", accent: "#e65100", - reportIds: ["rep-006", "rep-005"], + reportIds: ["rep-006", "rep-005", "rep-015"], }, ]; diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index c3fe0c5..f917936 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -290,5 +290,27 @@ export const REPORTS: ReportDefinition[] = [ dynamicOptionsParam: "stockCategory", options: [] }, ] - } + }, + { + id: "rep-015", + title: "M18 BOM Shop 同步記錄", + apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/bom-shop-sync-history`, + responseType: "excel", + fields: [ + { label: "同步日期:由 Sync Date Start", name: "syncDateStart", type: "date", required: false }, + { label: "同步日期:至 Sync Date End", name: "syncDateEnd", type: "date", required: false }, + { label: "成品貨號 Finished Item Code", name: "finishedItemCode", type: "text", required: false }, + { + label: "同步狀態 Sync Status", + name: "syncStatus", + type: "select", + required: false, + options: [ + { label: "全部 All", value: "all" }, + { label: "成功 Success", value: "success" }, + { label: "失敗 Failed", value: "failed" }, + ], + }, + ], + }, ] \ No newline at end of file