|
- "use client";
-
- import React, { useEffect, useMemo, useRef, useState } from "react";
- import {
- Alert,
- Box,
- Button,
- CircularProgress,
- Paper,
- Stack,
- Tab,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableRow,
- Tabs,
- TextField,
- Typography,
- } from "@mui/material";
- import { FileDownload } from "@mui/icons-material";
- import dayjs from "dayjs";
- import { formatHongKongDateTime } from "@/utils/formatHongKongDateTime";
- import { NEXT_PUBLIC_API_URL } from "@/config/api";
- import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
- import LotLabelPrintModal from "@/components/InventorySearch/LotLabelPrintModal";
- import {
- buildOnPackJobOrdersPayload,
- downloadOnPackTextQrZip,
- fetchJobOrders,
- pushOnPackTextQrZipToNgpcl,
- type JobOrderListItem,
- } from "@/app/api/bagPrint/actions";
- import {
- fetchLaserBag2Settings,
- runLaserBag2AutoSend,
- type LaserBag2AutoSendReport,
- type LaserLastReceiveSuccess,
- } from "@/app/api/laserPrint/actions";
- import * as XLSX from "xlsx";
-
- 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={`simple-tabpanel-${index}`}
- aria-labelledby={`simple-tab-${index}`}
- {...other}
- >
- {value === index && <Box sx={{ p: 3 }}>{children}</Box>}
- </div>
- );
- }
-
- export default function TestingPage() {
- const [tabValue, setTabValue] = useState(0);
- const [lotLabelModalOpen, setLotLabelModalOpen] = useState(false);
-
- const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
- setTabValue(newValue);
- };
-
- // --- 1. GRN Preview (M18) ---
- const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] =
- useState("2026-03-16");
- // --- 2. OnPack NGPCL (same job-order → ZIP logic as /bagPrint) ---
- const [onpackPlanDate, setOnpackPlanDate] = useState(() =>
- dayjs().format("YYYY-MM-DD"),
- );
- const [onpackJobOrders, setOnpackJobOrders] = useState<JobOrderListItem[]>(
- [],
- );
- const [onpackLoading, setOnpackLoading] = useState(false);
- const [onpackLoadError, setOnpackLoadError] = useState<string | null>(null);
- const [onpackLemonDownloading, setOnpackLemonDownloading] = useState(false);
- const [onpackPushLoading, setOnpackPushLoading] = useState(false);
- 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 [laserLastReceive, setLaserLastReceive] =
- useState<LaserLastReceiveSuccess | null>(null);
- const bomShopSyncInFlightRef = useRef(false);
- const bomShopSyncAllInFlightRef = useRef(false);
- const [bomShopSyncBomId, setBomShopSyncBomId] = useState("78");
- const [bomShopM18HeaderId, setBomShopM18HeaderId] = useState("");
- const [bomShopSyncLoading, setBomShopSyncLoading] = useState(false);
- const [bomShopSyncResult, setBomShopSyncResult] = useState<string | null>(
- null,
- );
- const [bomShopSyncAllLoading, setBomShopSyncAllLoading] = useState(false);
- const [bomShopSyncAllResult, setBomShopSyncAllResult] = useState<
- string | null
- >(null);
- const bomByItemCodeInFlightRef = useRef(false);
- const [bomByItemCodeInput, setBomByItemCodeInput] = useState("");
- const [bomByItemCodeLoading, setBomByItemCodeLoading] = useState(false);
- const [bomByItemCodeResult, setBomByItemCodeResult] = useState<string | null>(
- null,
- );
- const whatsAppTestInFlightRef = useRef(false);
- const [whatsAppVar1, setWhatsAppVar1] = useState("12/1");
- const [whatsAppVar2, setWhatsAppVar2] = useState("3pm");
- const [whatsAppTestLoading, setWhatsAppTestLoading] = useState(false);
- const [whatsAppTestResult, setWhatsAppTestResult] = useState<string | null>(
- null,
- );
- const emailTestInFlightRef = useRef(false);
- const [emailTestSubject, setEmailTestSubject] = useState(
- "FPSMS M18 sync alert [TEST]",
- );
- const [emailTestMessage, setEmailTestMessage] = useState(
- "FPSMS sync alert test message from /testing page.",
- );
- const [emailTestLoading, setEmailTestLoading] = useState(false);
- const [emailTestResult, setEmailTestResult] = useState<string | null>(null);
-
- const onpackPayload = useMemo(
- () => buildOnPackJobOrdersPayload(onpackJobOrders),
- [onpackJobOrders],
- );
-
- useEffect(() => {
- if (tabValue !== 1) return;
- let cancelled = false;
- (async () => {
- setOnpackLoading(true);
- setOnpackLoadError(null);
- try {
- const data = await fetchJobOrders(onpackPlanDate);
- if (!cancelled) setOnpackJobOrders(data);
- } catch (e) {
- if (!cancelled) {
- setOnpackLoadError(
- e instanceof Error ? e.message : "Failed to load job orders",
- );
- setOnpackJobOrders([]);
- }
- } finally {
- if (!cancelled) setOnpackLoading(false);
- }
- })();
- return () => {
- cancelled = true;
- };
- }, [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(
- `${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent(
- grnPreviewReceiptDate,
- )}`,
- { method: "GET" },
- );
- if (response.status === 401 || response.status === 403) return;
- if (!response.ok) throw new Error(`Download failed: ${response.status}`);
-
- const data = await response.json();
- const rows = Array.isArray(data?.rows) ? data.rows : [];
-
- const ws = XLSX.utils.json_to_sheet(rows);
- const wb = XLSX.utils.book_new();
- XLSX.utils.book_append_sheet(wb, ws, "GRN Preview");
-
- const xlsxArrayBuffer = XLSX.write(wb, {
- bookType: "xlsx",
- type: "array",
- });
- const blob = new Blob([xlsxArrayBuffer], {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- });
-
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.setAttribute(
- "download",
- `grn-preview-m18-${grnPreviewReceiptDate}.xlsx`,
- );
- document.body.appendChild(link);
- link.click();
- link.remove();
- window.URL.revokeObjectURL(url);
- } catch (e) {
- console.error("GRN Preview XLSX Download Error:", e);
- alert("GRN Preview XLSX download failed. Check console/network.");
- }
- };
-
- const downloadBlob = (blob: Blob, filename: string) => {
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.setAttribute("download", filename);
- document.body.appendChild(link);
- link.click();
- link.remove();
- window.URL.revokeObjectURL(url);
- };
-
- const handleOnpackDownloadLemonZip = async () => {
- if (onpackPayload.length === 0) {
- alert(
- "No job orders with item code for this plan date (same rule as Bag Print).",
- );
- return;
- }
- setOnpackLemonDownloading(true);
- try {
- const blob = await downloadOnPackTextQrZip({ jobOrders: onpackPayload });
- downloadBlob(blob, `onpack2023_lemon_qr_${onpackPlanDate}.zip`);
- } catch (e) {
- console.error("Lemon OnPack ZIP download error:", e);
- alert(e instanceof Error ? e.message : "Lemon OnPack ZIP failed");
- } finally {
- setOnpackLemonDownloading(false);
- }
- };
-
- 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 : 1,
- });
- setLaserAutoReport(report);
- try {
- const s = await fetchLaserBag2Settings();
- setLaserLastReceive(s.lastReceiveSuccess ?? null);
- } catch {
- /* ignore */
- }
- } 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.");
- return;
- }
- setOnpackPushLoading(true);
- setOnpackPushResult(null);
- try {
- const r = await pushOnPackTextQrZipToNgpcl({ jobOrders: onpackPayload });
- setOnpackPushResult(
- `${r.pushed ? "Pushed" : "Not pushed"}: ${r.message}`,
- );
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- setOnpackPushResult(`Error: ${msg}`);
- alert(msg);
- } finally {
- setOnpackPushLoading(false);
- }
- };
-
- const handleBomShopSyncM18 = async () => {
- if (bomShopSyncInFlightRef.current) return;
- const id = parseInt(bomShopSyncBomId.trim(), 10);
- if (!Number.isFinite(id) || id <= 0) {
- alert("Enter a valid BOM id (positive integer).");
- return;
- }
- bomShopSyncInFlightRef.current = true;
- setBomShopSyncLoading(true);
- setBomShopSyncResult(null);
- try {
- const m18H = bomShopM18HeaderId.trim();
- const qs =
- m18H && /^\d+$/.test(m18H)
- ? `?m18HeaderId=${encodeURIComponent(m18H)}`
- : "";
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/m18/test/bom-shop-sync/${id}${qs}`,
- { method: "POST" },
- );
- if (response.status === 401 || response.status === 403) return;
- const text = await response.text();
- let display = text;
- try {
- const parsed: unknown = JSON.parse(text);
- display = JSON.stringify(parsed, null, 2);
- } catch {
- /* keep raw */
- }
- if (!response.ok) {
- setBomShopSyncResult(`HTTP ${response.status}\n\n${display}`);
- return;
- }
- setBomShopSyncResult(display);
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- setBomShopSyncResult(`Error: ${msg}`);
- } finally {
- setBomShopSyncLoading(false);
- bomShopSyncInFlightRef.current = false;
- }
- };
-
- const handleBomShopSyncAllM18 = async () => {
- if (bomShopSyncAllInFlightRef.current) return;
- bomShopSyncAllInFlightRef.current = true;
- setBomShopSyncAllLoading(true);
- setBomShopSyncAllResult(null);
- try {
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/scheduler/trigger/bom-shop-sync-all`,
- { method: "GET" },
- );
- if (response.status === 401 || response.status === 403) return;
- const text = await response.text();
- let display = text;
- try {
- const parsed: unknown = JSON.parse(text);
- display = JSON.stringify(parsed, null, 2);
- } catch {
- /* plain string from backend is fine */
- }
- if (!response.ok) {
- setBomShopSyncAllResult(`HTTP ${response.status}\n\n${display}`);
- return;
- }
- setBomShopSyncAllResult(display);
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- setBomShopSyncAllResult(`Error: ${msg}`);
- } finally {
- setBomShopSyncAllLoading(false);
- bomShopSyncAllInFlightRef.current = false;
- }
- };
-
- const handleWhatsAppSyncAlertTest = async () => {
- if (whatsAppTestInFlightRef.current) return;
- whatsAppTestInFlightRef.current = true;
- setWhatsAppTestLoading(true);
- setWhatsAppTestResult(null);
- try {
- const params = new URLSearchParams();
- const v1 = whatsAppVar1.trim();
- const v2 = whatsAppVar2.trim();
- if (v1) params.set("var1", v1);
- if (v2) params.set("var2", v2);
- const qs = params.toString();
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/scheduler/trigger/sync-alert-test-whatsapp${qs ? `?${qs}` : ""}`,
- { method: "GET" },
- );
- if (response.status === 401 || response.status === 403) return;
- const text = await response.text();
- if (!response.ok) {
- setWhatsAppTestResult(`HTTP ${response.status}\n\n${text}`);
- return;
- }
- setWhatsAppTestResult(text);
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- setWhatsAppTestResult(`Error: ${msg}`);
- } finally {
- setWhatsAppTestLoading(false);
- whatsAppTestInFlightRef.current = false;
- }
- };
-
- const handleSyncAlertTestEmail = async () => {
- if (emailTestInFlightRef.current) return;
- emailTestInFlightRef.current = true;
- setEmailTestLoading(true);
- setEmailTestResult(null);
- try {
- const params = new URLSearchParams();
- const subj = emailTestSubject.trim();
- const msg = emailTestMessage.trim();
- if (subj) params.set("subject", subj);
- if (msg) params.set("message", msg);
- const qs = params.toString();
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/scheduler/trigger/sync-alert-test-email${qs ? `?${qs}` : ""}`,
- { method: "GET" },
- );
- if (response.status === 401 || response.status === 403) return;
- const text = await response.text();
- if (!response.ok) {
- setEmailTestResult(`HTTP ${response.status}\n\n${text}`);
- return;
- }
- setEmailTestResult(text);
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- setEmailTestResult(`Error: ${msg}`);
- } finally {
- setEmailTestLoading(false);
- emailTestInFlightRef.current = false;
- }
- };
-
- const handleBomLookupByItemCode = async () => {
- if (bomByItemCodeInFlightRef.current) return;
- const code = bomByItemCodeInput.trim();
- if (!code) {
- alert("Enter an item code.");
- return;
- }
- bomByItemCodeInFlightRef.current = true;
- setBomByItemCodeLoading(true);
- setBomByItemCodeResult(null);
- try {
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/bom/by-item-code?code=${encodeURIComponent(code)}`,
- { method: "GET" },
- );
- if (response.status === 401 || response.status === 403) return;
- const text = await response.text();
- let display = text;
- try {
- const parsed: unknown = JSON.parse(text);
- display = JSON.stringify(parsed, null, 2);
- } catch {
- /* keep raw */
- }
- setBomByItemCodeResult(display);
- if (!response.ok) {
- alert(`Lookup failed: HTTP ${response.status}`);
- }
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- setBomByItemCodeResult(`Error: ${msg}`);
- alert(msg);
- } finally {
- setBomByItemCodeLoading(false);
- bomByItemCodeInFlightRef.current = 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" }}>
- Testing
- </Typography>
-
- <Tabs
- value={tabValue}
- onChange={handleTabChange}
- aria-label="testing sections tabs"
- centered
- variant="fullWidth"
- >
- <Tab label="1. GRN Preview" />
- <Tab label="2. OnPack NGPCL" />
- <Tab label="3. Laser Bag2 自動送" />
- <Tab label="4. 批號標籤列印" />
- <Tab label="5. M18 BOM shop" />
- <Tab label="6. Sync alert email" />
- </Tabs>
-
- <TabPanel value={tabValue} index={0}>
- <Section title="1. GRN Preview (M18)">
- <Stack
- direction="row"
- spacing={2}
- sx={{ mb: 2, alignItems: "center" }}
- >
- <TextField
- size="small"
- label="Receipt Date"
- type="date"
- value={grnPreviewReceiptDate}
- onChange={(e) => setGrnPreviewReceiptDate(e.target.value)}
- InputLabelProps={{ shrink: true }}
- />
- <Button
- variant="contained"
- color="success"
- size="medium"
- startIcon={<FileDownload />}
- onClick={handleDownloadGrnPreviewXlsx}
- >
- Download GRN Preview XLSX
- </Button>
- </Stack>
- <Typography variant="body2" color="textSecondary">
- Backend endpoint:{" "}
- <code>/report/grn-preview-m18?receiptDate=YYYY-MM-DD</code>
- </Typography>
- </Section>
- </TabPanel>
-
- <TabPanel value={tabValue} index={1}>
- <Section title="2. OnPack NGPCL (same logic as /bagPrint)">
- <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{" "}
- <strong>Bag Print → 下載 OnPack2023檸檬機</strong>. The ZIP contains
- loose <code>.job</code> / <code>.image</code> / BMPs — extract
- before sending to NGE; the ZIP itself is only a transport bundle.
- </Alert>
- <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
- Distinct item codes in the list produce one label set each (backend
- groups by code). Configure <code>ngpcl.push-url</code> on the server
- to POST the same lemon ZIP bytes to your NGPCL HTTP gateway;
- otherwise use download only.
- </Typography>
-
- <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={onpackPlanDate}
- onChange={(e) => setOnpackPlanDate(e.target.value)}
- InputLabelProps={{ shrink: true }}
- />
- <Typography variant="body2" color="textSecondary">
- {onpackLoading ? (
- <>
- <CircularProgress
- size={16}
- sx={{ mr: 1, verticalAlign: "middle" }}
- />
- Loading job orders…
- </>
- ) : (
- `${onpackJobOrders.length} job order(s), ${onpackPayload.length} row(s) with item code → ZIP`
- )}
- </Typography>
- </Stack>
- {onpackLoadError ? (
- <Alert severity="error" sx={{ mb: 2 }}>
- {onpackLoadError}
- </Alert>
- ) : null}
-
- <Table size="small" sx={{ mb: 2, maxWidth: 900 }}>
- <TableHead>
- <TableRow>
- <TableCell>JO id</TableCell>
- <TableCell>Code</TableCell>
- <TableCell>Item code</TableCell>
- <TableCell>Lot</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {onpackJobOrders.length === 0 && !onpackLoading ? (
- <TableRow>
- <TableCell colSpan={4}>
- <Typography variant="body2" color="textSecondary">
- No rows for this date (or still loading).
- </Typography>
- </TableCell>
- </TableRow>
- ) : (
- onpackJobOrders.map((jo) => (
- <TableRow key={jo.id}>
- <TableCell>{jo.id}</TableCell>
- <TableCell>{jo.code ?? "—"}</TableCell>
- <TableCell>{jo.itemCode ?? "—"}</TableCell>
- <TableCell>{jo.lotNo ?? "—"}</TableCell>
- </TableRow>
- ))
- )}
- </TableBody>
- </Table>
-
- <TextField
- fullWidth
- multiline
- minRows={3}
- label="Resolved POST body (download-onpack-qr-text / NGPCL push)"
- value={JSON.stringify({ jobOrders: onpackPayload }, null, 2)}
- InputProps={{ readOnly: true }}
- sx={{ mb: 2, fontFamily: "monospace" }}
- />
-
- <Stack
- direction={{ xs: "column", sm: "row" }}
- spacing={2}
- sx={{ mb: 2, flexWrap: "wrap" }}
- >
- <Button
- variant="contained"
- color="success"
- onClick={handleOnpackDownloadLemonZip}
- disabled={onpackLemonDownloading || onpackLoading}
- >
- {onpackLemonDownloading
- ? "Downloading…"
- : "Download lemon OnPack ZIP"}
- </Button>
- <Button
- variant="outlined"
- onClick={handleOnpackPushNgpcl}
- disabled={onpackPushLoading || onpackLoading}
- >
- {onpackPushLoading
- ? "Pushing…"
- : "Push to NGPCL (server → ngpcl.push-url)"}
- </Button>
- </Stack>
- {onpackPushResult ? (
- <TextField
- fullWidth
- multiline
- minRows={2}
- label="Last NGPCL push result"
- value={onpackPushResult}
- InputProps={{ readOnly: true }}
- />
- ) : null}
- <Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
- <code>POST /plastic/download-onpack-qr-text</code> ·{" "}
- <code>POST /plastic/ngpcl/push-onpack-qr-text</code> (same body)
- </Typography>
- </Section>
- </TabPanel>
-
- <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 }}
- >
- {formatHongKongDateTime(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)。
- </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(目前固定只送第一筆)"
- value={laserAutoLimit}
- onChange={(e) => setLaserAutoLimit(e.target.value)}
- sx={{ width: 200 }}
- helperText="目前後端會限制為第一筆;此欄位保留給未來調整"
- />
- <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>
-
- <TabPanel value={tabValue} index={3}>
- <Section title="4. 批號標籤列印(掃碼 → 查同品批號 → 選印表機 → 列印)">
- <Alert severity="info" sx={{ mb: 2 }}>
- 此工具會呼叫後端 <code>/inventoryLotLine/analyze-qr-code</code>{" "}
- 找同品可用批號,再用 <code>/inventoryLotLine/print-label</code>(需
- printerId)送出列印。
- </Alert>
- <Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
- <Button
- variant="contained"
- onClick={() => setLotLabelModalOpen(true)}
- >
- 開啟列印視窗
- </Button>
- <Typography
- variant="body2"
- color="text.secondary"
- sx={{ alignSelf: "center" }}
- >
- 掃碼格式:<code>{'{"itemId":16431,"stockInLineId":10381'}</code>
- </Typography>
- </Stack>
- <LotLabelPrintModal
- open={lotLabelModalOpen}
- onClose={() => setLotLabelModalOpen(false)}
- />
- </Section>
- </TabPanel>
-
- <TabPanel value={tabValue} index={4}>
- <Section title="5. M18 BOM shop sync (udfBomForShop)">
- <Alert severity="info" sx={{ mb: 2 }}>
- Requires setting <code>M18.bom.shop.sync.enabled=true</code>. Use{" "}
- <code>m18HeaderId</code> query param (or the field below) so M18{" "}
- <strong>updates</strong> the existing udfBomForShop header instead of creating a duplicate.
- </Alert>
- <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
- Lookup BOM id by item code (like PO-by-code)
- </Typography>
- <Typography variant="body2" color="textSecondary" sx={{ mb: 1 }}>
- Uses <code>GET /bom/by-item-code?code=…</code> — item is the BOM
- header product (<code>bom.item</code>), same as FPSMS finished-good
- <code> items.code</code>.
- </Typography>
- <Stack
- direction={{ xs: "column", sm: "row" }}
- spacing={2}
- sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}
- >
- <TextField
- size="small"
- label="Item code"
- value={bomByItemCodeInput}
- onChange={(e) => setBomByItemCodeInput(e.target.value)}
- sx={{ width: 220 }}
- />
- <Button
- variant="outlined"
- onClick={() => void handleBomLookupByItemCode()}
- disabled={bomByItemCodeLoading}
- >
- {bomByItemCodeLoading ? "Looking up…" : "Lookup BOM id"}
- </Button>
- </Stack>
- {bomByItemCodeResult ? (
- <Stack spacing={1} sx={{ mb: 3 }}>
- <TextField
- fullWidth
- multiline
- minRows={4}
- label="Lookup response (BomIdByItemCodeResponse)"
- value={bomByItemCodeResult}
- InputProps={{ readOnly: true }}
- sx={{ fontFamily: "monospace" }}
- />
- <Button
- size="small"
- variant="text"
- onClick={() => {
- try {
- const o = JSON.parse(bomByItemCodeResult) as {
- bomId?: number;
- bomM18Id?: number;
- };
- if (o?.bomId != null)
- setBomShopSyncBomId(String(o.bomId));
- if (o?.bomM18Id != null)
- setBomShopM18HeaderId(String(o.bomM18Id));
- } catch {
- /* ignore */
- }
- }}
- >
- Copy bomId + bomM18Id to sync fields below
- </Button>
- </Stack>
- ) : null}
- <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
- M18 udfBomForShop sync
- </Typography>
- <Typography variant="body2" color="textSecondary" sx={{ mb: 1 }}>
- GET <code>/scheduler/trigger/bom-shop-sync-all</code>: push{" "}
- <strong>all</strong> non-deleted BOMs (same nightly job path;
- respects <code>M18.bom.shop.sync.enabled</code>). Check{" "}
- <code>scheduler_sync_log</code> (<code>M18_BOM_SHOP</code>) for row
- counts.
- </Typography>
- <Stack direction="row" spacing={2} sx={{ mb: 2, flexWrap: "wrap" }}>
- <Button
- variant="outlined"
- color="secondary"
- onClick={() => void handleBomShopSyncAllM18()}
- disabled={bomShopSyncAllLoading}
- >
- {bomShopSyncAllLoading
- ? "Syncing all BOMs…"
- : "Sync all BOMs to M18"}
- </Button>
- </Stack>
- {bomShopSyncAllResult ? (
- <TextField
- fullWidth
- multiline
- minRows={3}
- label="Bulk trigger response"
- value={bomShopSyncAllResult}
- InputProps={{ readOnly: true }}
- sx={{
- mb: 2,
- fontFamily: "monospace",
- }}
- />
- ) : null}
- <Typography variant="body2" color="textSecondary" sx={{ mb: 1 }}>
- POST <code>/m18/test/bom-shop-sync/:bomId</code> with optional{" "}
- <code>?m18HeaderId=</code> (M18 udfBomForShop header id for{" "}
- <strong>update</strong>; from lookup <code>bomM18Id</code> or{" "}
- <code>Bom.m18Id</code> in DB). If omitted, backend uses{" "}
- <code>bom.m18Id</code> when set; otherwise creates a new M18 row.
- </Typography>
- <Stack
- direction={{ xs: "column", sm: "row" }}
- spacing={2}
- sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}
- >
- <TextField
- size="small"
- label="BOM id"
- value={bomShopSyncBomId}
- onChange={(e) => setBomShopSyncBomId(e.target.value)}
- sx={{ width: 160 }}
- />
- <TextField
- size="small"
- label="M18 header id (optional, update)"
- value={bomShopM18HeaderId}
- onChange={(e) => setBomShopM18HeaderId(e.target.value)}
- sx={{ width: 220 }}
- helperText="e.g. 255 from bomM18Id — forces main.id in payload"
- />
- <Button
- variant="contained"
- color="primary"
- onClick={() => void handleBomShopSyncM18()}
- disabled={bomShopSyncLoading}
- >
- {bomShopSyncLoading ? "Syncing…" : "Sync BOM to M18"}
- </Button>
- </Stack>
- {bomShopSyncResult ? (
- <TextField
- fullWidth
- multiline
- minRows={10}
- label="Response (M18BomShopSyncTriggerResult)"
- value={bomShopSyncResult}
- InputProps={{ readOnly: true }}
- sx={{ fontFamily: "monospace" }}
- />
- ) : null}
- </Section>
- </TabPanel>
-
- <TabPanel value={tabValue} index={5}>
- <Section title="6. M18 sync alert email">
- <Alert severity="info" sx={{ mb: 2 }}>
- Production sync errors email{" "}
- <strong>[email protected]</strong> and{" "}
- <strong>[email protected]</strong> (see{" "}
- <code>scheduler.sync-alert.email.to-addresses</code>). WhatsApp/Twilio
- is disabled. SMTP from DB <code>MAIL.smtp.*</code> (e.g. Gmail{" "}
- <code>[email protected]</code> + app password).
- </Alert>
- <Stack spacing={2} sx={{ mb: 2, maxWidth: 720 }}>
- <TextField
- size="small"
- label="Email subject"
- value={emailTestSubject}
- onChange={(e) => setEmailTestSubject(e.target.value)}
- fullWidth
- />
- <TextField
- label="Test message"
- value={emailTestMessage}
- onChange={(e) => setEmailTestMessage(e.target.value)}
- multiline
- minRows={3}
- fullWidth
- />
- <Stack direction="row" spacing={2} sx={{ flexWrap: "wrap" }}>
- <Button
- variant="contained"
- color="primary"
- onClick={() => void handleSyncAlertTestEmail()}
- disabled={emailTestLoading}
- >
- {emailTestLoading ? "Sending…" : "Send test email message"}
- </Button>
- </Stack>
- </Stack>
- {emailTestResult ? (
- <TextField
- fullWidth
- multiline
- minRows={2}
- label="Email test response"
- value={emailTestResult}
- InputProps={{ readOnly: true }}
- sx={{ fontFamily: "monospace", mb: 3 }}
- />
- ) : null}
- <Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
- <code>
- GET /scheduler/trigger/sync-alert-test-email?subject=…&message=…
- </code>
- <br />
- <code>GET /scheduler/trigger/sync-alert-check</code> — run alert rules
- now (empty = OK)
- </Typography>
- </Section>
- </TabPanel>
- </Box>
- );
- }
|