diff --git a/src/app/(main)/m18Syn/layout.tsx b/src/app/(main)/m18Syn/layout.tsx new file mode 100644 index 0000000..ad756a7 --- /dev/null +++ b/src/app/(main)/m18Syn/layout.tsx @@ -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}; +} diff --git a/src/app/(main)/m18Syn/page.tsx b/src/app/(main)/m18Syn/page.tsx new file mode 100644 index 0000000..09705b5 --- /dev/null +++ b/src/app/(main)/m18Syn/page.tsx @@ -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 ( + + ); +} + +export default function M18SynPage() { + const [tabValue, setTabValue] = useState(0); + + const [m18PoCode, setM18PoCode] = useState(""); + const [isSyncingM18Po, setIsSyncingM18Po] = useState(false); + const [m18PoSyncResult, setM18PoSyncResult] = useState(""); + + const [m18DoCode, setM18DoCode] = useState(""); + const [isSyncingM18Do, setIsSyncingM18Do] = useState(false); + const [m18DoSyncResult, setM18DoSyncResult] = useState(""); + + const [m18ProductCode, setM18ProductCode] = useState(""); + const [isSyncingM18Product, setIsSyncingM18Product] = useState(false); + const [m18ProductSyncResult, setM18ProductSyncResult] = useState(""); + + 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 }) => ( + + + {title} + + {children || Waiting for implementation...} + + ); + + return ( + + + M18 Sync (by code) + + + ADMIN only. Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. + + + setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> + + + + + + +
+ + setM18PoCode(e.target.value)} + placeholder="e.g. PFP002PO26030341" + sx={{ minWidth: 320 }} + /> + + + {m18PoSyncResult ? ( + + ) : null} +
+
+ + +
+ + setM18DoCode(e.target.value)} + placeholder="e.g. same document code as M18 shop PO" + sx={{ minWidth: 320 }} + /> + + + {m18DoSyncResult ? ( + + ) : null} +
+
+ + +
+ + setM18ProductCode(e.target.value)} + placeholder="e.g. PP1175 (M18 item code)" + sx={{ minWidth: 320 }} + /> + + + {m18ProductSyncResult ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/app/(main)/testing/page.tsx b/src/app/(main)/testing/page.tsx index e8458b2..d2ce781 100644 --- a/src/app/(main)/testing/page.tsx +++ b/src/app/(main)/testing/page.tsx @@ -29,6 +29,10 @@ import { pushOnPackTextQrZipToNgpcl, type JobOrderListItem, } from "@/app/api/bagPrint/actions"; +import { + runLaserBag2AutoSend, + type LaserBag2AutoSendReport, +} from "@/app/api/laserPrint/actions"; import * as XLSX from "xlsx"; interface TabPanelProps { @@ -61,15 +65,7 @@ export default function TestingPage() { // --- 1. GRN Preview (M18) --- 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(""); - // --- 3. M18 DO Sync by Code --- - const [m18DoCode, setM18DoCode] = useState(""); - const [isSyncingM18Do, setIsSyncingM18Do] = useState(false); - const [m18DoSyncResult, setM18DoSyncResult] = useState(""); - // --- 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 [onpackJobOrders, setOnpackJobOrders] = useState([]); const [onpackLoading, setOnpackLoading] = useState(false); @@ -77,11 +73,17 @@ export default function TestingPage() { const [onpackLemonDownloading, setOnpackLemonDownloading] = useState(false); const [onpackPushLoading, setOnpackPushLoading] = useState(false); const [onpackPushResult, setOnpackPushResult] = useState(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(null); + const [laserAutoError, setLaserAutoError] = useState(null); const onpackPayload = useMemo(() => buildOnPackJobOrdersPayload(onpackJobOrders), [onpackJobOrders]); useEffect(() => { - if (tabValue !== 3) return; + if (tabValue !== 1) return; let cancelled = false; (async () => { 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 url = window.URL.createObjectURL(blob); 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 () => { if (onpackPayload.length === 0) { alert("No job orders with item code for this plan date."); @@ -254,9 +222,8 @@ export default function TestingPage() { - - - + + @@ -287,71 +254,7 @@ export default function TestingPage() { -
- - setM18PoCode(e.target.value)} - placeholder="e.g. PFP002PO26030341" - sx={{ minWidth: 320 }} - /> - - - - Backend endpoint: /m18/test/po-by-code?code=YOUR_CODE - - {m18PoSyncResult ? ( - - ) : null} -
-
- - -
- - setM18DoCode(e.target.value)} - placeholder="e.g. same document code as M18 shop PO" - sx={{ minWidth: 320 }} - /> - - - - Backend endpoint: /m18/test/do-by-code?code=YOUR_CODE - - {m18DoSyncResult ? ( - - ) : null} -
-
- - -
+
Uses GET /py/job-orders?planStart= for the day, then the same jobOrders payload as{" "} Bag Print → 下載 OnPack2023檸檬機. The ZIP contains loose .job / .image / BMPs — extract @@ -445,6 +348,55 @@ export default function TestingPage() {
+ + +
+ + 依資料庫 LASER_PRINT.hostLASER_PRINT.portLASER_PRINT.itemCodes 查當日包裝工單並送 TCP(每筆工單預設 3 次、間隔 3 秒,與前端點列相同)。 + 排程預設關閉;啟用請設 laser.bag2.auto-send.enabled=true(後端 application.yml)。 + + + setLaserAutoPlanDate(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setLaserAutoLimit(e.target.value)} + sx={{ width: 200 }} + helperText="手動測試建議 1;排程預設每分鐘最多 1 筆" + /> + + + {laserAutoError ? ( + + {laserAutoError} + + ) : null} + {laserAutoReport ? ( + + ) : null} + + POST /api/plastic/laser-bag2-auto-send?planStart=YYYY-MM-DD&limitPerRun=N + +
+
); } diff --git a/src/app/api/laserPrint/actions.ts b/src/app/api/laserPrint/actions.ts index 4255c3c..eb76fd4 100644 --- a/src/app/api/laserPrint/actions.ts +++ b/src/app/api/laserPrint/actions.ts @@ -128,6 +128,45 @@ export async function checkPrinterStatus(request: PrinterStatusRequest): Promise 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 { + 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; +} + export async function patchSetting(name: string, value: string): Promise { const url = `${NEXT_PUBLIC_API_URL}/settings/${encodeURIComponent(name)}`; const res = await clientAuthFetch(url, { diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 47c0843..76b2600 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -46,6 +46,7 @@ const pathToLabelMap: { [path: string]: string } = { "/putAway": "Put Away", "/stockIssue": "Stock Issue", "/report": "Report", + "/m18Syn": "M18 Sync", "/bagPrint": "打袋機", "/laserPrint": "檸檬機(激光機)", "/settings/itemPrice": "Price Inquiry", diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 065fa74..06852c7 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -37,6 +37,7 @@ import Label from "@mui/icons-material/Label"; import Checklist from "@mui/icons-material/Checklist"; import Science from "@mui/icons-material/Science"; import UploadFile from "@mui/icons-material/UploadFile"; +import Sync from "@mui/icons-material/Sync"; import { useTranslation } from "react-i18next"; import { usePathname } from "next/navigation"; import Link from "next/link"; @@ -194,6 +195,13 @@ const NavigationContent: React.FC = () => { requiredAbility: [AUTH.TESTING, AUTH.ADMIN], isHidden: false, }, + { + icon: , + label: "M18 Sync", + path: "/m18Syn", + requiredAbility: [AUTH.ADMIN], + isHidden: false, + }, { icon: , label: "圖表報告", diff --git a/src/routes.ts b/src/routes.ts index 4e87f95..e91a912 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,6 +1,7 @@ export const PRIVATE_ROUTES = [ "/analytics", "/dashboard", + "/m18Syn", "/testing", "/jo/testing", "/po/workbench",