FPSMS-frontend
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

1034 líneas
36 KiB

  1. "use client";
  2. import React, { useEffect, useMemo, useRef, useState } from "react";
  3. import {
  4. Alert,
  5. Box,
  6. Button,
  7. CircularProgress,
  8. Paper,
  9. Stack,
  10. Tab,
  11. Table,
  12. TableBody,
  13. TableCell,
  14. TableHead,
  15. TableRow,
  16. Tabs,
  17. TextField,
  18. Typography,
  19. } from "@mui/material";
  20. import { FileDownload } from "@mui/icons-material";
  21. import dayjs from "dayjs";
  22. import { formatHongKongDateTime } from "@/utils/formatHongKongDateTime";
  23. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  24. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  25. import LotLabelPrintModal from "@/components/InventorySearch/LotLabelPrintModal";
  26. import {
  27. buildOnPackJobOrdersPayload,
  28. downloadOnPackTextQrZip,
  29. fetchJobOrders,
  30. pushOnPackTextQrZipToNgpcl,
  31. type JobOrderListItem,
  32. } from "@/app/api/bagPrint/actions";
  33. import {
  34. fetchLaserBag2Settings,
  35. runLaserBag2AutoSend,
  36. type LaserBag2AutoSendReport,
  37. type LaserLastReceiveSuccess,
  38. } from "@/app/api/laserPrint/actions";
  39. import * as XLSX from "xlsx";
  40. interface TabPanelProps {
  41. children?: React.ReactNode;
  42. index: number;
  43. value: number;
  44. }
  45. function TabPanel(props: TabPanelProps) {
  46. const { children, value, index, ...other } = props;
  47. return (
  48. <div
  49. role="tabpanel"
  50. hidden={value !== index}
  51. id={`simple-tabpanel-${index}`}
  52. aria-labelledby={`simple-tab-${index}`}
  53. {...other}
  54. >
  55. {value === index && <Box sx={{ p: 3 }}>{children}</Box>}
  56. </div>
  57. );
  58. }
  59. export default function TestingPage() {
  60. const [tabValue, setTabValue] = useState(0);
  61. const [lotLabelModalOpen, setLotLabelModalOpen] = useState(false);
  62. const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
  63. setTabValue(newValue);
  64. };
  65. // --- 1. GRN Preview (M18) ---
  66. const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] =
  67. useState("2026-03-16");
  68. // --- 2. OnPack NGPCL (same job-order → ZIP logic as /bagPrint) ---
  69. const [onpackPlanDate, setOnpackPlanDate] = useState(() =>
  70. dayjs().format("YYYY-MM-DD"),
  71. );
  72. const [onpackJobOrders, setOnpackJobOrders] = useState<JobOrderListItem[]>(
  73. [],
  74. );
  75. const [onpackLoading, setOnpackLoading] = useState(false);
  76. const [onpackLoadError, setOnpackLoadError] = useState<string | null>(null);
  77. const [onpackLemonDownloading, setOnpackLemonDownloading] = useState(false);
  78. const [onpackPushLoading, setOnpackPushLoading] = useState(false);
  79. const [onpackPushResult, setOnpackPushResult] = useState<string | null>(null);
  80. // --- 3. Laser Bag2 auto-send (same as /laserPrint + DB LASER_PRINT.*) ---
  81. const [laserAutoPlanDate, setLaserAutoPlanDate] = useState(() =>
  82. dayjs().format("YYYY-MM-DD"),
  83. );
  84. const [laserAutoLimit, setLaserAutoLimit] = useState("1");
  85. const [laserAutoLoading, setLaserAutoLoading] = useState(false);
  86. const [laserAutoReport, setLaserAutoReport] =
  87. useState<LaserBag2AutoSendReport | null>(null);
  88. const [laserAutoError, setLaserAutoError] = useState<string | null>(null);
  89. const [laserLastReceive, setLaserLastReceive] =
  90. useState<LaserLastReceiveSuccess | null>(null);
  91. const bomShopSyncInFlightRef = useRef(false);
  92. const bomShopSyncAllInFlightRef = useRef(false);
  93. const [bomShopSyncBomId, setBomShopSyncBomId] = useState("78");
  94. const [bomShopM18HeaderId, setBomShopM18HeaderId] = useState("");
  95. const [bomShopSyncLoading, setBomShopSyncLoading] = useState(false);
  96. const [bomShopSyncResult, setBomShopSyncResult] = useState<string | null>(
  97. null,
  98. );
  99. const [bomShopSyncAllLoading, setBomShopSyncAllLoading] = useState(false);
  100. const [bomShopSyncAllResult, setBomShopSyncAllResult] = useState<
  101. string | null
  102. >(null);
  103. const bomByItemCodeInFlightRef = useRef(false);
  104. const [bomByItemCodeInput, setBomByItemCodeInput] = useState("");
  105. const [bomByItemCodeLoading, setBomByItemCodeLoading] = useState(false);
  106. const [bomByItemCodeResult, setBomByItemCodeResult] = useState<string | null>(
  107. null,
  108. );
  109. const whatsAppTestInFlightRef = useRef(false);
  110. const [whatsAppVar1, setWhatsAppVar1] = useState("12/1");
  111. const [whatsAppVar2, setWhatsAppVar2] = useState("3pm");
  112. const [whatsAppTestLoading, setWhatsAppTestLoading] = useState(false);
  113. const [whatsAppTestResult, setWhatsAppTestResult] = useState<string | null>(
  114. null,
  115. );
  116. const emailTestInFlightRef = useRef(false);
  117. const [emailTestSubject, setEmailTestSubject] = useState(
  118. "FPSMS M18 sync alert [TEST]",
  119. );
  120. const [emailTestMessage, setEmailTestMessage] = useState(
  121. "FPSMS sync alert test message from /testing page.",
  122. );
  123. const [emailTestLoading, setEmailTestLoading] = useState(false);
  124. const [emailTestResult, setEmailTestResult] = useState<string | null>(null);
  125. const onpackPayload = useMemo(
  126. () => buildOnPackJobOrdersPayload(onpackJobOrders),
  127. [onpackJobOrders],
  128. );
  129. useEffect(() => {
  130. if (tabValue !== 1) return;
  131. let cancelled = false;
  132. (async () => {
  133. setOnpackLoading(true);
  134. setOnpackLoadError(null);
  135. try {
  136. const data = await fetchJobOrders(onpackPlanDate);
  137. if (!cancelled) setOnpackJobOrders(data);
  138. } catch (e) {
  139. if (!cancelled) {
  140. setOnpackLoadError(
  141. e instanceof Error ? e.message : "Failed to load job orders",
  142. );
  143. setOnpackJobOrders([]);
  144. }
  145. } finally {
  146. if (!cancelled) setOnpackLoading(false);
  147. }
  148. })();
  149. return () => {
  150. cancelled = true;
  151. };
  152. }, [tabValue, onpackPlanDate]);
  153. useEffect(() => {
  154. if (tabValue !== 2) return;
  155. let cancelled = false;
  156. (async () => {
  157. try {
  158. const s = await fetchLaserBag2Settings();
  159. if (!cancelled) setLaserLastReceive(s.lastReceiveSuccess ?? null);
  160. } catch {
  161. if (!cancelled) setLaserLastReceive(null);
  162. }
  163. })();
  164. return () => {
  165. cancelled = true;
  166. };
  167. }, [tabValue]);
  168. const handleDownloadGrnPreviewXlsx = async () => {
  169. try {
  170. const response = await clientAuthFetch(
  171. `${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent(
  172. grnPreviewReceiptDate,
  173. )}`,
  174. { method: "GET" },
  175. );
  176. if (response.status === 401 || response.status === 403) return;
  177. if (!response.ok) throw new Error(`Download failed: ${response.status}`);
  178. const data = await response.json();
  179. const rows = Array.isArray(data?.rows) ? data.rows : [];
  180. const ws = XLSX.utils.json_to_sheet(rows);
  181. const wb = XLSX.utils.book_new();
  182. XLSX.utils.book_append_sheet(wb, ws, "GRN Preview");
  183. const xlsxArrayBuffer = XLSX.write(wb, {
  184. bookType: "xlsx",
  185. type: "array",
  186. });
  187. const blob = new Blob([xlsxArrayBuffer], {
  188. type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  189. });
  190. const url = window.URL.createObjectURL(blob);
  191. const link = document.createElement("a");
  192. link.href = url;
  193. link.setAttribute(
  194. "download",
  195. `grn-preview-m18-${grnPreviewReceiptDate}.xlsx`,
  196. );
  197. document.body.appendChild(link);
  198. link.click();
  199. link.remove();
  200. window.URL.revokeObjectURL(url);
  201. } catch (e) {
  202. console.error("GRN Preview XLSX Download Error:", e);
  203. alert("GRN Preview XLSX download failed. Check console/network.");
  204. }
  205. };
  206. const downloadBlob = (blob: Blob, filename: string) => {
  207. const url = window.URL.createObjectURL(blob);
  208. const link = document.createElement("a");
  209. link.href = url;
  210. link.setAttribute("download", filename);
  211. document.body.appendChild(link);
  212. link.click();
  213. link.remove();
  214. window.URL.revokeObjectURL(url);
  215. };
  216. const handleOnpackDownloadLemonZip = async () => {
  217. if (onpackPayload.length === 0) {
  218. alert(
  219. "No job orders with item code for this plan date (same rule as Bag Print).",
  220. );
  221. return;
  222. }
  223. setOnpackLemonDownloading(true);
  224. try {
  225. const blob = await downloadOnPackTextQrZip({ jobOrders: onpackPayload });
  226. downloadBlob(blob, `onpack2023_lemon_qr_${onpackPlanDate}.zip`);
  227. } catch (e) {
  228. console.error("Lemon OnPack ZIP download error:", e);
  229. alert(e instanceof Error ? e.message : "Lemon OnPack ZIP failed");
  230. } finally {
  231. setOnpackLemonDownloading(false);
  232. }
  233. };
  234. const handleLaserBag2AutoSend = async () => {
  235. setLaserAutoLoading(true);
  236. setLaserAutoError(null);
  237. setLaserAutoReport(null);
  238. try {
  239. const lim = parseInt(laserAutoLimit.trim(), 10);
  240. const report = await runLaserBag2AutoSend({
  241. planStart: laserAutoPlanDate,
  242. limitPerRun: Number.isFinite(lim) ? lim : 1,
  243. });
  244. setLaserAutoReport(report);
  245. try {
  246. const s = await fetchLaserBag2Settings();
  247. setLaserLastReceive(s.lastReceiveSuccess ?? null);
  248. } catch {
  249. /* ignore */
  250. }
  251. } catch (e) {
  252. setLaserAutoError(e instanceof Error ? e.message : String(e));
  253. } finally {
  254. setLaserAutoLoading(false);
  255. }
  256. };
  257. const handleOnpackPushNgpcl = async () => {
  258. if (onpackPayload.length === 0) {
  259. alert("No job orders with item code for this plan date.");
  260. return;
  261. }
  262. setOnpackPushLoading(true);
  263. setOnpackPushResult(null);
  264. try {
  265. const r = await pushOnPackTextQrZipToNgpcl({ jobOrders: onpackPayload });
  266. setOnpackPushResult(
  267. `${r.pushed ? "Pushed" : "Not pushed"}: ${r.message}`,
  268. );
  269. } catch (e) {
  270. const msg = e instanceof Error ? e.message : String(e);
  271. setOnpackPushResult(`Error: ${msg}`);
  272. alert(msg);
  273. } finally {
  274. setOnpackPushLoading(false);
  275. }
  276. };
  277. const handleBomShopSyncM18 = async () => {
  278. if (bomShopSyncInFlightRef.current) return;
  279. const id = parseInt(bomShopSyncBomId.trim(), 10);
  280. if (!Number.isFinite(id) || id <= 0) {
  281. alert("Enter a valid BOM id (positive integer).");
  282. return;
  283. }
  284. bomShopSyncInFlightRef.current = true;
  285. setBomShopSyncLoading(true);
  286. setBomShopSyncResult(null);
  287. try {
  288. const m18H = bomShopM18HeaderId.trim();
  289. const qs =
  290. m18H && /^\d+$/.test(m18H)
  291. ? `?m18HeaderId=${encodeURIComponent(m18H)}`
  292. : "";
  293. const response = await clientAuthFetch(
  294. `${NEXT_PUBLIC_API_URL}/m18/test/bom-shop-sync/${id}${qs}`,
  295. { method: "POST" },
  296. );
  297. if (response.status === 401 || response.status === 403) return;
  298. const text = await response.text();
  299. let display = text;
  300. try {
  301. const parsed: unknown = JSON.parse(text);
  302. display = JSON.stringify(parsed, null, 2);
  303. } catch {
  304. /* keep raw */
  305. }
  306. if (!response.ok) {
  307. setBomShopSyncResult(`HTTP ${response.status}\n\n${display}`);
  308. return;
  309. }
  310. setBomShopSyncResult(display);
  311. } catch (e) {
  312. const msg = e instanceof Error ? e.message : String(e);
  313. setBomShopSyncResult(`Error: ${msg}`);
  314. } finally {
  315. setBomShopSyncLoading(false);
  316. bomShopSyncInFlightRef.current = false;
  317. }
  318. };
  319. const handleBomShopSyncAllM18 = async () => {
  320. if (bomShopSyncAllInFlightRef.current) return;
  321. bomShopSyncAllInFlightRef.current = true;
  322. setBomShopSyncAllLoading(true);
  323. setBomShopSyncAllResult(null);
  324. try {
  325. const response = await clientAuthFetch(
  326. `${NEXT_PUBLIC_API_URL}/scheduler/trigger/bom-shop-sync-all`,
  327. { method: "GET" },
  328. );
  329. if (response.status === 401 || response.status === 403) return;
  330. const text = await response.text();
  331. let display = text;
  332. try {
  333. const parsed: unknown = JSON.parse(text);
  334. display = JSON.stringify(parsed, null, 2);
  335. } catch {
  336. /* plain string from backend is fine */
  337. }
  338. if (!response.ok) {
  339. setBomShopSyncAllResult(`HTTP ${response.status}\n\n${display}`);
  340. return;
  341. }
  342. setBomShopSyncAllResult(display);
  343. } catch (e) {
  344. const msg = e instanceof Error ? e.message : String(e);
  345. setBomShopSyncAllResult(`Error: ${msg}`);
  346. } finally {
  347. setBomShopSyncAllLoading(false);
  348. bomShopSyncAllInFlightRef.current = false;
  349. }
  350. };
  351. const handleWhatsAppSyncAlertTest = async () => {
  352. if (whatsAppTestInFlightRef.current) return;
  353. whatsAppTestInFlightRef.current = true;
  354. setWhatsAppTestLoading(true);
  355. setWhatsAppTestResult(null);
  356. try {
  357. const params = new URLSearchParams();
  358. const v1 = whatsAppVar1.trim();
  359. const v2 = whatsAppVar2.trim();
  360. if (v1) params.set("var1", v1);
  361. if (v2) params.set("var2", v2);
  362. const qs = params.toString();
  363. const response = await clientAuthFetch(
  364. `${NEXT_PUBLIC_API_URL}/scheduler/trigger/sync-alert-test-whatsapp${qs ? `?${qs}` : ""}`,
  365. { method: "GET" },
  366. );
  367. if (response.status === 401 || response.status === 403) return;
  368. const text = await response.text();
  369. if (!response.ok) {
  370. setWhatsAppTestResult(`HTTP ${response.status}\n\n${text}`);
  371. return;
  372. }
  373. setWhatsAppTestResult(text);
  374. } catch (e) {
  375. const msg = e instanceof Error ? e.message : String(e);
  376. setWhatsAppTestResult(`Error: ${msg}`);
  377. } finally {
  378. setWhatsAppTestLoading(false);
  379. whatsAppTestInFlightRef.current = false;
  380. }
  381. };
  382. const handleSyncAlertTestEmail = async () => {
  383. if (emailTestInFlightRef.current) return;
  384. emailTestInFlightRef.current = true;
  385. setEmailTestLoading(true);
  386. setEmailTestResult(null);
  387. try {
  388. const params = new URLSearchParams();
  389. const subj = emailTestSubject.trim();
  390. const msg = emailTestMessage.trim();
  391. if (subj) params.set("subject", subj);
  392. if (msg) params.set("message", msg);
  393. const qs = params.toString();
  394. const response = await clientAuthFetch(
  395. `${NEXT_PUBLIC_API_URL}/scheduler/trigger/sync-alert-test-email${qs ? `?${qs}` : ""}`,
  396. { method: "GET" },
  397. );
  398. if (response.status === 401 || response.status === 403) return;
  399. const text = await response.text();
  400. if (!response.ok) {
  401. setEmailTestResult(`HTTP ${response.status}\n\n${text}`);
  402. return;
  403. }
  404. setEmailTestResult(text);
  405. } catch (e) {
  406. const msg = e instanceof Error ? e.message : String(e);
  407. setEmailTestResult(`Error: ${msg}`);
  408. } finally {
  409. setEmailTestLoading(false);
  410. emailTestInFlightRef.current = false;
  411. }
  412. };
  413. const handleBomLookupByItemCode = async () => {
  414. if (bomByItemCodeInFlightRef.current) return;
  415. const code = bomByItemCodeInput.trim();
  416. if (!code) {
  417. alert("Enter an item code.");
  418. return;
  419. }
  420. bomByItemCodeInFlightRef.current = true;
  421. setBomByItemCodeLoading(true);
  422. setBomByItemCodeResult(null);
  423. try {
  424. const response = await clientAuthFetch(
  425. `${NEXT_PUBLIC_API_URL}/bom/by-item-code?code=${encodeURIComponent(code)}`,
  426. { method: "GET" },
  427. );
  428. if (response.status === 401 || response.status === 403) return;
  429. const text = await response.text();
  430. let display = text;
  431. try {
  432. const parsed: unknown = JSON.parse(text);
  433. display = JSON.stringify(parsed, null, 2);
  434. } catch {
  435. /* keep raw */
  436. }
  437. setBomByItemCodeResult(display);
  438. if (!response.ok) {
  439. alert(`Lookup failed: HTTP ${response.status}`);
  440. }
  441. } catch (e) {
  442. const msg = e instanceof Error ? e.message : String(e);
  443. setBomByItemCodeResult(`Error: ${msg}`);
  444. alert(msg);
  445. } finally {
  446. setBomByItemCodeLoading(false);
  447. bomByItemCodeInFlightRef.current = false;
  448. }
  449. };
  450. const Section = ({
  451. title,
  452. children,
  453. }: {
  454. title: string;
  455. children?: React.ReactNode;
  456. }) => (
  457. <Paper
  458. sx={{
  459. p: 3,
  460. minHeight: "450px",
  461. display: "flex",
  462. flexDirection: "column",
  463. }}
  464. >
  465. <Typography
  466. variant="h5"
  467. gutterBottom
  468. color="primary"
  469. sx={{ borderBottom: "2px solid #f0f0f0", pb: 1, mb: 2 }}
  470. >
  471. {title}
  472. </Typography>
  473. {children || (
  474. <Typography color="textSecondary" sx={{ m: "auto" }}>
  475. Waiting for implementation...
  476. </Typography>
  477. )}
  478. </Paper>
  479. );
  480. return (
  481. <Box sx={{ p: 4 }}>
  482. <Typography variant="h4" sx={{ mb: 4, fontWeight: "bold" }}>
  483. Testing
  484. </Typography>
  485. <Tabs
  486. value={tabValue}
  487. onChange={handleTabChange}
  488. aria-label="testing sections tabs"
  489. centered
  490. variant="fullWidth"
  491. >
  492. <Tab label="1. GRN Preview" />
  493. <Tab label="2. OnPack NGPCL" />
  494. <Tab label="3. Laser Bag2 自動送" />
  495. <Tab label="4. 批號標籤列印" />
  496. <Tab label="5. M18 BOM shop" />
  497. <Tab label="6. Sync alert email" />
  498. </Tabs>
  499. <TabPanel value={tabValue} index={0}>
  500. <Section title="1. GRN Preview (M18)">
  501. <Stack
  502. direction="row"
  503. spacing={2}
  504. sx={{ mb: 2, alignItems: "center" }}
  505. >
  506. <TextField
  507. size="small"
  508. label="Receipt Date"
  509. type="date"
  510. value={grnPreviewReceiptDate}
  511. onChange={(e) => setGrnPreviewReceiptDate(e.target.value)}
  512. InputLabelProps={{ shrink: true }}
  513. />
  514. <Button
  515. variant="contained"
  516. color="success"
  517. size="medium"
  518. startIcon={<FileDownload />}
  519. onClick={handleDownloadGrnPreviewXlsx}
  520. >
  521. Download GRN Preview XLSX
  522. </Button>
  523. </Stack>
  524. <Typography variant="body2" color="textSecondary">
  525. Backend endpoint:{" "}
  526. <code>/report/grn-preview-m18?receiptDate=YYYY-MM-DD</code>
  527. </Typography>
  528. </Section>
  529. </TabPanel>
  530. <TabPanel value={tabValue} index={1}>
  531. <Section title="2. OnPack NGPCL (same logic as /bagPrint)">
  532. <Alert severity="info" sx={{ mb: 2 }}>
  533. Uses <strong>GET /py/job-orders?planStart=</strong> for the day,
  534. then the same <code>jobOrders</code> payload as{" "}
  535. <strong>Bag Print → 下載 OnPack2023檸檬機</strong>. The ZIP contains
  536. loose <code>.job</code> / <code>.image</code> / BMPs — extract
  537. before sending to NGE; the ZIP itself is only a transport bundle.
  538. </Alert>
  539. <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
  540. Distinct item codes in the list produce one label set each (backend
  541. groups by code). Configure <code>ngpcl.push-url</code> on the server
  542. to POST the same lemon ZIP bytes to your NGPCL HTTP gateway;
  543. otherwise use download only.
  544. </Typography>
  545. <Stack
  546. direction={{ xs: "column", sm: "row" }}
  547. spacing={2}
  548. sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}
  549. >
  550. <TextField
  551. size="small"
  552. label="Plan date (planStart)"
  553. type="date"
  554. value={onpackPlanDate}
  555. onChange={(e) => setOnpackPlanDate(e.target.value)}
  556. InputLabelProps={{ shrink: true }}
  557. />
  558. <Typography variant="body2" color="textSecondary">
  559. {onpackLoading ? (
  560. <>
  561. <CircularProgress
  562. size={16}
  563. sx={{ mr: 1, verticalAlign: "middle" }}
  564. />
  565. Loading job orders…
  566. </>
  567. ) : (
  568. `${onpackJobOrders.length} job order(s), ${onpackPayload.length} row(s) with item code → ZIP`
  569. )}
  570. </Typography>
  571. </Stack>
  572. {onpackLoadError ? (
  573. <Alert severity="error" sx={{ mb: 2 }}>
  574. {onpackLoadError}
  575. </Alert>
  576. ) : null}
  577. <Table size="small" sx={{ mb: 2, maxWidth: 900 }}>
  578. <TableHead>
  579. <TableRow>
  580. <TableCell>JO id</TableCell>
  581. <TableCell>Code</TableCell>
  582. <TableCell>Item code</TableCell>
  583. <TableCell>Lot</TableCell>
  584. </TableRow>
  585. </TableHead>
  586. <TableBody>
  587. {onpackJobOrders.length === 0 && !onpackLoading ? (
  588. <TableRow>
  589. <TableCell colSpan={4}>
  590. <Typography variant="body2" color="textSecondary">
  591. No rows for this date (or still loading).
  592. </Typography>
  593. </TableCell>
  594. </TableRow>
  595. ) : (
  596. onpackJobOrders.map((jo) => (
  597. <TableRow key={jo.id}>
  598. <TableCell>{jo.id}</TableCell>
  599. <TableCell>{jo.code ?? "—"}</TableCell>
  600. <TableCell>{jo.itemCode ?? "—"}</TableCell>
  601. <TableCell>{jo.lotNo ?? "—"}</TableCell>
  602. </TableRow>
  603. ))
  604. )}
  605. </TableBody>
  606. </Table>
  607. <TextField
  608. fullWidth
  609. multiline
  610. minRows={3}
  611. label="Resolved POST body (download-onpack-qr-text / NGPCL push)"
  612. value={JSON.stringify({ jobOrders: onpackPayload }, null, 2)}
  613. InputProps={{ readOnly: true }}
  614. sx={{ mb: 2, fontFamily: "monospace" }}
  615. />
  616. <Stack
  617. direction={{ xs: "column", sm: "row" }}
  618. spacing={2}
  619. sx={{ mb: 2, flexWrap: "wrap" }}
  620. >
  621. <Button
  622. variant="contained"
  623. color="success"
  624. onClick={handleOnpackDownloadLemonZip}
  625. disabled={onpackLemonDownloading || onpackLoading}
  626. >
  627. {onpackLemonDownloading
  628. ? "Downloading…"
  629. : "Download lemon OnPack ZIP"}
  630. </Button>
  631. <Button
  632. variant="outlined"
  633. onClick={handleOnpackPushNgpcl}
  634. disabled={onpackPushLoading || onpackLoading}
  635. >
  636. {onpackPushLoading
  637. ? "Pushing…"
  638. : "Push to NGPCL (server → ngpcl.push-url)"}
  639. </Button>
  640. </Stack>
  641. {onpackPushResult ? (
  642. <TextField
  643. fullWidth
  644. multiline
  645. minRows={2}
  646. label="Last NGPCL push result"
  647. value={onpackPushResult}
  648. InputProps={{ readOnly: true }}
  649. />
  650. ) : null}
  651. <Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
  652. <code>POST /plastic/download-onpack-qr-text</code> ·{" "}
  653. <code>POST /plastic/ngpcl/push-onpack-qr-text</code> (same body)
  654. </Typography>
  655. </Section>
  656. </TabPanel>
  657. <TabPanel value={tabValue} index={2}>
  658. <Section title="3. Laser Bag2 自動送(與 /laserPrint 相同邏輯)">
  659. {laserLastReceive ? (
  660. <Alert severity="info" sx={{ mb: 2 }}>
  661. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  662. 上次印表機已確認(receive)的工單(資料庫)
  663. </Typography>
  664. <Typography variant="body2" sx={{ mt: 0.5 }}>
  665. 工單號:{laserLastReceive.jobOrderNo ?? "—"} Lot:
  666. {laserLastReceive.lotNo ?? "—"}
  667. </Typography>
  668. <Typography
  669. variant="body2"
  670. sx={{ mt: 0.5, fontFamily: "monospace" }}
  671. >
  672. JSON:{" "}
  673. {laserLastReceive.itemId != null &&
  674. laserLastReceive.stockInLineId != null
  675. ? JSON.stringify({
  676. itemId: laserLastReceive.itemId,
  677. stockInLineId: laserLastReceive.stockInLineId,
  678. })
  679. : "—"}
  680. </Typography>
  681. <Typography
  682. variant="caption"
  683. color="textSecondary"
  684. display="block"
  685. sx={{ mt: 0.5 }}
  686. >
  687. {formatHongKongDateTime(laserLastReceive.sentAt)} {laserLastReceive.source ?? ""}
  688. </Typography>
  689. </Alert>
  690. ) : null}
  691. <Alert severity="warning" sx={{ mb: 2 }}>
  692. 依資料庫 <strong>LASER_PRINT.host</strong>、
  693. <strong>LASER_PRINT.port</strong>、
  694. <strong>LASER_PRINT.itemCodes</strong> 查當日包裝工單並送
  695. TCP(每筆工單預設 3 次、間隔 3 秒,與前端點列相同)。
  696. 排程預設關閉;啟用請設{" "}
  697. <code>laser.bag2.auto-send.enabled=true</code>(後端
  698. application.yml)。
  699. </Alert>
  700. <Stack
  701. direction={{ xs: "column", sm: "row" }}
  702. spacing={2}
  703. sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}
  704. >
  705. <TextField
  706. size="small"
  707. label="Plan date (planStart)"
  708. type="date"
  709. value={laserAutoPlanDate}
  710. onChange={(e) => setLaserAutoPlanDate(e.target.value)}
  711. InputLabelProps={{ shrink: true }}
  712. />
  713. <TextField
  714. size="small"
  715. label="limitPerRun(目前固定只送第一筆)"
  716. value={laserAutoLimit}
  717. onChange={(e) => setLaserAutoLimit(e.target.value)}
  718. sx={{ width: 200 }}
  719. helperText="目前後端會限制為第一筆;此欄位保留給未來調整"
  720. />
  721. <Button
  722. variant="contained"
  723. color="primary"
  724. onClick={() => void handleLaserBag2AutoSend()}
  725. disabled={laserAutoLoading}
  726. >
  727. {laserAutoLoading
  728. ? "送出中…"
  729. : "執行 POST /plastic/laser-bag2-auto-send"}
  730. </Button>
  731. </Stack>
  732. {laserAutoError ? (
  733. <Alert severity="error" sx={{ mb: 2 }}>
  734. {laserAutoError}
  735. </Alert>
  736. ) : null}
  737. {laserAutoReport ? (
  738. <TextField
  739. fullWidth
  740. multiline
  741. minRows={8}
  742. label="回應(LaserBag2AutoSendReport)"
  743. value={JSON.stringify(laserAutoReport, null, 2)}
  744. InputProps={{ readOnly: true }}
  745. sx={{ fontFamily: "monospace" }}
  746. />
  747. ) : null}
  748. <Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
  749. <code>
  750. POST
  751. /api/plastic/laser-bag2-auto-send?planStart=YYYY-MM-DD&amp;limitPerRun=N
  752. </code>
  753. </Typography>
  754. </Section>
  755. </TabPanel>
  756. <TabPanel value={tabValue} index={3}>
  757. <Section title="4. 批號標籤列印(掃碼 → 查同品批號 → 選印表機 → 列印)">
  758. <Alert severity="info" sx={{ mb: 2 }}>
  759. 此工具會呼叫後端 <code>/inventoryLotLine/analyze-qr-code</code>{" "}
  760. 找同品可用批號,再用 <code>/inventoryLotLine/print-label</code>(需
  761. printerId)送出列印。
  762. </Alert>
  763. <Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
  764. <Button
  765. variant="contained"
  766. onClick={() => setLotLabelModalOpen(true)}
  767. >
  768. 開啟列印視窗
  769. </Button>
  770. <Typography
  771. variant="body2"
  772. color="text.secondary"
  773. sx={{ alignSelf: "center" }}
  774. >
  775. 掃碼格式:<code>{'{"itemId":16431,"stockInLineId":10381'}</code>
  776. </Typography>
  777. </Stack>
  778. <LotLabelPrintModal
  779. open={lotLabelModalOpen}
  780. onClose={() => setLotLabelModalOpen(false)}
  781. />
  782. </Section>
  783. </TabPanel>
  784. <TabPanel value={tabValue} index={4}>
  785. <Section title="5. M18 BOM shop sync (udfBomForShop)">
  786. <Alert severity="info" sx={{ mb: 2 }}>
  787. Requires setting <code>M18.bom.shop.sync.enabled=true</code>. Use{" "}
  788. <code>m18HeaderId</code> query param (or the field below) so M18{" "}
  789. <strong>updates</strong> the existing udfBomForShop header instead of creating a duplicate.
  790. </Alert>
  791. <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
  792. Lookup BOM id by item code (like PO-by-code)
  793. </Typography>
  794. <Typography variant="body2" color="textSecondary" sx={{ mb: 1 }}>
  795. Uses <code>GET /bom/by-item-code?code=…</code> — item is the BOM
  796. header product (<code>bom.item</code>), same as FPSMS finished-good
  797. <code> items.code</code>.
  798. </Typography>
  799. <Stack
  800. direction={{ xs: "column", sm: "row" }}
  801. spacing={2}
  802. sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}
  803. >
  804. <TextField
  805. size="small"
  806. label="Item code"
  807. value={bomByItemCodeInput}
  808. onChange={(e) => setBomByItemCodeInput(e.target.value)}
  809. sx={{ width: 220 }}
  810. />
  811. <Button
  812. variant="outlined"
  813. onClick={() => void handleBomLookupByItemCode()}
  814. disabled={bomByItemCodeLoading}
  815. >
  816. {bomByItemCodeLoading ? "Looking up…" : "Lookup BOM id"}
  817. </Button>
  818. </Stack>
  819. {bomByItemCodeResult ? (
  820. <Stack spacing={1} sx={{ mb: 3 }}>
  821. <TextField
  822. fullWidth
  823. multiline
  824. minRows={4}
  825. label="Lookup response (BomIdByItemCodeResponse)"
  826. value={bomByItemCodeResult}
  827. InputProps={{ readOnly: true }}
  828. sx={{ fontFamily: "monospace" }}
  829. />
  830. <Button
  831. size="small"
  832. variant="text"
  833. onClick={() => {
  834. try {
  835. const o = JSON.parse(bomByItemCodeResult) as {
  836. bomId?: number;
  837. bomM18Id?: number;
  838. };
  839. if (o?.bomId != null)
  840. setBomShopSyncBomId(String(o.bomId));
  841. if (o?.bomM18Id != null)
  842. setBomShopM18HeaderId(String(o.bomM18Id));
  843. } catch {
  844. /* ignore */
  845. }
  846. }}
  847. >
  848. Copy bomId + bomM18Id to sync fields below
  849. </Button>
  850. </Stack>
  851. ) : null}
  852. <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
  853. M18 udfBomForShop sync
  854. </Typography>
  855. <Typography variant="body2" color="textSecondary" sx={{ mb: 1 }}>
  856. GET <code>/scheduler/trigger/bom-shop-sync-all</code>: push{" "}
  857. <strong>all</strong> non-deleted BOMs (same nightly job path;
  858. respects <code>M18.bom.shop.sync.enabled</code>). Check{" "}
  859. <code>scheduler_sync_log</code> (<code>M18_BOM_SHOP</code>) for row
  860. counts.
  861. </Typography>
  862. <Stack direction="row" spacing={2} sx={{ mb: 2, flexWrap: "wrap" }}>
  863. <Button
  864. variant="outlined"
  865. color="secondary"
  866. onClick={() => void handleBomShopSyncAllM18()}
  867. disabled={bomShopSyncAllLoading}
  868. >
  869. {bomShopSyncAllLoading
  870. ? "Syncing all BOMs…"
  871. : "Sync all BOMs to M18"}
  872. </Button>
  873. </Stack>
  874. {bomShopSyncAllResult ? (
  875. <TextField
  876. fullWidth
  877. multiline
  878. minRows={3}
  879. label="Bulk trigger response"
  880. value={bomShopSyncAllResult}
  881. InputProps={{ readOnly: true }}
  882. sx={{
  883. mb: 2,
  884. fontFamily: "monospace",
  885. }}
  886. />
  887. ) : null}
  888. <Typography variant="body2" color="textSecondary" sx={{ mb: 1 }}>
  889. POST <code>/m18/test/bom-shop-sync/:bomId</code> with optional{" "}
  890. <code>?m18HeaderId=</code> (M18 udfBomForShop header id for{" "}
  891. <strong>update</strong>; from lookup <code>bomM18Id</code> or{" "}
  892. <code>Bom.m18Id</code> in DB). If omitted, backend uses{" "}
  893. <code>bom.m18Id</code> when set; otherwise creates a new M18 row.
  894. </Typography>
  895. <Stack
  896. direction={{ xs: "column", sm: "row" }}
  897. spacing={2}
  898. sx={{ mb: 2, alignItems: "center", flexWrap: "wrap" }}
  899. >
  900. <TextField
  901. size="small"
  902. label="BOM id"
  903. value={bomShopSyncBomId}
  904. onChange={(e) => setBomShopSyncBomId(e.target.value)}
  905. sx={{ width: 160 }}
  906. />
  907. <TextField
  908. size="small"
  909. label="M18 header id (optional, update)"
  910. value={bomShopM18HeaderId}
  911. onChange={(e) => setBomShopM18HeaderId(e.target.value)}
  912. sx={{ width: 220 }}
  913. helperText="e.g. 255 from bomM18Id — forces main.id in payload"
  914. />
  915. <Button
  916. variant="contained"
  917. color="primary"
  918. onClick={() => void handleBomShopSyncM18()}
  919. disabled={bomShopSyncLoading}
  920. >
  921. {bomShopSyncLoading ? "Syncing…" : "Sync BOM to M18"}
  922. </Button>
  923. </Stack>
  924. {bomShopSyncResult ? (
  925. <TextField
  926. fullWidth
  927. multiline
  928. minRows={10}
  929. label="Response (M18BomShopSyncTriggerResult)"
  930. value={bomShopSyncResult}
  931. InputProps={{ readOnly: true }}
  932. sx={{ fontFamily: "monospace" }}
  933. />
  934. ) : null}
  935. </Section>
  936. </TabPanel>
  937. <TabPanel value={tabValue} index={5}>
  938. <Section title="6. M18 sync alert email">
  939. <Alert severity="info" sx={{ mb: 2 }}>
  940. Production sync errors email{" "}
  941. <strong>[email protected]</strong> and{" "}
  942. <strong>[email protected]</strong> (see{" "}
  943. <code>scheduler.sync-alert.email.to-addresses</code>). WhatsApp/Twilio
  944. is disabled. SMTP from DB <code>MAIL.smtp.*</code> (e.g. Gmail{" "}
  945. <code>[email protected]</code> + app password).
  946. </Alert>
  947. <Stack spacing={2} sx={{ mb: 2, maxWidth: 720 }}>
  948. <TextField
  949. size="small"
  950. label="Email subject"
  951. value={emailTestSubject}
  952. onChange={(e) => setEmailTestSubject(e.target.value)}
  953. fullWidth
  954. />
  955. <TextField
  956. label="Test message"
  957. value={emailTestMessage}
  958. onChange={(e) => setEmailTestMessage(e.target.value)}
  959. multiline
  960. minRows={3}
  961. fullWidth
  962. />
  963. <Stack direction="row" spacing={2} sx={{ flexWrap: "wrap" }}>
  964. <Button
  965. variant="contained"
  966. color="primary"
  967. onClick={() => void handleSyncAlertTestEmail()}
  968. disabled={emailTestLoading}
  969. >
  970. {emailTestLoading ? "Sending…" : "Send test email message"}
  971. </Button>
  972. </Stack>
  973. </Stack>
  974. {emailTestResult ? (
  975. <TextField
  976. fullWidth
  977. multiline
  978. minRows={2}
  979. label="Email test response"
  980. value={emailTestResult}
  981. InputProps={{ readOnly: true }}
  982. sx={{ fontFamily: "monospace", mb: 3 }}
  983. />
  984. ) : null}
  985. <Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
  986. <code>
  987. GET /scheduler/trigger/sync-alert-test-email?subject=…&amp;message=…
  988. </code>
  989. <br />
  990. <code>GET /scheduler/trigger/sync-alert-check</code> — run alert rules
  991. now (empty = OK)
  992. </Typography>
  993. </Section>
  994. </TabPanel>
  995. </Box>
  996. );
  997. }