diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 6c58f39..0ddae50 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -101,6 +101,66 @@ function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null return activeSuggestedLots[0]; } +/** Parse workbench QR / test shortcut into itemId + stockInLineId. */ +function parseWorkbenchScanQrPayload( + raw: string, +): { itemId: number; stockInLineId: number } | null { + const text = String(raw || "").trim(); + if (!text) return null; + if ( + (text.startsWith("{2fitest") || text.startsWith("{2fittest")) && + text.endsWith("}") + ) { + let content = ""; + if (text.startsWith("{2fittest")) { + content = text.substring(9, text.length - 1); + } else { + content = text.substring(8, text.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 ( + Number.isFinite(itemId) && + itemId > 0 && + Number.isFinite(stockInLineId) && + stockInLineId > 0 + ) { + return { itemId, stockInLineId }; + } + } + return null; + } + try { + const obj = JSON.parse(text); + const itemId = Number(obj?.itemId); + const stockInLineId = Number(obj?.stockInLineId); + if ( + Number.isFinite(itemId) && + itemId > 0 && + Number.isFinite(stockInLineId) && + stockInLineId > 0 + ) { + return { itemId, stockInLineId }; + } + } catch { + /* not JSON */ + } + return null; +} + +/** True when another pending/partial SOL still suggests this stockInLineId (same lot across lines). */ +function activeLotsStillNeedStockInLine( + activeLotsForItem: any[] | undefined, + stockInLineId: number, +): boolean { + if (!activeLotsForItem?.length) return false; + return activeLotsForItem.some( + (lot) => Number(lot?.stockInLineId) === Number(stockInLineId), + ); +} + const ManualLotConfirmationModal: React.FC<{ open: boolean; onClose: () => void; @@ -601,6 +661,12 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); // Use refs for processed QR tracking to avoid useEffect dependency issues and delays const processedQrCodesRef = useRef>(new Set()); const lastProcessedQrRef = useRef(''); + /** Prevent double-submit while a scan-pick API is in flight (scanner bounce). */ + const qrProcessInFlightRef = useRef(false); + /** Latest lot indexes for QR re-scan allow checks outside processOutsideQrCode. */ + const lotDataIndexesRef = useRef<{ + activeLotsByItemId: Map; + } | null>(null); // Store callbacks in refs to avoid useEffect dependency issues const processOutsideQrCodeRef = useRef< @@ -1399,6 +1465,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO return { byItemId, byItemCode, byLotId, byLotNo, byStockInLineId, activeLotsByItemId }; }, [combinedLotData.length, combinedLotData]); + + lotDataIndexesRef.current = { + activeLotsByItemId: lotDataIndexes.activeLotsByItemId, + }; // Store resetScan in ref for immediate access (update on every render) resetScanRef.current = resetScan; @@ -1447,10 +1517,19 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const scannedItemId = qrData.itemId; const scannedStockInLineId = qrData.stockInLineId; - // ✅ Check if this combination was already processed + // ✅ Skip only when this lot was already processed AND no other active line still needs it. + // Same item + same stockInLineId across different pick-order lines must be re-scannable. const duplicateCheckStartTime = performance.now(); const itemProcessedSet = processedQrCombinations.get(scannedItemId); - if (itemProcessedSet?.has(scannedStockInLineId)) { + const activeSuggestedLotsEarly = + indexes.activeLotsByItemId.get(scannedItemId) || []; + if ( + itemProcessedSet?.has(scannedStockInLineId) && + !activeLotsStillNeedStockInLine( + activeSuggestedLotsEarly, + scannedStockInLineId, + ) + ) { const duplicateCheckTime = performance.now() - duplicateCheckStartTime; console.log(` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed(2)}ms)`); return; @@ -1460,7 +1539,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) const lookupStartTime = performance.now(); - const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; + const activeSuggestedLots = activeSuggestedLotsEarly; // ✅ 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; @@ -2007,7 +2086,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描 const mismatchCheckStartTime = performance.now(); const itemProcessedSet2 = processedQrCombinations.get(scannedItemId); - if (itemProcessedSet2?.has(scannedStockInLineId)) { + if ( + itemProcessedSet2?.has(scannedStockInLineId) && + !activeLotsStillNeedStockInLine(activeSuggestedLots, scannedStockInLineId) + ) { const mismatchCheckTime = performance.now() - mismatchCheckStartTime; console.log( ` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed(2)}ms)`, @@ -2272,6 +2354,11 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // ✅ Process immediately (bypass QR scanner delay) if (processOutsideQrCodeRef.current) { + if (qrProcessInFlightRef.current) { + console.log(` [TEST QR] Skipping — scan-pick already in flight`); + return; + } + qrProcessInFlightRef.current = true; 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)`); @@ -2279,6 +2366,27 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO }).catch((error) => { const testTime = performance.now() - testStartTime; console.error(`❌ [TEST QR] Error after ${testTime.toFixed(2)}ms:`, error); + }).finally(async () => { + await new Promise((r) => setTimeout(r, 50)); + try { + const activeForItem = + lotDataIndexesRef.current?.activeLotsByItemId.get(itemId); + if ( + activeLotsStillNeedStockInLine(activeForItem, stockInLineId) + ) { + processedQrCodesRef.current.delete(latestQr); + if (lastProcessedQrRef.current === latestQr) { + lastProcessedQrRef.current = ""; + } + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + setLastProcessedQr(""); + console.log( + ` [TEST QR] Cleared processed test QR so same lot can be scanned for remaining line(s)`, + ); + } + } finally { + qrProcessInFlightRef.current = false; + } }); } @@ -2315,11 +2423,30 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); 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) + // Skip if already processed — unless another active line still needs the same lot (cross-POL same lot). const checkProcessedStartTime = performance.now(); - 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`); + const alreadySeenQr = + processedQrCodesRef.current.has(latestQr) || + lastProcessedQrRef.current === latestQr; + if (alreadySeenQr) { + const payload = parseWorkbenchScanQrPayload(latestQr); + const activeForItem = payload + ? lotDataIndexesRef.current?.activeLotsByItemId.get(payload.itemId) + : undefined; + const allowSameLotRescan = + !!payload && + activeLotsStillNeedStockInLine(activeForItem, payload.stockInLineId); + if (!allowSameLotRescan) { + const checkTime = performance.now() - checkProcessedStartTime; + console.log(` [QR PROCESS] Already processed check time: ${checkTime.toFixed(2)}ms`); + return; + } + console.log( + ` [QR PROCESS] Allowing re-scan of same lot QR for remaining active pick line(s)`, + ); + } + if (qrProcessInFlightRef.current) { + console.log(` [QR PROCESS] Skipping — scan-pick already in flight`); return; } const checkTime = performance.now() - checkProcessedStartTime; @@ -2389,17 +2516,46 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // 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`); - }); + qrProcessInFlightRef.current = true; + 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`); + }) + .finally(async () => { + // Wait a tick so refresh/local status updates commit into lotDataIndexesRef. + await new Promise((r) => setTimeout(r, 50)); + try { + const payload = parseWorkbenchScanQrPayload(latestQr); + const activeForItem = payload + ? lotDataIndexesRef.current?.activeLotsByItemId.get(payload.itemId) + : undefined; + if ( + payload && + activeLotsStillNeedStockInLine(activeForItem, payload.stockInLineId) + ) { + processedQrCodesRef.current.delete(latestQr); + if (lastProcessedQrRef.current === latestQr) { + lastProcessedQrRef.current = ""; + } + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + setLastProcessedQr(""); + console.log( + ` [QR PROCESS] Cleared processed QR so same lot can be scanned for remaining line(s)`, + ); + } + } finally { + qrProcessInFlightRef.current = false; + } + }); } // Update state for UI (but don't block on it) @@ -3053,6 +3209,10 @@ const handleStartScan = useCallback(() => { setProcessedQrCodes(new Set()); setLastProcessedQr(''); + setProcessedQrCombinations(new Map()); + processedQrCodesRef.current = new Set(); + lastProcessedQrRef.current = ''; + qrProcessInFlightRef.current = false; setQrScanError(false); setQrScanSuccess(false); @@ -3742,16 +3902,22 @@ paginatedData.map((row, index) => { const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); const isSolRejected = solSt === "rejected" || String(lot.lotAvailability || "").toLowerCase() === "rejected"; + const groupRowBg = + row.groupDisplayIndex % 2 === 0 ? "action.hover" : "background.paper"; + const nextRow = paginatedData[index + 1]; + const isLastInGroup = + !nextRow || nextRow.groupDisplayIndex !== row.groupDisplayIndex; return ( @@ -4003,58 +4169,56 @@ paginatedData.map((row, index) => { ? String(pickQtyData[lotKey]) : String(displayedSubmitQty) : String(displayedSubmitQty); + const pickStatus = String(lot.stockOutLineStatus ?? "").toLowerCase(); + const isRowPicked = + pickStatus === "completed" || + pickStatus === "checked" || + pickStatus === "partially_completed" || + pickStatus === "partially_complete"; return ( - - {/* - - */} - - { - if (!qtyFieldEnabled) return; - if (e.key !== "{") return; - e.preventDefault(); - setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({ - ...prev, - [lotKey]: false, - })); - (e.currentTarget as HTMLInputElement).blur(); - }} - onChange={(e) => { - if (!qtyFieldEnabled) return; - const n = Number(e.target.value); - if (Number.isFinite(n) && n < 0) return; - handlePickQtyChange(lotKey, e.target.value); - }} - inputProps={{ min: 0, step: 1 }} - sx={{ - width: 96, - '& .MuiInputBase-input': { fontSize: '0.75rem', py: 0.5, textAlign: 'center' }, - }} - /> + + {isRowPicked ? ( + + {textFieldValue} + + ) : ( + { + if (!qtyFieldEnabled) return; + if (e.key !== "{") return; + e.preventDefault(); + setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({ + ...prev, + [lotKey]: false, + })); + (e.currentTarget as HTMLInputElement).blur(); + }} + onChange={(e) => { + if (!qtyFieldEnabled) return; + const n = Number(e.target.value); + if (Number.isFinite(n) && n < 0) return; + handlePickQtyChange(lotKey, e.target.value); + }} + inputProps={{ min: 0, step: 1 }} + sx={{ + width: 96, + "& .MuiInputBase-input": { fontSize: "0.75rem", py: 0.5, textAlign: "center" }, + }} + /> + )}