From 55ef2bfc00d0f5b5bf8753067d72c8a08cae3260 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Fri, 21 Aug 2026 21:44:11 +0800 Subject: [PATCH] added for expiry date for bag printing --- src/app/api/bagPrint/actions.ts | 132 +++++- src/app/api/laserPrint/actions.ts | 5 + src/components/BagPrint/BagPrintSearch.tsx | 414 +++++++++++++++++- .../LaserPrint/LaserPrintSearch.tsx | 1 + 4 files changed, 547 insertions(+), 5 deletions(-) diff --git a/src/app/api/bagPrint/actions.ts b/src/app/api/bagPrint/actions.ts index 3749ee30..5ec95ec8 100644 --- a/src/app/api/bagPrint/actions.ts +++ b/src/app/api/bagPrint/actions.ts @@ -13,6 +13,12 @@ export interface JobOrderListItem { 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 */ @@ -37,6 +43,8 @@ export interface OnPackQrDownloadRequest { 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. */ @@ -80,17 +88,27 @@ export async function pushOnPackTextQrZipToNgpcl(request: OnPackQrDownloadReques /** 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 }; + 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 */ } @@ -98,7 +116,7 @@ async function zipDownloadError(res: Response): Promise { if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) { return new Error(text); } - return new Error(`下載失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`); + return new Error(`${action}失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`); } /** @@ -149,6 +167,24 @@ export async function downloadOnPackQrZip( 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, @@ -166,3 +202,95 @@ export async function downloadOnPackTextQrZip( 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 模板"); + } +} diff --git a/src/app/api/laserPrint/actions.ts b/src/app/api/laserPrint/actions.ts index 183b0c5b..749385cd 100644 --- a/src/app/api/laserPrint/actions.ts +++ b/src/app/api/laserPrint/actions.ts @@ -13,6 +13,9 @@ export interface JobOrderListItem { stockInLineId: number | null; itemId: number | null; lotNo: string | null; + defaultShelfLifeDays?: number | null; + useMinus18?: boolean | null; + expiryDate?: string | null; bagPrintedQty?: number; labelPrintedQty?: number; laserPrintedQty?: number; @@ -48,6 +51,8 @@ export interface LaserBag2SendRequest { jobOrderId?: number | null; jobOrderNo?: string | null; lotNo?: string | null; + /** Print-time expiry from job list (`yyyy-MM-dd`); backend sends as 4th TCP field. */ + expiryDate?: string | null; source?: string | null; } diff --git a/src/components/BagPrint/BagPrintSearch.tsx b/src/components/BagPrint/BagPrintSearch.tsx index 63e7579b..428b532b 100644 --- a/src/components/BagPrint/BagPrintSearch.tsx +++ b/src/components/BagPrint/BagPrintSearch.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Box, Button, + Chip, FormControl, InputLabel, MenuItem, @@ -29,11 +30,22 @@ import { buildOnPackJobOrdersPayload, checkPrinterStatus, downloadOnPackQrZip, + downloadOnPackQrZipWithExpiry, downloadOnPackTextQrZip, + downloadOnPackTextQrZipWithExpiry, + deleteOnPackTemplate, fetchJobOrders, + fetchOnPackSupportedCatalog, + listOnPackTemplates, + uploadOnPackTemplates, JobOrderListItem, + OnPackMachine, + OnPackSupportedCatalogDto, + OnPackTemplateFileDto, } from "@/app/api/bagPrint/actions"; import dayjs from "dayjs"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; @@ -56,6 +68,28 @@ const REFRESH_MS = 60 * 1000; const PRINTER_CHECK_MS = 60 * 1000; const PRINTER_RETRY_MS = 30 * 1000; const SETTINGS_KEY = "bagPrint_settings"; +const ONPACK_ADMIN_USERNAME = "2fi"; + +/** Login username from backend JWT `sub` (UserDetails.username). */ +function loginUsernameFromSession(session: SessionWithTokens | null | undefined): string { + const token = session?.accessToken?.trim(); + if (token) { + try { + const parts = token.split("."); + if (parts.length >= 2) { + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const payload = JSON.parse(atob(padded)) as { sub?: unknown }; + if (typeof payload.sub === "string" && payload.sub.trim()) { + return payload.sub.trim(); + } + } + } catch { + // fall through to display name + } + } + return (session?.user?.name ?? "").trim(); +} const DEFAULT_SETTINGS = { dabag_ip: "", @@ -95,7 +129,21 @@ function getBatch(jo: JobOrderListItem): string { return (jo.lotNo || "—").trim() || "—"; } +function printableCodes(items: { itemCode: string; printable: boolean }[] | undefined): Set { + return new Set((items ?? []).filter((r) => r.printable).map((r) => r.itemCode.toUpperCase())); +} + +function supportLabel(row: { inDatabase: boolean; builtin: boolean; printable: boolean }): string { + if (!row.printable) return "缺模板"; + if (row.inDatabase && row.builtin) return "資料庫+內建"; + if (row.inDatabase) return "資料庫"; + return "內建"; +} + const BagPrintSearch: React.FC = () => { + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const canSeeOnPackAdmin = + loginUsernameFromSession(session).toLowerCase() === ONPACK_ADMIN_USERNAME; const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); const [jobOrders, setJobOrders] = useState([]); const [loading, setLoading] = useState(true); @@ -109,12 +157,28 @@ const BagPrintSearch: React.FC = () => { const [printContinuous, setPrintContinuous] = useState(false); const [printing, setPrinting] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [templatesOpen, setTemplatesOpen] = useState(false); + const [templateMachine, setTemplateMachine] = useState("juice"); + const [templateItemCode, setTemplateItemCode] = useState(""); + const [templateFiles, setTemplateFiles] = useState([]); + const [templateLoading, setTemplateLoading] = useState(false); + const [templateUploading, setTemplateUploading] = useState(false); + const [supportedCatalog, setSupportedCatalog] = useState(null); + const templateUploadRef = useRef(false); + const templateDeleteRef = useRef(false); + const templateFileInputRef = useRef(null); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" }); const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [printerConnected, setPrinterConnected] = useState(false); const [printerMessage, setPrinterMessage] = useState("列印機未連接"); const [downloadingOnPack, setDownloadingOnPack] = useState(false); + const [downloadingOnPackExp, setDownloadingOnPackExp] = useState(false); const [downloadingOnPackText, setDownloadingOnPackText] = useState(false); + const [downloadingOnPackTextExp, setDownloadingOnPackTextExp] = useState(false); + const downloadingOnPackRef = useRef(false); + const downloadingOnPackExpRef = useRef(false); + const downloadingOnPackTextRef = useRef(false); + const downloadingOnPackTextExpRef = useRef(false); useEffect(() => { setSettings(loadSettings()); @@ -275,6 +339,7 @@ const BagPrintSearch: React.FC = () => { }; const handleDownloadOnPackQr = async () => { + if (downloadingOnPackRef.current) return; const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); if (onPackJobOrders.length === 0) { @@ -282,6 +347,7 @@ const BagPrintSearch: React.FC = () => { return; } + downloadingOnPackRef.current = true; setDownloadingOnPack(true); try { const blob = await downloadOnPackQrZip({ @@ -306,10 +372,51 @@ const BagPrintSearch: React.FC = () => { }); } finally { setDownloadingOnPack(false); + downloadingOnPackRef.current = false; + } + }; + + const handleDownloadOnPackQrWithExpiry = async () => { + if (downloadingOnPackExpRef.current) return; + const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); + + if (onPackJobOrders.length === 0) { + setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); + return; + } + + downloadingOnPackExpRef.current = true; + setDownloadingOnPackExp(true); + try { + const blob = await downloadOnPackQrZipWithExpiry({ + jobOrders: onPackJobOrders, + planDate, + }); + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `onpack_qr_exp_${planDate}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + setSnackbar({ open: true, message: "OnPack 汁水機(含到期日)ZIP 已下載", severity: "success" }); + } catch (e) { + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "下載 OnPack 汁水機(含到期日)失敗", + severity: "error", + }); + } finally { + setDownloadingOnPackExp(false); + downloadingOnPackExpRef.current = false; } }; const handleDownloadOnPackTextQr = async () => { + if (downloadingOnPackTextRef.current) return; const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); if (onPackJobOrders.length === 0) { @@ -317,6 +424,7 @@ const BagPrintSearch: React.FC = () => { return; } + downloadingOnPackTextRef.current = true; setDownloadingOnPackText(true); try { const blob = await downloadOnPackTextQrZip({ @@ -341,6 +449,140 @@ const BagPrintSearch: React.FC = () => { }); } finally { setDownloadingOnPackText(false); + downloadingOnPackTextRef.current = false; + } + }; + + const handleDownloadOnPackTextQrWithExpiry = async () => { + if (downloadingOnPackTextExpRef.current) return; + const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); + + if (onPackJobOrders.length === 0) { + setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" }); + return; + } + + downloadingOnPackTextExpRef.current = true; + setDownloadingOnPackTextExp(true); + try { + const blob = await downloadOnPackTextQrZipWithExpiry({ + jobOrders: onPackJobOrders, + planDate, + }); + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `onpack2023_lemon_qr_exp_${planDate}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + setSnackbar({ open: true, message: "OnPack2023檸檬機(含到期日)ZIP 已下載", severity: "success" }); + } catch (e) { + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "下載 OnPack2023檸檬機(含到期日)失敗", + severity: "error", + }); + } finally { + setDownloadingOnPackTextExp(false); + downloadingOnPackTextExpRef.current = false; + } + }; + + const loadTemplateFiles = useCallback(async (machine: OnPackMachine) => { + setTemplateLoading(true); + try { + const rows = await listOnPackTemplates(machine); + setTemplateFiles(rows); + } catch (e) { + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "讀取 OnPack 模板失敗", + severity: "error", + }); + } finally { + setTemplateLoading(false); + } + }, []); + + const loadSupportedCatalog = useCallback(async (notify = false) => { + try { + setSupportedCatalog(await fetchOnPackSupportedCatalog()); + } catch (e) { + if (notify) { + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "讀取 OnPack 支援清單失敗", + severity: "error", + }); + } + } + }, []); + + useEffect(() => { + void loadSupportedCatalog(); + }, [loadSupportedCatalog]); + + useEffect(() => { + if (!templatesOpen) return; + void loadTemplateFiles(templateMachine); + void loadSupportedCatalog(true); + }, [templatesOpen, templateMachine, loadTemplateFiles, loadSupportedCatalog]); + + const handleUploadTemplates = async (fileList: FileList | null) => { + if (templateUploadRef.current) return; + const itemCode = templateItemCode.trim(); + if (!itemCode) { + setSnackbar({ open: true, message: "請先填寫品號", severity: "error" }); + return; + } + if (!fileList || fileList.length === 0) { + setSnackbar({ open: true, message: "請選擇 .image / .bmp / .job 檔案", severity: "error" }); + return; + } + templateUploadRef.current = true; + setTemplateUploading(true); + try { + const result = await uploadOnPackTemplates(templateMachine, itemCode, Array.from(fileList)); + setSnackbar({ + open: true, + message: `已儲存 ${result.itemCode}:${result.saved.join("、")}`, + severity: "success", + }); + if (templateFileInputRef.current) templateFileInputRef.current.value = ""; + await loadTemplateFiles(templateMachine); + await loadSupportedCatalog(); + } catch (e) { + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "上傳 OnPack 模板失敗", + severity: "error", + }); + } finally { + setTemplateUploading(false); + templateUploadRef.current = false; + } + }; + + const handleDeleteTemplate = async (row: OnPackTemplateFileDto) => { + if (templateDeleteRef.current) return; + templateDeleteRef.current = true; + try { + await deleteOnPackTemplate(row.id); + setSnackbar({ open: true, message: `已刪除 ${row.itemCode} / ${row.fileName}`, severity: "success" }); + await loadTemplateFiles(templateMachine); + await loadSupportedCatalog(); + } catch (e) { + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "刪除失敗", + severity: "error", + }); + } finally { + templateDeleteRef.current = false; } }; @@ -369,6 +611,11 @@ const BagPrintSearch: React.FC = () => { + {canSeeOnPackAdmin && ( + + )} { variant="contained" startIcon={} onClick={handleDownloadOnPackQr} - disabled={loading || downloadingOnPack || downloadingOnPackText || jobOrders.length === 0} + disabled={ + loading || + downloadingOnPack || + downloadingOnPackExp || + downloadingOnPackText || + downloadingOnPackTextExp || + jobOrders.length === 0 + } > {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"} + + {canSeeOnPackAdmin && ( + + )} @@ -436,6 +730,9 @@ const BagPrintSearch: React.FC = () => { const batch = getBatch(jo); const qtyStr = formatQty(jo.reqQty); const isSelected = selectedId === jo.id; + const codeKey = (jo.itemCode || "").trim().toUpperCase(); + const juiceOk = printableCodes(supportedCatalog?.juice).has(codeKey); + const lemonOk = printableCodes(supportedCatalog?.lemon).has(codeKey); return ( { {jo.itemCode || "—"} + + {juiceOk ? : null} + {lemonOk ? : null} + @@ -632,6 +933,113 @@ const BagPrintSearch: React.FC = () => { + setTemplatesOpen(false)} + maxWidth="md" + fullWidth + > + OnPack 支援品號 / 模板 + + + + 下列為目前可做 OnPack 列印的品號(內建只列 PP 品號)。上傳新套模板後會自動加入清單。下方「刪除」只作用於資料庫檔,內建檔無法從畫面移除。 + + + + + 汁水機 OnPack({printableCodes(supportedCatalog?.juice).size}) + + + {(supportedCatalog?.juice ?? []).filter((r) => r.printable).map((row) => ( + + {row.itemCode} · {supportLabel(row)} + + ))} + + + + + 檸檬機 OnPack({printableCodes(supportedCatalog?.lemon).size}) + + + {(supportedCatalog?.lemon ?? []).filter((r) => r.printable).map((row) => ( + + {row.itemCode} · {supportLabel(row)} + + ))} + + + + + 新增品號:上傳該套 .image / .bmp / .job。汁水機請上傳{" "} + 品號.image;檸檬機請一併上傳模板裡引用的 BMP。 + + + 機台 + + + setTemplateItemCode(e.target.value)} + /> + + {templateLoading ? ( + + + + ) : templateFiles.length === 0 ? ( + 資料庫尚無此機台的上傳檔 + ) : ( + + {templateFiles.map((row) => ( + + + {row.itemCode} · {row.fileName} · {Math.max(1, Math.round(row.byteSize / 1024))} KB + + + + ))} + + )} + + + + + + + { jobOrderId: jo.id, jobOrderNo: jo.code, lotNo: jo.lotNo, + expiryDate: jo.expiryDate ?? null, source: "MANUAL", });