| @@ -101,6 +101,66 @@ function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null | |||
| return activeSuggestedLots[0]; | |||
| } | |||
| /** Parse workbench QR / test shortcut into itemId + stockInLineId. */ | |||
| function parseWorkbenchScanQrPayload( | |||
| raw: string, | |||
| ): { itemId: number; stockInLineId: number } | null { | |||
| const text = String(raw || "").trim(); | |||
| if (!text) return null; | |||
| if ( | |||
| (text.startsWith("{2fitest") || text.startsWith("{2fittest")) && | |||
| text.endsWith("}") | |||
| ) { | |||
| let content = ""; | |||
| if (text.startsWith("{2fittest")) { | |||
| content = text.substring(9, text.length - 1); | |||
| } else { | |||
| content = text.substring(8, text.length - 1); | |||
| } | |||
| const parts = content.split(","); | |||
| if (parts.length === 2) { | |||
| const itemId = parseInt(parts[0].trim(), 10); | |||
| const stockInLineId = parseInt(parts[1].trim(), 10); | |||
| if ( | |||
| Number.isFinite(itemId) && | |||
| itemId > 0 && | |||
| Number.isFinite(stockInLineId) && | |||
| stockInLineId > 0 | |||
| ) { | |||
| return { itemId, stockInLineId }; | |||
| } | |||
| } | |||
| return null; | |||
| } | |||
| try { | |||
| const obj = JSON.parse(text); | |||
| const itemId = Number(obj?.itemId); | |||
| const stockInLineId = Number(obj?.stockInLineId); | |||
| if ( | |||
| Number.isFinite(itemId) && | |||
| itemId > 0 && | |||
| Number.isFinite(stockInLineId) && | |||
| stockInLineId > 0 | |||
| ) { | |||
| return { itemId, stockInLineId }; | |||
| } | |||
| } catch { | |||
| /* not JSON */ | |||
| } | |||
| return null; | |||
| } | |||
| /** True when another pending/partial SOL still suggests this stockInLineId (same lot across lines). */ | |||
| function activeLotsStillNeedStockInLine( | |||
| activeLotsForItem: any[] | undefined, | |||
| stockInLineId: number, | |||
| ): boolean { | |||
| if (!activeLotsForItem?.length) return false; | |||
| return activeLotsForItem.some( | |||
| (lot) => Number(lot?.stockInLineId) === Number(stockInLineId), | |||
| ); | |||
| } | |||
| const ManualLotConfirmationModal: React.FC<{ | |||
| open: boolean; | |||
| onClose: () => void; | |||
| @@ -601,6 +661,12 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||
| // Use refs for processed QR tracking to avoid useEffect dependency issues and delays | |||
| const processedQrCodesRef = useRef<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); | |||
| /** Latest lot indexes for QR re-scan allow checks outside processOutsideQrCode. */ | |||
| const lotDataIndexesRef = useRef<{ | |||
| activeLotsByItemId: Map<number, any[]>; | |||
| } | null>(null); | |||
| // Store callbacks in refs to avoid useEffect dependency issues | |||
| const processOutsideQrCodeRef = useRef< | |||
| @@ -1399,6 +1465,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| return { byItemId, byItemCode, byLotId, byLotNo, byStockInLineId, activeLotsByItemId }; | |||
| }, [combinedLotData.length, combinedLotData]); | |||
| lotDataIndexesRef.current = { | |||
| activeLotsByItemId: lotDataIndexes.activeLotsByItemId, | |||
| }; | |||
| // Store resetScan in ref for immediate access (update on every render) | |||
| resetScanRef.current = resetScan; | |||
| @@ -1447,10 +1517,19 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| const scannedItemId = qrData.itemId; | |||
| const scannedStockInLineId = qrData.stockInLineId; | |||
| // ✅ Check if this combination was already processed | |||
| // ✅ Skip only when this lot was already processed AND no other active line still needs it. | |||
| // Same item + same stockInLineId across different pick-order lines must be re-scannable. | |||
| const duplicateCheckStartTime = performance.now(); | |||
| const itemProcessedSet = processedQrCombinations.get(scannedItemId); | |||
| if (itemProcessedSet?.has(scannedStockInLineId)) { | |||
| const activeSuggestedLotsEarly = | |||
| indexes.activeLotsByItemId.get(scannedItemId) || []; | |||
| if ( | |||
| itemProcessedSet?.has(scannedStockInLineId) && | |||
| !activeLotsStillNeedStockInLine( | |||
| activeSuggestedLotsEarly, | |||
| scannedStockInLineId, | |||
| ) | |||
| ) { | |||
| const duplicateCheckTime = performance.now() - duplicateCheckStartTime; | |||
| console.log(` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed(2)}ms)`); | |||
| return; | |||
| @@ -1460,7 +1539,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) | |||
| const lookupStartTime = performance.now(); | |||
| const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; | |||
| const activeSuggestedLots = activeSuggestedLotsEarly; | |||
| // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected | |||
| const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; | |||
| const lookupTime = performance.now() - lookupStartTime; | |||
| @@ -2007,7 +2086,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| // Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描 | |||
| const mismatchCheckStartTime = performance.now(); | |||
| const itemProcessedSet2 = processedQrCombinations.get(scannedItemId); | |||
| if (itemProcessedSet2?.has(scannedStockInLineId)) { | |||
| if ( | |||
| itemProcessedSet2?.has(scannedStockInLineId) && | |||
| !activeLotsStillNeedStockInLine(activeSuggestedLots, scannedStockInLineId) | |||
| ) { | |||
| const mismatchCheckTime = performance.now() - mismatchCheckStartTime; | |||
| console.log( | |||
| ` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed(2)}ms)`, | |||
| @@ -2272,6 +2354,11 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| // ✅ Process immediately (bypass QR scanner delay) | |||
| if (processOutsideQrCodeRef.current) { | |||
| if (qrProcessInFlightRef.current) { | |||
| console.log(` [TEST QR] Skipping — scan-pick already in flight`); | |||
| return; | |||
| } | |||
| qrProcessInFlightRef.current = true; | |||
| processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => { | |||
| const testTime = performance.now() - testStartTime; | |||
| console.log(` [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`); | |||
| @@ -2279,6 +2366,27 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| }).catch((error) => { | |||
| const testTime = performance.now() - testStartTime; | |||
| console.error(`❌ [TEST QR] Error after ${testTime.toFixed(2)}ms:`, error); | |||
| }).finally(async () => { | |||
| await new Promise((r) => setTimeout(r, 50)); | |||
| try { | |||
| const activeForItem = | |||
| lotDataIndexesRef.current?.activeLotsByItemId.get(itemId); | |||
| if ( | |||
| activeLotsStillNeedStockInLine(activeForItem, stockInLineId) | |||
| ) { | |||
| processedQrCodesRef.current.delete(latestQr); | |||
| if (lastProcessedQrRef.current === latestQr) { | |||
| lastProcessedQrRef.current = ""; | |||
| } | |||
| setProcessedQrCodes(new Set(processedQrCodesRef.current)); | |||
| setLastProcessedQr(""); | |||
| console.log( | |||
| ` [TEST QR] Cleared processed test QR so same lot can be scanned for remaining line(s)`, | |||
| ); | |||
| } | |||
| } finally { | |||
| qrProcessInFlightRef.current = false; | |||
| } | |||
| }); | |||
| } | |||
| @@ -2315,11 +2423,30 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); | |||
| console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`); | |||
| // Skip if already processed (use refs to avoid dependency issues and delays) | |||
| // Skip if already processed — unless another active line still needs the same lot (cross-POL same lot). | |||
| const checkProcessedStartTime = performance.now(); | |||
| if (processedQrCodesRef.current.has(latestQr) || lastProcessedQrRef.current === latestQr) { | |||
| const checkTime = performance.now() - checkProcessedStartTime; | |||
| console.log(` [QR PROCESS] Already processed check time: ${checkTime.toFixed(2)}ms`); | |||
| const alreadySeenQr = | |||
| processedQrCodesRef.current.has(latestQr) || | |||
| lastProcessedQrRef.current === latestQr; | |||
| if (alreadySeenQr) { | |||
| const payload = parseWorkbenchScanQrPayload(latestQr); | |||
| const activeForItem = payload | |||
| ? lotDataIndexesRef.current?.activeLotsByItemId.get(payload.itemId) | |||
| : undefined; | |||
| const allowSameLotRescan = | |||
| !!payload && | |||
| activeLotsStillNeedStockInLine(activeForItem, payload.stockInLineId); | |||
| if (!allowSameLotRescan) { | |||
| const checkTime = performance.now() - checkProcessedStartTime; | |||
| console.log(` [QR PROCESS] Already processed check time: ${checkTime.toFixed(2)}ms`); | |||
| return; | |||
| } | |||
| console.log( | |||
| ` [QR PROCESS] Allowing re-scan of same lot QR for remaining active pick line(s)`, | |||
| ); | |||
| } | |||
| if (qrProcessInFlightRef.current) { | |||
| console.log(` [QR PROCESS] Skipping — scan-pick already in flight`); | |||
| return; | |||
| } | |||
| const checkTime = performance.now() - checkProcessedStartTime; | |||
| @@ -2389,17 +2516,46 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| // Use ref to avoid dependency issues | |||
| const processCallStartTime = performance.now(); | |||
| if (processOutsideQrCodeRef.current) { | |||
| processOutsideQrCodeRef.current(latestQr, qrValues.length).then(() => { | |||
| const processCallTime = performance.now() - processCallStartTime; | |||
| const totalProcessingTime = performance.now() - processingStartTime; | |||
| console.log(` [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`); | |||
| console.log(` [QR PROCESS] Total processing time: ${totalProcessingTime.toFixed(2)}ms (${(totalProcessingTime / 1000).toFixed(3)}s)`); | |||
| }).catch((error) => { | |||
| const processCallTime = performance.now() - processCallStartTime; | |||
| const totalProcessingTime = performance.now() - processingStartTime; | |||
| console.error(`❌ [QR PROCESS] processOutsideQrCode error after ${processCallTime.toFixed(2)}ms:`, error); | |||
| console.error(`❌ [QR PROCESS] Total processing time before error: ${totalProcessingTime.toFixed(2)}ms`); | |||
| }); | |||
| qrProcessInFlightRef.current = true; | |||
| processOutsideQrCodeRef.current(latestQr, qrValues.length) | |||
| .then(() => { | |||
| const processCallTime = performance.now() - processCallStartTime; | |||
| const totalProcessingTime = performance.now() - processingStartTime; | |||
| console.log(` [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`); | |||
| console.log(` [QR PROCESS] Total processing time: ${totalProcessingTime.toFixed(2)}ms (${(totalProcessingTime / 1000).toFixed(3)}s)`); | |||
| }) | |||
| .catch((error) => { | |||
| const processCallTime = performance.now() - processCallStartTime; | |||
| const totalProcessingTime = performance.now() - processingStartTime; | |||
| console.error(`❌ [QR PROCESS] processOutsideQrCode error after ${processCallTime.toFixed(2)}ms:`, error); | |||
| console.error(`❌ [QR PROCESS] Total processing time before error: ${totalProcessingTime.toFixed(2)}ms`); | |||
| }) | |||
| .finally(async () => { | |||
| // Wait a tick so refresh/local status updates commit into lotDataIndexesRef. | |||
| await new Promise((r) => setTimeout(r, 50)); | |||
| try { | |||
| const payload = parseWorkbenchScanQrPayload(latestQr); | |||
| const activeForItem = payload | |||
| ? lotDataIndexesRef.current?.activeLotsByItemId.get(payload.itemId) | |||
| : undefined; | |||
| if ( | |||
| payload && | |||
| activeLotsStillNeedStockInLine(activeForItem, payload.stockInLineId) | |||
| ) { | |||
| processedQrCodesRef.current.delete(latestQr); | |||
| if (lastProcessedQrRef.current === latestQr) { | |||
| lastProcessedQrRef.current = ""; | |||
| } | |||
| setProcessedQrCodes(new Set(processedQrCodesRef.current)); | |||
| setLastProcessedQr(""); | |||
| console.log( | |||
| ` [QR PROCESS] Cleared processed QR so same lot can be scanned for remaining line(s)`, | |||
| ); | |||
| } | |||
| } finally { | |||
| qrProcessInFlightRef.current = false; | |||
| } | |||
| }); | |||
| } | |||
| // Update state for UI (but don't block on it) | |||
| @@ -3053,6 +3209,10 @@ const handleStartScan = useCallback(() => { | |||
| setProcessedQrCodes(new Set()); | |||
| setLastProcessedQr(''); | |||
| setProcessedQrCombinations(new Map()); | |||
| processedQrCodesRef.current = new Set(); | |||
| lastProcessedQrRef.current = ''; | |||
| qrProcessInFlightRef.current = false; | |||
| setQrScanError(false); | |||
| setQrScanSuccess(false); | |||
| @@ -3742,16 +3902,22 @@ paginatedData.map((row, index) => { | |||
| const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); | |||
| const isSolRejected = | |||
| solSt === "rejected" || String(lot.lotAvailability || "").toLowerCase() === "rejected"; | |||
| const groupRowBg = | |||
| row.groupDisplayIndex % 2 === 0 ? "action.hover" : "background.paper"; | |||
| const nextRow = paginatedData[index + 1]; | |||
| const isLastInGroup = | |||
| !nextRow || nextRow.groupDisplayIndex !== row.groupDisplayIndex; | |||
| return ( | |||
| <TableRow | |||
| key={`${lot.pickOrderLineId}-${lot.lotId || 'null'}`} | |||
| sx={{ | |||
| //backgroundColor: isIssueLot ? '#fff3e0' : 'inherit', | |||
| // opacity: isIssueLot ? 0.6 : 1, | |||
| '& .MuiTableCell-root': { | |||
| //color: isIssueLot ? 'warning.main' : 'inherit' | |||
| } | |||
| backgroundColor: groupRowBg, | |||
| "&:nth-of-type(even)": { backgroundColor: groupRowBg }, | |||
| "& .MuiTableCell-root": { | |||
| backgroundColor: groupRowBg, | |||
| ...(!isLastInGroup ? { borderBottom: "none" } : {}), | |||
| }, | |||
| }} | |||
| > | |||
| <TableCell> | |||
| @@ -4003,58 +4169,56 @@ paginatedData.map((row, index) => { | |||
| ? String(pickQtyData[lotKey]) | |||
| : String(displayedSubmitQty) | |||
| : String(displayedSubmitQty); | |||
| const pickStatus = String(lot.stockOutLineStatus ?? "").toLowerCase(); | |||
| const isRowPicked = | |||
| pickStatus === "completed" || | |||
| pickStatus === "checked" || | |||
| pickStatus === "partially_completed" || | |||
| pickStatus === "partially_complete"; | |||
| return ( | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| {/* | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => { | |||
| const submitQty = displayedSubmitQty; | |||
| handlePickQtyChange(lotKey, submitQty); | |||
| handleSubmitPickQtyWithQty(lot, submitQty, 'singleSubmit'); | |||
| }} | |||
| disabled={ | |||
| lot.lotAvailability === 'expired' || | |||
| isInventoryLotLineUnavailable(lot) || | |||
| lot.lotAvailability === 'rejected' || | |||
| lot.stockOutLineStatus === 'completed' || | |||
| lot.stockOutLineStatus === 'pending' || | |||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | |||
| } | |||
| sx={{ fontSize: '0.75rem', py: 0.5, minHeight: '28px', minWidth: '70px' }} | |||
| > | |||
| {t("Submit")} | |||
| </Button> | |||
| */} | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| disabled={!qtyFieldEnabled} | |||
| value={textFieldValue} | |||
| onKeyDown={(e) => { | |||
| 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' }, | |||
| }} | |||
| /> | |||
| <Stack direction="row" spacing={1} alignItems="center" justifyContent="center"> | |||
| {isRowPicked ? ( | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ | |||
| width: 96, | |||
| textAlign: "center", | |||
| fontSize: "1rem", | |||
| fontWeight: 500, | |||
| }} | |||
| > | |||
| {textFieldValue} | |||
| </Typography> | |||
| ) : ( | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| disabled={!qtyFieldEnabled} | |||
| value={textFieldValue} | |||
| onKeyDown={(e) => { | |||
| 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" }, | |||
| }} | |||
| /> | |||
| )} | |||
| <Button | |||
| variant="outlined" | |||