diff --git a/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx b/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx index 27bdf72..8a520ed 100644 --- a/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx +++ b/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx @@ -83,7 +83,7 @@ const DoSearchWorkbench: React.FC = ({ //console.log("🔍 DoSearch - session:", session); //console.log("🔍 DoSearch - currentUserId:", currentUserId); const [searchTimeout, setSearchTimeout] = useState(null); - /** 使用者明確取消勾選的送貨單 id;未在此集合中的搜尋結果視為「已選」以便跨頁記憶 */ + /** 使用者明確取消勾選的送貨單 id;未在此集合中的搜索結果視為「已選」以便跨頁記憶 */ const [excludedRowIds, setExcludedRowIds] = useState([]); const [searchAllDos, setSearchAllDos] = useState([]); diff --git a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx index d4a08e6..d3419cb 100644 --- a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx +++ b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx @@ -442,12 +442,15 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ sos.forEach((so: any) => { flatLotData.push({ pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode, + pickOrderLineId: line.id, itemCode: line.item?.code, itemName: line.item?.name, lotNo: so.lotNo || lot.lotNo, location: so.location || lot.location, deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode, - requiredQty: lineRequiredQty, + pickOrderLineRequiredQty: lineRequiredQty, + requiredQty: + so?.requiredQty ?? so?.suggestedPickLotQty ?? lot?.requiredQty ?? null, actualPickQty: so.qty ?? lot.actualPickQty ?? 0, processingStatus: toProc(so.status), stockOutLineStatus: so.status, @@ -457,12 +460,14 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ } else { flatLotData.push({ pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode, + pickOrderLineId: line.id, itemCode: line.item?.code, itemName: line.item?.name, lotNo: lot.lotNo, location: lot.location, deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode, - requiredQty: lot.requiredQty, + pickOrderLineRequiredQty: lineRequiredQty, + requiredQty: lot.requiredQty ?? null, actualPickQty: lot.actualPickQty ?? 0, processingStatus: lot.processingStatus || "pending", stockOutLineStatus: lot.stockOutLineStatus || "pending", @@ -474,12 +479,14 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ lineStockouts.forEach((so: any) => { flatLotData.push({ pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode, + pickOrderLineId: line.id, itemCode: line.item?.code, itemName: line.item?.name, lotNo: so.lotNo || "", location: so.location || "", deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode, - requiredQty: line.requiredQty ?? 0, + pickOrderLineRequiredQty: lineRequiredQty, + requiredQty: so?.requiredQty ?? so?.suggestedPickLotQty ?? null, actualPickQty: so.qty ?? 0, processingStatus: toProc(so.status), stockOutLineStatus: so.status, @@ -581,24 +588,40 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ - {data.lots.map((lot: any, index: number) => ( - - {index + 1} - {lot.itemCode || "N/A"} - {lot.itemName || "N/A"} - {lot.lotNo || "N/A"} - {lot.location || "N/A"} - {lot.requiredQty || 0} - {lot.actualPickQty || 0} - - - - - ))} + {(() => { + const seenGroupKeys = new Set(); + return data.lots.map((lot: any, index: number) => { + const groupKey = + lot.pickOrderLineId != null + ? `pol:${lot.pickOrderLineId}` + : `item:${lot.itemCode || ""}__do:${lot.deliveryOrderCode || ""}`; + const isGroupFirst = !seenGroupKeys.has(groupKey); + if (isGroupFirst) { + seenGroupKeys.add(groupKey); + } + const requiredQtyDisplay = isGroupFirst + ? lot.pickOrderLineRequiredQty ?? null + : null; + return ( + + {index + 1} + {lot.itemCode || "N/A"} + {lot.itemName || "N/A"} + {lot.lotNo || "N/A"} + {lot.location || "N/A"} + {requiredQtyDisplay} + {lot.actualPickQty || 0} + + + + + ); + }); + })()} diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index beca2cd..0d54f36 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -81,20 +81,115 @@ interface Props { onWorkbenchHierarchyEmpty?: () => void; } -/** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */ -function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null { - if (!activeSuggestedLots?.length) return null; - const withLotNo = activeSuggestedLots.filter( - (l) => l.lotNo != null && String(l.lotNo).trim() !== "" +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]; +} + +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; +} + +function parseWorkbenchQrPayload( + latestQr: string, +): { itemId: number; stockInLineId: number } | null { + try { + const qrData = JSON.parse(latestQr); + const itemId = Number(qrData?.itemId); + const stockInLineId = Number(qrData?.stockInLineId); + if (!Number.isFinite(itemId) || !Number.isFinite(stockInLineId)) return null; + return { itemId, stockInLineId }; + } catch { + return null; } - return activeSuggestedLots[0]; +} + +function hasPendingActiveRowForStockInLine( + indexes: { + byStockInLineId: Map; + activeLotsByItemId: Map; + }, + itemId: number, + stockInLineId: number, + processedByItemId: ProcessedStockOutLinesByItemId, +): boolean { + 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<{ @@ -580,8 +675,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); @@ -596,6 +692,7 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); // Use refs for processed QR tracking to avoid useEffect dependency issues and delays const processedQrCodesRef = useRef>(new Set()); const lastProcessedQrRef = useRef(''); + const qrPickInFlightRef = useRef(false); // Store callbacks in refs to avoid useEffect dependency issues const processOutsideQrCodeRef = useRef< @@ -847,21 +944,25 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO : mergedPickOrder.pickOrderLines || []; pickOrderLinesForDisplay.forEach((line: any) => { - // 用来记录这一行已经通过 lots 出现过的 lotId + // 用来记录这一行已经通过 lots 出现过的 lotId / stockOutLineId const lotIdSet = new Set(); + const stockOutLineIdSet = new Set(); - // ✅ lots:按 lotId 去重并合并 requiredQty + // ✅ lots:按 SOL 优先去重(其次 lotId),保持 requiredQty 原值,不在前端累计 if (line.lots && line.lots.length > 0) { - const lotMap = new Map(); + const lotMap = new Map(); - line.lots.forEach((lot: any) => { + line.lots.forEach((lot: any, lotIdx: number) => { const lotId = lot.id; - if (lotMap.has(lotId)) { - const existingLot = lotMap.get(lotId); - existingLot.requiredQty = - (existingLot.requiredQty || 0) + (lot.requiredQty || 0); - } else { - lotMap.set(lotId, { ...lot }); + const solId = Number(lot?.stockOutLineId); + const lotKey = + Number.isFinite(solId) && solId > 0 + ? `sol:${solId}` + : lotId != null + ? `lot:${lotId}` + : `idx:${lotIdx}`; + if (!lotMap.has(lotKey)) { + lotMap.set(lotKey, { ...lot }); } }); @@ -869,6 +970,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO if (lot.id != null) { lotIdSet.add(lot.id); } + const solId = Number(lot?.stockOutLineId); + if (Number.isFinite(solId) && solId > 0) { + stockOutLineIdSet.add(solId); + } flatLotData.push({ pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes) @@ -921,20 +1026,19 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO if (line.stockouts && line.stockouts.length > 0) { line.stockouts.forEach((stockout: any) => { const hasLot = stockout.lotId != null; + const hasSolId = stockout.id != null; const lotAlreadyInLots = hasLot && lotIdSet.has(stockout.lotId as number); + const solAlreadyInLots = + hasSolId && stockOutLineIdSet.has(Number(stockout.id)); // 有批次 & 已经通过 lots 渲染过 → 跳过,避免一条变两行 - if (!stockout.noLot && lotAlreadyInLots) { + if (!stockout.noLot && (solAlreadyInLots || lotAlreadyInLots)) { return; } - const stockoutRequiredQty = Number( - stockout?.requiredQty ?? stockout?.suggestedPickLotQty, - ); - const effectiveStockoutRequiredQty = Number.isFinite(stockoutRequiredQty) - ? stockoutRequiredQty - : Number(line.requiredQty) || 0; + const effectiveStockoutRequiredQty = + stockout?.requiredQty ?? stockout?.suggestedPickLotQty ?? null; const fallbackRouteFromLine = line?.lots?.[0]?.router?.route ?? line?.lots?.[0]?.location ?? null; @@ -1399,16 +1503,33 @@ 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()}`); - - // ✅ Measure index access time + const indexAccessStart = performance.now(); - const indexes = lotDataIndexes; // Access the memoized indexes + const indexes = lotDataIndexes; 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; @@ -1441,17 +1562,20 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const scannedItemId = qrData.itemId; const scannedStockInLineId = qrData.stockInLineId; - - // ✅ Check if this combination was already processed - const duplicateCheckStartTime = performance.now(); - const itemProcessedSet = processedQrCombinations.get(scannedItemId); - if (itemProcessedSet?.has(scannedStockInLineId)) { - const duplicateCheckTime = performance.now() - duplicateCheckStartTime; - console.log(` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed(2)}ms)`); + + if ( + !hasPendingActiveRowForStockInLine( + indexes, + scannedItemId, + scannedStockInLineId, + processedQrCombinations, + ) + ) { + 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(); @@ -1463,9 +1587,15 @@ 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 = @@ -1482,13 +1612,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; - }); + if (scannedLot?.stockOutLineId != null) { + const nextProcessed = markProcessedStockOutLine( + processedQrCombinations, + scannedItemId, + scannedLot.stockOutLineId, + ); + setProcessedQrCombinations(nextProcessed); + } return; } @@ -1675,12 +1806,13 @@ 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(); @@ -1691,16 +1823,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'}`); @@ -1709,7 +1838,8 @@ 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); @@ -1842,12 +1972,13 @@ 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(); } @@ -1940,14 +2071,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // Mark this combination as processed const markProcessedStartTime = performance.now(); - setProcessedQrCombinations(prev => { - const newMap = new Map(prev); - if (!newMap.has(scannedItemId)) { - newMap.set(scannedItemId, new Set()); - } - newMap.get(scannedItemId)!.add(scannedStockInLineId); - return newMap; - }); + const 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`); @@ -1958,7 +2088,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" @@ -2000,20 +2130,11 @@ 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)) { - const mismatchCheckTime = performance.now() - mismatchCheckStartTime; - console.log( - ` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed(2)}ms)`, - ); - return; - } - const mismatchCheckTime = performance.now() - mismatchCheckStartTime; - console.log(` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); - const expectedLotStartTime = performance.now(); - const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); + const expectedLot = pickExpectedLotForSubstitution( + activeSuggestedLots, + processedQrCombinations, + ); if (!expectedLot) { console.error("Could not determine expected lot for auto-switch"); startTransition(() => { @@ -2152,12 +2273,13 @@ 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(); @@ -2175,7 +2297,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, expectedLot=${expectedLotTime.toFixed(2)}ms`, ); } catch (error) { const totalTime = performance.now() - totalStartTime; @@ -2187,6 +2309,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO }); return; } + } finally { + qrPickInFlightRef.current = false; + } }, [ lotDataIndexes, processedQrCombinations, @@ -2310,13 +2435,32 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`); - // Skip if already processed (use refs to avoid dependency issues and delays) + const qrPayload = parseWorkbenchQrPayload(latestQr); + const canRetrySamePhysicalLot = + qrPayload != null && + hasPendingActiveRowForStockInLine( + lotDataIndexes, + qrPayload.itemId, + qrPayload.stockInLineId, + processedQrCombinations, + ); + + // 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) { + 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`); @@ -2351,8 +2495,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`); @@ -2416,7 +2559,15 @@ 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); @@ -2642,7 +2793,9 @@ useEffect(() => { type RowMeta = { lot: any; isGroupFirst: boolean; + isGroupLast: boolean; groupDisplayIndex: number; + isMultiLotGroup: boolean; }; const isCompletedStatus = (lot: any) => { @@ -2676,15 +2829,14 @@ useEffect(() => { { firstIndex: number; items: { lot: any; originalIndex: number }[] } >(); combinedLotData.forEach((lot: any, originalIndex: number) => { - const routeKey = String(lot?.routerRoute ?? "").trim(); const pickOrderLineKey = lot?.pickOrderLineId != null ? `pol:${String(lot.pickOrderLineId)}` : "pol:unknown"; const itemKey = lot?.itemId != null ? `itemId:${String(lot.itemId)}` : `itemCode:${String(lot?.itemCode ?? "").trim()}`; - // Group by pickOrderLine first so no-lot row stays with its lot rows even when route is empty. - const key = `${pickOrderLineKey}__${itemKey}__${routeKey}`; + // Group by pick order line + item so split lots (different routes) share one display index. + const key = `${pickOrderLineKey}__${itemKey}`; const g = groups.get(key); if (!g) { groups.set(key, { firstIndex: originalIndex, items: [{ lot, originalIndex }] }); @@ -2712,7 +2864,9 @@ useEffect(() => { flattened.push({ lot: it.lot, isGroupFirst: idx === 0, + isGroupLast: idx === sortedWithin.length - 1, groupDisplayIndex, + isMultiLotGroup: sortedWithin.length > 1, }); }); } @@ -3331,12 +3485,12 @@ const handleSubmitAllScanned = useCallback(async () => { // 添加调试日志 const noLotCount = filtered.filter(l => l.noLot === true).length; const normalCount = filtered.filter(l => l.noLot !== true).length; - console.log(`📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`); - console.log(`📊 All items breakdown:`, { - total: combinedLotData.length, - noLot: combinedLotData.filter(l => l.noLot === true).length, - normal: combinedLotData.filter(l => l.noLot !== true).length - }); + //console.log(`📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`); + //console.log(`📊 All items breakdown:`, { + //total: combinedLotData.length, + //noLot: combinedLotData.filter(l => l.noLot === true).length, + //normal: combinedLotData.filter(l => l.noLot !== true).length + //}); return filtered.length; }, [combinedLotData]); @@ -3647,16 +3801,42 @@ paginatedData.map((row, index) => { const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); const isSolRejected = solSt === "rejected" || String(lot.lotAvailability || "").toLowerCase() === "rejected"; - + const isFirstInGroup = row.isGroupFirst; + const isLastInGroup = row.isGroupLast; + const shouldOutline = row.isMultiLotGroup; + const outlineColor = "#008000"; + const groupUsesAltBg = row.groupDisplayIndex % 2 === 0; + const groupRowBg = groupUsesAltBg ? "neutral.50" : "background.paper"; return ( @@ -3671,7 +3851,7 @@ paginatedData.map((row, index) => { - {lot.routerRoute || '-'} + {lot.noLot ? "-" : lot.routerRoute || "-"} @@ -3746,8 +3926,13 @@ paginatedData.map((row, index) => { {(() => { - const requiredQty = lot.requiredQty || 0; - return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')'; + if (!row.isGroupFirst) return null; + const requiredQty = lot.pickOrderLineRequiredQty; + if (requiredQty == null) return null; + const requiredQtyNum = Number(requiredQty); + if (!Number.isFinite(requiredQtyNum)) return String(requiredQty); + const uom = lot.uomShortDesc ? `(${lot.uomShortDesc})` : ""; + return requiredQtyNum.toLocaleString() + uom; })()} @@ -3848,7 +4033,6 @@ paginatedData.map((row, index) => { const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; const isNoLot = !lot.lotNo; const isUnavailableRow = isInventoryLotLineUnavailable(lot); - // ✅ rejected lot:显示提示文本(换行显示) if (isRejected && !isNoLot) { const rejectHint = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t); @@ -3908,103 +4092,103 @@ paginatedData.map((row, index) => { ? String(pickQtyData[lotKey]) : String(displayedSubmitQty) : String(displayedSubmitQty); + const isRowPicked = + status === "completed" || + status === "checked" || + status === "partially_completed" || + status === "partially_complete"; return ( - - {/* - - */} - - { - if (!qtyFieldEnabled) return; - if (e.key !== "{") return; - e.preventDefault(); - setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({ - ...prev, - [lotKey]: false, - })); - (e.currentTarget as HTMLInputElement).blur(); - }} - onChange={(e) => { - if (!qtyFieldEnabled) return; - const n = Number(e.target.value); - if (Number.isFinite(n) && n < 0) return; - handlePickQtyChange(lotKey, e.target.value); - }} - inputProps={{ min: 0, step: 1 }} - sx={{ - width: 96, - '& .MuiInputBase-input': { fontSize: '0.75rem', py: 0.5, textAlign: 'center' }, - }} - /> - - - + {isRowPicked ? ( + + {t("")} + + ) : ( + <> + + + + )} ); }