| @@ -0,0 +1,22 @@ | |||||
| import { Metadata } from "next"; | |||||
| import { getServerSession } from "next-auth"; | |||||
| import { redirect } from "next/navigation"; | |||||
| import { authOptions } from "@/config/authConfig"; | |||||
| import { AUTH } from "@/authorities"; | |||||
| export const metadata: Metadata = { | |||||
| title: "M18 Sync", | |||||
| }; | |||||
| export default async function M18SynLayout({ | |||||
| children, | |||||
| }: { | |||||
| children: React.ReactNode; | |||||
| }) { | |||||
| const session = await getServerSession(authOptions); | |||||
| const abilities = session?.user?.abilities ?? []; | |||||
| if (!abilities.includes(AUTH.ADMIN)) { | |||||
| redirect("/dashboard"); | |||||
| } | |||||
| return <>{children}</>; | |||||
| } | |||||
| @@ -0,0 +1,239 @@ | |||||
| "use client"; | |||||
| import React, { useState } from "react"; | |||||
| import { Box, Button, Paper, Stack, Tab, Tabs, TextField, Typography } from "@mui/material"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| interface TabPanelProps { | |||||
| children?: React.ReactNode; | |||||
| index: number; | |||||
| value: number; | |||||
| } | |||||
| function TabPanel(props: TabPanelProps) { | |||||
| const { children, value, index, ...other } = props; | |||||
| return ( | |||||
| <div | |||||
| role="tabpanel" | |||||
| hidden={value !== index} | |||||
| id={`m18syn-tabpanel-${index}`} | |||||
| aria-labelledby={`m18syn-tab-${index}`} | |||||
| {...other} | |||||
| > | |||||
| {value === index && <Box sx={{ p: 3 }}>{children}</Box>} | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| export default function M18SynPage() { | |||||
| const [tabValue, setTabValue] = useState(0); | |||||
| const [m18PoCode, setM18PoCode] = useState(""); | |||||
| const [isSyncingM18Po, setIsSyncingM18Po] = useState(false); | |||||
| const [m18PoSyncResult, setM18PoSyncResult] = useState<string>(""); | |||||
| const [m18DoCode, setM18DoCode] = useState(""); | |||||
| const [isSyncingM18Do, setIsSyncingM18Do] = useState(false); | |||||
| const [m18DoSyncResult, setM18DoSyncResult] = useState<string>(""); | |||||
| const [m18ProductCode, setM18ProductCode] = useState(""); | |||||
| const [isSyncingM18Product, setIsSyncingM18Product] = useState(false); | |||||
| const [m18ProductSyncResult, setM18ProductSyncResult] = useState<string>(""); | |||||
| const handleSyncM18PoByCode = async () => { | |||||
| if (!m18PoCode.trim()) { | |||||
| alert("Please enter PO code."); | |||||
| return; | |||||
| } | |||||
| setIsSyncingM18Po(true); | |||||
| setM18PoSyncResult(""); | |||||
| try { | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`, | |||||
| { method: "GET" }, | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| const text = await response.text(); | |||||
| setM18PoSyncResult(text); | |||||
| if (!response.ok) { | |||||
| alert(`Sync failed: ${response.status}`); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error("M18 PO Sync By Code Error:", e); | |||||
| alert("M18 PO sync failed. Check console/network."); | |||||
| } finally { | |||||
| setIsSyncingM18Po(false); | |||||
| } | |||||
| }; | |||||
| const handleSyncM18DoByCode = async () => { | |||||
| if (!m18DoCode.trim()) { | |||||
| alert("Please enter DO / shop PO code."); | |||||
| return; | |||||
| } | |||||
| setIsSyncingM18Do(true); | |||||
| setM18DoSyncResult(""); | |||||
| try { | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(m18DoCode.trim())}`, | |||||
| { method: "GET" }, | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| const text = await response.text(); | |||||
| setM18DoSyncResult(text); | |||||
| if (!response.ok) { | |||||
| alert(`Sync failed: ${response.status}`); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error("M18 DO Sync By Code Error:", e); | |||||
| alert("M18 DO sync failed. Check console/network."); | |||||
| } finally { | |||||
| setIsSyncingM18Do(false); | |||||
| } | |||||
| }; | |||||
| const handleSyncM18ProductByCode = async () => { | |||||
| if (!m18ProductCode.trim()) { | |||||
| alert("Please enter M18 item / product code."); | |||||
| return; | |||||
| } | |||||
| setIsSyncingM18Product(true); | |||||
| setM18ProductSyncResult(""); | |||||
| try { | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/m18/test/product-by-code?code=${encodeURIComponent(m18ProductCode.trim())}`, | |||||
| { method: "GET" }, | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| const text = await response.text(); | |||||
| setM18ProductSyncResult(text); | |||||
| if (!response.ok) { | |||||
| alert(`Sync failed: ${response.status}`); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error("M18 Product Sync By Code Error:", e); | |||||
| alert("M18 product sync failed. Check console/network."); | |||||
| } finally { | |||||
| setIsSyncingM18Product(false); | |||||
| } | |||||
| }; | |||||
| const Section = ({ title, children }: { title: string; children?: React.ReactNode }) => ( | |||||
| <Paper sx={{ p: 3, minHeight: "450px", display: "flex", flexDirection: "column" }}> | |||||
| <Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: "2px solid #f0f0f0", pb: 1, mb: 2 }}> | |||||
| {title} | |||||
| </Typography> | |||||
| {children || <Typography color="textSecondary" sx={{ m: "auto" }}>Waiting for implementation...</Typography>} | |||||
| </Paper> | |||||
| ); | |||||
| return ( | |||||
| <Box sx={{ p: 4 }}> | |||||
| <Typography variant="h4" sx={{ mb: 4, fontWeight: "bold" }}> | |||||
| M18 Sync (by code) | |||||
| </Typography> | |||||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | |||||
| ADMIN only. Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. | |||||
| </Typography> | |||||
| <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | |||||
| <Tab label="1. PO" id="m18syn-tab-0" /> | |||||
| <Tab label="2. DO" id="m18syn-tab-1" /> | |||||
| <Tab label="3. Product" id="m18syn-tab-2" /> | |||||
| </Tabs> | |||||
| <TabPanel value={tabValue} index={0}> | |||||
| <Section title="M18 Purchase Order — sync by code"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||||
| <TextField | |||||
| size="small" | |||||
| label="PO Code" | |||||
| value={m18PoCode} | |||||
| onChange={(e) => setM18PoCode(e.target.value)} | |||||
| placeholder="e.g. PFP002PO26030341" | |||||
| sx={{ minWidth: 320 }} | |||||
| /> | |||||
| <Button variant="contained" color="primary" onClick={handleSyncM18PoByCode} disabled={isSyncingM18Po}> | |||||
| {isSyncingM18Po ? "Syncing..." : "Sync PO from M18"} | |||||
| </Button> | |||||
| </Stack> | |||||
| {m18PoSyncResult ? ( | |||||
| <TextField | |||||
| fullWidth | |||||
| multiline | |||||
| minRows={4} | |||||
| margin="normal" | |||||
| label="Sync Result" | |||||
| value={m18PoSyncResult} | |||||
| InputProps={{ readOnly: true }} | |||||
| /> | |||||
| ) : null} | |||||
| </Section> | |||||
| </TabPanel> | |||||
| <TabPanel value={tabValue} index={1}> | |||||
| <Section title="M18 Delivery Order — sync by code"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||||
| <TextField | |||||
| size="small" | |||||
| label="DO / Shop PO Code" | |||||
| value={m18DoCode} | |||||
| onChange={(e) => setM18DoCode(e.target.value)} | |||||
| placeholder="e.g. same document code as M18 shop PO" | |||||
| sx={{ minWidth: 320 }} | |||||
| /> | |||||
| <Button variant="contained" color="primary" onClick={handleSyncM18DoByCode} disabled={isSyncingM18Do}> | |||||
| {isSyncingM18Do ? "Syncing..." : "Sync DO from M18"} | |||||
| </Button> | |||||
| </Stack> | |||||
| {m18DoSyncResult ? ( | |||||
| <TextField | |||||
| fullWidth | |||||
| multiline | |||||
| minRows={4} | |||||
| margin="normal" | |||||
| label="Sync Result" | |||||
| value={m18DoSyncResult} | |||||
| InputProps={{ readOnly: true }} | |||||
| /> | |||||
| ) : null} | |||||
| </Section> | |||||
| </TabPanel> | |||||
| <TabPanel value={tabValue} index={2}> | |||||
| <Section title="M18 Product / material — sync by code"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||||
| <TextField | |||||
| size="small" | |||||
| label="Item / product code" | |||||
| value={m18ProductCode} | |||||
| onChange={(e) => setM18ProductCode(e.target.value)} | |||||
| placeholder="e.g. PP1175 (M18 item code)" | |||||
| sx={{ minWidth: 320 }} | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| color="primary" | |||||
| onClick={handleSyncM18ProductByCode} | |||||
| disabled={isSyncingM18Product} | |||||
| > | |||||
| {isSyncingM18Product ? "Syncing..." : "Sync product from M18"} | |||||
| </Button> | |||||
| </Stack> | |||||
| {m18ProductSyncResult ? ( | |||||
| <TextField | |||||
| fullWidth | |||||
| multiline | |||||
| minRows={4} | |||||
| margin="normal" | |||||
| label="Sync Result" | |||||
| value={m18ProductSyncResult} | |||||
| InputProps={{ readOnly: true }} | |||||
| /> | |||||
| ) : null} | |||||
| </Section> | |||||
| </TabPanel> | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| @@ -29,6 +29,10 @@ import { | |||||
| pushOnPackTextQrZipToNgpcl, | pushOnPackTextQrZipToNgpcl, | ||||
| type JobOrderListItem, | type JobOrderListItem, | ||||
| } from "@/app/api/bagPrint/actions"; | } from "@/app/api/bagPrint/actions"; | ||||
| import { | |||||
| runLaserBag2AutoSend, | |||||
| type LaserBag2AutoSendReport, | |||||
| } from "@/app/api/laserPrint/actions"; | |||||
| import * as XLSX from "xlsx"; | import * as XLSX from "xlsx"; | ||||
| interface TabPanelProps { | interface TabPanelProps { | ||||
| @@ -61,15 +65,7 @@ export default function TestingPage() { | |||||
| // --- 1. GRN Preview (M18) --- | // --- 1. GRN Preview (M18) --- | ||||
| const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16"); | const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16"); | ||||
| // --- 2. M18 PO Sync by Code --- | |||||
| const [m18PoCode, setM18PoCode] = useState(""); | |||||
| const [isSyncingM18Po, setIsSyncingM18Po] = useState(false); | |||||
| const [m18PoSyncResult, setM18PoSyncResult] = useState<string>(""); | |||||
| // --- 3. M18 DO Sync by Code --- | |||||
| const [m18DoCode, setM18DoCode] = useState(""); | |||||
| const [isSyncingM18Do, setIsSyncingM18Do] = useState(false); | |||||
| const [m18DoSyncResult, setM18DoSyncResult] = useState<string>(""); | |||||
| // --- 4. OnPack NGPCL (same job-order → ZIP logic as /bagPrint) --- | |||||
| // --- 2. OnPack NGPCL (same job-order → ZIP logic as /bagPrint) --- | |||||
| const [onpackPlanDate, setOnpackPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | const [onpackPlanDate, setOnpackPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | ||||
| const [onpackJobOrders, setOnpackJobOrders] = useState<JobOrderListItem[]>([]); | const [onpackJobOrders, setOnpackJobOrders] = useState<JobOrderListItem[]>([]); | ||||
| const [onpackLoading, setOnpackLoading] = useState(false); | const [onpackLoading, setOnpackLoading] = useState(false); | ||||
| @@ -77,11 +73,17 @@ export default function TestingPage() { | |||||
| const [onpackLemonDownloading, setOnpackLemonDownloading] = useState(false); | const [onpackLemonDownloading, setOnpackLemonDownloading] = useState(false); | ||||
| const [onpackPushLoading, setOnpackPushLoading] = useState(false); | const [onpackPushLoading, setOnpackPushLoading] = useState(false); | ||||
| const [onpackPushResult, setOnpackPushResult] = useState<string | null>(null); | const [onpackPushResult, setOnpackPushResult] = useState<string | null>(null); | ||||
| // --- 3. Laser Bag2 auto-send (same as /laserPrint + DB LASER_PRINT.*) --- | |||||
| const [laserAutoPlanDate, setLaserAutoPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | |||||
| const [laserAutoLimit, setLaserAutoLimit] = useState("1"); | |||||
| const [laserAutoLoading, setLaserAutoLoading] = useState(false); | |||||
| const [laserAutoReport, setLaserAutoReport] = useState<LaserBag2AutoSendReport | null>(null); | |||||
| const [laserAutoError, setLaserAutoError] = useState<string | null>(null); | |||||
| const onpackPayload = useMemo(() => buildOnPackJobOrdersPayload(onpackJobOrders), [onpackJobOrders]); | const onpackPayload = useMemo(() => buildOnPackJobOrdersPayload(onpackJobOrders), [onpackJobOrders]); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| if (tabValue !== 3) return; | |||||
| if (tabValue !== 1) return; | |||||
| let cancelled = false; | let cancelled = false; | ||||
| (async () => { | (async () => { | ||||
| setOnpackLoading(true); | setOnpackLoading(true); | ||||
| @@ -138,58 +140,6 @@ export default function TestingPage() { | |||||
| } | } | ||||
| }; | }; | ||||
| const handleSyncM18PoByCode = async () => { | |||||
| if (!m18PoCode.trim()) { | |||||
| alert("Please enter PO code."); | |||||
| return; | |||||
| } | |||||
| setIsSyncingM18Po(true); | |||||
| setM18PoSyncResult(""); | |||||
| try { | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`, | |||||
| { method: "GET" }, | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| const text = await response.text(); | |||||
| setM18PoSyncResult(text); | |||||
| if (!response.ok) { | |||||
| alert(`Sync failed: ${response.status}`); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error("M18 PO Sync By Code Error:", e); | |||||
| alert("M18 PO sync failed. Check console/network."); | |||||
| } finally { | |||||
| setIsSyncingM18Po(false); | |||||
| } | |||||
| }; | |||||
| const handleSyncM18DoByCode = async () => { | |||||
| if (!m18DoCode.trim()) { | |||||
| alert("Please enter DO / shop PO code."); | |||||
| return; | |||||
| } | |||||
| setIsSyncingM18Do(true); | |||||
| setM18DoSyncResult(""); | |||||
| try { | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(m18DoCode.trim())}`, | |||||
| { method: "GET" }, | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| const text = await response.text(); | |||||
| setM18DoSyncResult(text); | |||||
| if (!response.ok) { | |||||
| alert(`Sync failed: ${response.status}`); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error("M18 DO Sync By Code Error:", e); | |||||
| alert("M18 DO sync failed. Check console/network."); | |||||
| } finally { | |||||
| setIsSyncingM18Do(false); | |||||
| } | |||||
| }; | |||||
| const downloadBlob = (blob: Blob, filename: string) => { | const downloadBlob = (blob: Blob, filename: string) => { | ||||
| const url = window.URL.createObjectURL(blob); | const url = window.URL.createObjectURL(blob); | ||||
| const link = document.createElement("a"); | const link = document.createElement("a"); | ||||
| @@ -218,6 +168,24 @@ export default function TestingPage() { | |||||
| } | } | ||||
| }; | }; | ||||
| const handleLaserBag2AutoSend = async () => { | |||||
| setLaserAutoLoading(true); | |||||
| setLaserAutoError(null); | |||||
| setLaserAutoReport(null); | |||||
| try { | |||||
| const lim = parseInt(laserAutoLimit.trim(), 10); | |||||
| const report = await runLaserBag2AutoSend({ | |||||
| planStart: laserAutoPlanDate, | |||||
| limitPerRun: Number.isFinite(lim) ? lim : 0, | |||||
| }); | |||||
| setLaserAutoReport(report); | |||||
| } catch (e) { | |||||
| setLaserAutoError(e instanceof Error ? e.message : String(e)); | |||||
| } finally { | |||||
| setLaserAutoLoading(false); | |||||
| } | |||||
| }; | |||||
| const handleOnpackPushNgpcl = async () => { | const handleOnpackPushNgpcl = async () => { | ||||
| if (onpackPayload.length === 0) { | if (onpackPayload.length === 0) { | ||||
| alert("No job orders with item code for this plan date."); | alert("No job orders with item code for this plan date."); | ||||
| @@ -254,9 +222,8 @@ export default function TestingPage() { | |||||
| <Tabs value={tabValue} onChange={handleTabChange} aria-label="testing sections tabs" centered variant="fullWidth"> | <Tabs value={tabValue} onChange={handleTabChange} aria-label="testing sections tabs" centered variant="fullWidth"> | ||||
| <Tab label="1. GRN Preview" /> | <Tab label="1. GRN Preview" /> | ||||
| <Tab label="2. M18 PO Sync" /> | |||||
| <Tab label="3. M18 DO Sync" /> | |||||
| <Tab label="4. OnPack NGPCL" /> | |||||
| <Tab label="2. OnPack NGPCL" /> | |||||
| <Tab label="3. Laser Bag2 自動送" /> | |||||
| </Tabs> | </Tabs> | ||||
| <TabPanel value={tabValue} index={0}> | <TabPanel value={tabValue} index={0}> | ||||
| @@ -287,71 +254,7 @@ export default function TestingPage() { | |||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tabValue} index={1}> | <TabPanel value={tabValue} index={1}> | ||||
| <Section title="2. M18 PO Sync by Code"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||||
| <TextField | |||||
| size="small" | |||||
| label="PO Code" | |||||
| value={m18PoCode} | |||||
| onChange={(e) => setM18PoCode(e.target.value)} | |||||
| placeholder="e.g. PFP002PO26030341" | |||||
| sx={{ minWidth: 320 }} | |||||
| /> | |||||
| <Button variant="contained" color="primary" onClick={handleSyncM18PoByCode} disabled={isSyncingM18Po}> | |||||
| {isSyncingM18Po ? "Syncing..." : "Sync PO from M18"} | |||||
| </Button> | |||||
| </Stack> | |||||
| <Typography variant="body2" color="textSecondary"> | |||||
| Backend endpoint: <code>/m18/test/po-by-code?code=YOUR_CODE</code> | |||||
| </Typography> | |||||
| {m18PoSyncResult ? ( | |||||
| <TextField | |||||
| fullWidth | |||||
| multiline | |||||
| minRows={4} | |||||
| margin="normal" | |||||
| label="Sync Result" | |||||
| value={m18PoSyncResult} | |||||
| InputProps={{ readOnly: true }} | |||||
| /> | |||||
| ) : null} | |||||
| </Section> | |||||
| </TabPanel> | |||||
| <TabPanel value={tabValue} index={2}> | |||||
| <Section title="3. M18 DO Sync by Code"> | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||||
| <TextField | |||||
| size="small" | |||||
| label="DO / Shop PO Code" | |||||
| value={m18DoCode} | |||||
| onChange={(e) => setM18DoCode(e.target.value)} | |||||
| placeholder="e.g. same document code as M18 shop PO" | |||||
| sx={{ minWidth: 320 }} | |||||
| /> | |||||
| <Button variant="contained" color="primary" onClick={handleSyncM18DoByCode} disabled={isSyncingM18Do}> | |||||
| {isSyncingM18Do ? "Syncing..." : "Sync DO from M18"} | |||||
| </Button> | |||||
| </Stack> | |||||
| <Typography variant="body2" color="textSecondary"> | |||||
| Backend endpoint: <code>/m18/test/do-by-code?code=YOUR_CODE</code> | |||||
| </Typography> | |||||
| {m18DoSyncResult ? ( | |||||
| <TextField | |||||
| fullWidth | |||||
| multiline | |||||
| minRows={4} | |||||
| margin="normal" | |||||
| label="Sync Result" | |||||
| value={m18DoSyncResult} | |||||
| InputProps={{ readOnly: true }} | |||||
| /> | |||||
| ) : null} | |||||
| </Section> | |||||
| </TabPanel> | |||||
| <TabPanel value={tabValue} index={3}> | |||||
| <Section title="4. OnPack NGPCL (same logic as /bagPrint)"> | |||||
| <Section title="2. OnPack NGPCL (same logic as /bagPrint)"> | |||||
| <Alert severity="info" sx={{ mb: 2 }}> | <Alert severity="info" sx={{ mb: 2 }}> | ||||
| Uses <strong>GET /py/job-orders?planStart=</strong> for the day, then the same <code>jobOrders</code> payload as{" "} | Uses <strong>GET /py/job-orders?planStart=</strong> for the day, then the same <code>jobOrders</code> payload as{" "} | ||||
| <strong>Bag Print → 下載 OnPack2023檸檬機</strong>. The ZIP contains loose <code>.job</code> / <code>.image</code> / BMPs — extract | <strong>Bag Print → 下載 OnPack2023檸檬機</strong>. The ZIP contains loose <code>.job</code> / <code>.image</code> / BMPs — extract | ||||
| @@ -445,6 +348,55 @@ export default function TestingPage() { | |||||
| </Typography> | </Typography> | ||||
| </Section> | </Section> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tabValue} index={2}> | |||||
| <Section title="3. Laser Bag2 自動送(與 /laserPrint 相同邏輯)"> | |||||
| <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)。 | |||||
| </Alert> | |||||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}> | |||||
| <TextField | |||||
| size="small" | |||||
| label="Plan date (planStart)" | |||||
| type="date" | |||||
| value={laserAutoPlanDate} | |||||
| onChange={(e) => setLaserAutoPlanDate(e.target.value)} | |||||
| InputLabelProps={{ shrink: true }} | |||||
| /> | |||||
| <TextField | |||||
| size="small" | |||||
| label="limitPerRun(0=全部符合)" | |||||
| value={laserAutoLimit} | |||||
| onChange={(e) => setLaserAutoLimit(e.target.value)} | |||||
| sx={{ width: 200 }} | |||||
| helperText="手動測試建議 1;排程預設每分鐘最多 1 筆" | |||||
| /> | |||||
| <Button variant="contained" color="primary" onClick={() => void handleLaserBag2AutoSend()} disabled={laserAutoLoading}> | |||||
| {laserAutoLoading ? "送出中…" : "執行 POST /plastic/laser-bag2-auto-send"} | |||||
| </Button> | |||||
| </Stack> | |||||
| {laserAutoError ? ( | |||||
| <Alert severity="error" sx={{ mb: 2 }}> | |||||
| {laserAutoError} | |||||
| </Alert> | |||||
| ) : null} | |||||
| {laserAutoReport ? ( | |||||
| <TextField | |||||
| fullWidth | |||||
| multiline | |||||
| minRows={8} | |||||
| label="回應(LaserBag2AutoSendReport)" | |||||
| value={JSON.stringify(laserAutoReport, null, 2)} | |||||
| InputProps={{ readOnly: true }} | |||||
| sx={{ fontFamily: "monospace" }} | |||||
| /> | |||||
| ) : null} | |||||
| <Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}> | |||||
| <code>POST /api/plastic/laser-bag2-auto-send?planStart=YYYY-MM-DD&limitPerRun=N</code> | |||||
| </Typography> | |||||
| </Section> | |||||
| </TabPanel> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -128,6 +128,45 @@ export async function checkPrinterStatus(request: PrinterStatusRequest): Promise | |||||
| return data; | return data; | ||||
| } | } | ||||
| export interface LaserBag2JobSendResult { | |||||
| jobOrderId: number; | |||||
| itemCode: string | null; | |||||
| success: boolean; | |||||
| message: string; | |||||
| } | |||||
| export interface LaserBag2AutoSendReport { | |||||
| planStart: string; | |||||
| jobOrdersFound: number; | |||||
| jobOrdersProcessed: number; | |||||
| results: LaserBag2JobSendResult[]; | |||||
| } | |||||
| /** | |||||
| * Same workflow as /laserPrint row click: list job orders (LASER_PRINT.itemCodes) for planStart, | |||||
| * then TCP send(s) using DB LASER_PRINT.host / port. limitPerRun 0 = all matches. | |||||
| */ | |||||
| export async function runLaserBag2AutoSend(params?: { | |||||
| planStart?: string; | |||||
| limitPerRun?: number; | |||||
| }): Promise<LaserBag2AutoSendReport> { | |||||
| const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); | |||||
| if (!base) { | |||||
| throw new Error("NEXT_PUBLIC_API_URL is not set."); | |||||
| } | |||||
| const sp = new URLSearchParams(); | |||||
| if (params?.planStart) sp.set("planStart", params.planStart); | |||||
| if (params?.limitPerRun != null) sp.set("limitPerRun", String(params.limitPerRun)); | |||||
| const q = sp.toString(); | |||||
| const url = `${base}/plastic/laser-bag2-auto-send${q ? `?${q}` : ""}`; | |||||
| const res = await clientAuthFetch(url, { method: "POST" }); | |||||
| if (!res.ok) { | |||||
| const t = await res.text().catch(() => ""); | |||||
| throw new Error(t || `HTTP ${res.status}`); | |||||
| } | |||||
| return res.json() as Promise<LaserBag2AutoSendReport>; | |||||
| } | |||||
| export async function patchSetting(name: string, value: string): Promise<void> { | export async function patchSetting(name: string, value: string): Promise<void> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/settings/${encodeURIComponent(name)}`; | const url = `${NEXT_PUBLIC_API_URL}/settings/${encodeURIComponent(name)}`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| @@ -46,6 +46,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/putAway": "Put Away", | "/putAway": "Put Away", | ||||
| "/stockIssue": "Stock Issue", | "/stockIssue": "Stock Issue", | ||||
| "/report": "Report", | "/report": "Report", | ||||
| "/m18Syn": "M18 Sync", | |||||
| "/bagPrint": "打袋機", | "/bagPrint": "打袋機", | ||||
| "/laserPrint": "檸檬機(激光機)", | "/laserPrint": "檸檬機(激光機)", | ||||
| "/settings/itemPrice": "Price Inquiry", | "/settings/itemPrice": "Price Inquiry", | ||||
| @@ -37,6 +37,7 @@ import Label from "@mui/icons-material/Label"; | |||||
| import Checklist from "@mui/icons-material/Checklist"; | import Checklist from "@mui/icons-material/Checklist"; | ||||
| import Science from "@mui/icons-material/Science"; | import Science from "@mui/icons-material/Science"; | ||||
| import UploadFile from "@mui/icons-material/UploadFile"; | import UploadFile from "@mui/icons-material/UploadFile"; | ||||
| import Sync from "@mui/icons-material/Sync"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
| import Link from "next/link"; | import Link from "next/link"; | ||||
| @@ -194,6 +195,13 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | ||||
| isHidden: false, | isHidden: false, | ||||
| }, | }, | ||||
| { | |||||
| icon: <Sync />, | |||||
| label: "M18 Sync", | |||||
| path: "/m18Syn", | |||||
| requiredAbility: [AUTH.ADMIN], | |||||
| isHidden: false, | |||||
| }, | |||||
| { | { | ||||
| icon: <ShowChart />, | icon: <ShowChart />, | ||||
| label: "圖表報告", | label: "圖表報告", | ||||
| @@ -1,6 +1,7 @@ | |||||
| export const PRIVATE_ROUTES = [ | export const PRIVATE_ROUTES = [ | ||||
| "/analytics", | "/analytics", | ||||
| "/dashboard", | "/dashboard", | ||||
| "/m18Syn", | |||||
| "/testing", | "/testing", | ||||
| "/jo/testing", | "/jo/testing", | ||||
| "/po/workbench", | "/po/workbench", | ||||