diff --git a/src/app/(main)/testing/page.tsx b/src/app/(main)/testing/page.tsx index ac10ecb..1062628 100644 --- a/src/app/(main)/testing/page.tsx +++ b/src/app/(main)/testing/page.tsx @@ -22,6 +22,7 @@ import { FileDownload } from "@mui/icons-material"; import dayjs from "dayjs"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import LotLabelPrintModal from "@/components/InventorySearch/LotLabelPrintModal"; import { buildOnPackJobOrdersPayload, downloadOnPackTextQrZip, @@ -60,30 +61,43 @@ function TabPanel(props: TabPanelProps) { 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"); + 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([]); + const [onpackPlanDate, setOnpackPlanDate] = useState(() => + dayjs().format("YYYY-MM-DD"), + ); + const [onpackJobOrders, setOnpackJobOrders] = useState( + [], + ); const [onpackLoading, setOnpackLoading] = useState(false); const [onpackLoadError, setOnpackLoadError] = useState(null); 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 [laserAutoPlanDate, setLaserAutoPlanDate] = useState(() => + dayjs().format("YYYY-MM-DD"), + ); const [laserAutoLimit, setLaserAutoLimit] = useState("1"); const [laserAutoLoading, setLaserAutoLoading] = useState(false); - const [laserAutoReport, setLaserAutoReport] = useState(null); + const [laserAutoReport, setLaserAutoReport] = + useState(null); const [laserAutoError, setLaserAutoError] = useState(null); - const [laserLastReceive, setLaserLastReceive] = useState(null); + const [laserLastReceive, setLaserLastReceive] = + useState(null); - const onpackPayload = useMemo(() => buildOnPackJobOrdersPayload(onpackJobOrders), [onpackJobOrders]); + const onpackPayload = useMemo( + () => buildOnPackJobOrdersPayload(onpackJobOrders), + [onpackJobOrders], + ); useEffect(() => { if (tabValue !== 1) return; @@ -96,7 +110,9 @@ export default function TestingPage() { if (!cancelled) setOnpackJobOrders(data); } catch (e) { if (!cancelled) { - setOnpackLoadError(e instanceof Error ? e.message : "Failed to load job orders"); + setOnpackLoadError( + e instanceof Error ? e.message : "Failed to load job orders", + ); setOnpackJobOrders([]); } } finally { @@ -127,7 +143,9 @@ export default function TestingPage() { const handleDownloadGrnPreviewXlsx = async () => { try { const response = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent(grnPreviewReceiptDate)}`, + `${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent( + grnPreviewReceiptDate, + )}`, { method: "GET" }, ); if (response.status === 401 || response.status === 403) return; @@ -140,7 +158,10 @@ export default function TestingPage() { 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 xlsxArrayBuffer = XLSX.write(wb, { + bookType: "xlsx", + type: "array", + }); const blob = new Blob([xlsxArrayBuffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }); @@ -148,7 +169,10 @@ export default function TestingPage() { const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.setAttribute("download", `grn-preview-m18-${grnPreviewReceiptDate}.xlsx`); + link.setAttribute( + "download", + `grn-preview-m18-${grnPreviewReceiptDate}.xlsx`, + ); document.body.appendChild(link); link.click(); link.remove(); @@ -172,7 +196,9 @@ export default function TestingPage() { const handleOnpackDownloadLemonZip = async () => { if (onpackPayload.length === 0) { - alert("No job orders with item code for this plan date (same rule as Bag Print)."); + alert( + "No job orders with item code for this plan date (same rule as Bag Print).", + ); return; } setOnpackLemonDownloading(true); @@ -220,7 +246,9 @@ export default function TestingPage() { setOnpackPushResult(null); try { const r = await pushOnPackTextQrZipToNgpcl({ jobOrders: onpackPayload }); - setOnpackPushResult(`${r.pushed ? "Pushed" : "Not pushed"}: ${r.message}`); + setOnpackPushResult( + `${r.pushed ? "Pushed" : "Not pushed"}: ${r.message}`, + ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setOnpackPushResult(`Error: ${msg}`); @@ -230,12 +258,34 @@ export default function TestingPage() { } }; - const Section = ({ title, children }: { title: string; children?: React.ReactNode }) => ( - - + const Section = ({ + title, + children, + }: { + title: string; + children?: React.ReactNode; + }) => ( + + {title} - {children || Waiting for implementation...} + {children || ( + + Waiting for implementation... + + )} ); @@ -245,15 +295,26 @@ export default function TestingPage() { Testing - + +
- + - Backend endpoint: /report/grn-preview-m18?receiptDate=YYYY-MM-DD + Backend endpoint:{" "} + /report/grn-preview-m18?receiptDate=YYYY-MM-DD
@@ -281,16 +343,24 @@ export default function TestingPage() {
- 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 + 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 before sending to NGE; the ZIP itself is only a transport bundle. - Distinct item codes in the list produce one label set each (backend groups by code). Configure ngpcl.push-url on the server - to POST the same lemon ZIP bytes to your NGPCL HTTP gateway; otherwise use download only. + Distinct item codes in the list produce one label set each (backend + groups by code). Configure ngpcl.push-url on the server + to POST the same lemon ZIP bytes to your NGPCL HTTP gateway; + otherwise use download only. - + {onpackLoading ? ( <> - + Loading job orders… ) : ( @@ -357,19 +430,44 @@ export default function TestingPage() { sx={{ mb: 2, fontFamily: "monospace" }} /> - - - {onpackPushResult ? ( - + ) : null} - POST /plastic/download-onpack-qr-text · POST /plastic/ngpcl/push-onpack-qr-text (same body) + POST /plastic/download-onpack-qr-text ·{" "} + POST /plastic/ngpcl/push-onpack-qr-text (same body)
@@ -382,27 +480,46 @@ export default function TestingPage() { 上次印表機已確認(receive)的工單(資料庫) - 工單號:{laserLastReceive.jobOrderNo ?? "—"} Lot:{laserLastReceive.lotNo ?? "—"} + 工單號:{laserLastReceive.jobOrderNo ?? "—"} Lot: + {laserLastReceive.lotNo ?? "—"} - + JSON:{" "} - {laserLastReceive.itemId != null && laserLastReceive.stockInLineId != null + {laserLastReceive.itemId != null && + laserLastReceive.stockInLineId != null ? JSON.stringify({ itemId: laserLastReceive.itemId, stockInLineId: laserLastReceive.stockInLineId, }) : "—"} - + {laserLastReceive.sentAt ?? ""} {laserLastReceive.source ?? ""} ) : null} - 依資料庫 LASER_PRINT.hostLASER_PRINT.portLASER_PRINT.itemCodes 查當日包裝工單並送 TCP(每筆工單預設 3 次、間隔 3 秒,與前端點列相同)。 - 排程預設關閉;啟用請設 laser.bag2.auto-send.enabled=true(後端 application.yml)。 + 依資料庫 LASER_PRINT.host、 + LASER_PRINT.port、 + LASER_PRINT.itemCodes 查當日包裝工單並送 + TCP(每筆工單預設 3 次、間隔 3 秒,與前端點列相同)。 + 排程預設關閉;啟用請設{" "} + laser.bag2.auto-send.enabled=true(後端 + application.yml)。 - + - {laserAutoError ? ( @@ -440,10 +564,42 @@ export default function TestingPage() { /> ) : null} - POST /api/plastic/laser-bag2-auto-send?planStart=YYYY-MM-DD&limitPerRun=N + + POST + /api/plastic/laser-bag2-auto-send?planStart=YYYY-MM-DD&limitPerRun=N + + + +
+ + 此工具會呼叫後端 /inventoryLotLine/analyze-qr-code{" "} + 找同品可用批號,再用 /inventoryLotLine/print-label(需 + printerId)送出列印。 + + + + + 掃碼格式:{'{"itemId":16431,"stockInLineId":10381'} + + + setLotLabelModalOpen(false)} + /> +
+
); } diff --git a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx index c09f7e5..13cd5a8 100644 --- a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx +++ b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx @@ -20,10 +20,17 @@ import { Modal, Chip, } from "@mui/material"; -import dayjs from 'dayjs'; -import TestQrCodeProvider from '../QrCodeScannerProvider/TestQrCodeProvider'; +import dayjs from "dayjs"; +import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; import { fetchLotDetail } from "@/app/api/inventory/actions"; -import React, { useCallback, useEffect, useState, useRef, useMemo, startTransition } from "react"; +import React, { + useCallback, + useEffect, + useState, + useRef, + useMemo, + startTransition, +} from "react"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/navigation"; import { @@ -32,7 +39,7 @@ import { updateStockOutLine, recordPickExecutionIssue, fetchFGPickOrders, // Add this import - FGPickOrderResponse, + FGPickOrderResponse, stockReponse, PickExecutionIssueData, checkPickOrderCompletion, @@ -42,12 +49,12 @@ import { updateSuggestedLotLineId, updateStockOutLineStatusByQRCodeAndLotNo, confirmLotSubstitution, - fetchDoPickOrderDetail, // 必须添加 - DoPickOrderDetail, // 必须添加 - fetchFGPickOrdersByUserId , + fetchDoPickOrderDetail, // 必须添加 + DoPickOrderDetail, // 必须添加 + fetchFGPickOrdersByUserId, batchQrSubmit, - batchSubmitList, // 添加:导入 batchSubmitList - batchSubmitListRequest, // 添加:导入类型 + batchSubmitList, // 添加:导入 batchSubmitList + batchSubmitListRequest, // 添加:导入类型 batchSubmitListLineRequest, batchScan, BatchScanRequest, @@ -57,25 +64,27 @@ import { import FGPickOrderInfoCard from "./FGPickOrderInfoCard"; import LotConfirmationModal from "./LotConfirmationModal"; //import { fetchItem } from "@/app/api/settings/item"; -import { updateInventoryLotLineStatus, analyzeQrCode } from "@/app/api/inventory/actions"; -import { fetchNameList, NameList } from "@/app/api/user/actions"; import { - FormProvider, - useForm, -} from "react-hook-form"; + updateInventoryLotLineStatus, + analyzeQrCode, +} from "@/app/api/inventory/actions"; +import { fetchNameList, NameList } from "@/app/api/user/actions"; +import { FormProvider, useForm } from "react-hook-form"; import SearchBox, { Criterion } from "../SearchBox"; import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; import { updateInventoryLotLineQuantities } from "@/app/api/inventory/actions"; -import QrCodeIcon from '@mui/icons-material/QrCode'; -import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; +import QrCodeIcon from "@mui/icons-material/QrCode"; +import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; +import { AUTH } from "@/authorities"; import { fetchStockInLineInfo } from "@/app/api/po/actions"; import GoodPickExecutionForm from "./GoodPickExecutionForm"; -import FGPickOrderCard from "./FGPickOrderCard"; +import FGPickOrderCard from "./FGPickOrderCard"; import LinearProgressWithLabel from "../common/LinearProgressWithLabel"; import ScanStatusAlert from "../common/ScanStatusAlert"; import { translateLotSubstitutionFailure } from "./lotSubstitutionMessage"; +import LotLabelPrintModal from "@/components/InventorySearch/LotLabelPrintModal"; interface Props { filterArgs: Record; onSwitchToRecordTab?: () => void; @@ -98,15 +107,17 @@ type LotConfirmRunContext = { }; /** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */ -function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null { +function pickExpectedLotForSubstitution( + activeSuggestedLots: any[], +): any | null { if (!activeSuggestedLots?.length) return null; const withLotNo = activeSuggestedLots.filter( - (l) => l.lotNo != null && String(l.lotNo).trim() !== "" + (l) => l.lotNo != null && String(l.lotNo).trim() !== "", ); if (withLotNo.length === 1) return withLotNo[0]; if (withLotNo.length > 1) { const pending = withLotNo.find( - (l) => (l.stockOutLineStatus || "").toLowerCase() === "pending" + (l) => (l.stockOutLineStatus || "").toLowerCase() === "pending", ); return pending || withLotNo[0]; } @@ -121,86 +132,116 @@ const QrCodeModal: React.FC<{ onQrCodeSubmit: (lotNo: string) => void; combinedLotData: any[]; // Add this prop lotConfirmationOpen: boolean; -}> = ({ open, onClose, lot, onQrCodeSubmit, combinedLotData,lotConfirmationOpen = false }) => { +}> = ({ + open, + onClose, + lot, + onQrCodeSubmit, + combinedLotData, + lotConfirmationOpen = false, +}) => { const { t } = useTranslation("pickOrder"); - const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); - const [manualInput, setManualInput] = useState(''); - - const [manualInputSubmitted, setManualInputSubmitted] = useState(false); + const { + values: qrValues, + isScanning, + startScan, + stopScan, + resetScan, + } = useQrCodeScannerContext(); + const [manualInput, setManualInput] = useState(""); + + const [manualInputSubmitted, setManualInputSubmitted] = + useState(false); const [manualInputError, setManualInputError] = useState(false); const [isProcessingQr, setIsProcessingQr] = useState(false); const [qrScanFailed, setQrScanFailed] = useState(false); const [qrScanSuccess, setQrScanSuccess] = useState(false); - const [processedQrCodes, setProcessedQrCodes] = useState>(new Set()); - const [scannedQrResult, setScannedQrResult] = useState(''); - const [fgPickOrder, setFgPickOrder] = useState(null); - const fetchingRef = useRef>(new Set()); + const [processedQrCodes, setProcessedQrCodes] = useState>( + new Set(), + ); + const [scannedQrResult, setScannedQrResult] = useState(""); + const [fgPickOrder, setFgPickOrder] = useState( + null, + ); + const fetchingRef = useRef>(new Set()); useEffect(() => { // ✅ Don't process if modal is not open if (!open) { return; } - + // ✅ Don't process if lot confirmation modal is open if (lotConfirmationOpen) { - console.log("Lot confirmation modal is open, skipping QrCodeModal processing..."); + console.log( + "Lot confirmation modal is open, skipping QrCodeModal processing...", + ); return; } - + if (qrValues.length > 0 && lot && !isProcessingQr && !qrScanSuccess) { const latestQr = qrValues[qrValues.length - 1]; - + if (processedQrCodes.has(latestQr)) { console.log("QR code already processed, skipping..."); return; } - + try { const qrData = JSON.parse(latestQr); - + if (qrData.stockInLineId && qrData.itemId) { // ✅ Check if we're already fetching this stockInLineId if (fetchingRef.current.has(qrData.stockInLineId)) { - console.log(` [QR MODAL] Already fetching stockInLineId: ${qrData.stockInLineId}, skipping duplicate call`); + console.log( + ` [QR MODAL] Already fetching stockInLineId: ${qrData.stockInLineId}, skipping duplicate call`, + ); return; } - - setProcessedQrCodes(prev => new Set(prev).add(latestQr)); + + setProcessedQrCodes((prev) => new Set(prev).add(latestQr)); setIsProcessingQr(true); setQrScanFailed(false); - + // ✅ Mark as fetching fetchingRef.current.add(qrData.stockInLineId); - + const fetchStartTime = performance.now(); - console.log(` [QR MODAL] Starting fetchStockInLineInfo for stockInLineId: ${qrData.stockInLineId}`); - + console.log( + ` [QR MODAL] Starting fetchStockInLineInfo for stockInLineId: ${qrData.stockInLineId}`, + ); + fetchStockInLineInfo(qrData.stockInLineId) .then((stockInLineInfo) => { // ✅ Remove from fetching set fetchingRef.current.delete(qrData.stockInLineId); - + // ✅ Check again if modal is still open and lot confirmation is not open if (!open || lotConfirmationOpen) { console.log("Modal state changed, skipping result processing"); return; } - + const fetchTime = performance.now() - fetchStartTime; - console.log(` [QR MODAL] fetchStockInLineInfo time: ${fetchTime.toFixed(2)}ms (${(fetchTime / 1000).toFixed(3)}s)`); + console.log( + ` [QR MODAL] fetchStockInLineInfo time: ${fetchTime.toFixed( + 2, + )}ms (${(fetchTime / 1000).toFixed(3)}s)`, + ); console.log("Stock in line info:", stockInLineInfo); - setScannedQrResult(stockInLineInfo.lotNo || 'Unknown lot number'); - + setScannedQrResult(stockInLineInfo.lotNo || "Unknown lot number"); + if (stockInLineInfo.lotNo === lot.lotNo) { console.log(` QR Code verified for lot: ${lot.lotNo}`); setQrScanSuccess(true); onQrCodeSubmit(lot.lotNo); - // onClose(); + // onClose(); //resetScan(); } else { - console.log(` QR Code mismatch. Expected: ${lot.lotNo}, Got: ${stockInLineInfo.lotNo}`); + console.log( + ` QR Code mismatch. Expected: ${lot.lotNo}, Got: ${stockInLineInfo.lotNo}`, + ); setQrScanFailed(true); setManualInputError(true); setManualInputSubmitted(true); @@ -209,16 +250,21 @@ const QrCodeModal: React.FC<{ .catch((error) => { // ✅ Remove from fetching set fetchingRef.current.delete(qrData.stockInLineId); - + // ✅ Check again if modal is still open if (!open || lotConfirmationOpen) { console.log("Modal state changed, skipping error handling"); return; } - + const fetchTime = performance.now() - fetchStartTime; - console.error(`❌ [QR MODAL] fetchStockInLineInfo failed after ${fetchTime.toFixed(2)}ms:`, error); - setScannedQrResult('Error fetching data'); + console.error( + `❌ [QR MODAL] fetchStockInLineInfo failed after ${fetchTime.toFixed( + 2, + )}ms:`, + error, + ); + setScannedQrResult("Error fetching data"); setQrScanFailed(true); setManualInputError(true); setManualInputSubmitted(true); @@ -227,9 +273,9 @@ const QrCodeModal: React.FC<{ setIsProcessingQr(false); }); } else { - const qrContent = latestQr.replace(/[{}]/g, ''); + const qrContent = latestQr.replace(/[{}]/g, ""); setScannedQrResult(qrContent); - + if (qrContent === lot.lotNo) { setQrScanSuccess(true); onQrCodeSubmit(lot.lotNo); @@ -243,9 +289,9 @@ const QrCodeModal: React.FC<{ } } catch (error) { console.log("QR code is not JSON format, trying direct comparison"); - const qrContent = latestQr.replace(/[{}]/g, ''); + const qrContent = latestQr.replace(/[{}]/g, ""); setScannedQrResult(qrContent); - + if (qrContent === lot.lotNo) { setQrScanSuccess(true); onQrCodeSubmit(lot.lotNo); @@ -258,49 +304,65 @@ const QrCodeModal: React.FC<{ } } } - }, [qrValues, lot, onQrCodeSubmit, onClose, resetScan, isProcessingQr, qrScanSuccess, processedQrCodes, lotConfirmationOpen, open]); + }, [ + qrValues, + lot, + onQrCodeSubmit, + onClose, + resetScan, + isProcessingQr, + qrScanSuccess, + processedQrCodes, + lotConfirmationOpen, + open, + ]); // Clear states when modal opens useEffect(() => { if (open) { - setManualInput(''); + setManualInput(""); setManualInputSubmitted(false); setManualInputError(false); setIsProcessingQr(false); setQrScanFailed(false); setQrScanSuccess(false); - setScannedQrResult(''); + setScannedQrResult(""); setProcessedQrCodes(new Set()); } }, [open]); useEffect(() => { if (lot) { - setManualInput(''); + setManualInput(""); setManualInputSubmitted(false); setManualInputError(false); setIsProcessingQr(false); setQrScanFailed(false); setQrScanSuccess(false); - setScannedQrResult(''); + setScannedQrResult(""); setProcessedQrCodes(new Set()); } }, [lot]); // Auto-submit manual input when it matches useEffect(() => { - if (manualInput.trim() === lot?.lotNo && manualInput.trim() !== '' && !qrScanFailed && !qrScanSuccess) { - console.log(' Auto-submitting manual input:', manualInput.trim()); - + if ( + manualInput.trim() === lot?.lotNo && + manualInput.trim() !== "" && + !qrScanFailed && + !qrScanSuccess + ) { + console.log(" Auto-submitting manual input:", manualInput.trim()); + const timer = setTimeout(() => { setQrScanSuccess(true); onQrCodeSubmit(lot.lotNo); onClose(); - setManualInput(''); + setManualInput(""); setManualInputError(false); setManualInputSubmitted(false); }, 200); - + return () => clearTimeout(timer); } }, [manualInput, lot, onQrCodeSubmit, onClose, qrScanFailed, qrScanSuccess]); @@ -310,7 +372,7 @@ const QrCodeModal: React.FC<{ setQrScanSuccess(true); onQrCodeSubmit(lot.lotNo); onClose(); - setManualInput(''); + setManualInput(""); } else { setQrScanFailed(true); setManualInputError(true); @@ -326,28 +388,32 @@ const QrCodeModal: React.FC<{ return ( - + {t("QR Code Scan for Lot")}: {lot?.lotNo} - + {isProcessingQr && ( - + - {t("Processing QR code...")} + {t("Processing QR code...")} )} - + {t("Manual Input")}: @@ -368,8 +434,10 @@ const QrCodeModal: React.FC<{ error={manualInputSubmitted && manualInputError} helperText={ manualInputSubmitted && manualInputError - ? `${t("The input is not the same as the expected lot number.")}` - : '' + ? `${t( + "The input is not the same as the expected lot number.", + )}` + : "" } /> @@ -426,18 +509,25 @@ const ManualLotConfirmationModal: React.FC<{ itemName: string; } | null; isLoading?: boolean; -}> = ({ open, onClose, onConfirm, expectedLot, scannedLot, isLoading = false }) => { +}> = ({ + open, + onClose, + onConfirm, + expectedLot, + scannedLot, + isLoading = false, +}) => { const { t } = useTranslation("pickOrder"); - const [expectedLotInput, setExpectedLotInput] = useState(''); - const [scannedLotInput, setScannedLotInput] = useState(''); - const [error, setError] = useState(''); + const [expectedLotInput, setExpectedLotInput] = useState(""); + const [scannedLotInput, setScannedLotInput] = useState(""); + const [error, setError] = useState(""); // 当模态框打开时,预填充输入框 useEffect(() => { if (open) { - setExpectedLotInput(expectedLot?.lotNo || ''); - setScannedLotInput(scannedLot?.lotNo || ''); - setError(''); + setExpectedLotInput(expectedLot?.lotNo || ""); + setScannedLotInput(scannedLot?.lotNo || ""); + setError(""); } }, [open, expectedLot, scannedLot]); @@ -446,7 +536,7 @@ const ManualLotConfirmationModal: React.FC<{ setError(t("Please enter both expected and scanned lot numbers.")); return; } - + if (expectedLotInput.trim() === scannedLotInput.trim()) { setError(t("Expected and scanned lot numbers cannot be the same.")); return; @@ -457,20 +547,22 @@ const ManualLotConfirmationModal: React.FC<{ return ( - + {t("Manual Lot Confirmation")} - + {t("Expected Lot Number")}: @@ -481,7 +573,7 @@ const ManualLotConfirmationModal: React.FC<{ value={expectedLotInput} onChange={(e) => { setExpectedLotInput(e.target.value); - setError(''); + setError(""); }} placeholder={expectedLot?.lotNo || t("Enter expected lot number")} sx={{ mb: 2 }} @@ -499,7 +591,7 @@ const ManualLotConfirmationModal: React.FC<{ value={scannedLotInput} onChange={(e) => { setScannedLotInput(e.target.value); - setError(''); + setError(""); }} placeholder={scannedLot?.lotNo || t("Enter scanned lot number")} sx={{ mb: 2 }} @@ -508,22 +600,28 @@ const ManualLotConfirmationModal: React.FC<{ {error && ( - + {error} )} - + - @@ -566,55 +664,91 @@ function loadIssuePickedMap(doPickOrderId: number): Record { } } -function saveIssuePickedMap(doPickOrderId: number, map: Record) { +function saveIssuePickedMap( + doPickOrderId: number, + map: Record, +) { if (typeof window === "undefined" || !doPickOrderId) return; try { - sessionStorage.setItem(FG_ISSUE_PICKED_KEY(doPickOrderId), JSON.stringify(map)); + sessionStorage.setItem( + FG_ISSUE_PICKED_KEY(doPickOrderId), + JSON.stringify(map), + ); } catch { // quota / private mode } } -const PickExecution: React.FC = ({ filterArgs, onSwitchToRecordTab, onRefreshReleasedOrderCount }) => { +const PickExecution: React.FC = ({ + filterArgs, + onSwitchToRecordTab, + onRefreshReleasedOrderCount, +}) => { const { t } = useTranslation("pickOrder"); const router = useRouter(); const { data: session } = useSession() as { data: SessionWithTokens | null }; - const [doPickOrderDetail, setDoPickOrderDetail] = useState(null); -const [selectedPickOrderId, setSelectedPickOrderId] = useState(null); -const [pickOrderSwitching, setPickOrderSwitching] = useState(false); + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + const isAdmin = abilities.some((a) => String(a).trim() === AUTH.ADMIN); + const [doPickOrderDetail, setDoPickOrderDetail] = + useState(null); + const [selectedPickOrderId, setSelectedPickOrderId] = useState( + null, + ); + const [pickOrderSwitching, setPickOrderSwitching] = useState(false); const currentUserId = session?.id ? parseInt(session.id) : undefined; const [allLotsCompleted, setAllLotsCompleted] = useState(false); const [combinedLotData, setCombinedLotData] = useState([]); const [combinedDataLoading, setCombinedDataLoading] = useState(false); const [originalCombinedData, setOriginalCombinedData] = useState([]); // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required) - const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState>({}); - const applyLocalStockOutLineUpdate = useCallback(( - stockOutLineId: number, - status: string, - actualPickQty?: number - ) => { - setCombinedLotData(prev => prev.map((lot) => { - if (Number(lot.stockOutLineId) !== Number(stockOutLineId)) return lot; - return { - ...lot, - stockOutLineStatus: status, - ...(typeof actualPickQty === "number" - ? { actualPickQty, stockOutLineQty: actualPickQty } - : {}), - }; - })); - }, []); + const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState< + Record + >({}); + const applyLocalStockOutLineUpdate = useCallback( + (stockOutLineId: number, status: string, actualPickQty?: number) => { + setCombinedLotData((prev) => + prev.map((lot) => { + if (Number(lot.stockOutLineId) !== Number(stockOutLineId)) return lot; + return { + ...lot, + stockOutLineStatus: status, + ...(typeof actualPickQty === "number" + ? { actualPickQty, stockOutLineQty: actualPickQty } + : {}), + }; + }), + ); + }, + [], + ); // 防止重复点击(Submit / Just Completed / Issue) - const [actionBusyBySolId, setActionBusyBySolId] = useState>({}); - - const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); - - const [qrScanInput, setQrScanInput] = useState(''); + const [actionBusyBySolId, setActionBusyBySolId] = useState< + Record + >({}); + + const { + values: qrValues, + isScanning, + startScan, + stopScan, + resetScan, + } = useQrCodeScannerContext(); + + const [qrScanInput, setQrScanInput] = useState(""); const [qrScanError, setQrScanError] = useState(false); - const [qrScanErrorMsg, setQrScanErrorMsg] = useState(''); + const [qrScanErrorMsg, setQrScanErrorMsg] = useState(""); const [qrScanSuccess, setQrScanSuccess] = useState(false); - const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false); + const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = + useState(false); + const [lotLabelPrintModalOpen, setLotLabelPrintModalOpen] = useState(false); + const [lotLabelPrintInitialPayload, setLotLabelPrintInitialPayload] = + useState<{ + itemId: number; + stockInLineId: number; + } | null>(null); + const [lotLabelPrintReminderText, setLotLabelPrintReminderText] = useState< + string | null + >(null); const [pickQtyData, setPickQtyData] = useState>({}); const [searchQuery, setSearchQuery] = useState>({}); @@ -634,50 +768,62 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); // QR scanner states (always-on, no modal) const [selectedLotForQr, setSelectedLotForQr] = useState(null); const [lotConfirmationOpen, setLotConfirmationOpen] = useState(false); - const [lotConfirmationError, setLotConfirmationError] = useState(null); + const [lotConfirmationError, setLotConfirmationError] = useState< + string | null + >(null); /** QR 静默换批失败时显示在对应行的 Lot# 列,key = stockOutLineId */ - const [lotSwitchFailByStockOutLineId, setLotSwitchFailByStockOutLineId] = useState< - Record - >({}); -const [expectedLotData, setExpectedLotData] = useState(null); -const [scannedLotData, setScannedLotData] = useState(null); -const [isConfirmingLot, setIsConfirmingLot] = useState(false); + const [lotSwitchFailByStockOutLineId, setLotSwitchFailByStockOutLineId] = + useState>({}); + const [expectedLotData, setExpectedLotData] = useState(null); + const [scannedLotData, setScannedLotData] = useState(null); + const [isConfirmingLot, setIsConfirmingLot] = useState(false); // Add GoodPickExecutionForm states const [pickExecutionFormOpen, setPickExecutionFormOpen] = useState(false); - const [selectedLotForExecutionForm, setSelectedLotForExecutionForm] = useState(null); + const [selectedLotForExecutionForm, setSelectedLotForExecutionForm] = + useState(null); const [fgPickOrders, setFgPickOrders] = useState([]); const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false); - // Add these missing state variables after line 352 - const [isManualScanning, setIsManualScanning] = useState(false); - // Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling - const [processedQrCombinations, setProcessedQrCombinations] = useState>>(new Map()); - const [processedQrCodes, setProcessedQrCodes] = useState>(new Set()); - const [lastProcessedQr, setLastProcessedQr] = useState(''); - const [isRefreshingData, setIsRefreshingData] = useState(false); - const [isSubmittingAll, setIsSubmittingAll] = useState(false); - - // Cache for fetchStockInLineInfo API calls to avoid redundant requests - const stockInLineInfoCache = useRef>(new Map()); - const CACHE_TTL = 60000; // 60 seconds cache TTL - const abortControllerRef = useRef(null); - const qrProcessingTimeoutRef = useRef(null); - - // Use refs for processed QR tracking to avoid useEffect dependency issues and delays - const processedQrCodesRef = useRef>(new Set()); - const lastProcessedQrRef = useRef(''); - - // Store callbacks in refs to avoid useEffect dependency issues - const processOutsideQrCodeRef = useRef< - ((latestQr: string, qrScanCountAtInvoke?: number) => Promise) | null - >(null); - const resetScanRef = useRef<(() => void) | null>(null); - const lotConfirmOpenedQrCountRef = useRef(0); - const lotConfirmLastQrRef = useRef(''); - const lotConfirmSkipNextScanRef = useRef(false); - const lotConfirmOpenedAtRef = useRef(0); - const handleLotConfirmationRef = useRef< - ((overrideScannedLot?: any, runContext?: LotConfirmRunContext) => Promise) | null - >(null); + // Add these missing state variables after line 352 + const [isManualScanning, setIsManualScanning] = useState(false); + // Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling + const [processedQrCombinations, setProcessedQrCombinations] = useState< + Map> + >(new Map()); + const [processedQrCodes, setProcessedQrCodes] = useState>( + new Set(), + ); + const [lastProcessedQr, setLastProcessedQr] = useState(""); + const [isRefreshingData, setIsRefreshingData] = useState(false); + const [isSubmittingAll, setIsSubmittingAll] = useState(false); + + // Cache for fetchStockInLineInfo API calls to avoid redundant requests + const stockInLineInfoCache = useRef< + Map + >(new Map()); + const CACHE_TTL = 60000; // 60 seconds cache TTL + const abortControllerRef = useRef(null); + const qrProcessingTimeoutRef = useRef(null); + + // Use refs for processed QR tracking to avoid useEffect dependency issues and delays + const processedQrCodesRef = useRef>(new Set()); + const lastProcessedQrRef = useRef(""); + + // Store callbacks in refs to avoid useEffect dependency issues + const processOutsideQrCodeRef = useRef< + ((latestQr: string, qrScanCountAtInvoke?: number) => Promise) | null + >(null); + const resetScanRef = useRef<(() => void) | null>(null); + const lotConfirmOpenedQrCountRef = useRef(0); + const lotConfirmLastQrRef = useRef(""); + const lotConfirmSkipNextScanRef = useRef(false); + const lotConfirmOpenedAtRef = useRef(0); + const handleLotConfirmationRef = useRef< + | (( + overrideScannedLot?: any, + runContext?: LotConfirmRunContext, + ) => Promise) + | null + >(null); // Handle QR code button click const handleQrCodeClick = (pickOrderId: number) => { @@ -693,7 +839,12 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); const nonPendingCount = combinedLotData.filter((lot) => { const status = lot.stockOutLineStatus?.toLowerCase(); if (status !== "pending") return true; - if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) return true; + if ( + lot.noLot === true || + isLotAvailabilityExpired(lot) || + isInventoryLotLineUnavailable(lot) + ) + return true; return false; }).length; @@ -703,162 +854,217 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); }; }, [combinedLotData]); - // Cached version of fetchStockInLineInfo to avoid redundant API calls - const fetchStockInLineInfoCached = useCallback(async (stockInLineId: number): Promise<{ lotNo: string | null }> => { - const now = Date.now(); - const cached = stockInLineInfoCache.current.get(stockInLineId); - - // Return cached value if still valid - if (cached && (now - cached.timestamp) < CACHE_TTL) { - console.log(`✅ [CACHE HIT] Using cached stockInLineInfo for ${stockInLineId}`); - return { lotNo: cached.lotNo }; - } - - // Cancel previous request if exists - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - // Create new abort controller for this request - const abortController = new AbortController(); - abortControllerRef.current = abortController; - - try { - console.log(` [CACHE MISS] Fetching stockInLineInfo for ${stockInLineId}`); - const stockInLineInfo = await fetchStockInLineInfo(stockInLineId); - - // Store in cache - stockInLineInfoCache.current.set(stockInLineId, { - lotNo: stockInLineInfo.lotNo || null, - timestamp: now - }); - - // Limit cache size to prevent memory leaks - if (stockInLineInfoCache.current.size > 100) { - const firstKey = stockInLineInfoCache.current.keys().next().value; - if (firstKey !== undefined) { - stockInLineInfoCache.current.delete(firstKey); - } + const fetchStockInLineInfoCached = useCallback( + async (stockInLineId: number): Promise<{ lotNo: string | null }> => { + const now = Date.now(); + const cached = stockInLineInfoCache.current.get(stockInLineId); + + // Return cached value if still valid + if (cached && now - cached.timestamp < CACHE_TTL) { + console.log( + `✅ [CACHE HIT] Using cached stockInLineInfo for ${stockInLineId}`, + ); + return { lotNo: cached.lotNo }; } - - return { lotNo: stockInLineInfo.lotNo || null }; - } catch (error: any) { - if (error.name === 'AbortError') { - console.log(` [CACHE] Request aborted for ${stockInLineId}`); - throw error; + + // Cancel previous request if exists + if (abortControllerRef.current) { + abortControllerRef.current.abort(); } - console.error(`❌ [CACHE] Error fetching stockInLineInfo for ${stockInLineId}:`, error); - throw error; - } - }, []); - const handleLotMismatch = useCallback((fullExpectedLotRow: any, scannedLot: any, qrScanCountAtOpen?: number) => { - const mismatchStartTime = performance.now(); - console.log(` [HANDLE LOT MISMATCH START]`); - console.log(` Start time: ${new Date().toISOString()}`); - console.log("Lot mismatch detected:", { fullExpectedLotRow, scannedLot }); + // Create new abort controller for this request + const abortController = new AbortController(); + abortControllerRef.current = abortController; - lotConfirmOpenedQrCountRef.current = - typeof qrScanCountAtOpen === "number" ? qrScanCountAtOpen : 1; - - // ✅ Use setTimeout to avoid flushSync warning - schedule state + silent substitution in next tick - const setTimeoutStartTime = performance.now(); - console.time('setLotMismatchStateAndSubstitute'); - setTimeout(() => { - const setStateStartTime = performance.now(); - const expectedForDisplay = { - lotNo: fullExpectedLotRow.lotNo, - itemCode: fullExpectedLotRow.itemCode, - itemName: fullExpectedLotRow.itemName, - }; - const scannedMerged = { - ...scannedLot, - lotNo: scannedLot.lotNo || null, - }; - setExpectedLotData(expectedForDisplay); - setScannedLotData(scannedMerged); - setSelectedLotForQr(fullExpectedLotRow); - // The QR that triggered mismatch must NOT be treated as confirmation rescan. - lotConfirmSkipNextScanRef.current = true; - lotConfirmOpenedAtRef.current = Date.now(); - - const sid = Number(scannedLot.stockInLineId); - if (!Number.isFinite(sid)) { - console.error(` [HANDLE LOT MISMATCH] Invalid stockInLineId for substitution: ${scannedLot.stockInLineId}`); - const errMsg = t("Lot switch failed; pick line was not marked as checked."); - const rowSol = Number(fullExpectedLotRow.stockOutLineId); - if (Number.isFinite(rowSol)) { - setLotSwitchFailByStockOutLineId((prev) => ({ ...prev, [rowSol]: errMsg })); + try { + console.log( + ` [CACHE MISS] Fetching stockInLineInfo for ${stockInLineId}`, + ); + const stockInLineInfo = await fetchStockInLineInfo(stockInLineId); + + // Store in cache + stockInLineInfoCache.current.set(stockInLineId, { + lotNo: stockInLineInfo.lotNo || null, + timestamp: now, + }); + + // Limit cache size to prevent memory leaks + if (stockInLineInfoCache.current.size > 100) { + const firstKey = stockInLineInfoCache.current.keys().next().value; + if (firstKey !== undefined) { + stockInLineInfoCache.current.delete(firstKey); + } } - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(errMsg); - const setStateTime = performance.now() - setStateStartTime; - console.timeEnd('setLotMismatchStateAndSubstitute'); - console.log(` [HANDLE LOT MISMATCH] Lot switch failed (invalid stockInLineId), setState time: ${setStateTime.toFixed(2)}ms`); - return; - } - const runContext: LotConfirmRunContext = { - expectedLotData: expectedForDisplay, - scannedLotData: { - ...scannedMerged, - stockInLineId: sid, - itemCode: scannedMerged.itemCode ?? fullExpectedLotRow.itemCode, - itemName: scannedMerged.itemName ?? fullExpectedLotRow.itemName, - inventoryLotLineId: scannedLot.inventoryLotLineId ?? scannedLot.lotId ?? null, - }, - selectedLotForQr: fullExpectedLotRow, - }; - void handleLotConfirmationRef.current?.(undefined, runContext); - - const setStateTime = performance.now() - setStateStartTime; - console.timeEnd('setLotMismatchStateAndSubstitute'); - console.log(` [HANDLE LOT MISMATCH] Silent lot substitution scheduled (setState time: ${setStateTime.toFixed(2)}ms)`); - }, 0); - const setTimeoutTime = performance.now() - setTimeoutStartTime; - console.log(` [PERF] setTimeout scheduling time: ${setTimeoutTime.toFixed(2)}ms`); - - // ✅ Fetch lotNo in background ONLY for display purposes (using cached version) - if (!scannedLot.lotNo && scannedLot.stockInLineId) { - const stockInLineId = scannedLot.stockInLineId; - if (typeof stockInLineId !== 'number') { - console.warn(` [HANDLE LOT MISMATCH] Invalid stockInLineId: ${stockInLineId}`); - return; + return { lotNo: stockInLineInfo.lotNo || null }; + } catch (error: any) { + if (error.name === "AbortError") { + console.log(` [CACHE] Request aborted for ${stockInLineId}`); + throw error; + } + console.error( + `❌ [CACHE] Error fetching stockInLineInfo for ${stockInLineId}:`, + error, + ); + throw error; } - console.log(` [HANDLE LOT MISMATCH] Fetching lotNo in background (stockInLineId: ${stockInLineId})`); - const fetchStartTime = performance.now(); - - fetchStockInLineInfoCached(stockInLineId) - .then((stockInLineInfo) => { - const fetchTime = performance.now() - fetchStartTime; - console.log(` [HANDLE LOT MISMATCH] fetchStockInLineInfoCached time: ${fetchTime.toFixed(2)}ms (${(fetchTime / 1000).toFixed(3)}s)`); - - const updateStateStartTime = performance.now(); - startTransition(() => { - setScannedLotData((prev: any) => ({ + }, + [], + ); + + const handleLotMismatch = useCallback( + (fullExpectedLotRow: any, scannedLot: any, qrScanCountAtOpen?: number) => { + const mismatchStartTime = performance.now(); + console.log(` [HANDLE LOT MISMATCH START]`); + console.log(` Start time: ${new Date().toISOString()}`); + console.log("Lot mismatch detected:", { fullExpectedLotRow, scannedLot }); + + lotConfirmOpenedQrCountRef.current = + typeof qrScanCountAtOpen === "number" ? qrScanCountAtOpen : 1; + + // ✅ Use setTimeout to avoid flushSync warning - schedule state + silent substitution in next tick + const setTimeoutStartTime = performance.now(); + console.time("setLotMismatchStateAndSubstitute"); + setTimeout(() => { + const setStateStartTime = performance.now(); + const expectedForDisplay = { + lotNo: fullExpectedLotRow.lotNo, + itemCode: fullExpectedLotRow.itemCode, + itemName: fullExpectedLotRow.itemName, + }; + const scannedMerged = { + ...scannedLot, + lotNo: scannedLot.lotNo || null, + }; + setExpectedLotData(expectedForDisplay); + setScannedLotData(scannedMerged); + setSelectedLotForQr(fullExpectedLotRow); + // The QR that triggered mismatch must NOT be treated as confirmation rescan. + lotConfirmSkipNextScanRef.current = true; + lotConfirmOpenedAtRef.current = Date.now(); + + const sid = Number(scannedLot.stockInLineId); + if (!Number.isFinite(sid)) { + console.error( + ` [HANDLE LOT MISMATCH] Invalid stockInLineId for substitution: ${scannedLot.stockInLineId}`, + ); + const errMsg = t( + "Lot switch failed; pick line was not marked as checked.", + ); + const rowSol = Number(fullExpectedLotRow.stockOutLineId); + if (Number.isFinite(rowSol)) { + setLotSwitchFailByStockOutLineId((prev) => ({ ...prev, - lotNo: stockInLineInfo.lotNo || null, + [rowSol]: errMsg, })); - }); - const updateStateTime = performance.now() - updateStateStartTime; - console.log(` [PERF] Update scanned lot data time: ${updateStateTime.toFixed(2)}ms`); - - const totalTime = performance.now() - mismatchStartTime; - console.log(` [HANDLE LOT MISMATCH] Background fetch completed: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); - }) - .catch((error) => { - if (error.name !== 'AbortError') { - const fetchTime = performance.now() - fetchStartTime; - console.error(`❌ [HANDLE LOT MISMATCH] fetchStockInLineInfoCached failed after ${fetchTime.toFixed(2)}ms:`, error); } - }); - } else { - const totalTime = performance.now() - mismatchStartTime; - console.log(` [HANDLE LOT MISMATCH END] Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); - } - }, [fetchStockInLineInfoCached, t]); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + const setStateTime = performance.now() - setStateStartTime; + console.timeEnd("setLotMismatchStateAndSubstitute"); + console.log( + ` [HANDLE LOT MISMATCH] Lot switch failed (invalid stockInLineId), setState time: ${setStateTime.toFixed( + 2, + )}ms`, + ); + return; + } + + const runContext: LotConfirmRunContext = { + expectedLotData: expectedForDisplay, + scannedLotData: { + ...scannedMerged, + stockInLineId: sid, + itemCode: scannedMerged.itemCode ?? fullExpectedLotRow.itemCode, + itemName: scannedMerged.itemName ?? fullExpectedLotRow.itemName, + inventoryLotLineId: + scannedLot.inventoryLotLineId ?? scannedLot.lotId ?? null, + }, + selectedLotForQr: fullExpectedLotRow, + }; + void handleLotConfirmationRef.current?.(undefined, runContext); + + const setStateTime = performance.now() - setStateStartTime; + console.timeEnd("setLotMismatchStateAndSubstitute"); + console.log( + ` [HANDLE LOT MISMATCH] Silent lot substitution scheduled (setState time: ${setStateTime.toFixed( + 2, + )}ms)`, + ); + }, 0); + const setTimeoutTime = performance.now() - setTimeoutStartTime; + console.log( + ` [PERF] setTimeout scheduling time: ${setTimeoutTime.toFixed(2)}ms`, + ); + + // ✅ Fetch lotNo in background ONLY for display purposes (using cached version) + if (!scannedLot.lotNo && scannedLot.stockInLineId) { + const stockInLineId = scannedLot.stockInLineId; + if (typeof stockInLineId !== "number") { + console.warn( + ` [HANDLE LOT MISMATCH] Invalid stockInLineId: ${stockInLineId}`, + ); + return; + } + console.log( + ` [HANDLE LOT MISMATCH] Fetching lotNo in background (stockInLineId: ${stockInLineId})`, + ); + const fetchStartTime = performance.now(); + + fetchStockInLineInfoCached(stockInLineId) + .then((stockInLineInfo) => { + const fetchTime = performance.now() - fetchStartTime; + console.log( + ` [HANDLE LOT MISMATCH] fetchStockInLineInfoCached time: ${fetchTime.toFixed( + 2, + )}ms (${(fetchTime / 1000).toFixed(3)}s)`, + ); + + const updateStateStartTime = performance.now(); + startTransition(() => { + setScannedLotData((prev: any) => ({ + ...prev, + lotNo: stockInLineInfo.lotNo || null, + })); + }); + const updateStateTime = performance.now() - updateStateStartTime; + console.log( + ` [PERF] Update scanned lot data time: ${updateStateTime.toFixed( + 2, + )}ms`, + ); + + const totalTime = performance.now() - mismatchStartTime; + console.log( + ` [HANDLE LOT MISMATCH] Background fetch completed: ${totalTime.toFixed( + 2, + )}ms (${(totalTime / 1000).toFixed(3)}s)`, + ); + }) + .catch((error) => { + if (error.name !== "AbortError") { + const fetchTime = performance.now() - fetchStartTime; + console.error( + `❌ [HANDLE LOT MISMATCH] fetchStockInLineInfoCached failed after ${fetchTime.toFixed( + 2, + )}ms:`, + error, + ); + } + }); + } else { + const totalTime = performance.now() - mismatchStartTime; + console.log( + ` [HANDLE LOT MISMATCH END] Total time: ${totalTime.toFixed( + 2, + )}ms (${(totalTime / 1000).toFixed(3)}s)`, + ); + } + }, + [fetchStockInLineInfoCached, t], + ); const checkAllLotsCompleted = useCallback((lotData: any[]) => { if (lotData.length === 0) { setAllLotsCompleted(false); @@ -866,9 +1072,10 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); } // Filter out rejected lots - const nonRejectedLots = lotData.filter(lot => - lot.lotAvailability !== 'rejected' && - lot.stockOutLineStatus !== 'rejected' + const nonRejectedLots = lotData.filter( + (lot) => + lot.lotAvailability !== "rejected" && + lot.stockOutLineStatus !== "rejected", ); if (nonRejectedLots.length === 0) { @@ -877,424 +1084,473 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); } // Check if all non-rejected lots are completed - const allCompleted = nonRejectedLots.every(lot => - lot.stockOutLineStatus === 'completed' + const allCompleted = nonRejectedLots.every( + (lot) => lot.stockOutLineStatus === "completed", ); setAllLotsCompleted(allCompleted); return allCompleted; }, []); -// 在 fetchAllCombinedLotData 函数中(约 446-684 行) + // 在 fetchAllCombinedLotData 函数中(约 446-684 行) -const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdOverride?: number) => { - setCombinedDataLoading(true); - try { - const userIdToUse = userId || currentUserId; - - console.log(" fetchAllCombinedLotData called with userId:", userIdToUse); - - if (!userIdToUse) { - console.warn("⚠️ No userId available, skipping API call"); - setCombinedLotData([]); - setOriginalCombinedData([]); - setAllLotsCompleted(false); - setIssuePickedQtyBySolId({}); - return; - } - - // 获取新结构的层级数据 - const hierarchicalData = await fetchAllPickOrderLotsHierarchical(userIdToUse); - console.log(" Hierarchical data (new structure):", hierarchicalData); - - // 检查数据结构 - if (!hierarchicalData.fgInfo || !hierarchicalData.pickOrders || hierarchicalData.pickOrders.length === 0) { - console.warn("⚠️ No FG info or pick orders found"); - setCombinedLotData([]); - setOriginalCombinedData([]); - setAllLotsCompleted(false); - setIssuePickedQtyBySolId({}); - return; - } - - // 使用合并后的 pick order 对象(现在只有一个对象,包含所有数据) - const mergedPickOrder = hierarchicalData.pickOrders[0]; - - // 设置 FG info 到 fgPickOrders(用于显示 FG 信息卡片) - // 修改第 478-509 行的 fgOrder 构建逻辑: - - const fgOrder: FGPickOrderResponse = { - doPickOrderId: hierarchicalData.fgInfo.doPickOrderId, - ticketNo: hierarchicalData.fgInfo.ticketNo, - storeId: hierarchicalData.fgInfo.storeId, - shopCode: hierarchicalData.fgInfo.shopCode, - shopName: hierarchicalData.fgInfo.shopName, - truckLanceCode: hierarchicalData.fgInfo.truckLanceCode, - DepartureTime: hierarchicalData.fgInfo.departureTime, - shopAddress: "", - pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", - - // 兼容字段(注意 consoCodes 是数组) - pickOrderId: mergedPickOrder.pickOrderIds?.[0] || 0, - pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes) - ? mergedPickOrder.consoCodes[0] || "" - : "", - pickOrderTargetDate: mergedPickOrder.targetDate || "", - pickOrderStatus: mergedPickOrder.status || "", - deliveryOrderId: mergedPickOrder.doOrderIds?.[0] || 0, - deliveryNo: mergedPickOrder.deliveryOrderCodes?.[0] || "", - deliveryDate: "", - shopId: 0, - shopPoNo: "", - numberOfCartons: mergedPickOrder.pickOrderLines?.length || 0, - qrCodeData: hierarchicalData.fgInfo.doPickOrderId, - - // 多个 pick orders 信息:全部保留为数组 - numberOfPickOrders: mergedPickOrder.pickOrderIds?.length || 0, - pickOrderIds: mergedPickOrder.pickOrderIds || [], - pickOrderCodes: Array.isArray(mergedPickOrder.pickOrderCodes) - ? mergedPickOrder.pickOrderCodes - : [], - deliveryOrderIds: mergedPickOrder.doOrderIds || [], - deliveryNos: Array.isArray(mergedPickOrder.deliveryOrderCodes) - ? mergedPickOrder.deliveryOrderCodes - : [], - lineCountsPerPickOrder: Array.isArray(mergedPickOrder.lineCountsPerPickOrder) - ? mergedPickOrder.lineCountsPerPickOrder - : [], - }; - - setFgPickOrders([fgOrder]); - console.log(" DEBUG fgOrder.lineCountsPerPickOrder:", fgOrder.lineCountsPerPickOrder); - console.log(" DEBUG fgOrder.pickOrderCodes:", fgOrder.pickOrderCodes); - console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); - - // 直接使用合并后的 pickOrderLines - console.log("🎯 Displaying merged pick order lines"); - - // 将层级数据转换为平铺格式(用于表格显示) - const flatLotData: any[] = []; - - // 2/F 與後端 store_id 一致時需按 itemOrder;避免 API 未走 2F 分支時畫面仍亂序 - const doFloorKey = String(hierarchicalData.fgInfo.storeId ?? '') - .trim() - .toUpperCase() - .replace(/\//g, '') - .replace(/\s/g, ''); - const pickOrderLinesForDisplay = - doFloorKey === '2F' - ? [...(mergedPickOrder.pickOrderLines || [])].sort((a: any, b: any) => { - const ao = a.itemOrder != null ? Number(a.itemOrder) : 999999; - const bo = b.itemOrder != null ? Number(b.itemOrder) : 999999; - if (ao !== bo) return ao - bo; - return (Number(a.id) || 0) - (Number(b.id) || 0); - }) - : mergedPickOrder.pickOrderLines || []; - - pickOrderLinesForDisplay.forEach((line: any) => { - // 用来记录这一行已经通过 lots 出现过的 lotId - const lotIdSet = new Set(); - /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數) */ - let lotsAllocatedSumForLine = 0; - - // ✅ lots:按 lotId 去重并合并 requiredQty - if (line.lots && line.lots.length > 0) { - const lotMap = new Map(); - - line.lots.forEach((lot: any) => { - const lotId = lot.id; - if (lotMap.has(lotId)) { - const existingLot = lotMap.get(lotId); - existingLot.requiredQty = - (existingLot.requiredQty || 0) + (lot.requiredQty || 0); - } else { - lotMap.set(lotId, { ...lot }); - } - }); - - lotMap.forEach((lot: any) => { - lotsAllocatedSumForLine += Number(lot.requiredQty) || 0; - if (lot.id != null) { - lotIdSet.add(lot.id); + const fetchAllCombinedLotData = useCallback( + async (userId?: number, pickOrderIdOverride?: number) => { + setCombinedDataLoading(true); + try { + const userIdToUse = userId || currentUserId; + + console.log( + " fetchAllCombinedLotData called with userId:", + userIdToUse, + ); + + if (!userIdToUse) { + console.warn("⚠️ No userId available, skipping API call"); + setCombinedLotData([]); + setOriginalCombinedData([]); + setAllLotsCompleted(false); + setIssuePickedQtyBySolId({}); + return; } - - flatLotData.push({ - pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes) - ? mergedPickOrder.consoCodes[0] || "" - : "", - pickOrderTargetDate: mergedPickOrder.targetDate, - pickOrderStatus: mergedPickOrder.status, - pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0, - pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", - pickOrderLineId: line.id, - pickOrderLineRequiredQty: line.requiredQty, - pickOrderLineStatus: line.status, - - itemId: line.item.id, - itemCode: line.item.code, - itemName: line.item.name, - uomDesc: line.item.uomDesc, - uomShortDesc: line.item.uomShortDesc, - - lotId: lot.id, - lotNo: lot.lotNo, - expiryDate: lot.expiryDate, - location: lot.location, - stockUnit: lot.stockUnit, - availableQty: lot.availableQty, - requiredQty: lot.requiredQty, - actualPickQty: lot.actualPickQty, - inQty: lot.inQty, - outQty: lot.outQty, - holdQty: lot.holdQty, - lotStatus: lot.lotStatus, - lotAvailability: lot.lotAvailability, - processingStatus: lot.processingStatus, - suggestedPickLotId: lot.suggestedPickLotId, - stockOutLineId: lot.stockOutLineId, - stockOutLineStatus: lot.stockOutLineStatus, - stockOutLineQty: lot.stockOutLineQty, - stockInLineId: lot.stockInLineId, - routerId: lot.router?.id, - routerIndex: lot.router?.index, - routerRoute: lot.router?.route, - routerArea: lot.router?.area, - noLot: false, - }); - }); - } - - // ✅ stockouts:只保留“真正无批次 / 未在 lots 出现过”的行 - if (line.stockouts && line.stockouts.length > 0) { - line.stockouts.forEach((stockout: any) => { - const hasLot = stockout.lotId != null; - const lotAlreadyInLots = - hasLot && lotIdSet.has(stockout.lotId as number); - - // 有批次 & 已经通过 lots 渲染过 → 跳过,避免一条变两行 - if (!stockout.noLot && lotAlreadyInLots) { + + // 获取新结构的层级数据 + const hierarchicalData = + await fetchAllPickOrderLotsHierarchical(userIdToUse); + console.log(" Hierarchical data (new structure):", hierarchicalData); + + // 检查数据结构 + if ( + !hierarchicalData.fgInfo || + !hierarchicalData.pickOrders || + hierarchicalData.pickOrders.length === 0 + ) { + console.warn("⚠️ No FG info or pick orders found"); + setCombinedLotData([]); + setOriginalCombinedData([]); + setAllLotsCompleted(false); + setIssuePickedQtyBySolId({}); return; } - - // 只渲染: - // - noLot === true 的 Null stock 行 - // - 或者 lotId 在 lots 中不存在的特殊情况 - flatLotData.push({ + + // 使用合并后的 pick order 对象(现在只有一个对象,包含所有数据) + const mergedPickOrder = hierarchicalData.pickOrders[0]; + + // 设置 FG info 到 fgPickOrders(用于显示 FG 信息卡片) + // 修改第 478-509 行的 fgOrder 构建逻辑: + + const fgOrder: FGPickOrderResponse = { + doPickOrderId: hierarchicalData.fgInfo.doPickOrderId, + ticketNo: hierarchicalData.fgInfo.ticketNo, + storeId: hierarchicalData.fgInfo.storeId, + shopCode: hierarchicalData.fgInfo.shopCode, + shopName: hierarchicalData.fgInfo.shopName, + truckLanceCode: hierarchicalData.fgInfo.truckLanceCode, + DepartureTime: hierarchicalData.fgInfo.departureTime, + shopAddress: "", + pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", + + // 兼容字段(注意 consoCodes 是数组) + pickOrderId: mergedPickOrder.pickOrderIds?.[0] || 0, pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes) ? mergedPickOrder.consoCodes[0] || "" : "", - pickOrderTargetDate: mergedPickOrder.targetDate, - pickOrderStatus: mergedPickOrder.status, - pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0, - pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", - pickOrderLineId: line.id, - pickOrderLineRequiredQty: line.requiredQty, - pickOrderLineStatus: line.status, - - itemId: line.item.id, - itemCode: line.item.code, - itemName: line.item.name, - uomDesc: line.item.uomDesc, - uomShortDesc: line.item.uomShortDesc, - - lotId: stockout.lotId || null, - lotNo: stockout.lotNo || null, - expiryDate: null, - location: stockout.location || null, - stockUnit: line.item.uomDesc, - availableQty: stockout.availableQty || 0, - // 無批次列對應 suggested_pick_lot 的缺口量(如 11),勿用整行 POL 需求(100)以免顯示成 89 / 100 - requiredQty: stockout.noLot - ? Math.max( - 0, - (Number(line.requiredQty) || 0) - lotsAllocatedSumForLine + pickOrderTargetDate: mergedPickOrder.targetDate || "", + pickOrderStatus: mergedPickOrder.status || "", + deliveryOrderId: mergedPickOrder.doOrderIds?.[0] || 0, + deliveryNo: mergedPickOrder.deliveryOrderCodes?.[0] || "", + deliveryDate: "", + shopId: 0, + shopPoNo: "", + numberOfCartons: mergedPickOrder.pickOrderLines?.length || 0, + qrCodeData: hierarchicalData.fgInfo.doPickOrderId, + + // 多个 pick orders 信息:全部保留为数组 + numberOfPickOrders: mergedPickOrder.pickOrderIds?.length || 0, + pickOrderIds: mergedPickOrder.pickOrderIds || [], + pickOrderCodes: Array.isArray(mergedPickOrder.pickOrderCodes) + ? mergedPickOrder.pickOrderCodes + : [], + deliveryOrderIds: mergedPickOrder.doOrderIds || [], + deliveryNos: Array.isArray(mergedPickOrder.deliveryOrderCodes) + ? mergedPickOrder.deliveryOrderCodes + : [], + lineCountsPerPickOrder: Array.isArray( + mergedPickOrder.lineCountsPerPickOrder, + ) + ? mergedPickOrder.lineCountsPerPickOrder + : [], + }; + + setFgPickOrders([fgOrder]); + console.log( + " DEBUG fgOrder.lineCountsPerPickOrder:", + fgOrder.lineCountsPerPickOrder, + ); + console.log(" DEBUG fgOrder.pickOrderCodes:", fgOrder.pickOrderCodes); + console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); + + // 直接使用合并后的 pickOrderLines + console.log("🎯 Displaying merged pick order lines"); + + // 将层级数据转换为平铺格式(用于表格显示) + const flatLotData: any[] = []; + + // 2/F 與後端 store_id 一致時需按 itemOrder;避免 API 未走 2F 分支時畫面仍亂序 + const doFloorKey = String(hierarchicalData.fgInfo.storeId ?? "") + .trim() + .toUpperCase() + .replace(/\//g, "") + .replace(/\s/g, ""); + const pickOrderLinesForDisplay = + doFloorKey === "2F" + ? [...(mergedPickOrder.pickOrderLines || [])].sort( + (a: any, b: any) => { + const ao = a.itemOrder != null ? Number(a.itemOrder) : 999999; + const bo = b.itemOrder != null ? Number(b.itemOrder) : 999999; + if (ao !== bo) return ao - bo; + return (Number(a.id) || 0) - (Number(b.id) || 0); + }, ) - : Number(line.requiredQty) || 0, - actualPickQty: stockout.qty || 0, - inQty: 0, - outQty: 0, - holdQty: 0, - lotStatus: stockout.noLot ? "unavailable" : "available", - lotAvailability: stockout.noLot ? "insufficient_stock" : "available", - processingStatus: stockout.status || "pending", - suggestedPickLotId: null, - stockOutLineId: stockout.id || null, - stockOutLineStatus: stockout.status || null, - stockOutLineQty: stockout.qty || 0, - - routerId: null, - routerIndex: stockout.noLot ? 999999 : null, - routerRoute: null, - routerArea: null, - noLot: !!stockout.noLot, - }); - }); - } - }); - - console.log(" Transformed flat lot data:", flatLotData); - console.log(" Total items (including null stock):", flatLotData.length); - - setCombinedLotData(flatLotData); - setOriginalCombinedData(flatLotData); - const doPid = hierarchicalData.fgInfo?.doPickOrderId; - if (doPid) { - setIssuePickedQtyBySolId(loadIssuePickedMap(doPid)); - } - checkAllLotsCompleted(flatLotData); - } catch (error) { - console.error(" Error fetching combined lot data:", error); - setCombinedLotData([]); - setOriginalCombinedData([]); - setAllLotsCompleted(false); - setIssuePickedQtyBySolId({}); - } finally { - setCombinedDataLoading(false); - } -}, [currentUserId, checkAllLotsCompleted]); // 移除 selectedPickOrderId 依赖 - // Add effect to check completion when lot data changes - const handleManualLotConfirmation = useCallback(async (currentLotNo: string, newLotNo: string) => { - console.log(` Manual lot confirmation: Current=${currentLotNo}, New=${newLotNo}`); - - // 使用第一个输入框的 lot number 查找当前数据 - const currentLot = combinedLotData.find(lot => - lot.lotNo && lot.lotNo === currentLotNo - ); - - if (!currentLot) { - console.error(`❌ Current lot not found: ${currentLotNo}`); - alert(t("Current lot number not found. Please verify and try again.")); - return; - } - - if (!currentLot.stockOutLineId) { - console.error("❌ No stockOutLineId found for current lot"); - alert(t("No stock out line found for current lot. Please contact administrator.")); - return; - } - - setIsConfirmingLot(true); - - try { - // 调用 updateStockOutLineStatusByQRCodeAndLotNo API - // 第一个 lot 用于获取 pickOrderLineId, stockOutLineId, itemId - // 第二个 lot 作为 inventoryLotNo - const res = await updateStockOutLineStatusByQRCodeAndLotNo({ - pickOrderLineId: currentLot.pickOrderLineId, - inventoryLotNo: newLotNo, // 第二个输入框的值 - stockOutLineId: currentLot.stockOutLineId, - itemId: currentLot.itemId, - status: "checked", - }); - - console.log("📥 updateStockOutLineStatusByQRCodeAndLotNo result:", res); - - if (res.code === "checked" || res.code === "SUCCESS") { - // ✅ 更新本地状态 - const entity = res.entity as any; - - setCombinedLotData(prev => prev.map(lot => { - if (lot.stockOutLineId === currentLot.stockOutLineId && - lot.pickOrderLineId === currentLot.pickOrderLineId) { - return { - ...lot, - stockOutLineStatus: 'checked', - stockOutLineQty: entity?.qty ? Number(entity.qty) : lot.stockOutLineQty, - }; + : mergedPickOrder.pickOrderLines || []; + + pickOrderLinesForDisplay.forEach((line: any) => { + // 用来记录这一行已经通过 lots 出现过的 lotId + const lotIdSet = new Set(); + /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數) */ + let lotsAllocatedSumForLine = 0; + + // ✅ lots:按 lotId 去重并合并 requiredQty + if (line.lots && line.lots.length > 0) { + const lotMap = new Map(); + + line.lots.forEach((lot: any) => { + const lotId = lot.id; + if (lotMap.has(lotId)) { + const existingLot = lotMap.get(lotId); + existingLot.requiredQty = + (existingLot.requiredQty || 0) + (lot.requiredQty || 0); + } else { + lotMap.set(lotId, { ...lot }); + } + }); + + lotMap.forEach((lot: any) => { + lotsAllocatedSumForLine += Number(lot.requiredQty) || 0; + if (lot.id != null) { + lotIdSet.add(lot.id); + } + + flatLotData.push({ + pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes) + ? mergedPickOrder.consoCodes[0] || "" + : "", + pickOrderTargetDate: mergedPickOrder.targetDate, + pickOrderStatus: mergedPickOrder.status, + pickOrderId: + line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0, + pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", + pickOrderLineId: line.id, + pickOrderLineRequiredQty: line.requiredQty, + pickOrderLineStatus: line.status, + + itemId: line.item.id, + itemCode: line.item.code, + itemName: line.item.name, + uomDesc: line.item.uomDesc, + uomShortDesc: line.item.uomShortDesc, + + lotId: lot.id, + lotNo: lot.lotNo, + expiryDate: lot.expiryDate, + location: lot.location, + stockUnit: lot.stockUnit, + availableQty: lot.availableQty, + requiredQty: lot.requiredQty, + actualPickQty: lot.actualPickQty, + inQty: lot.inQty, + outQty: lot.outQty, + holdQty: lot.holdQty, + lotStatus: lot.lotStatus, + lotAvailability: lot.lotAvailability, + processingStatus: lot.processingStatus, + suggestedPickLotId: lot.suggestedPickLotId, + stockOutLineId: lot.stockOutLineId, + stockOutLineStatus: lot.stockOutLineStatus, + stockOutLineQty: lot.stockOutLineQty, + stockInLineId: lot.stockInLineId, + routerId: lot.router?.id, + routerIndex: lot.router?.index, + routerRoute: lot.router?.route, + routerArea: lot.router?.area, + noLot: false, + }); + }); } - return lot; - })); - - setOriginalCombinedData(prev => prev.map(lot => { - if (lot.stockOutLineId === currentLot.stockOutLineId && - lot.pickOrderLineId === currentLot.pickOrderLineId) { - return { - ...lot, - stockOutLineStatus: 'checked', - stockOutLineQty: entity?.qty ? Number(entity.qty) : lot.stockOutLineQty, - }; + + // ✅ stockouts:只保留“真正无批次 / 未在 lots 出现过”的行 + if (line.stockouts && line.stockouts.length > 0) { + line.stockouts.forEach((stockout: any) => { + const hasLot = stockout.lotId != null; + const lotAlreadyInLots = + hasLot && lotIdSet.has(stockout.lotId as number); + + // 有批次 & 已经通过 lots 渲染过 → 跳过,避免一条变两行 + if (!stockout.noLot && lotAlreadyInLots) { + return; + } + + // 只渲染: + // - noLot === true 的 Null stock 行 + // - 或者 lotId 在 lots 中不存在的特殊情况 + flatLotData.push({ + pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes) + ? mergedPickOrder.consoCodes[0] || "" + : "", + pickOrderTargetDate: mergedPickOrder.targetDate, + pickOrderStatus: mergedPickOrder.status, + pickOrderId: + line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0, + pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", + pickOrderLineId: line.id, + pickOrderLineRequiredQty: line.requiredQty, + pickOrderLineStatus: line.status, + + itemId: line.item.id, + itemCode: line.item.code, + itemName: line.item.name, + uomDesc: line.item.uomDesc, + uomShortDesc: line.item.uomShortDesc, + + lotId: stockout.lotId || null, + lotNo: stockout.lotNo || null, + expiryDate: null, + location: stockout.location || null, + stockUnit: line.item.uomDesc, + availableQty: stockout.availableQty || 0, + // 無批次列對應 suggested_pick_lot 的缺口量(如 11),勿用整行 POL 需求(100)以免顯示成 89 / 100 + requiredQty: stockout.noLot + ? Math.max( + 0, + (Number(line.requiredQty) || 0) - lotsAllocatedSumForLine, + ) + : Number(line.requiredQty) || 0, + actualPickQty: stockout.qty || 0, + inQty: 0, + outQty: 0, + holdQty: 0, + lotStatus: stockout.noLot ? "unavailable" : "available", + lotAvailability: stockout.noLot + ? "insufficient_stock" + : "available", + processingStatus: stockout.status || "pending", + suggestedPickLotId: null, + stockOutLineId: stockout.id || null, + stockOutLineStatus: stockout.status || null, + stockOutLineQty: stockout.qty || 0, + + routerId: null, + routerIndex: stockout.noLot ? 999999 : null, + routerRoute: null, + routerArea: null, + noLot: !!stockout.noLot, + }); + }); } - return lot; - })); - - console.log("✅ Lot substitution completed successfully"); - setQrScanSuccess(true); - setQrScanError(false); - - // 关闭手动输入模态框 - setManualLotConfirmationOpen(false); - - // 刷新数据 - await fetchAllCombinedLotData(); - } else if (res.code === "LOT_NUMBER_MISMATCH") { - console.warn("⚠️ Backend reported LOT_NUMBER_MISMATCH:", res.message); - - // ✅ 打开 lot confirmation modal 而不是显示 alert - // 从响应消息中提取 expected lot number(如果可能) - // 或者使用 currentLotNo 作为 expected lot - const expectedLotNo = currentLotNo; // 当前 lot 是期望的 - - // 查找新 lot 的信息(如果存在于 combinedLotData 中) - const newLot = combinedLotData.find(lot => - lot.lotNo && lot.lotNo === newLotNo - ); - - // 设置 expected lot data - setExpectedLotData({ - lotNo: expectedLotNo, - itemCode: currentLot.itemCode || '', - itemName: currentLot.itemName || '' }); - - // 设置 scanned lot data - setScannedLotData({ - lotNo: newLotNo, - itemCode: newLot?.itemCode || currentLot.itemCode || '', - itemName: newLot?.itemName || currentLot.itemName || '', - inventoryLotLineId: newLot?.lotId || null, - stockInLineId: null // 手动输入时可能没有 stockInLineId - }); - - // 设置 selectedLotForQr 为当前 lot - setSelectedLotForQr(currentLot); - - // 关闭手动输入模态框 - setManualLotConfirmationOpen(false); - - // 打开 lot confirmation modal - setLotConfirmationOpen(true); - - setQrScanError(false); // 不显示错误,因为会打开确认模态框 - setQrScanSuccess(false); - } else if (res.code === "ITEM_MISMATCH") { - console.warn("⚠️ Backend reported ITEM_MISMATCH:", res.message); - alert(t("Item mismatch: {message}", { message: res.message || "" })); - setQrScanError(true); - setQrScanSuccess(false); - - // 关闭手动输入模态框 - setManualLotConfirmationOpen(false); - } else { - console.warn("⚠️ Unexpected response code:", res.code); - alert(t("Failed to update lot status. Response: {code}", { code: res.code })); - setQrScanError(true); - setQrScanSuccess(false); - - // 关闭手动输入模态框 - setManualLotConfirmationOpen(false); + + console.log(" Transformed flat lot data:", flatLotData); + console.log( + " Total items (including null stock):", + flatLotData.length, + ); + + setCombinedLotData(flatLotData); + setOriginalCombinedData(flatLotData); + const doPid = hierarchicalData.fgInfo?.doPickOrderId; + if (doPid) { + setIssuePickedQtyBySolId(loadIssuePickedMap(doPid)); + } + checkAllLotsCompleted(flatLotData); + } catch (error) { + console.error(" Error fetching combined lot data:", error); + setCombinedLotData([]); + setOriginalCombinedData([]); + setAllLotsCompleted(false); + setIssuePickedQtyBySolId({}); + } finally { + setCombinedDataLoading(false); } - - } catch (error) { - console.error("❌ Error in manual lot confirmation:", error); - alert(t("Failed to confirm lot substitution. Please try again.")); - setQrScanError(true); - setQrScanSuccess(false); - - // 关闭手动输入模态框 - setManualLotConfirmationOpen(false); - } finally { - setIsConfirmingLot(false); - } - }, [combinedLotData, fetchAllCombinedLotData, t]); + }, + [currentUserId, checkAllLotsCompleted], + ); // 移除 selectedPickOrderId 依赖 + // Add effect to check completion when lot data changes + const handleManualLotConfirmation = useCallback( + async (currentLotNo: string, newLotNo: string) => { + console.log( + ` Manual lot confirmation: Current=${currentLotNo}, New=${newLotNo}`, + ); + + // 使用第一个输入框的 lot number 查找当前数据 + const currentLot = combinedLotData.find( + (lot) => lot.lotNo && lot.lotNo === currentLotNo, + ); + + if (!currentLot) { + console.error(`❌ Current lot not found: ${currentLotNo}`); + alert(t("Current lot number not found. Please verify and try again.")); + return; + } + + if (!currentLot.stockOutLineId) { + console.error("❌ No stockOutLineId found for current lot"); + alert( + t( + "No stock out line found for current lot. Please contact administrator.", + ), + ); + return; + } + + setIsConfirmingLot(true); + + try { + // 调用 updateStockOutLineStatusByQRCodeAndLotNo API + // 第一个 lot 用于获取 pickOrderLineId, stockOutLineId, itemId + // 第二个 lot 作为 inventoryLotNo + const res = await updateStockOutLineStatusByQRCodeAndLotNo({ + pickOrderLineId: currentLot.pickOrderLineId, + inventoryLotNo: newLotNo, // 第二个输入框的值 + stockOutLineId: currentLot.stockOutLineId, + itemId: currentLot.itemId, + status: "checked", + }); + + console.log("📥 updateStockOutLineStatusByQRCodeAndLotNo result:", res); + + if (res.code === "checked" || res.code === "SUCCESS") { + // ✅ 更新本地状态 + const entity = res.entity as any; + + setCombinedLotData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === currentLot.stockOutLineId && + lot.pickOrderLineId === currentLot.pickOrderLineId + ) { + return { + ...lot, + stockOutLineStatus: "checked", + stockOutLineQty: entity?.qty + ? Number(entity.qty) + : lot.stockOutLineQty, + }; + } + return lot; + }), + ); + + setOriginalCombinedData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === currentLot.stockOutLineId && + lot.pickOrderLineId === currentLot.pickOrderLineId + ) { + return { + ...lot, + stockOutLineStatus: "checked", + stockOutLineQty: entity?.qty + ? Number(entity.qty) + : lot.stockOutLineQty, + }; + } + return lot; + }), + ); + + console.log("✅ Lot substitution completed successfully"); + setQrScanSuccess(true); + setQrScanError(false); + + // 关闭手动输入模态框 + setManualLotConfirmationOpen(false); + + // 刷新数据 + await fetchAllCombinedLotData(); + } else if (res.code === "LOT_NUMBER_MISMATCH") { + console.warn("⚠️ Backend reported LOT_NUMBER_MISMATCH:", res.message); + + // ✅ 打开 lot confirmation modal 而不是显示 alert + // 从响应消息中提取 expected lot number(如果可能) + // 或者使用 currentLotNo 作为 expected lot + const expectedLotNo = currentLotNo; // 当前 lot 是期望的 + + // 查找新 lot 的信息(如果存在于 combinedLotData 中) + const newLot = combinedLotData.find( + (lot) => lot.lotNo && lot.lotNo === newLotNo, + ); + + // 设置 expected lot data + setExpectedLotData({ + lotNo: expectedLotNo, + itemCode: currentLot.itemCode || "", + itemName: currentLot.itemName || "", + }); + + // 设置 scanned lot data + setScannedLotData({ + lotNo: newLotNo, + itemCode: newLot?.itemCode || currentLot.itemCode || "", + itemName: newLot?.itemName || currentLot.itemName || "", + inventoryLotLineId: newLot?.lotId || null, + stockInLineId: null, // 手动输入时可能没有 stockInLineId + }); + + // 设置 selectedLotForQr 为当前 lot + setSelectedLotForQr(currentLot); + + // 关闭手动输入模态框 + setManualLotConfirmationOpen(false); + + // 打开 lot confirmation modal + setLotConfirmationOpen(true); + + setQrScanError(false); // 不显示错误,因为会打开确认模态框 + setQrScanSuccess(false); + } else if (res.code === "ITEM_MISMATCH") { + console.warn("⚠️ Backend reported ITEM_MISMATCH:", res.message); + alert(t("Item mismatch: {message}", { message: res.message || "" })); + setQrScanError(true); + setQrScanSuccess(false); + + // 关闭手动输入模态框 + setManualLotConfirmationOpen(false); + } else { + console.warn("⚠️ Unexpected response code:", res.code); + alert( + t("Failed to update lot status. Response: {code}", { + code: res.code, + }), + ); + setQrScanError(true); + setQrScanSuccess(false); + + // 关闭手动输入模态框 + setManualLotConfirmationOpen(false); + } + } catch (error) { + console.error("❌ Error in manual lot confirmation:", error); + alert(t("Failed to confirm lot substitution. Please try again.")); + setQrScanError(true); + setQrScanSuccess(false); + + // 关闭手动输入模态框 + setManualLotConfirmationOpen(false); + } finally { + setIsConfirmingLot(false); + } + }, + [combinedLotData, fetchAllCombinedLotData, t], + ); useEffect(() => { if (combinedLotData.length > 0) { checkAllLotsCompleted(combinedLotData); @@ -1309,459 +1565,574 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // Expose completion status to parent component useEffect(() => { // Dispatch custom event with completion status - const event = new CustomEvent('pickOrderCompletionStatus', { - detail: { + const event = new CustomEvent("pickOrderCompletionStatus", { + detail: { allLotsCompleted, - tabIndex: 1 // 明确指定这是来自标签页 1 的事件 - } + tabIndex: 1, // 明确指定这是来自标签页 1 的事件 + }, }); window.dispatchEvent(event); }, [allLotsCompleted]); - const clearLotConfirmationState = useCallback((clearProcessedRefs: boolean = false) => { - setLotConfirmationOpen(false); - setLotConfirmationError(null); - setExpectedLotData(null); - setScannedLotData(null); - setSelectedLotForQr(null); - lotConfirmLastQrRef.current = ''; - lotConfirmSkipNextScanRef.current = false; - lotConfirmOpenedAtRef.current = 0; - - if (clearProcessedRefs) { - setTimeout(() => { - lastProcessedQrRef.current = ''; - processedQrCodesRef.current.clear(); - console.log(` [LOT CONFIRM MODAL] Cleared refs to allow reprocessing`); - }, 100); - } - }, []); + const clearLotConfirmationState = useCallback( + (clearProcessedRefs: boolean = false) => { + setLotConfirmationOpen(false); + setLotConfirmationError(null); + setExpectedLotData(null); + setScannedLotData(null); + setSelectedLotForQr(null); + lotConfirmLastQrRef.current = ""; + lotConfirmSkipNextScanRef.current = false; + lotConfirmOpenedAtRef.current = 0; + + if (clearProcessedRefs) { + setTimeout(() => { + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.clear(); + console.log( + ` [LOT CONFIRM MODAL] Cleared refs to allow reprocessing`, + ); + }, 100); + } + }, + [], + ); - const parseQrPayload = useCallback((rawQr: string): { itemId: number; stockInLineId: number } | null => { - if (!rawQr) return null; + const parseQrPayload = useCallback( + (rawQr: string): { itemId: number; stockInLineId: number } | null => { + if (!rawQr) return null; - if ((rawQr.startsWith("{2fitest") || rawQr.startsWith("{2fittest")) && rawQr.endsWith("}")) { - let content = ''; - if (rawQr.startsWith("{2fittest")) { - content = rawQr.substring(9, rawQr.length - 1); - } else { - content = rawQr.substring(8, rawQr.length - 1); + if ( + (rawQr.startsWith("{2fitest") || rawQr.startsWith("{2fittest")) && + rawQr.endsWith("}") + ) { + let content = ""; + if (rawQr.startsWith("{2fittest")) { + content = rawQr.substring(9, rawQr.length - 1); + } else { + content = rawQr.substring(8, rawQr.length - 1); + } + + const parts = content.split(","); + if (parts.length === 2) { + const itemId = parseInt(parts[0].trim(), 10); + const stockInLineId = parseInt(parts[1].trim(), 10); + if (!isNaN(itemId) && !isNaN(stockInLineId)) { + return { itemId, stockInLineId }; + } + } + return null; } - const parts = content.split(','); - if (parts.length === 2) { - const itemId = parseInt(parts[0].trim(), 10); - const stockInLineId = parseInt(parts[1].trim(), 10); - if (!isNaN(itemId) && !isNaN(stockInLineId)) { - return { itemId, stockInLineId }; + try { + const parsed = JSON.parse(rawQr); + if (parsed?.itemId && parsed?.stockInLineId) { + return { itemId: parsed.itemId, stockInLineId: parsed.stockInLineId }; } + return null; + } catch { + return null; } - return null; - } + }, + [], + ); - try { - const parsed = JSON.parse(rawQr); - if (parsed?.itemId && parsed?.stockInLineId) { - return { itemId: parsed.itemId, stockInLineId: parsed.stockInLineId }; + const handleLotConfirmation = useCallback( + async (overrideScannedLot?: any, runContext?: LotConfirmRunContext) => { + const exp = runContext?.expectedLotData ?? expectedLotData; + const scan = + overrideScannedLot ?? runContext?.scannedLotData ?? scannedLotData; + const sel = runContext?.selectedLotForQr ?? selectedLotForQr; + if (!exp || !scan || !sel) return; + + const newStockInLineId = scan?.stockInLineId; + if (newStockInLineId == null || Number.isNaN(Number(newStockInLineId))) + return; + + const rowSolKey = Number(sel.stockOutLineId); + if (Number.isFinite(rowSolKey)) { + setLotSwitchFailByStockOutLineId((prev) => { + const next = { ...prev }; + delete next[rowSolKey]; + return next; + }); } - return null; - } catch { - return null; - } - }, []); - const handleLotConfirmation = useCallback(async (overrideScannedLot?: any, runContext?: LotConfirmRunContext) => { - const exp = runContext?.expectedLotData ?? expectedLotData; - const scan = overrideScannedLot ?? runContext?.scannedLotData ?? scannedLotData; - const sel = runContext?.selectedLotForQr ?? selectedLotForQr; - if (!exp || !scan || !sel) return; - - const newStockInLineId = scan?.stockInLineId; - if (newStockInLineId == null || Number.isNaN(Number(newStockInLineId))) return; - - const rowSolKey = Number(sel.stockOutLineId); - if (Number.isFinite(rowSolKey)) { - setLotSwitchFailByStockOutLineId((prev) => { - const next = { ...prev }; - delete next[rowSolKey]; - return next; - }); - } + setIsConfirmingLot(true); + setLotConfirmationError(null); + try { + const substitutionResult = await confirmLotSubstitution({ + pickOrderLineId: sel.pickOrderLineId, + stockOutLineId: sel.stockOutLineId, + originalSuggestedPickLotId: sel.suggestedPickLotId, + newInventoryLotNo: "", + newStockInLineId: newStockInLineId, + }); - setIsConfirmingLot(true); - setLotConfirmationError(null); - try { - const substitutionResult = await confirmLotSubstitution({ - pickOrderLineId: sel.pickOrderLineId, - stockOutLineId: sel.stockOutLineId, - originalSuggestedPickLotId: sel.suggestedPickLotId, - newInventoryLotNo: "", - newStockInLineId: newStockInLineId, - }); + const substitutionCode = substitutionResult?.code; + const switchedToUnavailable = + substitutionCode === "SUCCESS_UNAVAILABLE" || + substitutionCode === "BOUND_UNAVAILABLE"; + if ( + !substitutionResult || + (substitutionCode !== "SUCCESS" && !switchedToUnavailable) + ) { + const errMsg = translateLotSubstitutionFailure(t, substitutionResult); + if (Number.isFinite(rowSolKey)) { + setLotSwitchFailByStockOutLineId((prev) => ({ + ...prev, + [rowSolKey]: errMsg, + })); + } + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + return; + } - const substitutionCode = substitutionResult?.code; - const switchedToUnavailable = - substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE"; - if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) { - const errMsg = translateLotSubstitutionFailure(t, substitutionResult); - if (Number.isFinite(rowSolKey)) { - setLotSwitchFailByStockOutLineId((prev) => ({ ...prev, [rowSolKey]: errMsg })); + if (switchedToUnavailable && isAdmin) { + const itemId = Number(sel?.itemId ?? exp?.itemId); + const stockInLineId = Number(newStockInLineId); + if (Number.isFinite(itemId) && Number.isFinite(stockInLineId)) { + setLotLabelPrintInitialPayload({ itemId, stockInLineId }); + setLotLabelPrintReminderText( + "該批次不可用,請移除該Label並列印新Label。", + ); + setLotLabelPrintModalOpen(true); + } } - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(errMsg); - return; - } - setQrScanError(false); - setQrScanSuccess(false); - setQrScanInput(""); + setQrScanError(false); + setQrScanSuccess(false); + setQrScanInput(""); - resetScan(); + resetScan(); - setPickExecutionFormOpen(false); - if (sel?.stockOutLineId && !switchedToUnavailable) { - await updateStockOutLineStatus({ - id: sel.stockOutLineId, - status: "checked", - qty: 0, - }); - } + setPickExecutionFormOpen(false); + if (sel?.stockOutLineId && !switchedToUnavailable) { + await updateStockOutLineStatus({ + id: sel.stockOutLineId, + status: "checked", + qty: 0, + }); + } - clearLotConfirmationState(false); + clearLotConfirmationState(false); - setIsRefreshingData(true); - await fetchAllCombinedLotData(); - setIsRefreshingData(false); - } catch (error) { - console.error("Error confirming lot substitution:", error); - const errMsg = t("Lot confirmation failed. Please try again."); - if (Number.isFinite(rowSolKey)) { - setLotSwitchFailByStockOutLineId((prev) => ({ ...prev, [rowSolKey]: errMsg })); + setIsRefreshingData(true); + await fetchAllCombinedLotData(); + setIsRefreshingData(false); + } catch (error) { + console.error("Error confirming lot substitution:", error); + const errMsg = t("Lot confirmation failed. Please try again."); + if (Number.isFinite(rowSolKey)) { + setLotSwitchFailByStockOutLineId((prev) => ({ + ...prev, + [rowSolKey]: errMsg, + })); + } + setQrScanError(true); + setQrScanErrorMsg(errMsg); + } finally { + setIsConfirmingLot(false); } - setQrScanError(true); - setQrScanErrorMsg(errMsg); - } finally { - setIsConfirmingLot(false); - } - }, [expectedLotData, scannedLotData, selectedLotForQr, fetchAllCombinedLotData, resetScan, clearLotConfirmationState, t]); + }, + [ + expectedLotData, + scannedLotData, + selectedLotForQr, + fetchAllCombinedLotData, + resetScan, + clearLotConfirmationState, + t, + isAdmin, + ], + ); useEffect(() => { handleLotConfirmationRef.current = handleLotConfirmation; }, [handleLotConfirmation]); - const handleLotConfirmationByRescan = useCallback(async (rawQr: string): Promise => { - if (!lotConfirmationOpen || !selectedLotForQr || !expectedLotData || !scannedLotData) { - return false; - } - - const payload = parseQrPayload(rawQr); - const expectedStockInLineId = Number(selectedLotForQr.stockInLineId); - const mismatchedStockInLineId = Number(scannedLotData?.stockInLineId); - - if (payload) { - const rescannedStockInLineId = Number(payload.stockInLineId); - - // 再扫“差异 lot” => 直接执行切换 + const handleLotConfirmationByRescan = useCallback( + async (rawQr: string): Promise => { if ( - Number.isFinite(mismatchedStockInLineId) && - rescannedStockInLineId === mismatchedStockInLineId + !lotConfirmationOpen || + !selectedLotForQr || + !expectedLotData || + !scannedLotData ) { - await handleLotConfirmation(); - return true; + return false; } - // 再扫“原建议 lot” => 关闭弹窗并按原 lot 正常记一次扫描 - if ( - Number.isFinite(expectedStockInLineId) && - rescannedStockInLineId === expectedStockInLineId - ) { - clearLotConfirmationState(false); - if (processOutsideQrCodeRef.current) { - await processOutsideQrCodeRef.current(JSON.stringify(payload)); + const payload = parseQrPayload(rawQr); + const expectedStockInLineId = Number(selectedLotForQr.stockInLineId); + const mismatchedStockInLineId = Number(scannedLotData?.stockInLineId); + + if (payload) { + const rescannedStockInLineId = Number(payload.stockInLineId); + + // 再扫“差异 lot” => 直接执行切换 + if ( + Number.isFinite(mismatchedStockInLineId) && + rescannedStockInLineId === mismatchedStockInLineId + ) { + await handleLotConfirmation(); + return true; + } + + // 再扫“原建议 lot” => 关闭弹窗并按原 lot 正常记一次扫描 + if ( + Number.isFinite(expectedStockInLineId) && + rescannedStockInLineId === expectedStockInLineId + ) { + clearLotConfirmationState(false); + if (processOutsideQrCodeRef.current) { + await processOutsideQrCodeRef.current(JSON.stringify(payload)); + } + return true; } + + // 扫到第三个 lot(既不是当前差异 lot,也不是原建议 lot): + // 直接按“扫描到的这一批”执行切换。 + await handleLotConfirmation({ + lotNo: null, + itemCode: expectedLotData?.itemCode, + itemName: expectedLotData?.itemName, + inventoryLotLineId: null, + stockInLineId: rescannedStockInLineId, + }); return true; + } else { + // 兼容纯 lotNo 文本扫码 + const scannedText = rawQr?.trim(); + const expectedLotNo = expectedLotData?.lotNo?.trim(); + const mismatchedLotNo = scannedLotData?.lotNo?.trim(); + + if (mismatchedLotNo && scannedText === mismatchedLotNo) { + await handleLotConfirmation(); + return true; + } + + if (expectedLotNo && scannedText === expectedLotNo) { + clearLotConfirmationState(false); + if (processOutsideQrCodeRef.current) { + await processOutsideQrCodeRef.current( + JSON.stringify({ + itemId: selectedLotForQr.itemId, + stockInLineId: selectedLotForQr.stockInLineId, + }), + ); + } + return true; + } + } + + return false; + }, + [ + lotConfirmationOpen, + selectedLotForQr, + expectedLotData, + scannedLotData, + parseQrPayload, + handleLotConfirmation, + clearLotConfirmationState, + handleLotMismatch, + ], + ); + + const handleQrCodeSubmit = useCallback( + async (lotNo: string) => { + console.log(` Processing QR Code for lot: ${lotNo}`); + + // 检查 lotNo 是否为 null 或 undefined(包括字符串 "null") + if (!lotNo || lotNo === "null" || lotNo.trim() === "") { + console.error(" Invalid lotNo: null, undefined, or empty"); + return; } - // 扫到第三个 lot(既不是当前差异 lot,也不是原建议 lot): - // 直接按“扫描到的这一批”执行切换。 - await handleLotConfirmation({ - lotNo: null, - itemCode: expectedLotData?.itemCode, - itemName: expectedLotData?.itemName, - inventoryLotLineId: null, - stockInLineId: rescannedStockInLineId + // Use current data without refreshing to avoid infinite loop + const currentLotData = combinedLotData; + console.log( + ` Available lots:`, + currentLotData.map((lot) => lot.lotNo), + ); + + // 修复:在比较前确保 lotNo 不为 null + const lotNoLower = lotNo.toLowerCase(); + const matchingLots = currentLotData.filter((lot) => { + if (!lot.lotNo) return false; // 跳过 null lotNo + return lot.lotNo === lotNo || lot.lotNo.toLowerCase() === lotNoLower; }); - return true; - } else { - // 兼容纯 lotNo 文本扫码 - const scannedText = rawQr?.trim(); - const expectedLotNo = expectedLotData?.lotNo?.trim(); - const mismatchedLotNo = scannedLotData?.lotNo?.trim(); - if (mismatchedLotNo && scannedText === mismatchedLotNo) { - await handleLotConfirmation(); - return true; + if (matchingLots.length === 0) { + console.error(` Lot not found: ${lotNo}`); + setQrScanError(true); + setQrScanSuccess(false); + const availableLotNos = currentLotData + .map((lot) => lot.lotNo) + .join(", "); + console.log( + ` QR Code "${lotNo}" does not match any expected lots. Available lots: ${availableLotNos}`, + ); + return; } - if (expectedLotNo && scannedText === expectedLotNo) { - clearLotConfirmationState(false); - if (processOutsideQrCodeRef.current) { - await processOutsideQrCodeRef.current(JSON.stringify({ - itemId: selectedLotForQr.itemId, - stockInLineId: selectedLotForQr.stockInLineId, - })); - } - return true; + const hasExpiredLot = matchingLots.some( + (lot: any) => + String(lot.lotAvailability || "").toLowerCase() === "expired", + ); + if (hasExpiredLot) { + console.warn(`⚠️ [QR PROCESS] Scanned lot ${lotNo} is expired`); + setQrScanError(true); + setQrScanSuccess(false); + return; } - } - return false; - }, [lotConfirmationOpen, selectedLotForQr, expectedLotData, scannedLotData, parseQrPayload, handleLotConfirmation, clearLotConfirmationState, handleLotMismatch]); + console.log(` Found ${matchingLots.length} matching lots:`, matchingLots); + setQrScanError(false); - const handleQrCodeSubmit = useCallback(async (lotNo: string) => { - console.log(` Processing QR Code for lot: ${lotNo}`); - - // 检查 lotNo 是否为 null 或 undefined(包括字符串 "null") - if (!lotNo || lotNo === 'null' || lotNo.trim() === '') { - console.error(" Invalid lotNo: null, undefined, or empty"); - return; - } - - // Use current data without refreshing to avoid infinite loop - const currentLotData = combinedLotData; - console.log(` Available lots:`, currentLotData.map(lot => lot.lotNo)); - - // 修复:在比较前确保 lotNo 不为 null - const lotNoLower = lotNo.toLowerCase(); - const matchingLots = currentLotData.filter(lot => { - if (!lot.lotNo) return false; // 跳过 null lotNo - return lot.lotNo === lotNo || lot.lotNo.toLowerCase() === lotNoLower; - }); - - if (matchingLots.length === 0) { - console.error(` Lot not found: ${lotNo}`); - setQrScanError(true); - setQrScanSuccess(false); - const availableLotNos = currentLotData.map(lot => lot.lotNo).join(', '); - console.log(` QR Code "${lotNo}" does not match any expected lots. Available lots: ${availableLotNos}`); - return; - } + try { + let successCount = 0; + let errorCount = 0; - const hasExpiredLot = matchingLots.some( - (lot: any) => String(lot.lotAvailability || '').toLowerCase() === 'expired' - ); - if (hasExpiredLot) { - console.warn(`⚠️ [QR PROCESS] Scanned lot ${lotNo} is expired`); - setQrScanError(true); - setQrScanSuccess(false); - return; - } - - console.log(` Found ${matchingLots.length} matching lots:`, matchingLots); - setQrScanError(false); - - try { - let successCount = 0; - let errorCount = 0; - - for (const matchingLot of matchingLots) { - console.log(`🔄 Processing pick order line ${matchingLot.pickOrderLineId} for lot ${lotNo}`); - - if (matchingLot.stockOutLineId) { - const stockOutLineUpdate = await updateStockOutLineStatus({ - id: matchingLot.stockOutLineId, - status: 'checked', - qty: 0 - }); - console.log(`Update stock out line result for line ${matchingLot.pickOrderLineId}:`, stockOutLineUpdate); + for (const matchingLot of matchingLots) { + console.log( + `🔄 Processing pick order line ${matchingLot.pickOrderLineId} for lot ${lotNo}`, + ); - // Treat multiple backend shapes as success (type-safe via any) - const r: any = stockOutLineUpdate as any; - const updateOk = - r?.code === 'SUCCESS' || - typeof r?.id === 'number' || - r?.type === 'checked' || - r?.status === 'checked' || - typeof r?.entity?.id === 'number' || - r?.entity?.status === 'checked'; - - if (updateOk) { - successCount++; - } else { - errorCount++; - } - } else { - const createStockOutLineData = { - consoCode: matchingLot.pickOrderConsoCode, - pickOrderLineId: matchingLot.pickOrderLineId, - inventoryLotLineId: matchingLot.lotId, - qty: 0 - }; - - const createResult = await createStockOutLine(createStockOutLineData); - console.log(`Create stock out line result for line ${matchingLot.pickOrderLineId}:`, createResult); - - if (createResult && createResult.code === "SUCCESS") { - // Immediately set status to checked for new line - let newSolId: number | undefined; - const anyRes: any = createResult as any; - if (typeof anyRes?.id === 'number') { - newSolId = anyRes.id; - } else if (anyRes?.entity) { - newSolId = Array.isArray(anyRes.entity) ? anyRes.entity[0]?.id : anyRes.entity?.id; + if (matchingLot.stockOutLineId) { + const stockOutLineUpdate = await updateStockOutLineStatus({ + id: matchingLot.stockOutLineId, + status: "checked", + qty: 0, + }); + console.log( + `Update stock out line result for line ${matchingLot.pickOrderLineId}:`, + stockOutLineUpdate, + ); + + // Treat multiple backend shapes as success (type-safe via any) + const r: any = stockOutLineUpdate as any; + const updateOk = + r?.code === "SUCCESS" || + typeof r?.id === "number" || + r?.type === "checked" || + r?.status === "checked" || + typeof r?.entity?.id === "number" || + r?.entity?.status === "checked"; + + if (updateOk) { + successCount++; + } else { + errorCount++; } + } else { + const createStockOutLineData = { + consoCode: matchingLot.pickOrderConsoCode, + pickOrderLineId: matchingLot.pickOrderLineId, + inventoryLotLineId: matchingLot.lotId, + qty: 0, + }; - if (newSolId) { - const setChecked = await updateStockOutLineStatus({ - id: newSolId, - status: 'checked', - qty: 0 - }); - if (setChecked && setChecked.code === "SUCCESS") { - successCount++; + const createResult = await createStockOutLine( + createStockOutLineData, + ); + console.log( + `Create stock out line result for line ${matchingLot.pickOrderLineId}:`, + createResult, + ); + + if (createResult && createResult.code === "SUCCESS") { + // Immediately set status to checked for new line + let newSolId: number | undefined; + const anyRes: any = createResult as any; + if (typeof anyRes?.id === "number") { + newSolId = anyRes.id; + } else if (anyRes?.entity) { + newSolId = Array.isArray(anyRes.entity) + ? anyRes.entity[0]?.id + : anyRes.entity?.id; + } + + if (newSolId) { + const setChecked = await updateStockOutLineStatus({ + id: newSolId, + status: "checked", + qty: 0, + }); + if (setChecked && setChecked.code === "SUCCESS") { + successCount++; + } else { + errorCount++; + } } else { + console.warn( + "Created stock out line but no ID returned; cannot set to checked", + ); errorCount++; } } else { - console.warn("Created stock out line but no ID returned; cannot set to checked"); errorCount++; } - } else { - errorCount++; } } + + // FIXED: Set refresh flag before refreshing data + setIsRefreshingData(true); + console.log("🔄 Refreshing data after QR code processing..."); + await fetchAllCombinedLotData(); + + if (successCount > 0) { + console.log( + ` QR Code processing completed: ${successCount} updated/created`, + ); + setQrScanSuccess(true); + setQrScanError(false); + setQrScanInput(""); // Clear input after successful processing + //setIsManualScanning(false); + // stopScan(); + // resetScan(); + // Clear success state after a delay + + //setTimeout(() => { + //setQrScanSuccess(false); + //}, 2000); + } else { + console.error(` QR Code processing failed: ${errorCount} errors`); + setQrScanError(true); + setQrScanSuccess(false); + + // Clear error state after a delay + // setTimeout(() => { + // setQrScanError(false); + //}, 3000); + } + } catch (error) { + console.error(" Error processing QR code:", error); + setQrScanError(true); + setQrScanSuccess(false); + + // Clear error state after a delay + setTimeout(() => { + setQrScanError(false); + }, 3000); + } finally { + // Clear refresh flag after a short delay + setTimeout(() => { + setIsRefreshingData(false); + }, 1000); } - - // FIXED: Set refresh flag before refreshing data - setIsRefreshingData(true); - console.log("🔄 Refreshing data after QR code processing..."); - await fetchAllCombinedLotData(); - - if (successCount > 0) { - console.log(` QR Code processing completed: ${successCount} updated/created`); - setQrScanSuccess(true); - setQrScanError(false); - setQrScanInput(''); // Clear input after successful processing - //setIsManualScanning(false); - // stopScan(); - // resetScan(); - // Clear success state after a delay - - //setTimeout(() => { - //setQrScanSuccess(false); - //}, 2000); - } else { - console.error(` QR Code processing failed: ${errorCount} errors`); - setQrScanError(true); - setQrScanSuccess(false); - - // Clear error state after a delay - // setTimeout(() => { - // setQrScanError(false); - //}, 3000); - } - } catch (error) { - console.error(" Error processing QR code:", error); - setQrScanError(true); - setQrScanSuccess(false); - - - // Clear error state after a delay - setTimeout(() => { - setQrScanError(false); - }, 3000); - } finally { - // Clear refresh flag after a short delay - setTimeout(() => { - setIsRefreshingData(false); - }, 1000); - } - }, [combinedLotData]); - const handleFastQrScan = useCallback(async (lotNo: string) => { - const startTime = performance.now(); - console.log(` [FAST SCAN START] Lot: ${lotNo}`); - console.log(` Start time: ${new Date().toISOString()}`); - - // 从 combinedLotData 中找到对应的 lot - const findStartTime = performance.now(); - const matchingLot = combinedLotData.find(lot => - lot.lotNo && lot.lotNo === lotNo - ); - const findTime = performance.now() - findStartTime; - console.log(` Find lot time: ${findTime.toFixed(2)}ms`); - - if (!matchingLot || !matchingLot.stockOutLineId) { - const totalTime = performance.now() - startTime; - console.warn(`⚠️ Fast scan: Lot ${lotNo} not found or no stockOutLineId`); - console.log(` Total time: ${totalTime.toFixed(2)}ms`); - return; - } - - try { - // ✅ 使用快速 API - const apiStartTime = performance.now(); - const res = await updateStockOutLineStatusByQRCodeAndLotNo({ - pickOrderLineId: matchingLot.pickOrderLineId, - inventoryLotNo: lotNo, - stockOutLineId: matchingLot.stockOutLineId, - itemId: matchingLot.itemId, - status: "checked", - }); - const apiTime = performance.now() - apiStartTime; - console.log(` API call time: ${apiTime.toFixed(2)}ms`); - - if (res.code === "checked" || res.code === "SUCCESS") { - // ✅ 只更新本地状态,不调用 fetchAllCombinedLotData - const updateStartTime = performance.now(); - const entity = res.entity as any; - - setCombinedLotData(prev => prev.map(lot => { - if (lot.stockOutLineId === matchingLot.stockOutLineId && - lot.pickOrderLineId === matchingLot.pickOrderLineId) { - return { - ...lot, - stockOutLineStatus: 'checked', - stockOutLineQty: entity?.qty ? Number(entity.qty) : lot.stockOutLineQty, - }; - } - return lot; - })); - - setOriginalCombinedData(prev => prev.map(lot => { - if (lot.stockOutLineId === matchingLot.stockOutLineId && - lot.pickOrderLineId === matchingLot.pickOrderLineId) { - return { - ...lot, - stockOutLineStatus: 'checked', - stockOutLineQty: entity?.qty ? Number(entity.qty) : lot.stockOutLineQty, - }; - } - return lot; - })); - const updateTime = performance.now() - updateStartTime; - console.log(` State update time: ${updateTime.toFixed(2)}ms`); - + }, + [combinedLotData], + ); + const handleFastQrScan = useCallback( + async (lotNo: string) => { + const startTime = performance.now(); + console.log(` [FAST SCAN START] Lot: ${lotNo}`); + console.log(` Start time: ${new Date().toISOString()}`); + + // 从 combinedLotData 中找到对应的 lot + const findStartTime = performance.now(); + const matchingLot = combinedLotData.find( + (lot) => lot.lotNo && lot.lotNo === lotNo, + ); + const findTime = performance.now() - findStartTime; + console.log(` Find lot time: ${findTime.toFixed(2)}ms`); + + if (!matchingLot || !matchingLot.stockOutLineId) { const totalTime = performance.now() - startTime; - console.log(`✅ [FAST SCAN END] Lot: ${lotNo}`); - console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); - console.log(` End time: ${new Date().toISOString()}`); - } else { + console.warn( + `⚠️ Fast scan: Lot ${lotNo} not found or no stockOutLineId`, + ); + console.log(` Total time: ${totalTime.toFixed(2)}ms`); + return; + } + + try { + // ✅ 使用快速 API + const apiStartTime = performance.now(); + const res = await updateStockOutLineStatusByQRCodeAndLotNo({ + pickOrderLineId: matchingLot.pickOrderLineId, + inventoryLotNo: lotNo, + stockOutLineId: matchingLot.stockOutLineId, + itemId: matchingLot.itemId, + status: "checked", + }); + const apiTime = performance.now() - apiStartTime; + console.log(` API call time: ${apiTime.toFixed(2)}ms`); + + if (res.code === "checked" || res.code === "SUCCESS") { + // ✅ 只更新本地状态,不调用 fetchAllCombinedLotData + const updateStartTime = performance.now(); + const entity = res.entity as any; + + setCombinedLotData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === matchingLot.stockOutLineId && + lot.pickOrderLineId === matchingLot.pickOrderLineId + ) { + return { + ...lot, + stockOutLineStatus: "checked", + stockOutLineQty: entity?.qty + ? Number(entity.qty) + : lot.stockOutLineQty, + }; + } + return lot; + }), + ); + + setOriginalCombinedData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === matchingLot.stockOutLineId && + lot.pickOrderLineId === matchingLot.pickOrderLineId + ) { + return { + ...lot, + stockOutLineStatus: "checked", + stockOutLineQty: entity?.qty + ? Number(entity.qty) + : lot.stockOutLineQty, + }; + } + return lot; + }), + ); + const updateTime = performance.now() - updateStartTime; + console.log(` State update time: ${updateTime.toFixed(2)}ms`); + + const totalTime = performance.now() - startTime; + console.log(`✅ [FAST SCAN END] Lot: ${lotNo}`); + console.log( + ` Total time: ${totalTime.toFixed(2)}ms (${( + totalTime / 1000 + ).toFixed(3)}s)`, + ); + console.log(` End time: ${new Date().toISOString()}`); + } else { + const totalTime = performance.now() - startTime; + console.warn(`⚠️ Fast scan failed for ${lotNo}:`, res.code); + console.log(` Total time: ${totalTime.toFixed(2)}ms`); + } + } catch (error) { const totalTime = performance.now() - startTime; - console.warn(`⚠️ Fast scan failed for ${lotNo}:`, res.code); + console.error(` Fast scan error for ${lotNo}:`, error); console.log(` Total time: ${totalTime.toFixed(2)}ms`); } - } catch (error) { - const totalTime = performance.now() - startTime; - console.error(` Fast scan error for ${lotNo}:`, error); - console.log(` Total time: ${totalTime.toFixed(2)}ms`); - } - }, [combinedLotData, updateStockOutLineStatusByQRCodeAndLotNo]); + }, + [combinedLotData, updateStockOutLineStatusByQRCodeAndLotNo], + ); // Enhanced lotDataIndexes with cached active lots for better performance const lotDataIndexes = useMemo(() => { const indexStartTime = performance.now(); - console.log(` [PERF] lotDataIndexes calculation START, data length: ${combinedLotData.length}`); - + console.log( + ` [PERF] lotDataIndexes calculation START, data length: ${combinedLotData.length}`, + ); + const byItemId = new Map(); const byItemCode = new Map(); const byLotId = new Map(); @@ -1770,8 +2141,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // Cache active lots separately to avoid filtering on every scan const activeLotsByItemId = new Map(); - const rejectedStatuses = new Set(['rejected']); - + const rejectedStatuses = new Set(["rejected"]); + // ✅ Use for loop instead of forEach for better performance on tablets for (let i = 0; i < combinedLotData.length; i++) { const lot = combinedLotData[i]; @@ -1785,10 +2156,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO rejectedStatuses.has(solStatus) || rejectedStatuses.has(processingStatus); const isEnded = solStatus === "checked" || solStatus === "completed"; - const isPartially = solStatus === "partially_completed" || solStatus === "partially_complete"; + const isPartially = + solStatus === "partially_completed" || + solStatus === "partially_complete"; const isPending = solStatus === "pending" || solStatus === ""; - const isActive = !isRejected && !isUnavailable && !isExpired && !isEnded && (isPending || isPartially); - + const isActive = + !isRejected && + !isUnavailable && + !isExpired && + !isEnded && + (isPending || isPartially); + if (lot.itemId) { if (!byItemId.has(lot.itemId)) { byItemId.set(lot.itemId, []); @@ -1799,25 +2177,25 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO activeLotsByItemId.get(lot.itemId)!.push(lot); } } - + if (lot.itemCode) { if (!byItemCode.has(lot.itemCode)) { byItemCode.set(lot.itemCode, []); } byItemCode.get(lot.itemCode)!.push(lot); } - + if (lot.lotId) { byLotId.set(lot.lotId, lot); } - + if (lot.lotNo) { if (!byLotNo.has(lot.lotNo)) { byLotNo.set(lot.lotNo, []); } byLotNo.get(lot.lotNo)!.push(lot); } - + if (lot.stockInLineId) { if (!byStockInLineId.has(lot.stockInLineId)) { byStockInLineId.set(lot.stockInLineId, []); @@ -1825,516 +2203,688 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO byStockInLineId.get(lot.stockInLineId)!.push(lot); } } - + const indexTime = performance.now() - indexStartTime; if (indexTime > 10) { - console.log(` [PERF] lotDataIndexes calculation END: ${indexTime.toFixed(2)}ms (${(indexTime / 1000).toFixed(3)}s)`); + console.log( + ` [PERF] lotDataIndexes calculation END: ${indexTime.toFixed(2)}ms (${( + indexTime / 1000 + ).toFixed(3)}s)`, + ); } - - return { byItemId, byItemCode, byLotId, byLotNo, byStockInLineId, activeLotsByItemId }; + + return { + byItemId, + byItemCode, + byLotId, + byLotNo, + byStockInLineId, + activeLotsByItemId, + }; }, [combinedLotData.length, combinedLotData]); - + // Store resetScan in ref for immediate access (update on every render) resetScanRef.current = resetScan; - - const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => { - const totalStartTime = performance.now(); - - console.log(` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); - console.log(` Start time: ${new Date().toISOString()}`); - - // ✅ Measure index access time - const indexAccessStart = performance.now(); - const indexes = lotDataIndexes; // Access the memoized indexes - const indexAccessTime = performance.now() - indexAccessStart; - console.log(` [PERF] Index access time: ${indexAccessTime.toFixed(2)}ms`); - - // 1) Parse JSON safely (parse once, reuse) - const parseStartTime = performance.now(); - let qrData: any = null; - let parseTime = 0; - try { - qrData = JSON.parse(latestQr); - parseTime = performance.now() - parseStartTime; - console.log(` [PERF] JSON parse time: ${parseTime.toFixed(2)}ms`); - console.log("[QR DEBUG] qrData:", qrData); -console.log("[QR DEBUG] typeof itemId:", typeof qrData?.itemId, "value:", qrData?.itemId); -console.log("[QR DEBUG] typeof stockInLineId:", typeof qrData?.stockInLineId, "value:", qrData?.stockInLineId); - } catch { - console.log("QR content is not JSON; skipping lotNo direct submit to avoid false matches."); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - }); - return; - } - - try { - const validationStartTime = performance.now(); - if (!(qrData?.stockInLineId && qrData?.itemId)) { - console.log("QR JSON missing required fields (itemId, stockInLineId)."); + + const processOutsideQrCode = useCallback( + async (latestQr: string, qrScanCountAtInvoke?: number) => { + const totalStartTime = performance.now(); + console.log( + ` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`, + ); + console.log(` Start time: ${new Date().toISOString()}`); + + // ✅ Measure index access time + const indexAccessStart = performance.now(); + const indexes = lotDataIndexes; // Access the memoized indexes + const indexAccessTime = performance.now() - indexAccessStart; + console.log( + ` [PERF] Index access time: ${indexAccessTime.toFixed(2)}ms`, + ); + + // 1) Parse JSON safely (parse once, reuse) + const parseStartTime = performance.now(); + let qrData: any = null; + let parseTime = 0; + try { + qrData = JSON.parse(latestQr); + parseTime = performance.now() - parseStartTime; + console.log(` [PERF] JSON parse time: ${parseTime.toFixed(2)}ms`); + } catch { + console.log( + "QR content is not JSON; skipping lotNo direct submit to avoid false matches.", + ); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); }); return; } - const validationTime = performance.now() - validationStartTime; - console.log("[QR DEBUG] raw latestQr:", latestQr); - console.log("[QR DEBUG] qrData:", qrData); - console.log("[QR DEBUG] typeof qrData.itemId:", typeof qrData?.itemId, "value:", qrData?.itemId); - console.log("[QR DEBUG] typeof qrData.stockInLineId:", typeof qrData?.stockInLineId, "value:", qrData?.stockInLineId); - const scannedItemId = qrData.itemId; - const scannedStockInLineId = qrData.stockInLineId; - console.log("[QR DEBUG] combinedLotData.length:", combinedLotData?.length); - const directBySil = (combinedLotData || []).find( - (l: any) => String(l?.stockInLineId) === String(scannedStockInLineId) - ); - console.log("[QR DEBUG] direct match by stockInLineId:", directBySil); - - const sample0: any = combinedLotData?.[0]; - if (sample0) { - console.log("[QR DEBUG] combinedLotData[0] id fields:", { - itemId: sample0.itemId, - stockInLineId: sample0.stockInLineId, - tItemId: typeof sample0.itemId, - tStockInLineId: typeof sample0.stockInLineId, - lotNo: sample0.lotNo, - lotId: sample0.lotId, - }); - } else { - console.log("[QR DEBUG] combinedLotData[0] is empty/undefined"); - } - - // ✅ Check if this combination was already processed - const duplicateCheckStartTime = performance.now(); - const itemProcessedSet = processedQrCombinations.get(scannedItemId); - if (itemProcessedSet?.has(scannedStockInLineId)) { - const duplicateCheckTime = performance.now() - duplicateCheckStartTime; - console.log(` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed(2)}ms)`); - return; - } - const duplicateCheckTime = performance.now() - duplicateCheckStartTime; - console.log(` [PERF] Duplicate check time: ${duplicateCheckTime.toFixed(2)}ms`); - - // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) - const lookupStartTime = performance.now(); - const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; - console.log("[QR DEBUG] byItemId.has(num):", indexes.byItemId.has(Number(scannedItemId))); -//console.log("[QR DEBUG] byItemId.has(str):", indexes.byItemId.has(String(scannedItemId))); - -console.log("[QR DEBUG] byStockInLineId.has(num):", indexes.byStockInLineId.has(Number(scannedStockInLineId))); -//console.log("[QR DEBUG] byStockInLineId.has(str):", indexes.byStockInLineId.has(String(scannedStockInLineId))); - // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected - const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; - - const lookupTime = performance.now() - lookupStartTime; - console.log(` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`); - - // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots - // This allows users to scan other lots even when all suggested lots are rejected - const scannedLot = allLotsForItem.find( - (lot: any) => lot.stockInLineId === scannedStockInLineId - ); - - if (scannedLot) { - const isRejected = - scannedLot.stockOutLineStatus?.toLowerCase() === 'rejected' || - scannedLot.lotAvailability === 'rejected' || - isInventoryLotLineUnavailable(scannedLot); - - if (isRejected) { - console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected or unavailable`); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg( - `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` - ); - }); - // Mark as processed to prevent re-processing - setProcessedQrCombinations(prev => { - const newMap = new Map(prev); - if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); - newMap.get(scannedItemId)!.add(scannedStockInLineId); - return newMap; - }); - return; - } - const isExpired = - String(scannedLot.lotAvailability || '').toLowerCase() === 'expired'; - if (isExpired) { - console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired`); + try { + const validationStartTime = performance.now(); + if (!(qrData?.stockInLineId && qrData?.itemId)) { + console.log( + "QR JSON missing required fields (itemId, stockInLineId).", + ); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); - setQrScanErrorMsg( - `此批次(${scannedLot.lotNo || scannedStockInLineId})已过期,无法使用。请扫描其他批次。` - ); - }); - // Mark as processed to prevent re-processing the same expired QR repeatedly - setProcessedQrCombinations(prev => { - const newMap = new Map(prev); - if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); - newMap.get(scannedItemId)!.add(scannedStockInLineId); - return newMap; }); return; } - } - - // ✅ If no active suggested lots, but scanned lot is not rejected, allow lot switching - if (activeSuggestedLots.length === 0) { - // Check if there are any lots for this item (even if all are rejected) - if (allLotsForItem.length === 0) { - console.error("No lots found for this item"); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg("当前订单中没有此物品的批次信息"); - }); + const validationTime = performance.now() - validationStartTime; + console.log(` [PERF] Validation time: ${validationTime.toFixed(2)}ms`); + + const scannedItemId = qrData.itemId; + const scannedStockInLineId = qrData.stockInLineId; + + // ✅ Check if this combination was already processed + const duplicateCheckStartTime = performance.now(); + const itemProcessedSet = processedQrCombinations.get(scannedItemId); + if (itemProcessedSet?.has(scannedStockInLineId)) { + const duplicateCheckTime = + performance.now() - duplicateCheckStartTime; + console.log( + ` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed( + 2, + )}ms)`, + ); return; } - - // ✅ Allow lot switching: find a rejected lot as expected lot, or use first lot - // This allows users to switch to a new lot even when all suggested lots are rejected - console.log(`⚠️ [QR PROCESS] No active suggested lots, but allowing lot switching.`); - - // Find a rejected lot as expected lot (the one that was rejected) - const rejectedLot = allLotsForItem.find((lot: any) => - lot.stockOutLineStatus?.toLowerCase() === 'rejected' || - lot.lotAvailability === 'rejected' || - isInventoryLotLineUnavailable(lot) + const duplicateCheckTime = performance.now() - duplicateCheckStartTime; + console.log( + ` [PERF] Duplicate check time: ${duplicateCheckTime.toFixed(2)}ms`, ); - const expectedLot = - rejectedLot || - pickExpectedLotForSubstitution( - allLotsForItem.filter( - (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "" - ) - ) || - allLotsForItem[0]; - - // Silent lot substitution; modal only if switch fails - console.log(`⚠️ [QR PROCESS] Lot switch (no active lots), attempting substitution`); - setSelectedLotForQr(expectedLot); - handleLotMismatch( - expectedLot, - { - lotNo: scannedLot?.lotNo || null, - itemCode: expectedLot.itemCode, - itemName: expectedLot.itemName, - inventoryLotLineId: scannedLot?.lotId || null, - stockInLineId: scannedStockInLineId, - }, - qrScanCountAtInvoke + + // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) + const lookupStartTime = performance.now(); + const activeSuggestedLots = + indexes.activeLotsByItemId.get(scannedItemId) || []; + // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected + const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; + const lookupTime = performance.now() - lookupStartTime; + console.log( + ` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${ + activeSuggestedLots.length + } active lots, ${allLotsForItem.length} total lots`, ); - return; - } - - // ✅ OPTIMIZATION: Direct Map lookup for stockInLineId match (O(1)) - const matchStartTime = performance.now(); - let exactMatch: any = null; - const stockInLineLots = indexes.byStockInLineId.get(scannedStockInLineId) || []; - // Find exact match from stockInLineId index, then verify it's in active lots - for (let i = 0; i < stockInLineLots.length; i++) { - const lot = stockInLineLots[i]; - if (lot.itemId === scannedItemId && activeSuggestedLots.includes(lot)) { - exactMatch = lot; - break; - } - } - const matchTime = performance.now() - matchStartTime; - console.log(` [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${exactMatch ? 'yes' : 'no'}`); - - // ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots - // This handles the case where Lot A is rejected and user scans Lot B - // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) - if (!exactMatch) { - // Scanned lot is not in active suggested lots, open confirmation modal - const expectedLot = - pickExpectedLotForSubstitution(activeSuggestedLots) || allLotsForItem[0]; - if (expectedLot) { - // Check if scanned lot is different from expected, or if scannedLot is undefined (not in allLotsForItem) - const shouldOpenModal = !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); - if (shouldOpenModal) { - console.log(`⚠️ [QR PROCESS] Lot switch (scanned lot ${scannedLot?.lotNo || 'not in data'} not in active suggested lots)`); - setSelectedLotForQr(expectedLot); - handleLotMismatch( - expectedLot, - { - lotNo: scannedLot?.lotNo || null, - itemCode: expectedLot.itemCode, - itemName: expectedLot.itemName, - inventoryLotLineId: scannedLot?.lotId || null, - stockInLineId: scannedStockInLineId, - }, - qrScanCountAtInvoke + + // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots + // This allows users to scan other lots even when all suggested lots are rejected + const scannedLot = allLotsForItem.find( + (lot: any) => lot.stockInLineId === scannedStockInLineId, + ); + + if (scannedLot) { + const isRejected = + scannedLot.stockOutLineStatus?.toLowerCase() === "rejected" || + scannedLot.lotAvailability === "rejected" || + isInventoryLotLineUnavailable(scannedLot); + + if (isRejected) { + console.warn( + `⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected or unavailable`, ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + `此批次(${ + scannedLot.lotNo || scannedStockInLineId + })已被拒绝,无法使用。请扫描其他批次。`, + ); + }); + // Mark as processed to prevent re-processing + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) + newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); return; } - } - } - - if (exactMatch) { - // ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认 - console.log(`✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`); - - if (!exactMatch.stockOutLineId) { - console.warn("No stockOutLineId on exactMatch, cannot update status by QR."); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - }); - return; - } - - try { - const apiStartTime = performance.now(); - console.log(` [API CALL START] Calling updateStockOutLineStatusByQRCodeAndLotNo`); - console.log(` [API CALL] API start time: ${new Date().toISOString()}`); - const res = await updateStockOutLineStatusByQRCodeAndLotNo({ - pickOrderLineId: exactMatch.pickOrderLineId, - inventoryLotNo: exactMatch.lotNo, - stockOutLineId: exactMatch.stockOutLineId, - itemId: exactMatch.itemId, - status: "checked", - }); - const apiTime = performance.now() - apiStartTime; - console.log(` [API CALL END] Total API time: ${apiTime.toFixed(2)}ms (${(apiTime / 1000).toFixed(3)}s)`); - console.log(` [API CALL] API end time: ${new Date().toISOString()}`); - - if (res.code === "checked" || res.code === "SUCCESS") { - const entity = res.entity as any; - - // ✅ Batch state updates using startTransition - const stateUpdateStartTime = performance.now(); + + const isExpired = + String(scannedLot.lotAvailability || "").toLowerCase() === + "expired"; + if (isExpired) { + console.warn( + `⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired`, + ); startTransition(() => { - setQrScanError(false); - setQrScanSuccess(true); - - setCombinedLotData(prev => prev.map(lot => { - if (lot.stockOutLineId === exactMatch.stockOutLineId && - lot.pickOrderLineId === exactMatch.pickOrderLineId) { - return { - ...lot, - stockOutLineStatus: 'checked', - stockOutLineQty: entity?.qty ?? lot.stockOutLineQty, - }; - } - return lot; - })); - - setOriginalCombinedData(prev => prev.map(lot => { - if (lot.stockOutLineId === exactMatch.stockOutLineId && - lot.pickOrderLineId === exactMatch.pickOrderLineId) { - return { - ...lot, - stockOutLineStatus: 'checked', - stockOutLineQty: entity?.qty ?? lot.stockOutLineQty, - }; - } - return lot; - })); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + `此批次(${ + scannedLot.lotNo || scannedStockInLineId + })已过期,无法使用。请扫描其他批次。`, + ); }); - const stateUpdateTime = performance.now() - stateUpdateStartTime; - console.log(` [PERF] State update time: ${stateUpdateTime.toFixed(2)}ms`); - - // Mark this combination as processed - const markProcessedStartTime = performance.now(); - setProcessedQrCombinations(prev => { + // Mark as processed to prevent re-processing the same expired QR repeatedly + setProcessedQrCombinations((prev) => { const newMap = new Map(prev); - if (!newMap.has(scannedItemId)) { + if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); - } newMap.get(scannedItemId)!.add(scannedStockInLineId); return newMap; }); - const markProcessedTime = performance.now() - markProcessedStartTime; - console.log(` [PERF] Mark processed time: ${markProcessedTime.toFixed(2)}ms`); - - const totalTime = performance.now() - totalStartTime; - console.log(`✅ [PROCESS OUTSIDE QR END] Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); - console.log(` End time: ${new Date().toISOString()}`); - console.log(`📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, duplicateCheck=${duplicateCheckTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, api=${apiTime.toFixed(2)}ms, stateUpdate=${stateUpdateTime.toFixed(2)}ms, markProcessed=${markProcessedTime.toFixed(2)}ms`); - console.log("✅ Status updated locally, no full data refresh needed"); - } else { - console.warn("Unexpected response code from backend:", res.code); + return; + } + } + + // ✅ If no active suggested lots, but scanned lot is not rejected, allow lot switching + if (activeSuggestedLots.length === 0) { + // Check if there are any lots for this item (even if all are rejected) + if (allLotsForItem.length === 0) { + console.error("No lots found for this item"); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); + setQrScanErrorMsg("当前订单中没有此物品的批次信息"); }); + return; } - } catch (e) { - const totalTime = performance.now() - totalStartTime; - console.error(`❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`); - console.error("Error calling updateStockOutLineStatusByQRCodeAndLotNo:", e); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - }); - } - - return; // ✅ 直接返回,不需要确认表单 - } - - // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配 - 显示确认表单 - // Check if we should allow reopening (different stockInLineId) - const mismatchCheckStartTime = performance.now(); - const itemProcessedSet2 = processedQrCombinations.get(scannedItemId); - if (itemProcessedSet2?.has(scannedStockInLineId)) { - const mismatchCheckTime = performance.now() - mismatchCheckStartTime; - console.log(` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed(2)}ms)`); - return; - } - const mismatchCheckTime = performance.now() - mismatchCheckStartTime; - console.log(` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); - - // 取应被替换的活跃行(同物料多行时优先有建议批次的行) - const expectedLotStartTime = performance.now(); - const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); - if (!expectedLot) { - console.error("Could not determine expected lot for confirmation"); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - }); - return; - } - const expectedLotTime = performance.now() - expectedLotStartTime; - console.log(` [PERF] Get expected lot time: ${expectedLotTime.toFixed(2)}ms`); - - // ✅ 立即打开确认模态框,不等待其他操作 - console.log(`⚠️ Lot mismatch: Expected stockInLineId=${expectedLot.stockInLineId}, Scanned stockInLineId=${scannedStockInLineId}`); - - // Set selected lot immediately (no transition delay) - const setSelectedLotStartTime = performance.now(); - setSelectedLotForQr(expectedLot); - const setSelectedLotTime = performance.now() - setSelectedLotStartTime; - console.log(` [PERF] Set selected lot time: ${setSelectedLotTime.toFixed(2)}ms`); - - const handleMismatchStartTime = performance.now(); - handleLotMismatch( - expectedLot, - { - lotNo: null, - itemCode: expectedLot.itemCode, - itemName: expectedLot.itemName, - inventoryLotLineId: null, - stockInLineId: scannedStockInLineId, - }, - qrScanCountAtInvoke - ); - const handleMismatchTime = performance.now() - handleMismatchStartTime; - console.log(` [PERF] Handle mismatch call time: ${handleMismatchTime.toFixed(2)}ms`); - - const totalTime = performance.now() - totalStartTime; - console.log(`⚠️ [PROCESS OUTSIDE QR MISMATCH] Total time before modal: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); - console.log(` End time: ${new Date().toISOString()}`); - console.log(`📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, duplicateCheck=${duplicateCheckTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, mismatchCheck=${mismatchCheckTime.toFixed(2)}ms, expectedLot=${expectedLotTime.toFixed(2)}ms, setSelectedLot=${setSelectedLotTime.toFixed(2)}ms, handleMismatch=${handleMismatchTime.toFixed(2)}ms`); - } catch (error) { - const totalTime = performance.now() - totalStartTime; - console.error(`❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`); - console.error("Error during QR code processing:", error); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - }); - return; - } - }, [lotDataIndexes, handleLotMismatch, processedQrCombinations, combinedLotData, fetchStockInLineInfoCached]); - // Store processOutsideQrCode in ref for immediate access (update on every render) - processOutsideQrCodeRef.current = processOutsideQrCode; - - useEffect(() => { - // Skip if scanner is not active or no data available - if (!isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData) { - return; - } - - const qrValuesChangeStartTime = performance.now(); - console.log(` [QR VALUES EFFECT] Triggered at: ${new Date().toISOString()}`); - console.log(` [QR VALUES EFFECT] qrValues.length: ${qrValues.length}`); - console.log(` [QR VALUES EFFECT] qrValues:`, qrValues); - - const latestQr = qrValues[qrValues.length - 1]; - console.log(` [QR VALUES EFFECT] Latest QR: ${latestQr}`); - console.log(` [QR VALUES EFFECT] Latest QR detected at: ${new Date().toISOString()}`); - - // ✅ FIXED: Handle test shortcut {2fitestx,y} or {2fittestx,y} where x=itemId, y=stockInLineId - // Support both formats: {2fitest (2 t's) and {2fittest (3 t's) - if ((latestQr.startsWith("{2fitest") || latestQr.startsWith("{2fittest")) && latestQr.endsWith("}")) { - // Extract content: remove "{2fitest" or "{2fittest" and "}" - let content = ''; - if (latestQr.startsWith("{2fittest")) { - content = latestQr.substring(9, latestQr.length - 1); // Remove "{2fittest" and "}" - } else if (latestQr.startsWith("{2fitest")) { - content = latestQr.substring(8, latestQr.length - 1); // Remove "{2fitest" and "}" - } - - const parts = content.split(','); - - if (parts.length === 2) { - const itemId = parseInt(parts[0].trim(), 10); - const stockInLineId = parseInt(parts[1].trim(), 10); - - if (!isNaN(itemId) && !isNaN(stockInLineId)) { + + // ✅ Allow lot switching: find a rejected lot as expected lot, or use first lot + // This allows users to switch to a new lot even when all suggested lots are rejected console.log( - `%c TEST QR: Detected ${latestQr.substring(0, 9)}... - Simulating QR input (itemId=${itemId}, stockInLineId=${stockInLineId})`, - "color: purple; font-weight: bold" + `⚠️ [QR PROCESS] No active suggested lots, but allowing lot switching.`, ); - - // ✅ Simulate QR code JSON format - const simulatedQr = JSON.stringify({ - itemId: itemId, - stockInLineId: stockInLineId - }); - - console.log(` [TEST QR] Simulated QR content: ${simulatedQr}`); - console.log(` [TEST QR] Start time: ${new Date().toISOString()}`); - const testStartTime = performance.now(); - + + // Find a rejected lot as expected lot (the one that was rejected) + const rejectedLot = allLotsForItem.find( + (lot: any) => + lot.stockOutLineStatus?.toLowerCase() === "rejected" || + lot.lotAvailability === "rejected" || + isInventoryLotLineUnavailable(lot), + ); + const expectedLot = + rejectedLot || + pickExpectedLotForSubstitution( + allLotsForItem.filter( + (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "", + ), + ) || + allLotsForItem[0]; + + // Silent lot substitution; modal only if switch fails + console.log( + `⚠️ [QR PROCESS] Lot switch (no active lots), attempting substitution`, + ); + setSelectedLotForQr(expectedLot); + handleLotMismatch( + expectedLot, + { + lotNo: scannedLot?.lotNo || null, + itemCode: expectedLot.itemCode, + itemName: expectedLot.itemName, + inventoryLotLineId: scannedLot?.lotId || null, + stockInLineId: scannedStockInLineId, + }, + qrScanCountAtInvoke, + ); + return; + } + + // ✅ OPTIMIZATION: Direct Map lookup for stockInLineId match (O(1)) + const matchStartTime = performance.now(); + let exactMatch: any = null; + const stockInLineLots = + indexes.byStockInLineId.get(scannedStockInLineId) || []; + // Find exact match from stockInLineId index, then verify it's in active lots + for (let i = 0; i < stockInLineLots.length; i++) { + const lot = stockInLineLots[i]; + if ( + lot.itemId === scannedItemId && + activeSuggestedLots.includes(lot) + ) { + exactMatch = lot; + break; + } + } + const matchTime = performance.now() - matchStartTime; + console.log( + ` [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${ + exactMatch ? "yes" : "no" + }`, + ); + + // ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots + // This handles the case where Lot A is rejected and user scans Lot B + // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) + if (!exactMatch) { + // Scanned lot is not in active suggested lots, open confirmation modal + const expectedLot = + pickExpectedLotForSubstitution(activeSuggestedLots) || + allLotsForItem[0]; + if (expectedLot) { + // Check if scanned lot is different from expected, or if scannedLot is undefined (not in allLotsForItem) + const shouldOpenModal = + !scannedLot || + scannedLot.stockInLineId !== expectedLot.stockInLineId; + if (shouldOpenModal) { + console.log( + `⚠️ [QR PROCESS] Lot switch (scanned lot ${ + scannedLot?.lotNo || "not in data" + } not in active suggested lots)`, + ); + setSelectedLotForQr(expectedLot); + handleLotMismatch( + expectedLot, + { + lotNo: scannedLot?.lotNo || null, + itemCode: expectedLot.itemCode, + itemName: expectedLot.itemName, + inventoryLotLineId: scannedLot?.lotId || null, + stockInLineId: scannedStockInLineId, + }, + qrScanCountAtInvoke, + ); + return; + } + } + } + + if (exactMatch) { + // ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认 + console.log( + `✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`, + ); + + if (!exactMatch.stockOutLineId) { + console.warn( + "No stockOutLineId on exactMatch, cannot update status by QR.", + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + + try { + const apiStartTime = performance.now(); + console.log( + ` [API CALL START] Calling updateStockOutLineStatusByQRCodeAndLotNo`, + ); + console.log( + ` [API CALL] API start time: ${new Date().toISOString()}`, + ); + const res = await updateStockOutLineStatusByQRCodeAndLotNo({ + pickOrderLineId: exactMatch.pickOrderLineId, + inventoryLotNo: exactMatch.lotNo, + stockOutLineId: exactMatch.stockOutLineId, + itemId: exactMatch.itemId, + status: "checked", + }); + const apiTime = performance.now() - apiStartTime; + console.log( + ` [API CALL END] Total API time: ${apiTime.toFixed(2)}ms (${( + apiTime / 1000 + ).toFixed(3)}s)`, + ); + console.log( + ` [API CALL] API end time: ${new Date().toISOString()}`, + ); + + if (res.code === "checked" || res.code === "SUCCESS") { + const entity = res.entity as any; + + // ✅ Batch state updates using startTransition + const stateUpdateStartTime = performance.now(); + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + + setCombinedLotData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === exactMatch.stockOutLineId && + lot.pickOrderLineId === exactMatch.pickOrderLineId + ) { + return { + ...lot, + stockOutLineStatus: "checked", + stockOutLineQty: entity?.qty ?? lot.stockOutLineQty, + }; + } + return lot; + }), + ); + + setOriginalCombinedData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === exactMatch.stockOutLineId && + lot.pickOrderLineId === exactMatch.pickOrderLineId + ) { + return { + ...lot, + stockOutLineStatus: "checked", + stockOutLineQty: entity?.qty ?? lot.stockOutLineQty, + }; + } + return lot; + }), + ); + }); + const stateUpdateTime = performance.now() - stateUpdateStartTime; + console.log( + ` [PERF] State update time: ${stateUpdateTime.toFixed(2)}ms`, + ); + + // Mark this combination as processed + const markProcessedStartTime = performance.now(); + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) { + newMap.set(scannedItemId, new Set()); + } + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + const markProcessedTime = + performance.now() - markProcessedStartTime; + console.log( + ` [PERF] Mark processed time: ${markProcessedTime.toFixed( + 2, + )}ms`, + ); + + const totalTime = performance.now() - totalStartTime; + console.log( + `✅ [PROCESS OUTSIDE QR END] Total time: ${totalTime.toFixed( + 2, + )}ms (${(totalTime / 1000).toFixed(3)}s)`, + ); + console.log(` End time: ${new Date().toISOString()}`); + console.log( + `📊 Breakdown: parse=${parseTime.toFixed( + 2, + )}ms, validation=${validationTime.toFixed( + 2, + )}ms, duplicateCheck=${duplicateCheckTime.toFixed( + 2, + )}ms, lookup=${lookupTime.toFixed( + 2, + )}ms, match=${matchTime.toFixed(2)}ms, api=${apiTime.toFixed( + 2, + )}ms, stateUpdate=${stateUpdateTime.toFixed( + 2, + )}ms, markProcessed=${markProcessedTime.toFixed(2)}ms`, + ); + console.log( + "✅ Status updated locally, no full data refresh needed", + ); + } else { + console.warn("Unexpected response code from backend:", res.code); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + } + } catch (e) { + const totalTime = performance.now() - totalStartTime; + console.error( + `❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed( + 2, + )}ms`, + ); + console.error( + "Error calling updateStockOutLineStatusByQRCodeAndLotNo:", + e, + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + } + + return; // ✅ 直接返回,不需要确认表单 + } + + // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配 - 显示确认表单 + // Check if we should allow reopening (different stockInLineId) + const mismatchCheckStartTime = performance.now(); + const itemProcessedSet2 = processedQrCombinations.get(scannedItemId); + if (itemProcessedSet2?.has(scannedStockInLineId)) { + const mismatchCheckTime = performance.now() - mismatchCheckStartTime; + console.log( + ` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed( + 2, + )}ms)`, + ); + return; + } + const mismatchCheckTime = performance.now() - mismatchCheckStartTime; + console.log( + ` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`, + ); + + // 取应被替换的活跃行(同物料多行时优先有建议批次的行) + const expectedLotStartTime = performance.now(); + const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); + if (!expectedLot) { + console.error("Could not determine expected lot for confirmation"); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + const expectedLotTime = performance.now() - expectedLotStartTime; + console.log( + ` [PERF] Get expected lot time: ${expectedLotTime.toFixed(2)}ms`, + ); + + // ✅ 立即打开确认模态框,不等待其他操作 + console.log( + `⚠️ Lot mismatch: Expected stockInLineId=${expectedLot.stockInLineId}, Scanned stockInLineId=${scannedStockInLineId}`, + ); + + // Set selected lot immediately (no transition delay) + const setSelectedLotStartTime = performance.now(); + setSelectedLotForQr(expectedLot); + const setSelectedLotTime = performance.now() - setSelectedLotStartTime; + console.log( + ` [PERF] Set selected lot time: ${setSelectedLotTime.toFixed(2)}ms`, + ); + + const handleMismatchStartTime = performance.now(); + handleLotMismatch( + expectedLot, + { + lotNo: null, + itemCode: expectedLot.itemCode, + itemName: expectedLot.itemName, + inventoryLotLineId: null, + stockInLineId: scannedStockInLineId, + }, + qrScanCountAtInvoke, + ); + const handleMismatchTime = performance.now() - handleMismatchStartTime; + console.log( + ` [PERF] Handle mismatch call time: ${handleMismatchTime.toFixed( + 2, + )}ms`, + ); + + const totalTime = performance.now() - totalStartTime; + console.log( + `⚠️ [PROCESS OUTSIDE QR MISMATCH] Total time before modal: ${totalTime.toFixed( + 2, + )}ms (${(totalTime / 1000).toFixed(3)}s)`, + ); + console.log(` End time: ${new Date().toISOString()}`); + console.log( + `📊 Breakdown: parse=${parseTime.toFixed( + 2, + )}ms, validation=${validationTime.toFixed( + 2, + )}ms, duplicateCheck=${duplicateCheckTime.toFixed( + 2, + )}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed( + 2, + )}ms, mismatchCheck=${mismatchCheckTime.toFixed( + 2, + )}ms, expectedLot=${expectedLotTime.toFixed( + 2, + )}ms, setSelectedLot=${setSelectedLotTime.toFixed( + 2, + )}ms, handleMismatch=${handleMismatchTime.toFixed(2)}ms`, + ); + } catch (error) { + const totalTime = performance.now() - totalStartTime; + console.error( + `❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`, + ); + console.error("Error during QR code processing:", error); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + }, + [ + lotDataIndexes, + handleLotMismatch, + processedQrCombinations, + combinedLotData, + fetchStockInLineInfoCached, + ], + ); + // Store processOutsideQrCode in ref for immediate access (update on every render) + processOutsideQrCodeRef.current = processOutsideQrCode; + + useEffect(() => { + // Skip if scanner is not active or no data available + if ( + !isManualScanning || + qrValues.length === 0 || + combinedLotData.length === 0 || + isRefreshingData + ) { + return; + } + + const qrValuesChangeStartTime = performance.now(); + console.log( + ` [QR VALUES EFFECT] Triggered at: ${new Date().toISOString()}`, + ); + console.log(` [QR VALUES EFFECT] qrValues.length: ${qrValues.length}`); + console.log(` [QR VALUES EFFECT] qrValues:`, qrValues); + + const latestQr = qrValues[qrValues.length - 1]; + console.log(` [QR VALUES EFFECT] Latest QR: ${latestQr}`); + console.log( + ` [QR VALUES EFFECT] Latest QR detected at: ${new Date().toISOString()}`, + ); + + // ✅ FIXED: Handle test shortcut {2fitestx,y} or {2fittestx,y} where x=itemId, y=stockInLineId + // Support both formats: {2fitest (2 t's) and {2fittest (3 t's) + if ( + (latestQr.startsWith("{2fitest") || latestQr.startsWith("{2fittest")) && + latestQr.endsWith("}") + ) { + // Extract content: remove "{2fitest" or "{2fittest" and "}" + let content = ""; + if (latestQr.startsWith("{2fittest")) { + content = latestQr.substring(9, latestQr.length - 1); // Remove "{2fittest" and "}" + } else if (latestQr.startsWith("{2fitest")) { + content = latestQr.substring(8, latestQr.length - 1); // Remove "{2fitest" and "}" + } + + const parts = content.split(","); + + if (parts.length === 2) { + const itemId = parseInt(parts[0].trim(), 10); + const stockInLineId = parseInt(parts[1].trim(), 10); + + if (!isNaN(itemId) && !isNaN(stockInLineId)) { + console.log( + `%c TEST QR: Detected ${latestQr.substring( + 0, + 9, + )}... - Simulating QR input (itemId=${itemId}, stockInLineId=${stockInLineId})`, + "color: purple; font-weight: bold", + ); + + // ✅ Simulate QR code JSON format + const simulatedQr = JSON.stringify({ + itemId: itemId, + stockInLineId: stockInLineId, + }); + + console.log(` [TEST QR] Simulated QR content: ${simulatedQr}`); + console.log(` [TEST QR] Start time: ${new Date().toISOString()}`); + const testStartTime = performance.now(); + // ✅ Mark as processed FIRST to avoid duplicate processing lastProcessedQrRef.current = latestQr; processedQrCodesRef.current.add(latestQr); if (processedQrCodesRef.current.size > 100) { - const firstValue = processedQrCodesRef.current.values().next().value; + const firstValue = processedQrCodesRef.current + .values() + .next().value; if (firstValue !== undefined) { processedQrCodesRef.current.delete(firstValue); } } setLastProcessedQr(latestQr); setProcessedQrCodes(new Set(processedQrCodesRef.current)); - + // ✅ Process immediately (bypass QR scanner delay) if (processOutsideQrCodeRef.current) { - processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => { - const testTime = performance.now() - testStartTime; - console.log(` [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`); - console.log(` [TEST QR] End time: ${new Date().toISOString()}`); - }).catch((error) => { - const testTime = performance.now() - testStartTime; - console.error(`❌ [TEST QR] Error after ${testTime.toFixed(2)}ms:`, error); - }); + processOutsideQrCodeRef + .current(simulatedQr, qrValues.length) + .then(() => { + const testTime = performance.now() - testStartTime; + console.log( + ` [TEST QR] Total processing time: ${testTime.toFixed( + 2, + )}ms (${(testTime / 1000).toFixed(3)}s)`, + ); + console.log( + ` [TEST QR] End time: ${new Date().toISOString()}`, + ); + }) + .catch((error) => { + const testTime = performance.now() - testStartTime; + console.error( + `❌ [TEST QR] Error after ${testTime.toFixed(2)}ms:`, + error, + ); + }); } - + // Reset scan if (resetScanRef.current) { resetScanRef.current(); } - - const qrValuesChangeTime = performance.now() - qrValuesChangeStartTime; - console.log(` [QR VALUES EFFECT] Test QR handling time: ${qrValuesChangeTime.toFixed(2)}ms`); + + const qrValuesChangeTime = + performance.now() - qrValuesChangeStartTime; + console.log( + ` [QR VALUES EFFECT] Test QR handling time: ${qrValuesChangeTime.toFixed( + 2, + )}ms`, + ); return; // ✅ IMPORTANT: Return early to prevent normal processing } else { - console.warn(` [TEST QR] Invalid itemId or stockInLineId: itemId=${parts[0]}, stockInLineId=${parts[1]}`); + console.warn( + ` [TEST QR] Invalid itemId or stockInLineId: itemId=${parts[0]}, stockInLineId=${parts[1]}`, + ); } } else { - console.warn(` [TEST QR] Invalid format. Expected {2fitestx,y} or {2fittestx,y}, got: ${latestQr}`); + console.warn( + ` [TEST QR] Invalid format. Expected {2fitestx,y} or {2fittestx,y}, got: ${latestQr}`, + ); } } - + // 批次确认弹窗:须第二次扫码选择沿用建议批次或切换(不再自动确认) if (lotConfirmationOpen) { if (isConfirmingLot) { @@ -2342,7 +2892,7 @@ console.log("[QR DEBUG] byStockInLineId.has(num):", indexes.byStockInLineId.has( } if (lotConfirmSkipNextScanRef.current) { lotConfirmSkipNextScanRef.current = false; - lotConfirmLastQrRef.current = latestQr || ''; + lotConfirmLastQrRef.current = latestQr || ""; return; } if (!latestQr) { @@ -2351,7 +2901,9 @@ console.log("[QR DEBUG] byStockInLineId.has(num):", indexes.byStockInLineId.has( // Prevent auto-accept from buffered duplicate right after modal opens, // but allow intentional second scan of the same QR after debounce window. const sameQr = latestQr === lotConfirmLastQrRef.current; - const justOpened = lotConfirmOpenedAtRef.current > 0 && (Date.now() - lotConfirmOpenedAtRef.current) < 800; + const justOpened = + lotConfirmOpenedAtRef.current > 0 && + Date.now() - lotConfirmOpenedAtRef.current < 800; if (sameQr && justOpened) { return; } @@ -2378,27 +2930,46 @@ console.log("[QR DEBUG] byStockInLineId.has(num):", indexes.byStockInLineId.has( return; } // If it's a different QR, allow processing - console.log(` [QR PROCESS] Different QR detected while manual modal open, allowing processing`); + console.log( + ` [QR PROCESS] Different QR detected while manual modal open, allowing processing`, + ); } - + const qrDetectionStartTime = performance.now(); - console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); + console.log( + ` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`, + ); console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); - console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`); - + console.log( + ` [QR DETECTION] Time since QR scanner set value: ${( + qrDetectionStartTime - qrValuesChangeStartTime + ).toFixed(2)}ms`, + ); + // Skip if already processed (use refs to avoid dependency issues and delays) const checkProcessedStartTime = performance.now(); - if (processedQrCodesRef.current.has(latestQr) || lastProcessedQrRef.current === latestQr) { + if ( + processedQrCodesRef.current.has(latestQr) || + lastProcessedQrRef.current === latestQr + ) { const checkTime = performance.now() - checkProcessedStartTime; - console.log(` [QR PROCESS] Already processed check time: ${checkTime.toFixed(2)}ms`); + console.log( + ` [QR PROCESS] Already processed check time: ${checkTime.toFixed( + 2, + )}ms`, + ); return; } const checkTime = performance.now() - checkProcessedStartTime; - console.log(` [QR PROCESS] Not processed check time: ${checkTime.toFixed(2)}ms`); - + console.log( + ` [QR PROCESS] Not processed check time: ${checkTime.toFixed(2)}ms`, + ); + // Handle special shortcut if (latestQr === "{2fic}") { - console.log(" Detected {2fic} shortcut - opening manual lot confirmation form"); + console.log( + " Detected {2fic} shortcut - opening manual lot confirmation form", + ); setManualLotConfirmationOpen(true); if (resetScanRef.current) { resetScanRef.current(); @@ -2412,7 +2983,7 @@ console.log("[QR DEBUG] byStockInLineId.has(num):", indexes.byStockInLineId.has( } } setLastProcessedQr(latestQr); - setProcessedQrCodes(prev => { + setProcessedQrCodes((prev) => { const newSet = new Set(prev); newSet.add(latestQr); if (newSet.size > 100) { @@ -2425,24 +2996,32 @@ console.log("[QR DEBUG] byStockInLineId.has(num):", indexes.byStockInLineId.has( }); return; } - + // Process new QR code immediately (background mode - no modal) // Check against refs to avoid state update delays if (latestQr && latestQr !== lastProcessedQrRef.current) { const processingStartTime = performance.now(); - console.log(` [QR PROCESS] Starting processing at: ${new Date().toISOString()}`); - console.log(` [QR PROCESS] Time since detection: ${(processingStartTime - qrDetectionStartTime).toFixed(2)}ms`); - + console.log( + ` [QR PROCESS] Starting processing at: ${new Date().toISOString()}`, + ); + console.log( + ` [QR PROCESS] Time since detection: ${( + processingStartTime - qrDetectionStartTime + ).toFixed(2)}ms`, + ); + // ✅ Process immediately for better responsiveness // Clear any pending debounced processing if (qrProcessingTimeoutRef.current) { clearTimeout(qrProcessingTimeoutRef.current); qrProcessingTimeoutRef.current = null; } - + // Log immediately (console.log is synchronous) - console.log(` [QR PROCESS] Processing new QR code with enhanced validation: ${latestQr}`); - + console.log( + ` [QR PROCESS] Processing new QR code with enhanced validation: ${latestQr}`, + ); + // Update refs immediately (no state update delay) - do this FIRST const refUpdateStartTime = performance.now(); lastProcessedQrRef.current = latestQr; @@ -2454,80 +3033,124 @@ console.log("[QR DEBUG] byStockInLineId.has(num):", indexes.byStockInLineId.has( } } const refUpdateTime = performance.now() - refUpdateStartTime; - console.log(` [QR PROCESS] Ref update time: ${refUpdateTime.toFixed(2)}ms`); - + console.log( + ` [QR PROCESS] Ref update time: ${refUpdateTime.toFixed(2)}ms`, + ); + // Process immediately in background - no modal/form needed, no delays // Use ref to avoid dependency issues const processCallStartTime = performance.now(); if (processOutsideQrCodeRef.current) { - processOutsideQrCodeRef.current(latestQr, qrValues.length).then(() => { - const processCallTime = performance.now() - processCallStartTime; - const totalProcessingTime = performance.now() - processingStartTime; - console.log(` [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`); - console.log(` [QR PROCESS] Total processing time: ${totalProcessingTime.toFixed(2)}ms (${(totalProcessingTime / 1000).toFixed(3)}s)`); - }).catch((error) => { - const processCallTime = performance.now() - processCallStartTime; - const totalProcessingTime = performance.now() - processingStartTime; - console.error(`❌ [QR PROCESS] processOutsideQrCode error after ${processCallTime.toFixed(2)}ms:`, error); - console.error(`❌ [QR PROCESS] Total processing time before error: ${totalProcessingTime.toFixed(2)}ms`); - }); + processOutsideQrCodeRef + .current(latestQr, qrValues.length) + .then(() => { + const processCallTime = performance.now() - processCallStartTime; + const totalProcessingTime = performance.now() - processingStartTime; + console.log( + ` [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed( + 2, + )}ms`, + ); + console.log( + ` [QR PROCESS] Total processing time: ${totalProcessingTime.toFixed( + 2, + )}ms (${(totalProcessingTime / 1000).toFixed(3)}s)`, + ); + }) + .catch((error) => { + const processCallTime = performance.now() - processCallStartTime; + const totalProcessingTime = performance.now() - processingStartTime; + console.error( + `❌ [QR PROCESS] processOutsideQrCode error after ${processCallTime.toFixed( + 2, + )}ms:`, + error, + ); + console.error( + `❌ [QR PROCESS] Total processing time before error: ${totalProcessingTime.toFixed( + 2, + )}ms`, + ); + }); } - + // Update state for UI (but don't block on it) const stateUpdateStartTime = performance.now(); setLastProcessedQr(latestQr); setProcessedQrCodes(new Set(processedQrCodesRef.current)); const stateUpdateTime = performance.now() - stateUpdateStartTime; - console.log(` [QR PROCESS] State update time: ${stateUpdateTime.toFixed(2)}ms`); - + console.log( + ` [QR PROCESS] State update time: ${stateUpdateTime.toFixed(2)}ms`, + ); + const detectionTime = performance.now() - qrDetectionStartTime; const totalEffectTime = performance.now() - qrValuesChangeStartTime; - console.log(` [QR DETECTION] Total detection time: ${detectionTime.toFixed(2)}ms`); - console.log(` [QR VALUES EFFECT] Total effect time: ${totalEffectTime.toFixed(2)}ms`); + console.log( + ` [QR DETECTION] Total detection time: ${detectionTime.toFixed(2)}ms`, + ); + console.log( + ` [QR VALUES EFFECT] Total effect time: ${totalEffectTime.toFixed( + 2, + )}ms`, + ); } - + return () => { if (qrProcessingTimeoutRef.current) { clearTimeout(qrProcessingTimeoutRef.current); qrProcessingTimeoutRef.current = null; } }; - }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen, handleLotConfirmationByRescan, isConfirmingLot]); -const renderCountRef = useRef(0); -const renderStartTimeRef = useRef(null); - -// Track render performance -useEffect(() => { - renderCountRef.current++; - const now = performance.now(); - - if (renderStartTimeRef.current !== null) { - const renderTime = now - renderStartTimeRef.current; - if (renderTime > 100) { // Only log slow renders (>100ms) - console.log(` [PERF] Render #${renderCountRef.current} took ${renderTime.toFixed(2)}ms, combinedLotData length: ${combinedLotData.length}`); + }, [ + qrValues, + isManualScanning, + isRefreshingData, + combinedLotData.length, + lotConfirmationOpen, + manualLotConfirmationOpen, + handleLotConfirmationByRescan, + isConfirmingLot, + ]); + const renderCountRef = useRef(0); + const renderStartTimeRef = useRef(null); + + // Track render performance + useEffect(() => { + renderCountRef.current++; + const now = performance.now(); + + if (renderStartTimeRef.current !== null) { + const renderTime = now - renderStartTimeRef.current; + if (renderTime > 100) { + // Only log slow renders (>100ms) + console.log( + ` [PERF] Render #${renderCountRef.current} took ${renderTime.toFixed( + 2, + )}ms, combinedLotData length: ${combinedLotData.length}`, + ); + } + renderStartTimeRef.current = null; } - renderStartTimeRef.current = null; - } - - // Track when lotConfirmationOpen changes - if (lotConfirmationOpen) { - renderStartTimeRef.current = performance.now(); - console.log(` [PERF] Render triggered by lotConfirmationOpen=true`); - } -}, [combinedLotData.length, lotConfirmationOpen]); + + // Track when lotConfirmationOpen changes + if (lotConfirmationOpen) { + renderStartTimeRef.current = performance.now(); + console.log(` [PERF] Render triggered by lotConfirmationOpen=true`); + } + }, [combinedLotData.length, lotConfirmationOpen]); // Auto-start scanner only once on mount const scannerInitializedRef = useRef(false); - + useEffect(() => { if (session && currentUserId && !initializationRef.current) { console.log(" Session loaded, initializing pick order..."); initializationRef.current = true; - + // Only fetch existing data, no auto-assignment fetchAllCombinedLotData(); } }, [session, currentUserId, fetchAllCombinedLotData]); - + // Separate effect for auto-starting scanner (only once, prevents multiple resets) useEffect(() => { if (session && currentUserId && !scannerInitializedRef.current) { @@ -2546,99 +3169,108 @@ useEffect(() => { fetchAllCombinedLotData(); }; - window.addEventListener('pickOrderAssigned', handlePickOrderAssigned); - + window.addEventListener("pickOrderAssigned", handlePickOrderAssigned); + return () => { - window.removeEventListener('pickOrderAssigned', handlePickOrderAssigned); + window.removeEventListener("pickOrderAssigned", handlePickOrderAssigned); }; }, [fetchAllCombinedLotData]); - - const handleManualInputSubmit = useCallback(() => { - if (qrScanInput.trim() !== '') { + if (qrScanInput.trim() !== "") { handleQrCodeSubmit(qrScanInput.trim()); } }, [qrScanInput, handleQrCodeSubmit]); // Handle QR code submission from modal (internal scanning) - const handleQrCodeSubmitFromModal = useCallback(async (lotNo: string) => { - if (selectedLotForQr && selectedLotForQr.lotNo === lotNo) { - console.log(` QR Code verified for lot: ${lotNo}`); - - const requiredQty = selectedLotForQr.requiredQty; - const lotId = selectedLotForQr.lotId; - - // Create stock out line - - - try { - const stockOutLineUpdate = await updateStockOutLineStatus({ - id: selectedLotForQr.stockOutLineId, - status: 'checked', - qty: selectedLotForQr.stockOutLineQty || 0 - }); - console.log("Stock out line updated successfully!"); - setQrScanSuccess(true); - setQrScanError(false); + const handleQrCodeSubmitFromModal = useCallback( + async (lotNo: string) => { + if (selectedLotForQr && selectedLotForQr.lotNo === lotNo) { + console.log(` QR Code verified for lot: ${lotNo}`); - // Clear selected lot (scanner stays active) - setSelectedLotForQr(null); - - // Set pick quantity - const lotKey = `${selectedLotForQr.pickOrderLineId}-${lotId}`; - setTimeout(() => { - setPickQtyData(prev => ({ - ...prev, - [lotKey]: requiredQty - })); - console.log(` Auto-set pick quantity to ${requiredQty} for lot ${lotNo}`); - }, 500); - - - } catch (error) { - console.error("Error creating stock out line:", error); + const requiredQty = selectedLotForQr.requiredQty; + const lotId = selectedLotForQr.lotId; + + // Create stock out line + + try { + const stockOutLineUpdate = await updateStockOutLineStatus({ + id: selectedLotForQr.stockOutLineId, + status: "checked", + qty: selectedLotForQr.stockOutLineQty || 0, + }); + console.log("Stock out line updated successfully!"); + setQrScanSuccess(true); + setQrScanError(false); + + // Clear selected lot (scanner stays active) + setSelectedLotForQr(null); + + // Set pick quantity + const lotKey = `${selectedLotForQr.pickOrderLineId}-${lotId}`; + setTimeout(() => { + setPickQtyData((prev) => ({ + ...prev, + [lotKey]: requiredQty, + })); + console.log( + ` Auto-set pick quantity to ${requiredQty} for lot ${lotNo}`, + ); + }, 500); + } catch (error) { + console.error("Error creating stock out line:", error); + } } - } - }, [selectedLotForQr]); + }, + [selectedLotForQr], + ); + const handlePickQtyChange = useCallback( + (lotKey: string, value: number | string) => { + if (value === "" || value === null || value === undefined) { + setPickQtyData((prev) => ({ + ...prev, + [lotKey]: 0, + })); + return; + } - const handlePickQtyChange = useCallback((lotKey: string, value: number | string) => { - if (value === '' || value === null || value === undefined) { - setPickQtyData(prev => ({ - ...prev, - [lotKey]: 0 - })); - return; - } - - const numericValue = typeof value === 'string' ? parseFloat(value) : value; - - if (isNaN(numericValue)) { - setPickQtyData(prev => ({ + const numericValue = + typeof value === "string" ? parseFloat(value) : value; + + if (isNaN(numericValue)) { + setPickQtyData((prev) => ({ + ...prev, + [lotKey]: 0, + })); + return; + } + + setPickQtyData((prev) => ({ ...prev, - [lotKey]: 0 + [lotKey]: numericValue, })); - return; - } - - setPickQtyData(prev => ({ - ...prev, - [lotKey]: numericValue - })); - }, []); + }, + [], + ); - const [autoAssignStatus, setAutoAssignStatus] = useState<'idle' | 'checking' | 'assigned' | 'no_orders'>('idle'); - const [autoAssignMessage, setAutoAssignMessage] = useState(''); - const [completionStatus, setCompletionStatus] = useState(null); + const [autoAssignStatus, setAutoAssignStatus] = useState< + "idle" | "checking" | "assigned" | "no_orders" + >("idle"); + const [autoAssignMessage, setAutoAssignMessage] = useState(""); + const [completionStatus, setCompletionStatus] = + useState(null); const checkAndAutoAssignNext = useCallback(async () => { if (!currentUserId) return; - + try { const completionResponse = await checkPickOrderCompletion(currentUserId); - - if (completionResponse.code === "SUCCESS" && completionResponse.entity?.hasCompletedOrders) { + + if ( + completionResponse.code === "SUCCESS" && + completionResponse.entity?.hasCompletedOrders + ) { console.log("Found completed pick orders, auto-assigning next..."); // 移除前端的自动分配逻辑,因为后端已经处理了 // await handleAutoAssignAndRelease(); // 删除这个函数 @@ -2649,7 +3281,9 @@ useEffect(() => { }, [currentUserId]); const resolveSingleSubmitQty = useCallback( (lot: any) => { - const required = Number(lot.requiredQty || lot.pickOrderLineRequiredQty || 0); + const required = Number( + lot.requiredQty || lot.pickOrderLineRequiredQty || 0, + ); const solId = Number(lot.stockOutLineId) || 0; const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; const issuePicked = solId > 0 ? issuePickedQtyBySolId[solId] : undefined; @@ -2657,7 +3291,11 @@ useEffect(() => { return Number(issuePicked); } const fromPick = pickQtyData[lotKey]; - if (fromPick !== undefined && fromPick !== null && !Number.isNaN(Number(fromPick))) { + if ( + fromPick !== undefined && + fromPick !== null && + !Number.isNaN(Number(fromPick)) + ) { return Number(fromPick); } if (lot.noLot === true) { @@ -2671,79 +3309,92 @@ useEffect(() => { } return required; }, - [issuePickedQtyBySolId, pickQtyData] + [issuePickedQtyBySolId, pickQtyData], ); // Handle reject lot // Handle pick execution form const handlePickExecutionForm = useCallback((lot: any) => { console.log("=== Pick Execution Form ==="); console.log("Lot data:", lot); - + if (!lot) { console.warn("No lot data provided for pick execution form"); return; } - + console.log("Opening pick execution form for lot:", lot.lotNo); - + setSelectedLotForExecutionForm(lot); setPickExecutionFormOpen(true); - + console.log("Pick execution form opened for lot ID:", lot.lotId); }, []); - const handlePickExecutionFormSubmit = useCallback(async (data: any) => { - try { - console.log("Pick execution form submitted:", data); - const issueData = { - ...data, - type: "Do", // Delivery Order Record 类型 - pickerName: session?.user?.name || '', - }; - - const result = await recordPickExecutionIssue(issueData); - console.log("Pick execution issue recorded:", result); - - if (result && result.code === "SUCCESS") { - console.log(" Pick execution issue recorded successfully"); - // 关键:issue form 只记录问题,不会更新 SOL.qty - // 但 batch submit 需要知道“实际拣到多少”,否则会按 requiredQty 补拣到满 - const solId = Number(issueData.stockOutLineId || issueData.stockOutLineId === 0 ? issueData.stockOutLineId : data?.stockOutLineId); - if (solId > 0) { - const picked = Number(issueData.actualPickQty || 0); - setIssuePickedQtyBySolId((prev) => { - const next = { ...prev, [solId]: picked }; - const doId = fgPickOrders[0]?.doPickOrderId; - if (doId) saveIssuePickedMap(doId, next); - return next; - }); - setCombinedLotData(prev => prev.map(lot => { - if (Number(lot.stockOutLineId) === solId) { - return { ...lot, actualPickQty: picked, stockOutLineQty: picked }; - } - return lot; - })); + const handlePickExecutionFormSubmit = useCallback( + async (data: any) => { + try { + console.log("Pick execution form submitted:", data); + const issueData = { + ...data, + type: "Do", // Delivery Order Record 类型 + pickerName: session?.user?.name || "", + }; + + const result = await recordPickExecutionIssue(issueData); + console.log("Pick execution issue recorded:", result); + + if (result && result.code === "SUCCESS") { + console.log(" Pick execution issue recorded successfully"); + // 关键:issue form 只记录问题,不会更新 SOL.qty + // 但 batch submit 需要知道“实际拣到多少”,否则会按 requiredQty 补拣到满 + const solId = Number( + issueData.stockOutLineId || issueData.stockOutLineId === 0 + ? issueData.stockOutLineId + : data?.stockOutLineId, + ); + if (solId > 0) { + const picked = Number(issueData.actualPickQty || 0); + setIssuePickedQtyBySolId((prev) => { + const next = { ...prev, [solId]: picked }; + const doId = fgPickOrders[0]?.doPickOrderId; + if (doId) saveIssuePickedMap(doId, next); + return next; + }); + setCombinedLotData((prev) => + prev.map((lot) => { + if (Number(lot.stockOutLineId) === solId) { + return { + ...lot, + actualPickQty: picked, + stockOutLineQty: picked, + }; + } + return lot; + }), + ); + } + } else { + console.error(" Failed to record pick execution issue:", result); } - } else { - console.error(" Failed to record pick execution issue:", result); + + setPickExecutionFormOpen(false); + setSelectedLotForExecutionForm(null); + setQrScanError(false); + setQrScanSuccess(false); + setQrScanInput(""); + // ✅ Keep scanner active after form submission - don't stop scanning + // Only clear processed QR codes for the specific lot, not all + // setIsManualScanning(false); // Removed - keep scanner active + // stopScan(); // Removed - keep scanner active + // resetScan(); // Removed - keep scanner active + // Don't clear all processed codes - only clear for this specific lot if needed + await fetchAllCombinedLotData(); + } catch (error) { + console.error("Error submitting pick execution form:", error); } - - setPickExecutionFormOpen(false); - setSelectedLotForExecutionForm(null); - setQrScanError(false); - setQrScanSuccess(false); - setQrScanInput(''); - // ✅ Keep scanner active after form submission - don't stop scanning - // Only clear processed QR codes for the specific lot, not all - // setIsManualScanning(false); // Removed - keep scanner active - // stopScan(); // Removed - keep scanner active - // resetScan(); // Removed - keep scanner active - // Don't clear all processed codes - only clear for this specific lot if needed - await fetchAllCombinedLotData(); - } catch (error) { - console.error("Error submitting pick execution form:", error); - } - }, [fetchAllCombinedLotData, session, fgPickOrders]); + }, + [fetchAllCombinedLotData, session, fgPickOrders], + ); // Calculate remaining required quantity const calculateRemainingRequiredQty = useCallback((lot: any) => { @@ -2759,48 +3410,63 @@ useEffect(() => { paramName: "pickOrderCode", type: "text", }, - { - label: t("Item Code"), - paramName: "itemCode", - type: "text", - }, - { - label: t("Item Name"), - paramName: "itemName", - type: "text", - }, - { + { + label: t("Item Code"), + paramName: "itemCode", + type: "text", + }, + { + label: t("Item Name"), + paramName: "itemName", + type: "text", + }, + { label: t("Lot No"), paramName: "lotNo", type: "text", }, ]; - const handleSearch = useCallback((query: Record) => { - setSearchQuery({ ...query }); - console.log("Search query:", query); - - if (!originalCombinedData) return; - - const filtered = originalCombinedData.filter((lot: any) => { - const pickOrderCodeMatch = !query.pickOrderCode || - lot.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase()); - - const itemCodeMatch = !query.itemCode || - lot.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); - - const itemNameMatch = !query.itemName || - lot.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase()); - - const lotNoMatch = !query.lotNo || - lot.lotNo?.toLowerCase().includes((query.lotNo || "").toLowerCase()); - - return pickOrderCodeMatch && itemCodeMatch && itemNameMatch && lotNoMatch; - }); - - setCombinedLotData(filtered); - console.log("Filtered lots count:", filtered.length); - }, [originalCombinedData]); + const handleSearch = useCallback( + (query: Record) => { + setSearchQuery({ ...query }); + console.log("Search query:", query); + + if (!originalCombinedData) return; + + const filtered = originalCombinedData.filter((lot: any) => { + const pickOrderCodeMatch = + !query.pickOrderCode || + lot.pickOrderCode + ?.toLowerCase() + .includes((query.pickOrderCode || "").toLowerCase()); + + const itemCodeMatch = + !query.itemCode || + lot.itemCode + ?.toLowerCase() + .includes((query.itemCode || "").toLowerCase()); + + const itemNameMatch = + !query.itemName || + lot.itemName + ?.toLowerCase() + .includes((query.itemName || "").toLowerCase()); + + const lotNoMatch = + !query.lotNo || + lot.lotNo?.toLowerCase().includes((query.lotNo || "").toLowerCase()); + + return ( + pickOrderCodeMatch && itemCodeMatch && itemNameMatch && lotNoMatch + ); + }); + + setCombinedLotData(filtered); + console.log("Filtered lots count:", filtered.length); + }, + [originalCombinedData], + ); const handleReset = useCallback(() => { setSearchQuery({}); @@ -2810,270 +3476,358 @@ useEffect(() => { }, [originalCombinedData]); const handlePageChange = useCallback((event: unknown, newPage: number) => { - setPaginationController(prev => ({ + setPaginationController((prev) => ({ ...prev, pageNum: newPage, })); }, []); - const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { - const newPageSize = parseInt(event.target.value, 10); - setPaginationController({ - pageNum: 0, - pageSize: newPageSize === -1 ? -1 : newPageSize, - }); - }, []); + const handlePageSizeChange = useCallback( + (event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10); + setPaginationController({ + pageNum: 0, + pageSize: newPageSize === -1 ? -1 : newPageSize, + }); + }, + [], + ); // Pagination data with sorting by routerIndex - // Remove the sorting logic and just do pagination -// ✅ Memoize paginatedData to prevent re-renders when modal opens -const paginatedData = useMemo(() => { - if (paginationController.pageSize === -1) { - return combinedLotData; // Show all items - } - const startIndex = paginationController.pageNum * paginationController.pageSize; - const endIndex = startIndex + paginationController.pageSize; - return combinedLotData.slice(startIndex, endIndex); // No sorting needed -}, [combinedLotData, paginationController.pageNum, paginationController.pageSize]); -const allItemsReady = useMemo(() => { - if (combinedLotData.length === 0) return false; - - return combinedLotData.every((lot: any) => { - const status = lot.stockOutLineStatus?.toLowerCase(); - const isRejected = - status === 'rejected' || lot.lotAvailability === 'rejected'; - const isCompleted = - status === 'completed' || status === 'partially_completed' || status === 'partially_complete'; - const isChecked = status === 'checked'; - const isPending = status === 'pending'; - - // ✅ FIXED: 无库存(noLot)行:pending 状态也应该被视为 ready(可以提交) - // ✅ 過期批號(未換批):與 noLot 相同,視為可收尾 - if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { - return isChecked || isCompleted || isRejected || isPending; + // Remove the sorting logic and just do pagination + // ✅ Memoize paginatedData to prevent re-renders when modal opens + const paginatedData = useMemo(() => { + if (paginationController.pageSize === -1) { + return combinedLotData; // Show all items } + const startIndex = + paginationController.pageNum * paginationController.pageSize; + const endIndex = startIndex + paginationController.pageSize; + return combinedLotData.slice(startIndex, endIndex); // No sorting needed + }, [ + combinedLotData, + paginationController.pageNum, + paginationController.pageSize, + ]); + const allItemsReady = useMemo(() => { + if (combinedLotData.length === 0) return false; + + return combinedLotData.every((lot: any) => { + const status = lot.stockOutLineStatus?.toLowerCase(); + const isRejected = + status === "rejected" || lot.lotAvailability === "rejected"; + const isCompleted = + status === "completed" || + status === "partially_completed" || + status === "partially_complete"; + const isChecked = status === "checked"; + const isPending = status === "pending"; - // 正常 lot:必须已扫描/提交或者被拒收 - return isChecked || isCompleted || isRejected; - }); -}, [combinedLotData]); -const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: number, source: 'justComplete' | 'singleSubmit') => { - if (!lot.stockOutLineId) { - console.error("No stock out line found for this lot"); - return; - } - const solId = Number(lot.stockOutLineId); - if (solId > 0 && actionBusyBySolId[solId]) { - console.warn("Action already in progress for stockOutLineId:", solId); - return; - } - - try { - if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); - const targetUnavailable = isInventoryLotLineUnavailable(lot); - const effectiveSubmitQty = targetUnavailable && submitQty > 0 ? 0 : submitQty; - // Just Complete: mark checked only, real posting happens in batch submit - if (effectiveSubmitQty === 0 && source === 'justComplete') { - console.log(`=== SUBMITTING ALL ZEROS CASE ===`); - console.log(`Lot: ${lot.lotNo}`); - console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); - console.log(`Setting status to 'checked' with qty: 0`); - - const updateResult = await updateStockOutLineStatus({ - id: lot.stockOutLineId, - status: 'checked', - qty: 0 - }); - - console.log('Update result:', updateResult); - const r: any = updateResult as any; - const updateOk = - r?.code === 'SUCCESS' || - r?.type === 'completed' || - typeof r?.id === 'number' || - typeof r?.entity?.id === 'number' || - (r?.message && r.message.includes('successfully')); - if (!updateResult || !updateOk) { - console.error('Failed to update stock out line status:', updateResult); - throw new Error('Failed to update stock out line status'); - } - applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), "checked", Number(lot.actualPickQty || 0)); - - void fetchAllCombinedLotData(); - console.log("Just Complete marked as checked successfully (waiting for batch submit)."); - - setTimeout(() => { - checkAndAutoAssignNext(); - }, 1000); - - return; - } - if (effectiveSubmitQty === 0 && source === 'singleSubmit') { - console.log(`=== SUBMITTING ALL ZEROS CASE ===`); - console.log(`Lot: ${lot.lotNo}`); - console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); - console.log(`Setting status to 'checked' with qty: 0`); - - const updateResult = await updateStockOutLineStatus({ - id: lot.stockOutLineId, - status: 'checked', - qty: 0 - }); - - console.log('Update result:', updateResult); - const r: any = updateResult as any; - const updateOk = - r?.code === 'SUCCESS' || - r?.type === 'completed' || - typeof r?.id === 'number' || - typeof r?.entity?.id === 'number' || - (r?.message && r.message.includes('successfully')); - if (!updateResult || !updateOk) { - console.error('Failed to update stock out line status:', updateResult); - throw new Error('Failed to update stock out line status'); - } - applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), "checked", Number(lot.actualPickQty || 0)); - - void fetchAllCombinedLotData(); - console.log("Just Complete marked as checked successfully (waiting for batch submit)."); - - setTimeout(() => { - checkAndAutoAssignNext(); - }, 1000); - - return; - } - // FIXED: Calculate cumulative quantity correctly - const currentActualPickQty = lot.actualPickQty || 0; - const cumulativeQty = currentActualPickQty + effectiveSubmitQty; - - // FIXED: Determine status based on cumulative quantity vs required quantity - let newStatus = 'partially_completed'; - - if (cumulativeQty >= lot.requiredQty) { - newStatus = 'completed'; - } else if (cumulativeQty > 0) { - newStatus = 'partially_completed'; - } else { - newStatus = 'checked'; // QR scanned but no quantity submitted yet - } - - console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`); - console.log(`Lot: ${lot.lotNo}`); - console.log(`Required Qty: ${lot.requiredQty}`); - console.log(`Current Actual Pick Qty: ${currentActualPickQty}`); - console.log(`New Submitted Qty: ${effectiveSubmitQty}`); - console.log(`Cumulative Qty: ${cumulativeQty}`); - console.log(`New Status: ${newStatus}`); - console.log(`=====================================`); - - await updateStockOutLineStatus({ - id: lot.stockOutLineId, - status: newStatus, - // 后端 updateStatus 的 qty 是“增量 delta”,不能传 cumulativeQty(否则会重复累加导致 out/hold 大幅偏移) - qty: effectiveSubmitQty + // ✅ FIXED: 无库存(noLot)行:pending 状态也应该被视为 ready(可以提交) + // ✅ 過期批號(未換批):與 noLot 相同,視為可收尾 + if ( + lot.noLot === true || + isLotAvailabilityExpired(lot) || + isInventoryLotLineUnavailable(lot) + ) { + return isChecked || isCompleted || isRejected || isPending; + } + + // 正常 lot:必须已扫描/提交或者被拒收 + return isChecked || isCompleted || isRejected; }); - applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), newStatus, cumulativeQty); - // 注意:库存过账(hold->out)与 ledger 由后端 updateStatus 内部统一处理; - // 前端不再额外调用 updateInventoryLotLineQuantities(operation='pick'),避免 double posting。 - - // Check if pick order is completed when lot status becomes 'completed' - if (newStatus === 'completed' && lot.pickOrderConsoCode) { - console.log(` Lot ${lot.lotNo} completed, checking if pick order ${lot.pickOrderConsoCode} is complete...`); - + }, [combinedLotData]); + const handleSubmitPickQtyWithQty = useCallback( + async ( + lot: any, + submitQty: number, + source: "justComplete" | "singleSubmit", + ) => { + if (!lot.stockOutLineId) { + console.error("No stock out line found for this lot"); + return; + } + const solId = Number(lot.stockOutLineId); + if (solId > 0 && actionBusyBySolId[solId]) { + console.warn("Action already in progress for stockOutLineId:", solId); + return; + } + try { - const completionResponse = await checkAndCompletePickOrderByConsoCode(lot.pickOrderConsoCode); - console.log(` Pick order completion check result:`, completionResponse); - - if (completionResponse.code === "SUCCESS") { - console.log(` Pick order ${lot.pickOrderConsoCode} completed successfully!`); - } else if (completionResponse.message === "not completed") { - console.log(`⏳ Pick order not completed yet, more lines remaining`); + if (solId > 0) + setActionBusyBySolId((prev) => ({ ...prev, [solId]: true })); + const targetUnavailable = isInventoryLotLineUnavailable(lot); + const effectiveSubmitQty = + targetUnavailable && submitQty > 0 ? 0 : submitQty; + // Just Complete: mark checked only, real posting happens in batch submit + if (effectiveSubmitQty === 0 && source === "justComplete") { + console.log(`=== SUBMITTING ALL ZEROS CASE ===`); + console.log(`Lot: ${lot.lotNo}`); + console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); + console.log(`Setting status to 'checked' with qty: 0`); + + const updateResult = await updateStockOutLineStatus({ + id: lot.stockOutLineId, + status: "checked", + qty: 0, + }); + + console.log("Update result:", updateResult); + const r: any = updateResult as any; + const updateOk = + r?.code === "SUCCESS" || + r?.type === "completed" || + typeof r?.id === "number" || + typeof r?.entity?.id === "number" || + (r?.message && r.message.includes("successfully")); + if (!updateResult || !updateOk) { + console.error( + "Failed to update stock out line status:", + updateResult, + ); + throw new Error("Failed to update stock out line status"); + } + applyLocalStockOutLineUpdate( + Number(lot.stockOutLineId), + "checked", + Number(lot.actualPickQty || 0), + ); + + void fetchAllCombinedLotData(); + console.log( + "Just Complete marked as checked successfully (waiting for batch submit).", + ); + + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + + return; + } + if (effectiveSubmitQty === 0 && source === "singleSubmit") { + console.log(`=== SUBMITTING ALL ZEROS CASE ===`); + console.log(`Lot: ${lot.lotNo}`); + console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); + console.log(`Setting status to 'checked' with qty: 0`); + + const updateResult = await updateStockOutLineStatus({ + id: lot.stockOutLineId, + status: "checked", + qty: 0, + }); + + console.log("Update result:", updateResult); + const r: any = updateResult as any; + const updateOk = + r?.code === "SUCCESS" || + r?.type === "completed" || + typeof r?.id === "number" || + typeof r?.entity?.id === "number" || + (r?.message && r.message.includes("successfully")); + if (!updateResult || !updateOk) { + console.error( + "Failed to update stock out line status:", + updateResult, + ); + throw new Error("Failed to update stock out line status"); + } + applyLocalStockOutLineUpdate( + Number(lot.stockOutLineId), + "checked", + Number(lot.actualPickQty || 0), + ); + + void fetchAllCombinedLotData(); + console.log( + "Just Complete marked as checked successfully (waiting for batch submit).", + ); + + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + + return; + } + // FIXED: Calculate cumulative quantity correctly + const currentActualPickQty = lot.actualPickQty || 0; + const cumulativeQty = currentActualPickQty + effectiveSubmitQty; + + // FIXED: Determine status based on cumulative quantity vs required quantity + let newStatus = "partially_completed"; + + if (cumulativeQty >= lot.requiredQty) { + newStatus = "completed"; + } else if (cumulativeQty > 0) { + newStatus = "partially_completed"; } else { - console.error(` Error checking completion: ${completionResponse.message}`); + newStatus = "checked"; // QR scanned but no quantity submitted yet + } + + console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`); + console.log(`Lot: ${lot.lotNo}`); + console.log(`Required Qty: ${lot.requiredQty}`); + console.log(`Current Actual Pick Qty: ${currentActualPickQty}`); + console.log(`New Submitted Qty: ${effectiveSubmitQty}`); + console.log(`Cumulative Qty: ${cumulativeQty}`); + console.log(`New Status: ${newStatus}`); + console.log(`=====================================`); + + await updateStockOutLineStatus({ + id: lot.stockOutLineId, + status: newStatus, + // 后端 updateStatus 的 qty 是“增量 delta”,不能传 cumulativeQty(否则会重复累加导致 out/hold 大幅偏移) + qty: effectiveSubmitQty, + }); + applyLocalStockOutLineUpdate( + Number(lot.stockOutLineId), + newStatus, + cumulativeQty, + ); + // 注意:库存过账(hold->out)与 ledger 由后端 updateStatus 内部统一处理; + // 前端不再额外调用 updateInventoryLotLineQuantities(operation='pick'),避免 double posting。 + + // Check if pick order is completed when lot status becomes 'completed' + if (newStatus === "completed" && lot.pickOrderConsoCode) { + console.log( + ` Lot ${lot.lotNo} completed, checking if pick order ${lot.pickOrderConsoCode} is complete...`, + ); + + try { + const completionResponse = + await checkAndCompletePickOrderByConsoCode( + lot.pickOrderConsoCode, + ); + console.log( + ` Pick order completion check result:`, + completionResponse, + ); + + if (completionResponse.code === "SUCCESS") { + console.log( + ` Pick order ${lot.pickOrderConsoCode} completed successfully!`, + ); + } else if (completionResponse.message === "not completed") { + console.log( + `⏳ Pick order not completed yet, more lines remaining`, + ); + } else { + console.error( + ` Error checking completion: ${completionResponse.message}`, + ); + } + } catch (error) { + console.error("Error checking pick order completion:", error); + } } + + void fetchAllCombinedLotData(); + console.log("Pick quantity submitted successfully!"); + + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); } catch (error) { - console.error("Error checking pick order completion:", error); + console.error("Error submitting pick quantity:", error); + } finally { + if (solId > 0) + setActionBusyBySolId((prev) => ({ ...prev, [solId]: false })); } - } - - void fetchAllCombinedLotData(); - console.log("Pick quantity submitted successfully!"); - - setTimeout(() => { - checkAndAutoAssignNext(); - }, 1000); - - } catch (error) { - console.error("Error submitting pick quantity:", error); - } finally { - if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); - } -}, [fetchAllCombinedLotData, checkAndAutoAssignNext, actionBusyBySolId, applyLocalStockOutLineUpdate]); + }, + [ + fetchAllCombinedLotData, + checkAndAutoAssignNext, + actionBusyBySolId, + applyLocalStockOutLineUpdate, + ], + ); -const handleSkip = useCallback(async (lot: any) => { - try { - console.log("Just Complete clicked, mark checked with 0 qty for lot:", lot.lotNo); - await handleSubmitPickQtyWithQty(lot, 0, 'justComplete'); - } catch (err) { - console.error("Error in Skip:", err); - } -}, [handleSubmitPickQtyWithQty]); -const hasPendingBatchSubmit = useMemo(() => { - return combinedLotData.some((lot) => { - const status = String(lot.stockOutLineStatus || "").toLowerCase(); - return status === "checked" || status === "pending" || status === "partially_completed" || status === "partially_complete"; - }); -}, [combinedLotData]); -useEffect(() => { - if (!hasPendingBatchSubmit) return; - const handler = (event: BeforeUnloadEvent) => { - event.preventDefault(); - event.returnValue = ""; - }; - window.addEventListener("beforeunload", handler); - return () => window.removeEventListener("beforeunload", handler); -}, [hasPendingBatchSubmit]); -const handleStartScan = useCallback(() => { - const startTime = performance.now(); - console.log(` [START SCAN] Called at: ${new Date().toISOString()}`); - console.log(` [START SCAN] Starting manual QR scan...`); - - setIsManualScanning(true); - const setManualScanningTime = performance.now() - startTime; - console.log(` [START SCAN] setManualScanning time: ${setManualScanningTime.toFixed(2)}ms`); - - setProcessedQrCodes(new Set()); - setLastProcessedQr(''); - setQrScanError(false); - setQrScanSuccess(false); - - const beforeStartScanTime = performance.now(); - startScan(); - const startScanTime = performance.now() - beforeStartScanTime; - console.log(` [START SCAN] startScan() call time: ${startScanTime.toFixed(2)}ms`); - - const totalTime = performance.now() - startTime; - console.log(` [START SCAN] Total start scan time: ${totalTime.toFixed(2)}ms`); - console.log(` [START SCAN] Start scan completed at: ${new Date().toISOString()}`); -}, [startScan]); - const handlePickOrderSwitch = useCallback(async (pickOrderId: number) => { - if (pickOrderSwitching) return; - - setPickOrderSwitching(true); - try { - console.log(" Switching to pick order:", pickOrderId); - setSelectedPickOrderId(pickOrderId); - - // 强制刷新数据,确保显示正确的 pick order 数据 - await fetchAllCombinedLotData(currentUserId, pickOrderId); - } catch (error) { - console.error("Error switching pick order:", error); - } finally { - setPickOrderSwitching(false); + const handleSkip = useCallback( + async (lot: any) => { + try { + console.log( + "Just Complete clicked, mark checked with 0 qty for lot:", + lot.lotNo, + ); + await handleSubmitPickQtyWithQty(lot, 0, "justComplete"); + } catch (err) { + console.error("Error in Skip:", err); } - }, [pickOrderSwitching, currentUserId, fetchAllCombinedLotData]); - + }, + [handleSubmitPickQtyWithQty], + ); + const hasPendingBatchSubmit = useMemo(() => { + return combinedLotData.some((lot) => { + const status = String(lot.stockOutLineStatus || "").toLowerCase(); + return ( + status === "checked" || + status === "pending" || + status === "partially_completed" || + status === "partially_complete" + ); + }); + }, [combinedLotData]); + useEffect(() => { + if (!hasPendingBatchSubmit) return; + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [hasPendingBatchSubmit]); + const handleStartScan = useCallback(() => { + const startTime = performance.now(); + console.log(` [START SCAN] Called at: ${new Date().toISOString()}`); + console.log(` [START SCAN] Starting manual QR scan...`); + + setIsManualScanning(true); + const setManualScanningTime = performance.now() - startTime; + console.log( + ` [START SCAN] setManualScanning time: ${setManualScanningTime.toFixed( + 2, + )}ms`, + ); + + setProcessedQrCodes(new Set()); + setLastProcessedQr(""); + setQrScanError(false); + setQrScanSuccess(false); + + const beforeStartScanTime = performance.now(); + startScan(); + const startScanTime = performance.now() - beforeStartScanTime; + console.log( + ` [START SCAN] startScan() call time: ${startScanTime.toFixed(2)}ms`, + ); + + const totalTime = performance.now() - startTime; + console.log( + ` [START SCAN] Total start scan time: ${totalTime.toFixed(2)}ms`, + ); + console.log( + ` [START SCAN] Start scan completed at: ${new Date().toISOString()}`, + ); + }, [startScan]); + const handlePickOrderSwitch = useCallback( + async (pickOrderId: number) => { + if (pickOrderSwitching) return; + + setPickOrderSwitching(true); + try { + console.log(" Switching to pick order:", pickOrderId); + setSelectedPickOrderId(pickOrderId); + + // 强制刷新数据,确保显示正确的 pick order 数据 + await fetchAllCombinedLotData(currentUserId, pickOrderId); + } catch (error) { + console.error("Error switching pick order:", error); + } finally { + setPickOrderSwitching(false); + } + }, + [pickOrderSwitching, currentUserId, fetchAllCombinedLotData], + ); + const handleStopScan = useCallback(() => { console.log("⏸️ Pausing QR scanner..."); setIsManualScanning(false); @@ -3083,338 +3837,402 @@ const handleStartScan = useCallback(() => { resetScan(); }, [stopScan, resetScan]); // ... existing code around line 1469 ... - const handlelotnull = useCallback(async (lot: any) => { - // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId - const stockOutLineId = lot.stockOutLineId; - - if (!stockOutLineId) { - console.error(" No stockOutLineId found for lot:", lot); - return; - } - const solId = Number(stockOutLineId); - if (solId > 0 && actionBusyBySolId[solId]) { - console.warn("Action already in progress for stockOutLineId:", solId); - return; - } - - try { - if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); - // Step 1: Update stock out line status - await updateStockOutLineStatus({ - id: stockOutLineId, - status: 'completed', - qty: 0 - }); - - // Step 2: Create pick execution issue for no-lot case - // Get pick order ID from fgPickOrders or use 0 if not available - const pickOrderId = lot.pickOrderId || fgPickOrders[0]?.pickOrderId || 0; - const pickOrderCode = lot.pickOrderCode || fgPickOrders[0]?.pickOrderCode || lot.pickOrderConsoCode || ''; - - const issueData: PickExecutionIssueData = { - type: "Do", // Delivery Order type - pickOrderId: pickOrderId, - pickOrderCode: pickOrderCode, - pickOrderCreateDate: dayjs().format('YYYY-MM-DD'), // Use dayjs format - pickExecutionDate: dayjs().format('YYYY-MM-DD'), - pickOrderLineId: lot.pickOrderLineId, - itemId: lot.itemId, - itemCode: lot.itemCode || '', - itemDescription: lot.itemName || '', - lotId: null, // No lot available - lotNo: null, // No lot number - storeLocation: lot.location || '', - requiredQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0, - actualPickQty: 0, // No items picked (no lot available) - missQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0, // All quantity is missing - badItemQty: 0, - issueRemark: `No lot available for this item. Handled via handlelotnull.`, - pickerName: session?.user?.name || '', - - }; - - const result = await recordPickExecutionIssue(issueData); - console.log(" Pick execution issue created for no-lot item:", result); - - if (result && result.code === "SUCCESS") { - console.log(" No-lot item handled and issue recorded successfully"); - } else { - console.error(" Failed to record pick execution issue:", result); + const handlelotnull = useCallback( + async (lot: any) => { + // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId + const stockOutLineId = lot.stockOutLineId; + + if (!stockOutLineId) { + console.error(" No stockOutLineId found for lot:", lot); + return; } - - // Step 3: Refresh data - await fetchAllCombinedLotData(); - } catch (error) { - console.error(" Error in handlelotnull:", error); - } finally { - if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); - } - }, [fetchAllCombinedLotData, session, currentUserId, fgPickOrders, actionBusyBySolId]); + const solId = Number(stockOutLineId); + if (solId > 0 && actionBusyBySolId[solId]) { + console.warn("Action already in progress for stockOutLineId:", solId); + return; + } + + try { + if (solId > 0) + setActionBusyBySolId((prev) => ({ ...prev, [solId]: true })); + // Step 1: Update stock out line status + await updateStockOutLineStatus({ + id: stockOutLineId, + status: "completed", + qty: 0, + }); + + // Step 2: Create pick execution issue for no-lot case + // Get pick order ID from fgPickOrders or use 0 if not available + const pickOrderId = + lot.pickOrderId || fgPickOrders[0]?.pickOrderId || 0; + const pickOrderCode = + lot.pickOrderCode || + fgPickOrders[0]?.pickOrderCode || + lot.pickOrderConsoCode || + ""; + + const issueData: PickExecutionIssueData = { + type: "Do", // Delivery Order type + pickOrderId: pickOrderId, + pickOrderCode: pickOrderCode, + pickOrderCreateDate: dayjs().format("YYYY-MM-DD"), // Use dayjs format + pickExecutionDate: dayjs().format("YYYY-MM-DD"), + pickOrderLineId: lot.pickOrderLineId, + itemId: lot.itemId, + itemCode: lot.itemCode || "", + itemDescription: lot.itemName || "", + lotId: null, // No lot available + lotNo: null, // No lot number + storeLocation: lot.location || "", + requiredQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0, + actualPickQty: 0, // No items picked (no lot available) + missQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0, // All quantity is missing + badItemQty: 0, + issueRemark: `No lot available for this item. Handled via handlelotnull.`, + pickerName: session?.user?.name || "", + }; + + const result = await recordPickExecutionIssue(issueData); + console.log(" Pick execution issue created for no-lot item:", result); + + if (result && result.code === "SUCCESS") { + console.log(" No-lot item handled and issue recorded successfully"); + } else { + console.error(" Failed to record pick execution issue:", result); + } + + // Step 3: Refresh data + await fetchAllCombinedLotData(); + } catch (error) { + console.error(" Error in handlelotnull:", error); + } finally { + if (solId > 0) + setActionBusyBySolId((prev) => ({ ...prev, [solId]: false })); + } + }, + [ + fetchAllCombinedLotData, + session, + currentUserId, + fgPickOrders, + actionBusyBySolId, + ], + ); const handleBatchScan = useCallback(async () => { const startTime = performance.now(); console.log(` [BATCH SCAN START]`); console.log(` Start time: ${new Date().toISOString()}`); - + // 获取所有活跃批次(未扫描的) - const activeLots = combinedLotData.filter(lot => { + const activeLots = combinedLotData.filter((lot) => { return ( - lot.lotAvailability !== 'rejected' && - lot.stockOutLineStatus !== 'rejected' && - lot.stockOutLineStatus !== 'completed' && - lot.stockOutLineStatus !== 'checked' && // ✅ 只处理未扫描的 - lot.processingStatus !== 'completed' && + lot.lotAvailability !== "rejected" && + lot.stockOutLineStatus !== "rejected" && + lot.stockOutLineStatus !== "completed" && + lot.stockOutLineStatus !== "checked" && // ✅ 只处理未扫描的 + lot.processingStatus !== "completed" && lot.noLot !== true && lot.lotNo // ✅ 必须有 lotNo ); }); - + if (activeLots.length === 0) { console.log("No active lots to scan"); return; } - - console.log(`📦 Batch scanning ${activeLots.length} active lots using batch API...`); - + + console.log( + `📦 Batch scanning ${activeLots.length} active lots using batch API...`, + ); + try { // ✅ 转换为批量扫描 API 所需的格式 const lines: BatchScanLineRequest[] = activeLots.map((lot) => ({ pickOrderLineId: Number(lot.pickOrderLineId), inventoryLotLineId: lot.lotId ? Number(lot.lotId) : null, - pickOrderConsoCode: String(lot.pickOrderConsoCode || ''), + pickOrderConsoCode: String(lot.pickOrderConsoCode || ""), lotNo: lot.lotNo || null, itemId: Number(lot.itemId), - itemCode: String(lot.itemCode || ''), + itemCode: String(lot.itemCode || ""), stockOutLineId: lot.stockOutLineId ? Number(lot.stockOutLineId) : null, // ✅ 新增 })); - + const request: BatchScanRequest = { userId: currentUserId || 0, - lines: lines + lines: lines, + }; + + console.log(`📤 Sending batch scan request with ${lines.length} lines`); + console.log(`📋 Request data:`, JSON.stringify(request, null, 2)); + + const scanStartTime = performance.now(); + + // ✅ 使用新的批量扫描 API(一次性处理所有请求) + const result = await batchScan(request); + + const scanTime = performance.now() - scanStartTime; + console.log( + ` Batch scan API call completed in ${scanTime.toFixed(2)}ms (${( + scanTime / 1000 + ).toFixed(3)}s)`, + ); + console.log(`📥 Batch scan result:`, result); + + // ✅ 刷新数据以获取最新的状态 + const refreshStartTime = performance.now(); + await fetchAllCombinedLotData(); + const refreshTime = performance.now() - refreshStartTime; + console.log( + ` Data refresh time: ${refreshTime.toFixed(2)}ms (${( + refreshTime / 1000 + ).toFixed(3)}s)`, + ); + + const totalTime = performance.now() - startTime; + console.log(` [BATCH SCAN END]`); + console.log( + ` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed( + 3, + )}s)`, + ); + console.log(` End time: ${new Date().toISOString()}`); + + if (result && result.code === "SUCCESS") { + setQrScanSuccess(true); + setQrScanError(false); + } else { + console.error("❌ Batch scan failed:", result); + setQrScanError(true); + setQrScanSuccess(false); + } + } catch (error) { + console.error("❌ Error in batch scan:", error); + setQrScanError(true); + setQrScanSuccess(false); + } + }, [combinedLotData, fetchAllCombinedLotData, currentUserId]); + const handleSubmitAllScanned = useCallback(async () => { + const startTime = performance.now(); + console.log(` [BATCH SUBMIT START]`); + console.log(` Start time: ${new Date().toISOString()}`); + + const scannedLots = combinedLotData.filter((lot) => { + const status = lot.stockOutLineStatus; + const statusLower = String(status || "").toLowerCase(); + if (statusLower === "completed" || statusLower === "complete") { + return false; + } + // ✅ noLot / 過期批號:與 noLot 相同,允許 pending(未換批也可批量收尾) + if ( + lot.noLot === true || + isLotAvailabilityExpired(lot) || + isInventoryLotLineUnavailable(lot) + ) { + return ( + status === "checked" || + status === "pending" || + status === "partially_completed" || + status === "PARTIALLY_COMPLETE" + ); + } + return ( + status === "checked" || + status === "partially_completed" || + status === "PARTIALLY_COMPLETE" + ); + }); + + if (scannedLots.length === 0) { + console.log("No scanned items to submit"); + return; + } + + setIsSubmittingAll(true); + console.log( + `📦 Submitting ${scannedLots.length} scanned items using batchSubmitList...`, + ); + + try { + // 转换为 batchSubmitList 所需的格式(与后端 QrPickBatchSubmitRequest 匹配) + const lines: batchSubmitListLineRequest[] = scannedLots.map((lot) => { + // 1. 需求数量 + const requiredQty = Number( + lot.requiredQty || lot.pickOrderLineRequiredQty || 0, + ); + + // 2. 当前已经拣到的数量 + // issue form 不会写回 SOL.qty,所以如果这条 SOL 有 issue,就用 issue form 的 actualPickQty 作为“已拣到数量” + const solId = Number(lot.stockOutLineId) || 0; + const issuePicked = + solId > 0 ? issuePickedQtyBySolId[solId] : undefined; + const currentActualPickQty = Number( + issuePicked ?? lot.actualPickQty ?? 0, + ); + + // 🔹 判断是否走“只改状态模式” + // 这里先给一个简单条件示例:如果你不想再补拣,只想把当前数量标记完成, + // 就让这个条件为 true(后面你可以根据业务加 UI 开关或别的 flag)。 + const onlyComplete = + lot.stockOutLineStatus === "partially_completed" || + issuePicked !== undefined; + + const expired = isLotAvailabilityExpired(lot); + const unavailable = isInventoryLotLineUnavailable(lot); + + let targetActual: number; + let newStatus: string; + + // ✅ 過期且未在 Issue 填實際量:與 noLot 一樣按 0 完成 + if (unavailable) { + targetActual = currentActualPickQty; + newStatus = "completed"; + } else if (expired && issuePicked === undefined) { + targetActual = 0; + newStatus = "completed"; + } else if (onlyComplete) { + targetActual = currentActualPickQty; + newStatus = "completed"; + } else { + const remainingQty = Math.max(0, requiredQty - currentActualPickQty); + const cumulativeQty = currentActualPickQty + remainingQty; + + targetActual = cumulativeQty; + + newStatus = "partially_completed"; + if (requiredQty > 0 && cumulativeQty >= requiredQty) { + newStatus = "completed"; + } + } + + return { + stockOutLineId: Number(lot.stockOutLineId) || 0, + pickOrderLineId: Number(lot.pickOrderLineId), + inventoryLotLineId: lot.lotId ? Number(lot.lotId) : null, + requiredQty, + // 后端用 targetActual - 当前 qty 算增量,onlyComplete 时就是 0 + actualPickQty: targetActual, + stockOutLineStatus: newStatus, + pickOrderConsoCode: String(lot.pickOrderConsoCode || ""), + noLot: Boolean(lot.noLot === true), + }; + }); + + const request: batchSubmitListRequest = { + userId: currentUserId || 0, + lines: lines, }; - - console.log(`📤 Sending batch scan request with ${lines.length} lines`); + + console.log(`📤 Sending batch submit request with ${lines.length} lines`); console.log(`📋 Request data:`, JSON.stringify(request, null, 2)); - - const scanStartTime = performance.now(); - - // ✅ 使用新的批量扫描 API(一次性处理所有请求) - const result = await batchScan(request); - - const scanTime = performance.now() - scanStartTime; - console.log(` Batch scan API call completed in ${scanTime.toFixed(2)}ms (${(scanTime / 1000).toFixed(3)}s)`); - console.log(`📥 Batch scan result:`, result); - - // ✅ 刷新数据以获取最新的状态 + const submitStartTime = performance.now(); + + // 使用 batchSubmitList API + const result = await batchSubmitList(request); + + const submitTime = performance.now() - submitStartTime; + console.log( + ` Batch submit API call completed in ${submitTime.toFixed(2)}ms (${( + submitTime / 1000 + ).toFixed(3)}s)`, + ); + console.log(`📥 Batch submit result:`, result); + + // Refresh data once after batch submission const refreshStartTime = performance.now(); await fetchAllCombinedLotData(); const refreshTime = performance.now() - refreshStartTime; - console.log(` Data refresh time: ${refreshTime.toFixed(2)}ms (${(refreshTime / 1000).toFixed(3)}s)`); - + console.log( + ` Data refresh time: ${refreshTime.toFixed(2)}ms (${( + refreshTime / 1000 + ).toFixed(3)}s)`, + ); + const totalTime = performance.now() - startTime; - console.log(` [BATCH SCAN END]`); - console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); + console.log(` [BATCH SUBMIT END]`); + console.log( + ` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed( + 3, + )}s)`, + ); console.log(` End time: ${new Date().toISOString()}`); - + if (result && result.code === "SUCCESS") { setQrScanSuccess(true); - setQrScanError(false); + setTimeout(() => { + setQrScanSuccess(false); + checkAndAutoAssignNext(); + if (onSwitchToRecordTab) { + onSwitchToRecordTab(); + } + if (onRefreshReleasedOrderCount) { + onRefreshReleasedOrderCount(); + } + }, 2000); } else { - console.error("❌ Batch scan failed:", result); + console.error("Batch submit failed:", result); setQrScanError(true); - setQrScanSuccess(false); } - } catch (error) { - console.error("❌ Error in batch scan:", error); - setQrScanError(true); - setQrScanSuccess(false); - } - }, [combinedLotData, fetchAllCombinedLotData, currentUserId]); -const handleSubmitAllScanned = useCallback(async () => { - const startTime = performance.now(); - console.log(` [BATCH SUBMIT START]`); - console.log(` Start time: ${new Date().toISOString()}`); - - const scannedLots = combinedLotData.filter(lot => { - const status = lot.stockOutLineStatus; - const statusLower = String(status || "").toLowerCase(); - if (statusLower === "completed" || statusLower === "complete") { - return false; - } - // ✅ noLot / 過期批號:與 noLot 相同,允許 pending(未換批也可批量收尾) - if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { - return ( - status === "checked" || - status === "pending" || - status === "partially_completed" || - status === "PARTIALLY_COMPLETE" - ); - } - return ( - status === "checked" || - status === "partially_completed" || - status === "PARTIALLY_COMPLETE" - ); - }); - - if (scannedLots.length === 0) { - console.log("No scanned items to submit"); - return; - } - - setIsSubmittingAll(true); - console.log(`📦 Submitting ${scannedLots.length} scanned items using batchSubmitList...`); - - try { - // 转换为 batchSubmitList 所需的格式(与后端 QrPickBatchSubmitRequest 匹配) - const lines: batchSubmitListLineRequest[] = scannedLots.map((lot) => { - // 1. 需求数量 - const requiredQty = - Number(lot.requiredQty || lot.pickOrderLineRequiredQty || 0); - - // 2. 当前已经拣到的数量 - // issue form 不会写回 SOL.qty,所以如果这条 SOL 有 issue,就用 issue form 的 actualPickQty 作为“已拣到数量” - const solId = Number(lot.stockOutLineId) || 0; - const issuePicked = solId > 0 ? issuePickedQtyBySolId[solId] : undefined; - const currentActualPickQty = Number(issuePicked ?? lot.actualPickQty ?? 0); - - // 🔹 判断是否走“只改状态模式” - // 这里先给一个简单条件示例:如果你不想再补拣,只想把当前数量标记完成, - // 就让这个条件为 true(后面你可以根据业务加 UI 开关或别的 flag)。 - const onlyComplete = - lot.stockOutLineStatus === "partially_completed" || issuePicked !== undefined; - - const expired = isLotAvailabilityExpired(lot); - const unavailable = isInventoryLotLineUnavailable(lot); - - let targetActual: number; - let newStatus: string; - - // ✅ 過期且未在 Issue 填實際量:與 noLot 一樣按 0 完成 - if (unavailable) { - targetActual = currentActualPickQty; - newStatus = "completed"; - } else if (expired && issuePicked === undefined) { - targetActual = 0; - newStatus = "completed"; - } else if (onlyComplete) { - targetActual = currentActualPickQty; - newStatus = "completed"; - } else { - const remainingQty = Math.max(0, requiredQty - currentActualPickQty); - const cumulativeQty = currentActualPickQty + remainingQty; - - targetActual = cumulativeQty; - - newStatus = "partially_completed"; - if (requiredQty > 0 && cumulativeQty >= requiredQty) { - newStatus = "completed"; - } - } - - return { - stockOutLineId: Number(lot.stockOutLineId) || 0, - pickOrderLineId: Number(lot.pickOrderLineId), - inventoryLotLineId: lot.lotId ? Number(lot.lotId) : null, - requiredQty, - // 后端用 targetActual - 当前 qty 算增量,onlyComplete 时就是 0 - actualPickQty: targetActual, - stockOutLineStatus: newStatus, - pickOrderConsoCode: String(lot.pickOrderConsoCode || ""), - noLot: Boolean(lot.noLot === true), - }; - }); - - const request: batchSubmitListRequest = { - userId: currentUserId || 0, - lines: lines - }; - - console.log(`📤 Sending batch submit request with ${lines.length} lines`); - console.log(`📋 Request data:`, JSON.stringify(request, null, 2)); - const submitStartTime = performance.now(); - - // 使用 batchSubmitList API - const result = await batchSubmitList(request); - - const submitTime = performance.now() - submitStartTime; - console.log(` Batch submit API call completed in ${submitTime.toFixed(2)}ms (${(submitTime / 1000).toFixed(3)}s)`); - console.log(`📥 Batch submit result:`, result); - - // Refresh data once after batch submission - const refreshStartTime = performance.now(); - await fetchAllCombinedLotData(); - const refreshTime = performance.now() - refreshStartTime; - console.log(` Data refresh time: ${refreshTime.toFixed(2)}ms (${(refreshTime / 1000).toFixed(3)}s)`); - - const totalTime = performance.now() - startTime; - console.log(` [BATCH SUBMIT END]`); - console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); - console.log(` End time: ${new Date().toISOString()}`); - - if (result && result.code === "SUCCESS") { - setQrScanSuccess(true); - setTimeout(() => { - setQrScanSuccess(false); - checkAndAutoAssignNext(); - if (onSwitchToRecordTab) { - onSwitchToRecordTab(); - } - if (onRefreshReleasedOrderCount) { - onRefreshReleasedOrderCount(); - } - }, 2000); - } else { - console.error("Batch submit failed:", result); + console.error("Error submitting all scanned items:", error); setQrScanError(true); + } finally { + setIsSubmittingAll(false); } - - } catch (error) { - console.error("Error submitting all scanned items:", error); - setQrScanError(true); - } finally { - setIsSubmittingAll(false); - } -}, [combinedLotData, fetchAllCombinedLotData, checkAndAutoAssignNext, currentUserId, onSwitchToRecordTab, onRefreshReleasedOrderCount, issuePickedQtyBySolId]); + }, [ + combinedLotData, + fetchAllCombinedLotData, + checkAndAutoAssignNext, + currentUserId, + onSwitchToRecordTab, + onRefreshReleasedOrderCount, + issuePickedQtyBySolId, + ]); // Calculate scanned items count - // Calculate scanned items count (should match handleSubmitAllScanned filter logic) - const scannedItemsCount = useMemo(() => { - const filtered = combinedLotData.filter(lot => { - const status = lot.stockOutLineStatus; + // Calculate scanned items count (should match handleSubmitAllScanned filter logic) + const scannedItemsCount = useMemo(() => { + const filtered = combinedLotData.filter((lot) => { + const status = lot.stockOutLineStatus; const statusLower = String(status || "").toLowerCase(); if (statusLower === "completed" || statusLower === "complete") { return false; } - // ✅ 与 handleSubmitAllScanned 完全保持一致 - if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { - return ( - status === "checked" || - status === "pending" || - status === "partially_completed" || - status === "PARTIALLY_COMPLETE" - ); - } + // ✅ 与 handleSubmitAllScanned 完全保持一致 + if ( + lot.noLot === true || + isLotAvailabilityExpired(lot) || + isInventoryLotLineUnavailable(lot) + ) { return ( status === "checked" || + status === "pending" || status === "partially_completed" || status === "PARTIALLY_COMPLETE" ); - }); - - // 添加调试日志 - const noLotCount = filtered.filter(l => l.noLot === true).length; - const normalCount = filtered.filter(l => l.noLot !== true).length; - console.log(`📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`); - console.log(`📊 All items breakdown:`, { - total: combinedLotData.length, - noLot: combinedLotData.filter(l => l.noLot === true).length, - normal: combinedLotData.filter(l => l.noLot !== true).length - }); - - return filtered.length; - }, [combinedLotData]); -/* + } + return ( + status === "checked" || + status === "partially_completed" || + status === "PARTIALLY_COMPLETE" + ); + }); + + // 添加调试日志 + const noLotCount = filtered.filter((l) => l.noLot === true).length; + const normalCount = filtered.filter((l) => l.noLot !== true).length; + console.log( + `📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`, + ); + console.log(`📊 All items breakdown:`, { + total: combinedLotData.length, + noLot: combinedLotData.filter((l) => l.noLot === true).length, + normal: combinedLotData.filter((l) => l.noLot !== true).length, + }); + + return filtered.length; + }, [combinedLotData]); + /* // ADD THIS: Auto-stop scan when no data available useEffect(() => { if (isManualScanning && combinedLotData.length === 0) { @@ -3429,87 +4247,101 @@ const handleSubmitAllScanned = useCallback(async () => { return () => { // Cleanup when component unmounts (e.g., when switching tabs) if (isManualScanning) { - console.log("🧹 Pick execution component unmounting, stopping QR scanner..."); + console.log( + "🧹 Pick execution component unmounting, stopping QR scanner...", + ); stopScan(); resetScan(); } }; }, [isManualScanning, stopScan, resetScan]); - const getStatusMessage = useCallback((lot: any) => { - switch (lot.stockOutLineStatus?.toLowerCase()) { - case 'pending': - return t("Please finish QR code scan and pick order."); - case 'checked': - return t("Please submit the pick order."); - case 'partially_completed': - return t("Partial quantity submitted. Please submit more or complete the order."); - case 'completed': - return t("Pick order completed successfully!"); - case 'rejected': - return t("Lot has been rejected and marked as unavailable."); - case 'unavailable': - return t("This order is insufficient, please pick another lot."); - default: - return t("Please finish QR code scan and pick order."); - } - }, [t]); + const getStatusMessage = useCallback( + (lot: any) => { + switch (lot.stockOutLineStatus?.toLowerCase()) { + case "pending": + return t("Please finish QR code scan and pick order."); + case "checked": + return t("Please submit the pick order."); + case "partially_completed": + return t( + "Partial quantity submitted. Please submit more or complete the order.", + ); + case "completed": + return t("Pick order completed successfully!"); + case "rejected": + return t("Lot has been rejected and marked as unavailable."); + case "unavailable": + return t("This order is insufficient, please pick another lot."); + default: + return t("Please finish QR code scan and pick order."); + } + }, + [t], + ); return ( ( - lot.lotAvailability !== 'rejected' && - lot.stockOutLineStatus !== 'rejected' && - lot.stockOutLineStatus !== 'completed' - )} + filterActive={(lot) => + lot.lotAvailability !== "rejected" && + lot.stockOutLineStatus !== "rejected" && + lot.stockOutLineStatus !== "completed" + } > - - - - + + + + {/* DO Header */} - - - - {/* 保留:Combined Lot Table - 包含所有 QR 扫描功能 */} - - - - {t("All Pick Order Lots")} - - - - {/* Scanner status indicator (always visible) */} - {/* + {/* 保留:Combined Lot Table - 包含所有 QR 扫描功能 */} + + + + {t("All Pick Order Lots")} + + + + {/* Scanner status indicator (always visible) */} + {/* { */} - - {/* Pause/Resume button instead of Start/Stop */} - {isManualScanning ? ( - - ) : ( - - )} - - {/* 保留:Submit All Scanned Button */} - - - - - -{fgPickOrders.length > 0 && ( - - - {/* 基本信息 */} - - - {t("Shop Name")}: {fgPickOrders[0].shopName || '-'} - - - {t("Store ID")}: {fgPickOrders[0].storeId || '-'} - - - {t("Ticket No.")}: {fgPickOrders[0].ticketNo || '-'} - - - {t("Departure Time")}: {fgPickOrders[0].DepartureTime || '-'} - - - - {/* 改进:三个字段显示在一起,使用表格式布局 */} - {/* 改进:三个字段合并显示 */} -{/* 改进:表格式显示每个 pick order */} - - - {t("Pick Orders Details")}: - - - {(() => { - const pickOrderCodes = fgPickOrders[0].pickOrderCodes as string[] | string | undefined; - const deliveryNos = fgPickOrders[0].deliveryNos as string[] | string | undefined; - const lineCounts = fgPickOrders[0].lineCountsPerPickOrder; - - const pickOrderCodesArray = Array.isArray(pickOrderCodes) - ? pickOrderCodes - : (typeof pickOrderCodes === 'string' ? pickOrderCodes.split(', ') : []); - - const deliveryNosArray = Array.isArray(deliveryNos) - ? deliveryNos - : (typeof deliveryNos === 'string' ? deliveryNos.split(', ') : []); - - const lineCountsArray = Array.isArray(lineCounts) ? lineCounts : []; - - const maxLength = Math.max( - pickOrderCodesArray.length, - deliveryNosArray.length, - lineCountsArray.length - ); - - if (maxLength === 0) { - return -; - } - - // 使用与外部基本信息相同的样式 - return Array.from({ length: maxLength }, (_, idx) => ( - - - {t("Delivery Order")}: {deliveryNosArray[idx] || '-'} - - - {t("Pick Order")}: {pickOrderCodesArray[idx] || '-'} - - - {t("Finsihed good items")}: {lineCountsArray[idx] || '-'}{t("kinds")} - - - )); - })()} - - - - )} - + + {/* Pause/Resume button instead of Start/Stop */} + {isManualScanning ? ( + + ) : ( + + )} + + {/* 保留:Submit All Scanned Button */} + + + + + {fgPickOrders.length > 0 && ( + + + {/* 基本信息 */} + + + {t("Shop Name")}:{" "} + {fgPickOrders[0].shopName || "-"} + + + {t("Store ID")}:{" "} + {fgPickOrders[0].storeId || "-"} + + + {t("Ticket No.")}:{" "} + {fgPickOrders[0].ticketNo || "-"} + + + {t("Departure Time")}:{" "} + {fgPickOrders[0].DepartureTime || "-"} + + + + {/* 改进:三个字段显示在一起,使用表格式布局 */} + {/* 改进:三个字段合并显示 */} + {/* 改进:表格式显示每个 pick order */} + + + {t("Pick Orders Details")}: + + + {(() => { + const pickOrderCodes = fgPickOrders[0].pickOrderCodes as + | string[] + | string + | undefined; + const deliveryNos = fgPickOrders[0].deliveryNos as + | string[] + | string + | undefined; + const lineCounts = fgPickOrders[0].lineCountsPerPickOrder; + + const pickOrderCodesArray = Array.isArray(pickOrderCodes) + ? pickOrderCodes + : typeof pickOrderCodes === "string" + ? pickOrderCodes.split(", ") + : []; + + const deliveryNosArray = Array.isArray(deliveryNos) + ? deliveryNos + : typeof deliveryNos === "string" + ? deliveryNos.split(", ") + : []; + + const lineCountsArray = Array.isArray(lineCounts) + ? lineCounts + : []; + + const maxLength = Math.max( + pickOrderCodesArray.length, + deliveryNosArray.length, + lineCountsArray.length, + ); + + if (maxLength === 0) { + return ( + + - + + ); + } + + // 使用与外部基本信息相同的样式 + return Array.from({ length: maxLength }, (_, idx) => ( + + + {t("Delivery Order")}:{" "} + {deliveryNosArray[idx] || "-"} + + + {t("Pick Order")}:{" "} + {pickOrderCodesArray[idx] || "-"} + + + {t("Finsihed good items")}:{" "} + {lineCountsArray[idx] || "-"} + {t("kinds")} + + + )); + })()} + + + + )} @@ -3666,10 +4526,14 @@ const handleSubmitAllScanned = useCallback(async () => { {t("Item Code")}{t("Item Name")}{t("Lot#")} - {t("Lot Required Pick Qty")} + + {t("Lot Required Pick Qty")} + {t("Scan Result")}{t("Qty will submit")} - {t("Submit Required Pick Qty")} + + {t("Submit Required Pick Qty")} + @@ -3682,238 +4546,326 @@ const handleSubmitAllScanned = useCallback(async () => { ) : ( -// 在第 1797-1938 行之间,将整个 map 函数修改为: -paginatedData.map((lot, index) => { - // 检查是否是 issue lot - const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo; - const rowSolId = Number(lot.stockOutLineId); - const lotSwitchErr = - Number.isFinite(rowSolId) ? lotSwitchFailByStockOutLineId[rowSolId] : undefined; - - return ( - - - - {paginationController.pageNum * paginationController.pageSize + index + 1} - - - - - {lot.routerRoute || '-'} - - - {lot.itemCode} - {lot.itemName + '(' + lot.stockUnit + ')'} - - - - {lot.lotNo ? ( - isInventoryLotLineUnavailable(lot) ? ( - <> - {lot.lotNo}{' '} - {t('is unavable. Please check around have available QR code or not.')} - - ) : lot.lotAvailability === 'expired' ? ( - <> - {lot.lotNo}{' '} - {t('is expired. Please check around have available QR code or not.')} - - ) : ( - lot.lotNo - ) - ) : ( - t( - 'Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.' - ) - )} - - {lotSwitchErr ? ( - - {lotSwitchErr} - - ) : null} - - - - {(() => { - const requiredQty = lot.requiredQty || 0; - return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')'; - })()} - - - - {(() => { - const status = lot.stockOutLineStatus?.toLowerCase(); - const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; - const isNoLot = !lot.lotNo; - - // rejected lot:显示红色勾选(已扫描但被拒绝) - if (isRejected && !isNoLot) { - return ( - - - - ); - } - - // 過期批號:與 noLot 同類——視為已掃到/可處理(含 pending),顯示警示色勾選 - if (isLotAvailabilityExpired(lot) && status !== "rejected") { - return ( - - - - ); - } - - // 正常 lot:已扫描(checked/partially_completed/completed) - if (!isNoLot && status !== 'pending' && status !== 'rejected') { - return ( - - - - ); - } - - // noLot 且已完成/部分完成:显示红色勾选 - if (isNoLot && (status === 'partially_completed' || status === 'completed')) { - return ( - - - - ); - } - - return null; - })()} - - - {isInventoryLotLineUnavailable(lot) ? 0 : resolveSingleSubmitQty(lot)} - - - - {(() => { - const status = lot.stockOutLineStatus?.toLowerCase(); - const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; - const isNoLot = !lot.lotNo; - const isUnavailableLot = isInventoryLotLineUnavailable(lot); - - // ✅ rejected lot:显示提示文本(换行显示) - if (isRejected && !isNoLot) { - return ( - - {t("This lot is rejected, please scan another lot.")} - - ); - } - - // noLot 情况:只显示 Issue 按钮 - if (isNoLot) { - return ( - - ); - } - - // 正常 lot:显示 Submit 和 Issue 按钮 - return ( - - + ); + } + + // 正常 lot:显示 Submit 和 Issue 按钮 + return ( + + - - + + - - - ); - })()} - - - - ); -}) + disabled={true} + sx={{ + fontSize: "0.7rem", + py: 0.5, + minHeight: "28px", + minWidth: "60px", + borderColor: "warning.main", + color: "warning.main", + }} + title="Report missing or bad items" + > + {t("Edit")} + + + + ); + })()} + + + + ); + }) )}
- + { rowsPerPage={paginationController.pageSize} onPageChange={handlePageChange} onRowsPerPageChange={handlePageSizeChange} - rowsPerPageOptions={[10, 25, 50,-1]} + rowsPerPageOptions={[10, 25, 50, -1]} labelRowsPerPage={t("Rows per page")} - labelDisplayedRows={({ from, to, count }) => + labelDisplayedRows={({ from, to, count }) => `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` } />
- + {/* QR Code Scanner works in background - no modal needed */} { /> {/* 保留:Lot Confirmation Modal */} {lotConfirmationOpen && expectedLotData && scannedLotData && ( - { - console.log(` [LOT CONFIRM MODAL] Closing modal, reset scanner and release raw-QR dedupe`); - // 1) Reset scanner buffer first to avoid immediate reopen from buffered same QR. - if (resetScanRef.current) { - resetScanRef.current(); - } - // 2) Close modal state. - clearLotConfirmationState(false); - // 3) Release raw-QR dedupe after a short delay so user can re-scan B/C again. - setTimeout(() => { - lastProcessedQrRef.current = ''; - processedQrCodesRef.current.clear(); - }, 250); - }} - onConfirm={handleLotConfirmation} - expectedLot={expectedLotData} - scannedLot={scannedLotData} - isLoading={isConfirmingLot} - errorMessage={lotConfirmationError} - /> -)} - + { + console.log( + ` [LOT CONFIRM MODAL] Closing modal, reset scanner and release raw-QR dedupe`, + ); + // 1) Reset scanner buffer first to avoid immediate reopen from buffered same QR. + if (resetScanRef.current) { + resetScanRef.current(); + } + // 2) Close modal state. + clearLotConfirmationState(false); + // 3) Release raw-QR dedupe after a short delay so user can re-scan B/C again. + setTimeout(() => { + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.clear(); + }, 250); + }} + onConfirm={handleLotConfirmation} + expectedLot={expectedLotData} + scannedLot={scannedLotData} + isLoading={isConfirmingLot} + errorMessage={lotConfirmationError} + /> + )} + {/* 保留:Good Pick Execution Form Modal */} {pickExecutionFormOpen && selectedLotForExecutionForm && ( { pickOrderCode: selectedLotForExecutionForm.pickOrderCode, availableQty: selectedLotForExecutionForm.availableQty || 0, requiredQty: selectedLotForExecutionForm.requiredQty || 0, - // uomCode: selectedLotForExecutionForm.uomCode || '', - uomDesc: selectedLotForExecutionForm.uomDesc || '', + // uomCode: selectedLotForExecutionForm.uomCode || '', + uomDesc: selectedLotForExecutionForm.uomDesc || "", pickedQty: selectedLotForExecutionForm.actualPickQty || 0, - uomShortDesc: selectedLotForExecutionForm.uomShortDesc || '', + uomShortDesc: selectedLotForExecutionForm.uomShortDesc || "", suggestedList: [], noLotLines: [], }} @@ -4072,11 +5047,19 @@ paginatedData.map((lot, index) => { pickOrderCreateDate={new Date()} /> )} - - - - + { + setLotLabelPrintModalOpen(false); + setLotLabelPrintReminderText(null); + }} + initialPayload={lotLabelPrintInitialPayload} + defaultPrinterName="Label機 2F 大堂" + hideScanSection + reminderText={lotLabelPrintReminderText ?? undefined} + /> + ); }; diff --git a/src/components/InventorySearch/LotLabelPrintModal.tsx b/src/components/InventorySearch/LotLabelPrintModal.tsx new file mode 100644 index 0000000..681e17d --- /dev/null +++ b/src/components/InventorySearch/LotLabelPrintModal.tsx @@ -0,0 +1,595 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, + Snackbar, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; + +type ScanPayload = { + itemId: number; + stockInLineId: number; +}; + +type Printer = { + id: number; + name?: string; + description?: string; + ip?: string; + port?: number; + type?: string; + brand?: string; +}; + +type QrCodeAnalysisResponse = { + itemId: number; + itemCode: string; + itemName: string; + scanned: { + stockInLineId: number; + lotNo: string; + inventoryLotLineId: number; + }; + sameItemLots: Array<{ + lotNo: string; + inventoryLotLineId: number; + availableQty: number; + uom: string; + }>; +}; + +export interface LotLabelPrintModalProps { + open: boolean; + onClose: () => void; + /** 當由其他流程自動打開時,可帶入掃碼 payload 以便自動查詢 */ + initialPayload?: ScanPayload | null; + /** 預設要自動選取的印表機名稱(完全匹配優先,其次為包含) */ + defaultPrinterName?: string; + /** 隱藏「掃碼內容/查詢/清除」區塊(通常搭配 initialPayload 使用) */ + hideScanSection?: boolean; + /** 額外提醒(顯示在最上方) */ + reminderText?: string; +} + +function safeParseScanPayload(raw: string): ScanPayload | null { + try { + const obj = JSON.parse(raw); + const itemId = Number(obj?.itemId); + const stockInLineId = Number(obj?.stockInLineId); + if (!Number.isFinite(itemId) || !Number.isFinite(stockInLineId)) + return null; + return { itemId, stockInLineId }; + } catch { + return null; + } +} + +function formatPrinterLabel(p: Printer): string { + const name = (p.name || "").trim(); + if (name) return name; + const desc = (p.description || "").trim(); + if (desc) return desc; + const code = (p as { code?: string }).code?.trim?.() ?? ""; + if (code) return code; + return `#${p.id}`; +} + +function isLabelPrinter(p: Printer): boolean { + const s = `${p.name ?? ""} ${p.description ?? ""} ${ + (p as { code?: string }).code ?? "" + } ${p.type ?? ""} ${p.brand ?? ""}`.toLowerCase(); + // Keep only "Label 機" printers; exclude A4 printers etc. + return s.includes("label") && !s.includes("a4"); +} + +const LotLabelPrintModal: React.FC = ({ + open, + onClose, + initialPayload = null, + defaultPrinterName, + hideScanSection, + reminderText, +}) => { + const scanInputRef = useRef(null); + const [scanInput, setScanInput] = useState(""); + const [scanError, setScanError] = useState(null); + + const [printers, setPrinters] = useState([]); + const [printersLoading, setPrintersLoading] = useState(false); + const [selectedPrinterId, setSelectedPrinterId] = useState(""); + + const [analysisLoading, setAnalysisLoading] = useState(false); + const [analysis, setAnalysis] = useState(null); + + const [printQty, setPrintQty] = useState(1); + const [printingLotLineId, setPrintingLotLineId] = useState( + null, + ); + + const [snackbar, setSnackbar] = useState<{ + open: boolean; + message: string; + severity?: "success" | "info" | "error"; + }>({ + open: false, + message: "", + severity: "info", + }); + + const baseApi = useMemo( + () => (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""), + [], + ); + + const resetAll = useCallback(() => { + setScanInput(""); + setScanError(null); + setAnalysis(null); + setPrintQty(1); + setPrintingLotLineId(null); + }, []); + + useEffect(() => { + if (!open) return; + resetAll(); + const t = setTimeout(() => scanInputRef.current?.focus(), 50); + return () => clearTimeout(t); + }, [open, resetAll]); + + const loadPrinters = useCallback(async () => { + if (!baseApi) { + setSnackbar({ + open: true, + message: "NEXT_PUBLIC_API_URL 未設定,無法連線後端。", + severity: "error", + }); + return; + } + setPrintersLoading(true); + try { + const res = await clientAuthFetch(`${baseApi}/printers`, { + method: "GET", + }); + if (!res.ok) { + const msg = await res.text().catch(() => ""); + throw new Error(msg || `HTTP ${res.status}`); + } + const data = (await res.json()) as Printer[]; + const list = Array.isArray(data) ? data : []; + setPrinters(list.filter(isLabelPrinter)); + } catch (e) { + setPrinters([]); + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "載入印表機清單失敗", + severity: "error", + }); + } finally { + setPrintersLoading(false); + } + }, [baseApi]); + + useEffect(() => { + if (!open) return; + void loadPrinters(); + }, [open, loadPrinters]); + + const effectiveHideScanSection = hideScanSection ?? initialPayload != null; + + const pickDefaultPrinterId = useCallback( + (list: Printer[]): number | null => { + if (!defaultPrinterName) return null; + const target = defaultPrinterName.trim().toLowerCase(); + if (!target) return null; + const byExact = list.find( + (p) => formatPrinterLabel(p).trim().toLowerCase() === target, + ); + if (byExact) return byExact.id; + const byIncludes = list.find((p) => + formatPrinterLabel(p).trim().toLowerCase().includes(target), + ); + return byIncludes?.id ?? null; + }, + [defaultPrinterName], + ); + + useEffect(() => { + if (!open) return; + if (selectedPrinterId !== "") return; + if (printers.length === 0) return; + const id = pickDefaultPrinterId(printers); + if (id != null) setSelectedPrinterId(id); + }, [open, printers, selectedPrinterId, pickDefaultPrinterId]); + + const analyzePayload = useCallback( + async (payload: ScanPayload) => { + if (!baseApi) { + setScanError("NEXT_PUBLIC_API_URL 未設定,無法連線後端。"); + return; + } + + setScanError(null); + setAnalysisLoading(true); + try { + const res = await clientAuthFetch( + `${baseApi}/inventoryLotLine/analyze-qr-code`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!res.ok) { + const msg = await res.text().catch(() => ""); + throw new Error(msg || `分析失敗(HTTP ${res.status})`); + } + const data = (await res.json()) as QrCodeAnalysisResponse; + setAnalysis(data); + setSnackbar({ + open: true, + message: "已載入同品可用批號清單", + severity: "success", + }); + } catch (e) { + setAnalysis(null); + setScanError(e instanceof Error ? e.message : "分析失敗"); + } finally { + setAnalysisLoading(false); + } + }, + [baseApi], + ); + + const handleAnalyze = useCallback(async () => { + const raw = scanInput.trim(); + const payload = safeParseScanPayload(raw); + if (!payload) { + setScanError( + '掃碼內容格式錯誤,需為 JSON,例如 {"itemId":16431,"stockInLineId":10381}', + ); + setAnalysis(null); + return; + } + await analyzePayload(payload); + }, [scanInput, analyzePayload]); + + useEffect(() => { + if (!open) return; + if (!initialPayload) return; + setScanInput(JSON.stringify(initialPayload)); + void analyzePayload(initialPayload); + }, [open, initialPayload, analyzePayload]); + + const availableLots = useMemo(() => { + if (!analysis) return []; + const list = (analysis.sameItemLots ?? []).filter( + (x) => Number(x.availableQty) > 0 && !!String(x.lotNo || "").trim(), + ); + const scannedLot = analysis.scanned?.inventoryLotLineId + ? { + lotNo: analysis.scanned.lotNo, + inventoryLotLineId: analysis.scanned.inventoryLotLineId, + availableQty: (list.find( + (x) => x.inventoryLotLineId === analysis.scanned.inventoryLotLineId, + )?.availableQty ?? 0) as number, + uom: (list.find( + (x) => x.inventoryLotLineId === analysis.scanned.inventoryLotLineId, + )?.uom ?? "") as string, + _scanned: true as const, + } + : null; + + const merged = [ + ...(scannedLot ? [scannedLot] : []), + ...list + .filter((x) => x.inventoryLotLineId !== scannedLot?.inventoryLotLineId) + .map((x) => ({ ...x, _scanned: false as const })), + ]; + + return merged; + }, [analysis]); + + const selectedPrinter = useMemo(() => { + if (selectedPrinterId === "") return null; + return printers.find((p) => p.id === selectedPrinterId) ?? null; + }, [printers, selectedPrinterId]); + + const canPrint = + !!analysis && selectedPrinterId !== "" && printQty >= 1 && !analysisLoading; + + const handlePrintOne = useCallback( + async (inventoryLotLineId: number, lotNo: string) => { + if (!baseApi) { + setSnackbar({ + open: true, + message: "NEXT_PUBLIC_API_URL 未設定,無法連線後端。", + severity: "error", + }); + return; + } + if (selectedPrinterId === "") { + setSnackbar({ + open: true, + message: "請先選擇印表機", + severity: "error", + }); + return; + } + if (printQty < 1 || !Number.isFinite(printQty)) { + setSnackbar({ + open: true, + message: "列印張數需為大於等於 1 的整數", + severity: "error", + }); + return; + } + + setPrintingLotLineId(inventoryLotLineId); + try { + const sp = new URLSearchParams(); + sp.set("inventoryLotLineId", String(inventoryLotLineId)); + sp.set("printerId", String(selectedPrinterId)); + sp.set("printQty", String(Math.floor(printQty))); + + const url = `${baseApi}/inventoryLotLine/print-label?${sp.toString()}`; + const res = await clientAuthFetch(url, { method: "GET" }); + if (!res.ok) { + const msg = await res.text().catch(() => ""); + throw new Error(msg || `列印失敗(HTTP ${res.status})`); + } + setSnackbar({ + open: true, + message: `已送出列印:Lot ${lotNo}`, + severity: "success", + }); + } catch (e) { + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "列印失敗", + severity: "error", + }); + } finally { + setPrintingLotLineId(null); + } + }, + [baseApi, selectedPrinterId, printQty], + ); + + return ( + + 批號標籤列印 + + + {reminderText ? ( + {reminderText} + ) : null} + {effectiveHideScanSection ? null : ( + <> + + 請掃描條碼(JSON 格式),例如{" "} + {'{"itemId":16431,"stockInLineId":10381'}。 + + + + setScanInput(e.target.value)} + fullWidth + size="small" + error={!!scanError} + helperText={scanError || "掃描後按 Enter 或點「查詢」"} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleAnalyze(); + } + }} + disabled={analysisLoading} + /> + + + + + )} + + + + 印表機 + + + + setPrintQty(Number(e.target.value))} + sx={{ width: 140 }} + disabled={analysisLoading} + /> + + + + {selectedPrinter && ( + + 已選:{formatPrinterLabel(selectedPrinter)} + + )} + + + {analysis && ( + + + 品號:{analysis.itemCode} {analysis.itemName} + + + {availableLots.length === 0 ? ( + + 找不到可用批號(availableQty > 0)。 + + ) : ( + + {availableLots.map((lot) => { + const isPrinting = + printingLotLineId === lot.inventoryLotLineId; + return ( + + + + Lot:{lot.lotNo} + {lot._scanned ? "(已掃)" : ""} + + + 可用量:{Number(lot.availableQty).toLocaleString()}{" "} + {lot.uom || ""} + + + + + + + ); + })} + + )} + + )} + + {!analysis && !analysisLoading && ( + + 掃碼後會用 `/inventoryLotLine/analyze-qr-code` + 查詢同品批號,列印則呼叫 + `/inventoryLotLine/print-label`(需先選印表機)。 + + )} + + + + + + + setSnackbar((s) => ({ ...s, open: false }))} + message={snackbar.message} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + ); +}; + +export default LotLabelPrintModal;