"use client"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; export interface JobOrderListItem { id: number; code: string | null; planStart: string | null; itemCode: string | null; itemName: string | null; reqQty: number | null; stockInLineId: number | null; itemId: number | null; lotNo: string | null; /** Effective shelf life days used for print (chilled or -18, according to useMinus18). */ defaultShelfLifeDays?: number | null; /** True when expiry is computed from -18 warehouse days. */ useMinus18?: boolean | null; /** Print date + effective shelf life days (yyyy-MM-dd). */ expiryDate?: string | null; /** 打袋機 DataFlex cumulative printed qty */ bagPrintedQty?: number; /** 標簽機 cumulative printed qty */ labelPrintedQty?: number; /** 激光機 cumulative printed qty */ laserPrintedQty?: number; } export interface PrinterStatusRequest { printerType: "dataflex" | "laser"; printerIp?: string; printerPort?: number; } export interface PrinterStatusResponse { connected: boolean; message: string; } export interface OnPackQrDownloadRequest { jobOrders: { jobOrderId: number; itemCode: string; }[]; /** /bagPrint filter date (YYYY-MM-DD). Used by expiry ZIP for production date. */ planDate?: string; } /** Same mapping as Bag Print download buttons: one entry per row with a non-empty item code. */ export function buildOnPackJobOrdersPayload(jobOrders: JobOrderListItem[]): { jobOrderId: number; itemCode: string; }[] { return jobOrders .map((jobOrder) => ({ jobOrderId: jobOrder.id, itemCode: jobOrder.itemCode?.trim() || "", })) .filter((jobOrder) => jobOrder.itemCode.length > 0); } export interface NgpclPushResponse { pushed: boolean; message: string; } /** * POST the same lemon OnPack ZIP bytes as download-onpack-qr-text to the server-configured NGPCL HTTP endpoint (ngpcl.push-url). * When the URL is not configured, response has pushed=false — use download ZIP instead. */ export async function pushOnPackTextQrZipToNgpcl(request: OnPackQrDownloadRequest): Promise { const url = `${NEXT_PUBLIC_API_URL}/plastic/ngpcl/push-onpack-qr-text`; const res = await clientAuthFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); if (res.status === 401 || res.status === 403) { return { pushed: false, message: "Session expired or unauthorized." }; } const data = (await res.json()) as NgpclPushResponse; if (!res.ok) { throw new Error(data.message || `HTTP ${res.status}`); } return data; } /** Readable message when ZIP download returns non-OK (plain text, JSON error body, or generic). */ async function zipDownloadError(res: Response): Promise { return parseBagPrintApiError(res, "下載"); } /** Backend ErrorRes is `{ timestamp, traceId }` with no message; avoid calling that a ZIP download failure. */ async function parseBagPrintApiError(res: Response, action: string): Promise { const text = await res.text(); const ct = res.headers.get("content-type") ?? ""; if (ct.includes("application/json")) { try { const j = JSON.parse(text) as { message?: string; error?: string; traceId?: string }; if (typeof j.message === "string" && j.message.length > 0) { return new Error(j.message); } if (typeof j.error === "string" && j.error.length > 0) { return new Error(j.error); } if (typeof j.traceId === "string" && j.traceId.length > 0) { return new Error( `${action}失敗(HTTP ${res.status})。請重啟後端以執行 Liquibase,或查看日誌 traceId ${j.traceId}。`, ); } } catch { /* ignore parse */ } } if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) { return new Error(text); } return new Error(`${action}失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`); } /** * Fetch job orders by plan date from GET /py/job-orders. * Client-side only; uses auth token from localStorage. */ export async function fetchJobOrders(planStart: string): Promise { const url = `${NEXT_PUBLIC_API_URL}/py/job-orders?planStart=${encodeURIComponent(planStart)}`; const res = await clientAuthFetch(url, { method: "GET" }); if (!res.ok) { throw new Error(`Failed to fetch job orders: ${res.status}`); } return res.json(); } export async function checkPrinterStatus( request: PrinterStatusRequest, ): Promise { const url = `${NEXT_PUBLIC_API_URL}/plastic/check-printer`; const res = await clientAuthFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = (await res.json()) as PrinterStatusResponse; if (!res.ok) { return data; } return data; } export async function downloadOnPackQrZip( request: OnPackQrDownloadRequest, ): Promise { const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr`; const res = await clientAuthFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); if (!res.ok) { throw await zipDownloadError(res); } return res.blob(); } /** 汁水機 OnPack — same as QR ZIP, plus LOGO_EXP BMP from item_default_shelf_life. */ export async function downloadOnPackQrZipWithExpiry( request: OnPackQrDownloadRequest, ): Promise { const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`; const res = await clientAuthFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); if (!res.ok) { throw await zipDownloadError(res); } return res.blob(); } /** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */ export async function downloadOnPackTextQrZip( request: OnPackQrDownloadRequest, ): Promise { const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text`; const res = await clientAuthFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); if (!res.ok) { throw await zipDownloadError(res); } return res.blob(); } /** OnPack2023 檸檬機 — same as text ZIP, plus TEXT_EXP from item_default_shelf_life. */ export async function downloadOnPackTextQrZipWithExpiry( request: OnPackQrDownloadRequest, ): Promise { const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`; const res = await clientAuthFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); if (!res.ok) { throw await zipDownloadError(res); } return res.blob(); } export type OnPackMachine = "juice" | "lemon"; export interface OnPackTemplateFileDto { id: number; machine: OnPackMachine | string; itemCode: string; fileName: string; byteSize: number; modified?: string | null; } export interface OnPackTemplateUploadResponse { machine: string; itemCode: string; saved: string[]; } export interface OnPackSupportedItemDto { itemCode: string; printable: boolean; inDatabase: boolean; builtin: boolean; registered: boolean; } export interface OnPackSupportedCatalogDto { juice: OnPackSupportedItemDto[]; lemon: OnPackSupportedItemDto[]; } export async function fetchOnPackSupportedCatalog(): Promise { const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`; const res = await clientAuthFetch(url, { method: "GET" }); if (!res.ok) { throw await parseBagPrintApiError(res, "讀取 OnPack 支援清單"); } return (await res.json()) as OnPackSupportedCatalogDto; } export async function listOnPackTemplates(machine?: OnPackMachine): Promise { const q = machine ? `?machine=${encodeURIComponent(machine)}` : ""; const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates${q}`; const res = await clientAuthFetch(url, { method: "GET" }); if (!res.ok) { throw await parseBagPrintApiError(res, "讀取 OnPack 模板"); } return (await res.json()) as OnPackTemplateFileDto[]; } export async function uploadOnPackTemplates( machine: OnPackMachine, itemCode: string, files: File[], ): Promise { const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates`; const body = new FormData(); body.append("machine", machine); body.append("itemCode", itemCode); files.forEach((f) => body.append("files", f)); const res = await clientAuthFetch(url, { method: "POST", body }); if (!res.ok) { throw await parseBagPrintApiError(res, "上傳 OnPack 模板"); } return (await res.json()) as OnPackTemplateUploadResponse; } export async function deleteOnPackTemplate(id: number): Promise { const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/${id}`; const res = await clientAuthFetch(url, { method: "DELETE" }); if (!res.ok && res.status !== 204) { throw await parseBagPrintApiError(res, "刪除 OnPack 模板"); } }