diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 0ddae50..2e0e0c3 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -86,19 +86,40 @@ interface Props { } /** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */ -function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null { - if (!activeSuggestedLots?.length) return null; - const withLotNo = activeSuggestedLots.filter( - (l) => l.lotNo != null && String(l.lotNo).trim() !== "" +/** itemId → Set of processed stockOutLineId (allow same physical lot QR for next SOL). */ +type ProcessedStockOutLinesByItemId = Map>; + +function isLotRowPending(lot: any): boolean { + const st = String(lot?.stockOutLineStatus ?? "").toLowerCase(); + return ( + st === "pending" || + st === "partially_completed" || + st === "partially_complete" || + st === "" ); - if (withLotNo.length === 1) return withLotNo[0]; - if (withLotNo.length > 1) { - const pending = withLotNo.find( - (l) => (l.stockOutLineStatus || "").toLowerCase() === "pending" - ); - return pending || withLotNo[0]; - } - return activeSuggestedLots[0]; +} + +function isStockOutLineAlreadyProcessed( + processedByItemId: ProcessedStockOutLinesByItemId, + itemId: number, + stockOutLineId: number | null | undefined, +): boolean { + const solId = Number(stockOutLineId); + if (!Number.isFinite(solId) || solId <= 0) return false; + return processedByItemId.get(itemId)?.has(solId) ?? false; +} + +function markProcessedStockOutLine( + prev: ProcessedStockOutLinesByItemId, + itemId: number, + stockOutLineId: number | null | undefined, +): ProcessedStockOutLinesByItemId { + const solId = Number(stockOutLineId); + if (!Number.isFinite(solId) || solId <= 0) return prev; + const newMap = new Map(prev); + if (!newMap.has(itemId)) newMap.set(itemId, new Set()); + newMap.get(itemId)!.add(solId); + return newMap; } /** Parse workbench QR / test shortcut into itemId + stockInLineId. */ @@ -150,15 +171,67 @@ function parseWorkbenchScanQrPayload( return null; } -/** True when another pending/partial SOL still suggests this stockInLineId (same lot across lines). */ -function activeLotsStillNeedStockInLine( - activeLotsForItem: any[] | undefined, +function hasPendingActiveRowForStockInLine( + indexes: { + byStockInLineId: Map; + activeLotsByItemId: Map; + }, + itemId: number, stockInLineId: number, + processedByItemId: ProcessedStockOutLinesByItemId, ): boolean { - if (!activeLotsForItem?.length) return false; - return activeLotsForItem.some( - (lot) => Number(lot?.stockInLineId) === Number(stockInLineId), + const rows = indexes.byStockInLineId.get(stockInLineId) ?? []; + const activeSet = new Set(indexes.activeLotsByItemId.get(itemId) ?? []); + return rows.some( + (lot) => + lot.itemId === itemId && + activeSet.has(lot) && + isLotRowPending(lot) && + !isStockOutLineAlreadyProcessed(processedByItemId, itemId, lot.stockOutLineId), + ); +} + +function findExactActiveMatchForStockInLine( + stockInLineLots: any[], + scannedItemId: number, + activeSuggestedLots: any[], + processedByItemId: ProcessedStockOutLinesByItemId, +): any | null { + const activeSet = new Set(activeSuggestedLots); + const candidates = stockInLineLots.filter( + (lot) => + lot.itemId === scannedItemId && + activeSet.has(lot) && + !isStockOutLineAlreadyProcessed( + processedByItemId, + scannedItemId, + lot.stockOutLineId, + ), ); + if (candidates.length === 0) return null; + return candidates.find((lot) => isLotRowPending(lot)) ?? candidates[0]; +} + +function pickExpectedLotForSubstitution( + activeSuggestedLots: any[], + processedByItemId?: ProcessedStockOutLinesByItemId, +): any | null { + if (!activeSuggestedLots?.length) return null; + const itemId = activeSuggestedLots[0]?.itemId; + const processed = processedByItemId ?? new Map(); + const unprocessed = activeSuggestedLots.filter( + (l) => + !itemId || + !isStockOutLineAlreadyProcessed(processed, itemId, l.stockOutLineId), + ); + const pool = unprocessed.length > 0 ? unprocessed : activeSuggestedLots; + const withLotNo = pool.filter( + (l) => l.lotNo != null && String(l.lotNo).trim() !== "", + ); + const searchPool = withLotNo.length > 0 ? withLotNo : pool; + if (searchPool.length === 1) return searchPool[0]; + const pending = searchPool.find((l) => isLotRowPending(l)); + return pending ?? searchPool[0]; } const ManualLotConfirmationModal: React.FC<{ @@ -645,8 +718,9 @@ const [pickOrderSwitching, setPickOrderSwitching] = 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()); + // Track processed stock-out lines per item (allow same physical lot QR for next SOL) + const [processedQrCombinations, setProcessedQrCombinations] = + useState(new Map()); const [processedQrCodes, setProcessedQrCodes] = useState>(new Set()); const [lastProcessedQr, setLastProcessedQr] = useState(''); const [isRefreshingData, setIsRefreshingData] = useState(false); @@ -662,9 +736,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); 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); + const qrPickInFlightRef = useRef(false); /** Latest lot indexes for QR re-scan allow checks outside processOutsideQrCode. */ const lotDataIndexesRef = useRef<{ + byStockInLineId: Map; activeLotsByItemId: Map; } | null>(null); @@ -1467,6 +1542,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO }, [combinedLotData.length, combinedLotData]); lotDataIndexesRef.current = { + byStockInLineId: lotDataIndexes.byStockInLineId, activeLotsByItemId: lotDataIndexes.activeLotsByItemId, }; @@ -1474,6 +1550,12 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO resetScanRef.current = resetScan; const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => { + if (qrPickInFlightRef.current) { + console.log(" [SKIP] QR pick already in flight"); + return; + } + qrPickInFlightRef.current = true; + const totalStartTime = performance.now(); console.log(` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); console.log(` Start time: ${new Date().toISOString()}`); @@ -1483,7 +1565,26 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const indexes = lotDataIndexes; // Access the memoized indexes const indexAccessTime = performance.now() - indexAccessStart; console.log(` [PERF] Index access time: ${indexAccessTime.toFixed(2)}ms`); - + + const maybeReleaseQrForNextSol = ( + itemId: number, + stockInLineId: number, + processedAfterMark: ProcessedStockOutLinesByItemId, + ) => { + if ( + hasPendingActiveRowForStockInLine( + indexes, + itemId, + stockInLineId, + processedAfterMark, + ) + ) { + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.delete(latestQr); + } + }; + + try { // 1) Parse JSON safely (parse once, reuse) const parseStartTime = performance.now(); let qrData: any = null; @@ -1516,30 +1617,24 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const scannedItemId = qrData.itemId; const scannedStockInLineId = qrData.stockInLineId; - - // ✅ 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); - const activeSuggestedLotsEarly = - indexes.activeLotsByItemId.get(scannedItemId) || []; + if ( - itemProcessedSet?.has(scannedStockInLineId) && - !activeLotsStillNeedStockInLine( - activeSuggestedLotsEarly, + !hasPendingActiveRowForStockInLine( + indexes, + scannedItemId, scannedStockInLineId, + processedQrCombinations, ) ) { - const duplicateCheckTime = performance.now() - duplicateCheckStartTime; - console.log(` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed(2)}ms)`); + console.log( + ` [SKIP] No pending stock-out line left for itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId}`, + ); 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 = activeSuggestedLotsEarly; + 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; @@ -1547,9 +1642,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // ✅ 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 - ); + const stockInLineRows = indexes.byStockInLineId.get(scannedStockInLineId) ?? []; + const scannedLot = + findExactActiveMatchForStockInLine( + stockInLineRows, + scannedItemId, + activeSuggestedLots, + processedQrCombinations, + ) ?? + allLotsForItem.find( + (lot: any) => lot.stockInLineId === scannedStockInLineId, + ); if (scannedLot) { const isRejected = @@ -1566,13 +1669,14 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO `此批次(${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; - }); + // Mark this SOL as processed to prevent re-processing + setProcessedQrCombinations((prev) => + markProcessedStockOutLine( + prev, + scannedItemId, + scannedLot.stockOutLineId, + ), + ); return; } @@ -1631,7 +1735,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO pickExpectedLotForSubstitution( allLotsForItem.filter( (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "" - ) + ), + processedQrCombinations, ) || allLotsForItem[0]; @@ -1759,12 +1864,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO ); }); - setProcessedQrCombinations((prev) => { - const newMap = new Map(prev); - if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); - newMap.get(scannedItemId)!.add(scannedStockInLineId); - return newMap; - }); + const nextProcessedNoActive = markProcessedStockOutLine( + processedQrCombinations, + scannedItemId, + expectedLot.stockOutLineId, + ); + setProcessedQrCombinations(nextProcessedNoActive); + maybeReleaseQrForNextSol( + scannedItemId, + scannedStockInLineId, + nextProcessedNoActive, + ); if (workbenchMode) { await refreshWorkbenchAfterScanPick(); @@ -1775,16 +1885,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // ✅ 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 exactMatch = findExactActiveMatchForStockInLine( + stockInLineLots, + scannedItemId, + activeSuggestedLots, + processedQrCombinations, + ); const matchTime = performance.now() - matchStartTime; console.log(` [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${exactMatch ? 'yes' : 'no'}`); @@ -1793,7 +1900,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) if (!exactMatch) { const expectedLot = - pickExpectedLotForSubstitution(activeSuggestedLots) || allLotsForItem[0]; + pickExpectedLotForSubstitution( + activeSuggestedLots, + processedQrCombinations, + ) || allLotsForItem[0]; if (expectedLot) { const shouldAutoSwitch = !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); @@ -1926,12 +2036,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO ); }); - setProcessedQrCombinations((prev) => { - const newMap = new Map(prev); - if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); - newMap.get(scannedItemId)!.add(scannedStockInLineId); - return newMap; - }); + const nextProcessedAuto = markProcessedStockOutLine( + processedQrCombinations, + scannedItemId, + expectedLot.stockOutLineId, + ); + setProcessedQrCombinations(nextProcessedAuto); + maybeReleaseQrForNextSol( + scannedItemId, + scannedStockInLineId, + nextProcessedAuto, + ); if (workbenchMode) { await refreshWorkbenchAfterScanPick(); } @@ -2022,16 +2137,19 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const stateUpdateTime = performance.now() - stateUpdateStartTime; console.log(` [PERF] State update time: ${stateUpdateTime.toFixed(2)}ms`); - // Mark this combination as processed + // Mark this SOL as processed (same physical lot may still be needed by another SOL) 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 nextProcessedExact = markProcessedStockOutLine( + processedQrCombinations, + scannedItemId, + exactMatch.stockOutLineId, + ); + setProcessedQrCombinations(nextProcessedExact); + maybeReleaseQrForNextSol( + scannedItemId, + scannedStockInLineId, + nextProcessedExact, + ); const markProcessedTime = performance.now() - markProcessedStartTime; console.log(` [PERF] Mark processed time: ${markProcessedTime.toFixed(2)}ms`); @@ -2042,7 +2160,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO 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(`📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.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( workbenchMode ? "✅ Workbench scan-pick: list refreshed from server" @@ -2085,14 +2203,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配 // Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描 const mismatchCheckStartTime = performance.now(); - const itemProcessedSet2 = processedQrCombinations.get(scannedItemId); if ( - itemProcessedSet2?.has(scannedStockInLineId) && - !activeLotsStillNeedStockInLine(activeSuggestedLots, scannedStockInLineId) + !hasPendingActiveRowForStockInLine( + indexes, + scannedItemId, + scannedStockInLineId, + processedQrCombinations, + ) ) { const mismatchCheckTime = performance.now() - mismatchCheckStartTime; console.log( - ` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed(2)}ms)`, + ` [SKIP] No pending stock-out line left for mismatch path (check time: ${mismatchCheckTime.toFixed(2)}ms)`, ); return; } @@ -2100,7 +2221,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO console.log(` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); const expectedLotStartTime = performance.now(); - const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); + const expectedLot = pickExpectedLotForSubstitution( + activeSuggestedLots, + processedQrCombinations, + ); if (!expectedLot) { console.error("Could not determine expected lot for auto-switch"); startTransition(() => { @@ -2239,12 +2363,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO ); }); - setProcessedQrCombinations((prev) => { - const newMap = new Map(prev); - if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); - newMap.get(scannedItemId)!.add(scannedStockInLineId); - return newMap; - }); + const nextProcessedMismatch = markProcessedStockOutLine( + processedQrCombinations, + scannedItemId, + expectedLot.stockOutLineId, + ); + setProcessedQrCombinations(nextProcessedMismatch); + maybeReleaseQrForNextSol( + scannedItemId, + scannedStockInLineId, + nextProcessedMismatch, + ); if (workbenchMode) { await refreshWorkbenchAfterScanPick(); @@ -2262,7 +2391,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO ); 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`, + `📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, mismatchCheck=${mismatchCheckTime.toFixed(2)}ms, expectedLot=${expectedLotTime.toFixed(2)}ms`, ); } catch (error) { const totalTime = performance.now() - totalStartTime; @@ -2274,6 +2403,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO }); return; } + } finally { + qrPickInFlightRef.current = false; + } }, [ lotDataIndexes, processedQrCombinations, @@ -2354,39 +2486,29 @@ 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)`); console.log(` [TEST QR] End time: ${new Date().toISOString()}`); + // Release test shortcut string if another SOL still needs this lot + if ( + hasPendingActiveRowForStockInLine( + lotDataIndexes, + itemId, + stockInLineId, + processedQrCombinations, + ) + ) { + processedQrCodesRef.current.delete(latestQr); + if (lastProcessedQrRef.current === latestQr) { + lastProcessedQrRef.current = ""; + } + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + setLastProcessedQr(""); + } }).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; - } }); } @@ -2423,32 +2545,33 @@ 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 — unless another active line still needs the same lot (cross-POL same lot). - const checkProcessedStartTime = performance.now(); - 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)`, + const qrPayload = parseWorkbenchScanQrPayload(latestQr); + const canRetrySamePhysicalLot = + qrPayload != null && + hasPendingActiveRowForStockInLine( + lotDataIndexes, + qrPayload.itemId, + qrPayload.stockInLineId, + processedQrCombinations, ); - } - if (qrProcessInFlightRef.current) { - console.log(` [QR PROCESS] Skipping — scan-pick already in flight`); + + // Skip if already processed (allow same QR when another pending SOL needs this lot) + const checkProcessedStartTime = performance.now(); + if ( + (processedQrCodesRef.current.has(latestQr) || + lastProcessedQrRef.current === latestQr) && + !canRetrySamePhysicalLot + ) { + const checkTime = performance.now() - checkProcessedStartTime; + console.log(` [QR PROCESS] Already processed check time: ${checkTime.toFixed(2)}ms`); return; } + if (canRetrySamePhysicalLot) { + processedQrCodesRef.current.delete(latestQr); + if (lastProcessedQrRef.current === latestQr) { + lastProcessedQrRef.current = ""; + } + } const checkTime = performance.now() - checkProcessedStartTime; console.log(` [QR PROCESS] Not processed check time: ${checkTime.toFixed(2)}ms`); @@ -2483,8 +2606,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO } // Process new QR code immediately (background mode - no modal) - // Check against refs to avoid state update delays - if (latestQr && latestQr !== lastProcessedQrRef.current) { + if (latestQr && (latestQr !== lastProcessedQrRef.current || canRetrySamePhysicalLot)) { 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`); @@ -2516,45 +2638,21 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // Use ref to avoid dependency issues const processCallStartTime = performance.now(); if (processOutsideQrCodeRef.current) { - 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)`); + // maybeReleaseQrForNextSol may have cleared refs; sync UI state + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + setLastProcessedQr(lastProcessedQrRef.current); }) .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; - } }); } @@ -2577,7 +2675,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO qrProcessingTimeoutRef.current = null; } }; - }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, manualLotConfirmationOpen]); + }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, manualLotConfirmationOpen, lotDataIndexes, processedQrCombinations]); const renderCountRef = useRef(0); const renderStartTimeRef = useRef(null); @@ -3212,7 +3310,7 @@ const handleStartScan = useCallback(() => { setProcessedQrCombinations(new Map()); processedQrCodesRef.current = new Set(); lastProcessedQrRef.current = ''; - qrProcessInFlightRef.current = false; + qrPickInFlightRef.current = false; setQrScanError(false); setQrScanSuccess(false);