| @@ -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<number, Set<number>>; | |||
| 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<number, any[]>; | |||
| activeLotsByItemId: Map<number, any[]>; | |||
| }, | |||
| 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<boolean>(false); | |||
| // Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling | |||
| const [processedQrCombinations, setProcessedQrCombinations] = useState<Map<number, Set<number>>>(new Map()); | |||
| // Track processed stock-out lines per item (allow same physical lot QR for next SOL) | |||
| const [processedQrCombinations, setProcessedQrCombinations] = | |||
| useState<ProcessedStockOutLinesByItemId>(new Map()); | |||
| const [processedQrCodes, setProcessedQrCodes] = useState<Set<string>>(new Set()); | |||
| const [lastProcessedQr, setLastProcessedQr] = useState<string>(''); | |||
| const [isRefreshingData, setIsRefreshingData] = useState<boolean>(false); | |||
| @@ -662,9 +736,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||
| const processedQrCodesRef = useRef<Set<string>>(new Set()); | |||
| const lastProcessedQrRef = useRef<string>(''); | |||
| /** 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<number, any[]>; | |||
| activeLotsByItemId: Map<number, any[]>; | |||
| } | 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<number | null>(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); | |||