| @@ -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. */ | /** Parse workbench QR / test shortcut into itemId + stockInLineId. */ | ||||
| @@ -150,15 +171,67 @@ function parseWorkbenchScanQrPayload( | |||||
| return null; | 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, | stockInLineId: number, | ||||
| processedByItemId: ProcessedStockOutLinesByItemId, | |||||
| ): boolean { | ): 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<{ | const ManualLotConfirmationModal: React.FC<{ | ||||
| @@ -645,8 +718,9 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||||
| // Add these missing state variables after line 352 | // Add these missing state variables after line 352 | ||||
| const [isManualScanning, setIsManualScanning] = useState<boolean>(false); | 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 [processedQrCodes, setProcessedQrCodes] = useState<Set<string>>(new Set()); | ||||
| const [lastProcessedQr, setLastProcessedQr] = useState<string>(''); | const [lastProcessedQr, setLastProcessedQr] = useState<string>(''); | ||||
| const [isRefreshingData, setIsRefreshingData] = useState<boolean>(false); | const [isRefreshingData, setIsRefreshingData] = useState<boolean>(false); | ||||
| @@ -662,9 +736,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||||
| const processedQrCodesRef = useRef<Set<string>>(new Set()); | const processedQrCodesRef = useRef<Set<string>>(new Set()); | ||||
| const lastProcessedQrRef = useRef<string>(''); | const lastProcessedQrRef = useRef<string>(''); | ||||
| /** Prevent double-submit while a scan-pick API is in flight (scanner bounce). */ | /** 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. */ | /** Latest lot indexes for QR re-scan allow checks outside processOutsideQrCode. */ | ||||
| const lotDataIndexesRef = useRef<{ | const lotDataIndexesRef = useRef<{ | ||||
| byStockInLineId: Map<number, any[]>; | |||||
| activeLotsByItemId: Map<number, any[]>; | activeLotsByItemId: Map<number, any[]>; | ||||
| } | null>(null); | } | null>(null); | ||||
| @@ -1467,6 +1542,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| }, [combinedLotData.length, combinedLotData]); | }, [combinedLotData.length, combinedLotData]); | ||||
| lotDataIndexesRef.current = { | lotDataIndexesRef.current = { | ||||
| byStockInLineId: lotDataIndexes.byStockInLineId, | |||||
| activeLotsByItemId: lotDataIndexes.activeLotsByItemId, | activeLotsByItemId: lotDataIndexes.activeLotsByItemId, | ||||
| }; | }; | ||||
| @@ -1474,6 +1550,12 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| resetScanRef.current = resetScan; | resetScanRef.current = resetScan; | ||||
| const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => { | 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(); | const totalStartTime = performance.now(); | ||||
| console.log(` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); | console.log(` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); | ||||
| console.log(` Start time: ${new Date().toISOString()}`); | 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 indexes = lotDataIndexes; // Access the memoized indexes | ||||
| const indexAccessTime = performance.now() - indexAccessStart; | const indexAccessTime = performance.now() - indexAccessStart; | ||||
| console.log(` [PERF] Index access time: ${indexAccessTime.toFixed(2)}ms`); | 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) | // 1) Parse JSON safely (parse once, reuse) | ||||
| const parseStartTime = performance.now(); | const parseStartTime = performance.now(); | ||||
| let qrData: any = null; | let qrData: any = null; | ||||
| @@ -1516,30 +1617,24 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| const scannedItemId = qrData.itemId; | const scannedItemId = qrData.itemId; | ||||
| const scannedStockInLineId = qrData.stockInLineId; | 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 ( | if ( | ||||
| itemProcessedSet?.has(scannedStockInLineId) && | |||||
| !activeLotsStillNeedStockInLine( | |||||
| activeSuggestedLotsEarly, | |||||
| !hasPendingActiveRowForStockInLine( | |||||
| indexes, | |||||
| scannedItemId, | |||||
| scannedStockInLineId, | 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; | 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) | // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) | ||||
| const lookupStartTime = performance.now(); | 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 | // ✅ 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 allLotsForItem = indexes.byItemId.get(scannedItemId) || []; | ||||
| const lookupTime = performance.now() - lookupStartTime; | 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 | // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots | ||||
| // This allows users to scan other lots even when all suggested lots are rejected | // 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) { | if (scannedLot) { | ||||
| const isRejected = | const isRejected = | ||||
| @@ -1566,13 +1669,14 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` | `此批次(${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; | return; | ||||
| } | } | ||||
| @@ -1631,7 +1735,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| pickExpectedLotForSubstitution( | pickExpectedLotForSubstitution( | ||||
| allLotsForItem.filter( | allLotsForItem.filter( | ||||
| (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "" | (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "" | ||||
| ) | |||||
| ), | |||||
| processedQrCombinations, | |||||
| ) || | ) || | ||||
| allLotsForItem[0]; | 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) { | if (workbenchMode) { | ||||
| await refreshWorkbenchAfterScanPick(); | await refreshWorkbenchAfterScanPick(); | ||||
| @@ -1775,16 +1885,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // ✅ OPTIMIZATION: Direct Map lookup for stockInLineId match (O(1)) | // ✅ OPTIMIZATION: Direct Map lookup for stockInLineId match (O(1)) | ||||
| const matchStartTime = performance.now(); | const matchStartTime = performance.now(); | ||||
| let exactMatch: any = null; | |||||
| const stockInLineLots = indexes.byStockInLineId.get(scannedStockInLineId) || []; | 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; | const matchTime = performance.now() - matchStartTime; | ||||
| console.log(` [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${exactMatch ? 'yes' : 'no'}`); | 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) | // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) | ||||
| if (!exactMatch) { | if (!exactMatch) { | ||||
| const expectedLot = | const expectedLot = | ||||
| pickExpectedLotForSubstitution(activeSuggestedLots) || allLotsForItem[0]; | |||||
| pickExpectedLotForSubstitution( | |||||
| activeSuggestedLots, | |||||
| processedQrCombinations, | |||||
| ) || allLotsForItem[0]; | |||||
| if (expectedLot) { | if (expectedLot) { | ||||
| const shouldAutoSwitch = | const shouldAutoSwitch = | ||||
| !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); | !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) { | if (workbenchMode) { | ||||
| await refreshWorkbenchAfterScanPick(); | await refreshWorkbenchAfterScanPick(); | ||||
| } | } | ||||
| @@ -2022,16 +2137,19 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| const stateUpdateTime = performance.now() - stateUpdateStartTime; | const stateUpdateTime = performance.now() - stateUpdateStartTime; | ||||
| console.log(` [PERF] State update time: ${stateUpdateTime.toFixed(2)}ms`); | 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(); | 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; | const markProcessedTime = performance.now() - markProcessedStartTime; | ||||
| console.log(` [PERF] Mark processed time: ${markProcessedTime.toFixed(2)}ms`); | 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; | const totalTime = performance.now() - totalStartTime; | ||||
| console.log(`✅ [PROCESS OUTSIDE QR END] Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); | 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(` 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( | console.log( | ||||
| workbenchMode | workbenchMode | ||||
| ? "✅ Workbench scan-pick: list refreshed from server" | ? "✅ Workbench scan-pick: list refreshed from server" | ||||
| @@ -2085,14 +2203,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配 | // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配 | ||||
| // Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描 | // Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描 | ||||
| const mismatchCheckStartTime = performance.now(); | const mismatchCheckStartTime = performance.now(); | ||||
| const itemProcessedSet2 = processedQrCombinations.get(scannedItemId); | |||||
| if ( | if ( | ||||
| itemProcessedSet2?.has(scannedStockInLineId) && | |||||
| !activeLotsStillNeedStockInLine(activeSuggestedLots, scannedStockInLineId) | |||||
| !hasPendingActiveRowForStockInLine( | |||||
| indexes, | |||||
| scannedItemId, | |||||
| scannedStockInLineId, | |||||
| processedQrCombinations, | |||||
| ) | |||||
| ) { | ) { | ||||
| const mismatchCheckTime = performance.now() - mismatchCheckStartTime; | const mismatchCheckTime = performance.now() - mismatchCheckStartTime; | ||||
| console.log( | 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; | return; | ||||
| } | } | ||||
| @@ -2100,7 +2221,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| console.log(` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); | console.log(` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); | ||||
| const expectedLotStartTime = performance.now(); | const expectedLotStartTime = performance.now(); | ||||
| const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); | |||||
| const expectedLot = pickExpectedLotForSubstitution( | |||||
| activeSuggestedLots, | |||||
| processedQrCombinations, | |||||
| ); | |||||
| if (!expectedLot) { | if (!expectedLot) { | ||||
| console.error("Could not determine expected lot for auto-switch"); | console.error("Could not determine expected lot for auto-switch"); | ||||
| startTransition(() => { | 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) { | if (workbenchMode) { | ||||
| await refreshWorkbenchAfterScanPick(); | await refreshWorkbenchAfterScanPick(); | ||||
| @@ -2262,7 +2391,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| ); | ); | ||||
| console.log(` End time: ${new Date().toISOString()}`); | console.log(` End time: ${new Date().toISOString()}`); | ||||
| console.log( | 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) { | } catch (error) { | ||||
| const totalTime = performance.now() - totalStartTime; | const totalTime = performance.now() - totalStartTime; | ||||
| @@ -2274,6 +2403,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| } finally { | |||||
| qrPickInFlightRef.current = false; | |||||
| } | |||||
| }, [ | }, [ | ||||
| lotDataIndexes, | lotDataIndexes, | ||||
| processedQrCombinations, | processedQrCombinations, | ||||
| @@ -2354,39 +2486,29 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // ✅ Process immediately (bypass QR scanner delay) | // ✅ Process immediately (bypass QR scanner delay) | ||||
| if (processOutsideQrCodeRef.current) { | 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(() => { | processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => { | ||||
| const testTime = performance.now() - testStartTime; | 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] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`); | ||||
| console.log(` [TEST QR] End time: ${new Date().toISOString()}`); | 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) => { | }).catch((error) => { | ||||
| const testTime = performance.now() - testStartTime; | const testTime = performance.now() - testStartTime; | ||||
| console.error(`❌ [TEST QR] Error after ${testTime.toFixed(2)}ms:`, error); | 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] 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 — 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; | return; | ||||
| } | } | ||||
| if (canRetrySamePhysicalLot) { | |||||
| processedQrCodesRef.current.delete(latestQr); | |||||
| if (lastProcessedQrRef.current === latestQr) { | |||||
| lastProcessedQrRef.current = ""; | |||||
| } | |||||
| } | |||||
| const checkTime = performance.now() - checkProcessedStartTime; | 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`); | ||||
| @@ -2483,8 +2606,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| } | } | ||||
| // Process new QR code immediately (background mode - no modal) | // 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(); | const processingStartTime = performance.now(); | ||||
| console.log(` [QR PROCESS] Starting processing at: ${new Date().toISOString()}`); | 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] 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 | // Use ref to avoid dependency issues | ||||
| const processCallStartTime = performance.now(); | const processCallStartTime = performance.now(); | ||||
| if (processOutsideQrCodeRef.current) { | if (processOutsideQrCodeRef.current) { | ||||
| qrProcessInFlightRef.current = true; | |||||
| processOutsideQrCodeRef.current(latestQr, qrValues.length) | processOutsideQrCodeRef.current(latestQr, qrValues.length) | ||||
| .then(() => { | .then(() => { | ||||
| const processCallTime = performance.now() - processCallStartTime; | const processCallTime = performance.now() - processCallStartTime; | ||||
| const totalProcessingTime = performance.now() - processingStartTime; | const totalProcessingTime = performance.now() - processingStartTime; | ||||
| console.log(` [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`); | 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)`); | 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) => { | .catch((error) => { | ||||
| const processCallTime = performance.now() - processCallStartTime; | const processCallTime = performance.now() - processCallStartTime; | ||||
| const totalProcessingTime = performance.now() - processingStartTime; | const totalProcessingTime = performance.now() - processingStartTime; | ||||
| console.error(`❌ [QR PROCESS] processOutsideQrCode error after ${processCallTime.toFixed(2)}ms:`, error); | 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`); | 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; | qrProcessingTimeoutRef.current = null; | ||||
| } | } | ||||
| }; | }; | ||||
| }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, manualLotConfirmationOpen]); | |||||
| }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, manualLotConfirmationOpen, lotDataIndexes, processedQrCombinations]); | |||||
| const renderCountRef = useRef(0); | const renderCountRef = useRef(0); | ||||
| const renderStartTimeRef = useRef<number | null>(null); | const renderStartTimeRef = useRef<number | null>(null); | ||||
| @@ -3212,7 +3310,7 @@ const handleStartScan = useCallback(() => { | |||||
| setProcessedQrCombinations(new Map()); | setProcessedQrCombinations(new Map()); | ||||
| processedQrCodesRef.current = new Set(); | processedQrCodesRef.current = new Set(); | ||||
| lastProcessedQrRef.current = ''; | lastProcessedQrRef.current = ''; | ||||
| qrProcessInFlightRef.current = false; | |||||
| qrPickInFlightRef.current = false; | |||||
| setQrScanError(false); | setQrScanError(false); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||