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 (
+
+ {value === index && {children}}
+
+ );
+}
+
+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.host、LASER_PRINT.port、LASER_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",