| @@ -30,8 +30,10 @@ import { | |||
| type JobOrderListItem, | |||
| } from "@/app/api/bagPrint/actions"; | |||
| import { | |||
| fetchLaserBag2Settings, | |||
| runLaserBag2AutoSend, | |||
| type LaserBag2AutoSendReport, | |||
| type LaserLastReceiveSuccess, | |||
| } from "@/app/api/laserPrint/actions"; | |||
| import * as XLSX from "xlsx"; | |||
| @@ -79,6 +81,7 @@ export default function TestingPage() { | |||
| const [laserAutoLoading, setLaserAutoLoading] = useState(false); | |||
| const [laserAutoReport, setLaserAutoReport] = useState<LaserBag2AutoSendReport | null>(null); | |||
| const [laserAutoError, setLaserAutoError] = useState<string | null>(null); | |||
| const [laserLastReceive, setLaserLastReceive] = useState<LaserLastReceiveSuccess | null>(null); | |||
| const onpackPayload = useMemo(() => buildOnPackJobOrdersPayload(onpackJobOrders), [onpackJobOrders]); | |||
| @@ -105,6 +108,22 @@ export default function TestingPage() { | |||
| }; | |||
| }, [tabValue, onpackPlanDate]); | |||
| useEffect(() => { | |||
| if (tabValue !== 2) return; | |||
| let cancelled = false; | |||
| (async () => { | |||
| try { | |||
| const s = await fetchLaserBag2Settings(); | |||
| if (!cancelled) setLaserLastReceive(s.lastReceiveSuccess ?? null); | |||
| } catch { | |||
| if (!cancelled) setLaserLastReceive(null); | |||
| } | |||
| })(); | |||
| return () => { | |||
| cancelled = true; | |||
| }; | |||
| }, [tabValue]); | |||
| const handleDownloadGrnPreviewXlsx = async () => { | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| @@ -179,6 +198,12 @@ export default function TestingPage() { | |||
| limitPerRun: Number.isFinite(lim) ? lim : 0, | |||
| }); | |||
| setLaserAutoReport(report); | |||
| try { | |||
| const s = await fetchLaserBag2Settings(); | |||
| setLaserLastReceive(s.lastReceiveSuccess ?? null); | |||
| } catch { | |||
| /* ignore */ | |||
| } | |||
| } catch (e) { | |||
| setLaserAutoError(e instanceof Error ? e.message : String(e)); | |||
| } finally { | |||
| @@ -351,6 +376,28 @@ export default function TestingPage() { | |||
| <TabPanel value={tabValue} index={2}> | |||
| <Section title="3. Laser Bag2 自動送(與 /laserPrint 相同邏輯)"> | |||
| {laserLastReceive ? ( | |||
| <Alert severity="info" sx={{ mb: 2 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| 上次印表機已確認(receive)的工單(資料庫) | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ mt: 0.5 }}> | |||
| 工單號:{laserLastReceive.jobOrderNo ?? "—"} Lot:{laserLastReceive.lotNo ?? "—"} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ mt: 0.5, fontFamily: "monospace" }}> | |||
| JSON:{" "} | |||
| {laserLastReceive.itemId != null && laserLastReceive.stockInLineId != null | |||
| ? JSON.stringify({ | |||
| itemId: laserLastReceive.itemId, | |||
| stockInLineId: laserLastReceive.stockInLineId, | |||
| }) | |||
| : "—"} | |||
| </Typography> | |||
| <Typography variant="caption" color="textSecondary" display="block" sx={{ mt: 0.5 }}> | |||
| {laserLastReceive.sentAt ?? ""} {laserLastReceive.source ?? ""} | |||
| </Typography> | |||
| </Alert> | |||
| ) : null} | |||
| <Alert severity="warning" sx={{ mb: 2 }}> | |||
| 依資料庫 <strong>LASER_PRINT.host</strong>、<strong>LASER_PRINT.port</strong>、<strong>LASER_PRINT.itemCodes</strong> 查當日包裝工單並送 TCP(每筆工單預設 3 次、間隔 3 秒,與前端點列相同)。 | |||
| 排程預設關閉;啟用請設 <code>laser.bag2.auto-send.enabled=true</code>(後端 application.yml)。 | |||
| @@ -18,11 +18,24 @@ export interface JobOrderListItem { | |||
| laserPrintedQty?: number; | |||
| } | |||
| export interface LaserLastReceiveSuccess { | |||
| jobOrderId?: number | null; | |||
| jobOrderNo?: string | null; | |||
| lotNo?: string | null; | |||
| itemId?: number | null; | |||
| stockInLineId?: number | null; | |||
| printerAck?: string | null; | |||
| sentAt?: string | null; | |||
| source?: string | null; | |||
| } | |||
| export interface LaserBag2Settings { | |||
| host: string; | |||
| port: number; | |||
| /** Comma-separated item codes; empty string = show all packaging job orders */ | |||
| itemCodes: string; | |||
| /** Last job where the laser returned a receive ack (from DB settings). */ | |||
| lastReceiveSuccess?: LaserLastReceiveSuccess | null; | |||
| } | |||
| export interface LaserBag2SendRequest { | |||
| @@ -32,12 +45,20 @@ export interface LaserBag2SendRequest { | |||
| itemName: string | null; | |||
| printerIp?: string; | |||
| printerPort?: number; | |||
| jobOrderId?: number | null; | |||
| jobOrderNo?: string | null; | |||
| lotNo?: string | null; | |||
| source?: string | null; | |||
| } | |||
| export interface LaserBag2SendResponse { | |||
| success: boolean; | |||
| message: string; | |||
| payloadSent?: string | null; | |||
| /** Raw TCP reply from the laser plugin (often `receive;;`). */ | |||
| printerAck?: string | null; | |||
| /** True when the peer reply contained `receive` and not `invalid`. */ | |||
| receiveAcknowledged?: boolean; | |||
| } | |||
| /** | |||
| @@ -133,6 +154,8 @@ export interface LaserBag2JobSendResult { | |||
| itemCode: string | null; | |||
| success: boolean; | |||
| message: string; | |||
| printerAck?: string | null; | |||
| receiveAcknowledged?: boolean; | |||
| } | |||
| export interface LaserBag2AutoSendReport { | |||
| @@ -23,6 +23,7 @@ import { | |||
| checkPrinterStatus, | |||
| fetchLaserJobOrders, | |||
| fetchLaserBag2Settings, | |||
| type LaserLastReceiveSuccess, | |||
| JobOrderListItem, | |||
| patchSetting, | |||
| sendLaserBag2Job, | |||
| @@ -83,6 +84,7 @@ const LaserPrintSearch: React.FC = () => { | |||
| const [laserHost, setLaserHost] = useState("192.168.18.77"); | |||
| const [laserPort, setLaserPort] = useState("45678"); | |||
| const [laserItemCodes, setLaserItemCodes] = useState("PP1175"); | |||
| const [lastLaserReceive, setLastLaserReceive] = useState<LaserLastReceiveSuccess | null>(null); | |||
| const [settingsLoaded, setSettingsLoaded] = useState(false); | |||
| const [printerConnected, setPrinterConnected] = useState(false); | |||
| const [printerMessage, setPrinterMessage] = useState("檸檬機(激光機)未連接"); | |||
| @@ -93,8 +95,10 @@ const LaserPrintSearch: React.FC = () => { | |||
| setLaserHost(s.host); | |||
| setLaserPort(String(s.port)); | |||
| setLaserItemCodes(s.itemCodes ?? "PP1175"); | |||
| setLastLaserReceive(s.lastReceiveSuccess ?? null); | |||
| setSettingsLoaded(true); | |||
| } catch (e) { | |||
| setLastLaserReceive(null); | |||
| setErrorSnackbar({ | |||
| open: true, | |||
| message: e instanceof Error ? e.message : "無法載入系統設定", | |||
| @@ -183,6 +187,10 @@ const LaserPrintSearch: React.FC = () => { | |||
| stockInLineId: jo.stockInLineId, | |||
| itemCode: jo.itemCode, | |||
| itemName: jo.itemName, | |||
| jobOrderId: jo.id, | |||
| jobOrderNo: jo.code, | |||
| lotNo: jo.lotNo, | |||
| source: "MANUAL", | |||
| }); | |||
| const handleRowClick = async (jo: JobOrderListItem) => { | |||
| @@ -196,6 +204,8 @@ const LaserPrintSearch: React.FC = () => { | |||
| setSelectedId(jo.id); | |||
| setSendingJobId(jo.id); | |||
| try { | |||
| let lastAck: string | undefined; | |||
| let anyReceiveAck = false; | |||
| for (let i = 0; i < LASER_SEND_COUNT; i++) { | |||
| const r = await sendOne(jo); | |||
| if (!r.success) { | |||
| @@ -205,11 +215,20 @@ const LaserPrintSearch: React.FC = () => { | |||
| }); | |||
| return; | |||
| } | |||
| if (r.printerAck) lastAck = r.printerAck; | |||
| if (r.receiveAcknowledged) anyReceiveAck = true; | |||
| if (i < LASER_SEND_COUNT - 1) { | |||
| await delay(BETWEEN_SEND_MS); | |||
| } | |||
| } | |||
| setSuccessSignal(`已送出 ${LASER_SEND_COUNT} 次至檸檬機(激光機)`); | |||
| const ackHint = | |||
| anyReceiveAck && lastAck | |||
| ? `(印表機已回覆:${lastAck})` | |||
| : lastAck | |||
| ? `(最後回覆:${lastAck})` | |||
| : ""; | |||
| setSuccessSignal(`已送出 ${LASER_SEND_COUNT} 次至檸檬機(激光機)${ackHint}`); | |||
| await loadSystemSettings(); | |||
| } catch (e) { | |||
| setErrorSnackbar({ | |||
| open: true, | |||
| @@ -238,6 +257,14 @@ const LaserPrintSearch: React.FC = () => { | |||
| } | |||
| }; | |||
| const lastReceiveJson = | |||
| lastLaserReceive?.itemId != null && lastLaserReceive?.stockInLineId != null | |||
| ? JSON.stringify({ | |||
| itemId: lastLaserReceive.itemId, | |||
| stockInLineId: lastLaserReceive.stockInLineId, | |||
| }) | |||
| : null; | |||
| return ( | |||
| <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}> | |||
| {successSignal && ( | |||
| @@ -245,6 +272,23 @@ const LaserPrintSearch: React.FC = () => { | |||
| {successSignal} | |||
| </Alert> | |||
| )} | |||
| {settingsLoaded && lastLaserReceive && ( | |||
| <Alert severity="info" sx={{ mb: 2 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| 上次印表機已確認(receive)的工單 | |||
| </Typography> | |||
| <Typography variant="body2" component="div" sx={{ mt: 0.5 }}> | |||
| 工單號:{lastLaserReceive.jobOrderNo ?? "—"} 批號/Lot:{lastLaserReceive.lotNo ?? "—"} | |||
| </Typography> | |||
| <Typography variant="body2" component="div" sx={{ mt: 0.5, fontFamily: "monospace" }}> | |||
| JSON:{lastReceiveJson ?? "—"} | |||
| </Typography> | |||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}> | |||
| 時間:{lastLaserReceive.sentAt ?? "—"} 來源:{lastLaserReceive.source ?? "—"} | |||
| {lastLaserReceive.printerAck ? ` 回覆:${lastLaserReceive.printerAck}` : ""} | |||
| </Typography> | |||
| </Alert> | |||
| )} | |||
| <Paper sx={{ p: 2, mb: 2, backgroundColor: BG_TOP }}> | |||
| <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={2}> | |||