diff --git a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx index ca44093..43fd63e 100644 --- a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx +++ b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx @@ -653,6 +653,9 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); >(null); const resetScanRef = useRef<(() => void) | null>(null); const lotConfirmOpenedQrCountRef = useRef(0); + const lotConfirmLastQrRef = useRef(''); + const lotConfirmSkipNextScanRef = useRef(false); + const lotConfirmOpenedAtRef = useRef(0); @@ -666,11 +669,11 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); return { completed: 0, total: 0 }; } - // 與 allItemsReady 一致:noLot / 過期批號 的 pending 也算「已面對該行」可收尾 + // 與 allItemsReady 一致:noLot / 過期 / unavailable 的 pending 也算「已面對該行」可收尾 const nonPendingCount = combinedLotData.filter((lot) => { const status = lot.stockOutLineStatus?.toLowerCase(); if (status !== "pending") return true; - if (lot.noLot === true || isLotAvailabilityExpired(lot)) return true; + if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) return true; return false; }).length; @@ -749,6 +752,9 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); ...scannedLot, lotNo: scannedLot.lotNo || null, }); + // The QR that opened modal must NOT be treated as confirmation rescan. + lotConfirmSkipNextScanRef.current = true; + lotConfirmOpenedAtRef.current = Date.now(); setLotConfirmationOpen(true); const setStateTime = performance.now() - setStateStartTime; console.timeEnd('setLotConfirmationOpen'); @@ -1262,6 +1268,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO setExpectedLotData(null); setScannedLotData(null); setSelectedLotForQr(null); + lotConfirmLastQrRef.current = ''; + lotConfirmSkipNextScanRef.current = false; + lotConfirmOpenedAtRef.current = 0; if (clearProcessedRefs) { setTimeout(() => { @@ -1305,14 +1314,15 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO } }, []); - const handleLotConfirmation = useCallback(async () => { - if (!expectedLotData || !scannedLotData || !selectedLotForQr) return; + const handleLotConfirmation = useCallback(async (overrideScannedLot?: any) => { + const effectiveScannedLot = overrideScannedLot ?? scannedLotData; + if (!expectedLotData || !effectiveScannedLot || !selectedLotForQr) return; setIsConfirmingLot(true); setLotConfirmationError(null); try { - const newLotNo = scannedLotData?.lotNo; + const newLotNo = effectiveScannedLot?.lotNo; - const newStockInLineId = scannedLotData?.stockInLineId; + const newStockInLineId = effectiveScannedLot?.stockInLineId; const substitutionResult = await confirmLotSubstitution({ pickOrderLineId: selectedLotForQr.pickOrderLineId, @@ -1322,7 +1332,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO newStockInLineId: newStockInLineId }); - if (!substitutionResult || substitutionResult.code !== "SUCCESS") { + const substitutionCode = substitutionResult?.code; + const switchedToUnavailable = + substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE"; + if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) { const errMsg = substitutionResult?.code === "LOT_UNAVAILABLE" ? t( @@ -1350,7 +1363,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // setLastProcessedQr(''); setPickExecutionFormOpen(false); - if(selectedLotForQr?.stockOutLineId){ + if (selectedLotForQr?.stockOutLineId && !switchedToUnavailable) { const stockOutLineUpdate = await updateStockOutLineStatus({ id: selectedLotForQr.stockOutLineId, status: 'checked', @@ -1408,6 +1421,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO } return true; } + + // 扫到第三个 lot(既不是当前差异 lot,也不是原建议 lot): + // 直接按“扫描到的这一批”执行切换。 + await handleLotConfirmation({ + lotNo: null, + itemCode: expectedLotData?.itemCode, + itemName: expectedLotData?.itemName, + inventoryLotLineId: null, + stockInLineId: rescannedStockInLineId + }); + return true; } else { // 兼容纯 lotNo 文本扫码 const scannedText = rawQr?.trim(); @@ -1432,7 +1456,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO } return false; - }, [lotConfirmationOpen, selectedLotForQr, expectedLotData, scannedLotData, parseQrPayload, handleLotConfirmation, clearLotConfirmationState]); + }, [lotConfirmationOpen, selectedLotForQr, expectedLotData, scannedLotData, parseQrPayload, handleLotConfirmation, clearLotConfirmationState, handleLotMismatch]); const handleQrCodeSubmit = useCallback(async (lotNo: string) => { console.log(` Processing QR Code for lot: ${lotNo}`); @@ -1689,9 +1713,19 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // ✅ Use for loop instead of forEach for better performance on tablets for (let i = 0; i < combinedLotData.length; i++) { const lot = combinedLotData[i]; - const isActive = !rejectedStatuses.has(lot.lotAvailability) && - !rejectedStatuses.has(lot.stockOutLineStatus) && - !rejectedStatuses.has(lot.processingStatus); + const solStatus = String(lot.stockOutLineStatus || "").toLowerCase(); + const lotAvailability = String(lot.lotAvailability || "").toLowerCase(); + const processingStatus = String(lot.processingStatus || "").toLowerCase(); + const isUnavailable = isInventoryLotLineUnavailable(lot); + const isExpired = isLotAvailabilityExpired(lot); + const isRejected = + rejectedStatuses.has(lotAvailability) || + rejectedStatuses.has(solStatus) || + rejectedStatuses.has(processingStatus); + const isEnded = solStatus === "checked" || solStatus === "completed"; + const isPartially = solStatus === "partially_completed" || solStatus === "partially_complete"; + const isPending = solStatus === "pending" || solStatus === ""; + const isActive = !isRejected && !isUnavailable && !isExpired && !isEnded && (isPending || isPartially); if (lot.itemId) { if (!byItemId.has(lot.itemId)) { @@ -2228,9 +2262,22 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO if (isConfirmingLot) { return; } - if (qrValues.length <= lotConfirmOpenedQrCountRef.current) { + if (lotConfirmSkipNextScanRef.current) { + lotConfirmSkipNextScanRef.current = false; + lotConfirmLastQrRef.current = latestQr || ''; + return; + } + if (!latestQr) { + return; + } + // Prevent auto-accept from buffered duplicate right after modal opens, + // but allow intentional second scan of the same QR after debounce window. + const sameQr = latestQr === lotConfirmLastQrRef.current; + const justOpened = lotConfirmOpenedAtRef.current > 0 && (Date.now() - lotConfirmOpenedAtRef.current) < 800; + if (sameQr && justOpened) { return; } + lotConfirmLastQrRef.current = latestQr; void (async () => { try { const handled = await handleLotConfirmationByRescan(latestQr); @@ -2538,6 +2585,9 @@ useEffect(() => { if (lot.noLot === true) { return 0; } + if (isInventoryLotLineUnavailable(lot)) { + return 0; + } if (isLotAvailabilityExpired(lot)) { return 0; } @@ -2721,7 +2771,7 @@ const allItemsReady = useMemo(() => { // ✅ FIXED: 无库存(noLot)行:pending 状态也应该被视为 ready(可以提交) // ✅ 過期批號(未換批):與 noLot 相同,視為可收尾 - if (lot.noLot === true || isLotAvailabilityExpired(lot)) { + if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { return isChecked || isCompleted || isRejected || isPending; } @@ -2742,8 +2792,10 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe try { if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); + const targetUnavailable = isInventoryLotLineUnavailable(lot); + const effectiveSubmitQty = targetUnavailable && submitQty > 0 ? 0 : submitQty; // Just Complete: mark checked only, real posting happens in batch submit - if (submitQty === 0 && source === 'justComplete') { + if (effectiveSubmitQty === 0 && source === 'justComplete') { console.log(`=== SUBMITTING ALL ZEROS CASE ===`); console.log(`Lot: ${lot.lotNo}`); console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); @@ -2778,7 +2830,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe return; } - if (submitQty === 0 && source === 'singleSubmit') { + if (effectiveSubmitQty === 0 && source === 'singleSubmit') { console.log(`=== SUBMITTING ALL ZEROS CASE ===`); console.log(`Lot: ${lot.lotNo}`); console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); @@ -2815,7 +2867,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe } // FIXED: Calculate cumulative quantity correctly const currentActualPickQty = lot.actualPickQty || 0; - const cumulativeQty = currentActualPickQty + submitQty; + const cumulativeQty = currentActualPickQty + effectiveSubmitQty; // FIXED: Determine status based on cumulative quantity vs required quantity let newStatus = 'partially_completed'; @@ -2832,7 +2884,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe console.log(`Lot: ${lot.lotNo}`); console.log(`Required Qty: ${lot.requiredQty}`); console.log(`Current Actual Pick Qty: ${currentActualPickQty}`); - console.log(`New Submitted Qty: ${submitQty}`); + console.log(`New Submitted Qty: ${effectiveSubmitQty}`); console.log(`Cumulative Qty: ${cumulativeQty}`); console.log(`New Status: ${newStatus}`); console.log(`=====================================`); @@ -2841,7 +2893,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe id: lot.stockOutLineId, status: newStatus, // 后端 updateStatus 的 qty 是“增量 delta”,不能传 cumulativeQty(否则会重复累加导致 out/hold 大幅偏移) - qty: submitQty + qty: effectiveSubmitQty }); applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), newStatus, cumulativeQty); // 注意:库存过账(hold->out)与 ledger 由后端 updateStatus 内部统一处理; @@ -3112,7 +3164,7 @@ const handleSubmitAllScanned = useCallback(async () => { return false; } // ✅ noLot / 過期批號:與 noLot 相同,允許 pending(未換批也可批量收尾) - if (lot.noLot === true || isLotAvailabilityExpired(lot)) { + if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { return ( status === "checked" || status === "pending" || @@ -3155,12 +3207,16 @@ const handleSubmitAllScanned = useCallback(async () => { lot.stockOutLineStatus === "partially_completed" || issuePicked !== undefined; const expired = isLotAvailabilityExpired(lot); + const unavailable = isInventoryLotLineUnavailable(lot); let targetActual: number; let newStatus: string; // ✅ 過期且未在 Issue 填實際量:與 noLot 一樣按 0 完成 - if (expired && issuePicked === undefined) { + if (unavailable) { + targetActual = currentActualPickQty; + newStatus = "completed"; + } else if (expired && issuePicked === undefined) { targetActual = 0; newStatus = "completed"; } else if (onlyComplete) { @@ -3253,7 +3309,7 @@ const handleSubmitAllScanned = useCallback(async () => { return false; } // ✅ 与 handleSubmitAllScanned 完全保持一致 - if (lot.noLot === true || isLotAvailabilityExpired(lot)) { + if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { return ( status === "checked" || status === "pending" || @@ -3581,13 +3637,20 @@ paginatedData.map((lot, index) => { {lot.lotNo ? ( - lot.lotAvailability === 'expired' ? ( + isInventoryLotLineUnavailable(lot) ? ( + <> + {lot.lotNo}{' '} + {t('is unavable. Please check around have available QR code or not.')} + + ) : lot.lotAvailability === 'expired' ? ( <> {lot.lotNo}{' '} {t('is expired. Please check around have available QR code or not.')} @@ -3695,13 +3758,16 @@ paginatedData.map((lot, index) => { return null; })()} -{resolveSingleSubmitQty(lot)} + + {isInventoryLotLineUnavailable(lot) ? 0 : resolveSingleSubmitQty(lot)} + {(() => { const status = lot.stockOutLineStatus?.toLowerCase(); const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; const isNoLot = !lot.lotNo; + const isUnavailableLot = isInventoryLotLineUnavailable(lot); // ✅ rejected lot:显示提示文本(换行显示) if (isRejected && !isNoLot) { @@ -3806,12 +3872,14 @@ paginatedData.map((lot, index) => { variant="outlined" size="small" onClick={() => handleSkip(lot)} + title={isUnavailableLot ? t('is unavable. Please check around have available QR code or not.') : undefined} disabled={ lot.stockOutLineStatus === 'completed' || lot.stockOutLineStatus === 'checked' || lot.stockOutLineStatus === 'partially_completed' || lot.lotAvailability === 'expired' || + isUnavailableLot || // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交) (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) || @@ -3868,8 +3936,18 @@ paginatedData.map((lot, index) => { { - console.log(` [LOT CONFIRM MODAL] Closing modal, clearing state`); - clearLotConfirmationState(true); + console.log(` [LOT CONFIRM MODAL] Closing modal, reset scanner and release raw-QR dedupe`); + // 1) Reset scanner buffer first to avoid immediate reopen from buffered same QR. + if (resetScanRef.current) { + resetScanRef.current(); + } + // 2) Close modal state. + clearLotConfirmationState(false); + // 3) Release raw-QR dedupe after a short delay so user can re-scan B/C again. + setTimeout(() => { + lastProcessedQrRef.current = ''; + processedQrCodesRef.current.clear(); + }, 250); }} onConfirm={handleLotConfirmation} expectedLot={expectedLotData} diff --git a/src/components/Jodetail/LotConfirmationModal.tsx b/src/components/Jodetail/LotConfirmationModal.tsx index 887e963..11d0f16 100644 --- a/src/components/Jodetail/LotConfirmationModal.tsx +++ b/src/components/Jodetail/LotConfirmationModal.tsx @@ -112,6 +112,7 @@ const LotConfirmationModal: React.FC = ({ {t("If you confirm, the system will:")}
  • {t("Update your suggested lot to the this scanned lot")}
  • +
  • {t("You can also scan expected lot again to cancel this switch")}
diff --git a/src/components/Jodetail/newJobPickExecution.tsx b/src/components/Jodetail/newJobPickExecution.tsx index 9b44110..e2fcfec 100644 --- a/src/components/Jodetail/newJobPickExecution.tsx +++ b/src/components/Jodetail/newJobPickExecution.tsx @@ -519,6 +519,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { const [usernameList, setUsernameList] = useState([]); const initializationRef = useRef(false); + const scannerInitializedRef = useRef(false); const autoAssignRef = useRef(false); const formProps = useForm(); @@ -559,6 +560,9 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { // Store callbacks in refs to avoid useEffect dependency issues const processOutsideQrCodeRef = useRef<((latestQr: string) => Promise) | null>(null); const resetScanRef = useRef<(() => void) | null>(null); + const lotConfirmLastQrRef = useRef(''); + const lotConfirmSkipNextScanRef = useRef(false); + const lotConfirmOpenedAtRef = useRef(0); // Manual lot confirmation modal state (test shortcut {2fic}) const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false); @@ -726,11 +730,19 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { for (let i = 0; i < combinedLotData.length; i++) { const lot = combinedLotData[i]; - const isActive = - !rejectedStatuses.has(lot.lotAvailability) && - !rejectedStatuses.has(lot.stockOutLineStatus) && - !rejectedStatuses.has(lot.processingStatus) && - lot.stockOutLineStatus !== 'completed'; + const solStatus = String(lot.stockOutLineStatus || "").toLowerCase(); + const lotAvailability = String(lot.lotAvailability || "").toLowerCase(); + const processingStatus = String(lot.processingStatus || "").toLowerCase(); + const isUnavailable = isInventoryLotLineUnavailable(lot); + const isExpired = isLotAvailabilityExpired(lot); + const isRejected = + rejectedStatuses.has(lotAvailability) || + rejectedStatuses.has(solStatus) || + rejectedStatuses.has(processingStatus); + const isEnded = solStatus === "checked" || solStatus === "completed"; + const isPartially = solStatus === "partially_completed" || solStatus === "partially_complete"; + const isPending = solStatus === "pending" || solStatus === ""; + const isActive = !isRejected && !isUnavailable && !isExpired && !isEnded && (isPending || isPartially); if (lot.itemId) { if (!byItemId.has(lot.itemId)) { @@ -926,6 +938,23 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { } }, [session, currentUserId, fetchJobOrderData, loadUnassignedOrders, filterArgs?.pickOrderId]); + // 與 GoodPickExecutiondetail 一致:session 就緒後自動開啟背景掃碼(平板現場用) + useEffect(() => { + if (session && currentUserId && !scannerInitializedRef.current) { + scannerInitializedRef.current = true; + console.log("✅ [JO] Auto-starting QR scanner in background mode"); + setIsManualScanning(true); + startScan(); + } + }, [session, currentUserId, startScan]); + + // 僅在元件卸載時重置,讓 React Strict Mode 二次掛載仍能再走一次自動開掃(不因 startScan 引用變化而重複開掃) + useEffect(() => { + return () => { + scannerInitializedRef.current = false; + }; + }, []); + // Add event listener for manual assignment useEffect(() => { const handlePickOrderAssigned = () => { @@ -1085,6 +1114,8 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { ...scannedLot, lotNo: scannedLot.lotNo || null, }); + lotConfirmSkipNextScanRef.current = true; + lotConfirmOpenedAtRef.current = Date.now(); setLotConfirmationOpen(true); console.log("⚠️ [LOT MISMATCH] Modal opened - waiting for user confirmation"); }, 0); @@ -1110,9 +1141,28 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { } }, [fetchStockInLineInfoCached]); + const clearLotConfirmationState = useCallback((clearProcessedRefs: boolean = false) => { + setLotConfirmationOpen(false); + setLotConfirmationError(null); + setExpectedLotData(null); + setScannedLotData(null); + setSelectedLotForQr(null); + lotConfirmLastQrRef.current = ''; + lotConfirmSkipNextScanRef.current = false; + lotConfirmOpenedAtRef.current = 0; + + if (clearProcessedRefs) { + setTimeout(() => { + lastProcessedQrRef.current = ''; + processedQrCodesRef.current.clear(); + }, 100); + } + }, []); + // Add handleLotConfirmation function - const handleLotConfirmation = useCallback(async () => { - if (!expectedLotData || !scannedLotData || !selectedLotForQr) { + const handleLotConfirmation = useCallback(async (overrideScannedLot?: any) => { + const effectiveScannedLot = overrideScannedLot ?? scannedLotData; + if (!expectedLotData || !effectiveScannedLot || !selectedLotForQr) { console.error("❌ [LOT CONFIRM] Missing required data for lot confirmation"); return; } @@ -1125,8 +1175,8 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { setIsConfirmingLot(true); setLotConfirmationError(null); try { - let newLotLineId = scannedLotData?.inventoryLotLineId; - if (!newLotLineId && scannedLotData?.stockInLineId) { + let newLotLineId = effectiveScannedLot?.inventoryLotLineId; + if (!newLotLineId && effectiveScannedLot?.stockInLineId) { try { if (currentUserId && selectedLotForQr.pickOrderId && selectedLotForQr.itemId) { try { @@ -1136,8 +1186,8 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { console.error(`❌ [LOT CONFIRM] Error updating handler (non-critical):`, error); } } - console.log(`🔍 [LOT CONFIRM] Fetching lot detail for stockInLineId: ${scannedLotData.stockInLineId}`); - const ld = await fetchLotDetail(scannedLotData.stockInLineId); + console.log(`🔍 [LOT CONFIRM] Fetching lot detail for stockInLineId: ${effectiveScannedLot.stockInLineId}`); + const ld = await fetchLotDetail(effectiveScannedLot.stockInLineId); newLotLineId = ld.inventoryLotLineId; console.log(`✅ [LOT CONFIRM] Fetched lot detail: inventoryLotLineId=${newLotLineId}`); } catch (error) { @@ -1158,12 +1208,13 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { console.log("Suggested Pick Lot ID:", selectedLotForQr.suggestedPickLotId); console.log("Lot ID (fallback):", selectedLotForQr.lotId); console.log("New Inventory Lot Line ID:", newLotLineId); - console.log("Scanned Lot No:", scannedLotData.lotNo); - console.log("Scanned StockInLineId:", scannedLotData.stockInLineId); + console.log("Scanned Lot No:", effectiveScannedLot.lotNo); + console.log("Scanned StockInLineId:", effectiveScannedLot.stockInLineId); const originalSuggestedPickLotId = selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId; + let switchedToUnavailable = false; // noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo if (!originalSuggestedPickLotId) { if (!selectedLotForQr?.stockOutLineId) { @@ -1172,14 +1223,15 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { console.log("🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo..."); const res = await updateStockOutLineStatusByQRCodeAndLotNo({ pickOrderLineId: selectedLotForQr.pickOrderLineId, - inventoryLotNo: scannedLotData.lotNo || '', - stockInLineId: scannedLotData?.stockInLineId ?? null, + inventoryLotNo: effectiveScannedLot.lotNo || '', + stockInLineId: effectiveScannedLot?.stockInLineId ?? null, stockOutLineId: selectedLotForQr.stockOutLineId, itemId: selectedLotForQr.itemId, status: "checked", }); console.log("✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:", res); - const ok = res?.code === "checked" || res?.code === "SUCCESS"; + switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE"; + const ok = res?.code === "checked" || res?.code === "SUCCESS" || switchedToUnavailable; if (!ok) { const errMsg = res?.code === "LOT_UNAVAILABLE" @@ -1200,16 +1252,19 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { pickOrderLineId: selectedLotForQr.pickOrderLineId, stockOutLineId: selectedLotForQr.stockOutLineId, originalSuggestedPickLotId, - newInventoryLotNo: scannedLotData.lotNo || '', + newInventoryLotNo: effectiveScannedLot.lotNo || '', // ✅ required by LotSubstitutionConfirmRequest - newStockInLineId: scannedLotData?.stockInLineId ?? null, + newStockInLineId: effectiveScannedLot?.stockInLineId ?? null, }); console.log("✅ [LOT CONFIRM] Lot substitution result:", substitutionResult); // ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked. // Keep modal open so user can cancel/rescan. - if (!substitutionResult || substitutionResult.code !== "SUCCESS") { + switchedToUnavailable = + substitutionResult?.code === "SUCCESS_UNAVAILABLE" || + substitutionResult?.code === "BOUND_UNAVAILABLE"; + if (!substitutionResult || (substitutionResult.code !== "SUCCESS" && !switchedToUnavailable)) { console.error("❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status."); const errMsg = substitutionResult?.code === "LOT_UNAVAILABLE" @@ -1217,7 +1272,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated." ) : substitutionResult?.message || - `换批失败:stockInLineId ${scannedLotData?.stockInLineId ?? ""} 不存在或无法匹配`; + `换批失败:stockInLineId ${effectiveScannedLot?.stockInLineId ?? ""} 不存在或无法匹配`; setLotConfirmationError(errMsg); setQrScanError(true); setQrScanSuccess(false); @@ -1227,7 +1282,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { } // Update stock out line status to 'checked' after substitution - if(selectedLotForQr?.stockOutLineId){ + if (selectedLotForQr?.stockOutLineId && !switchedToUnavailable) { console.log(`🔄 [LOT CONFIRM] Updating stockOutLine ${selectedLotForQr.stockOutLineId} to 'checked'`); await updateStockOutLineStatus({ id: selectedLotForQr.stockOutLineId, @@ -1238,10 +1293,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { } // Close modal and clean up state BEFORE refreshing - setLotConfirmationOpen(false); - setExpectedLotData(null); - setScannedLotData(null); - setSelectedLotForQr(null); + clearLotConfirmationState(false); // Clear QR processing state but DON'T clear processedQrCodes yet setQrScanError(false); @@ -1267,12 +1319,12 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { setQrScanSuccess(false); setIsRefreshingData(false); // ✅ Clear processedQrCombinations to allow reprocessing the same QR if needed - if (scannedLotData?.stockInLineId && selectedLotForQr?.itemId) { + if (effectiveScannedLot?.stockInLineId && selectedLotForQr?.itemId) { setProcessedQrCombinations(prev => { const newMap = new Map(prev); const itemId = selectedLotForQr.itemId; if (itemId && newMap.has(itemId)) { - newMap.get(itemId)!.delete(scannedLotData.stockInLineId); + newMap.get(itemId)!.delete(effectiveScannedLot.stockInLineId); if (newMap.get(itemId)!.size === 0) { newMap.delete(itemId); } @@ -1294,7 +1346,62 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { } finally { setIsConfirmingLot(false); } - }, [expectedLotData, scannedLotData, selectedLotForQr, fetchJobOrderData, currentUserId, updateHandledBy, tPick]); + }, [expectedLotData, scannedLotData, selectedLotForQr, fetchJobOrderData, currentUserId, updateHandledBy, tPick, clearLotConfirmationState]); + + const handleLotConfirmationByRescan = useCallback(async (rawQr: string): Promise => { + if (!lotConfirmationOpen || !selectedLotForQr || !expectedLotData || !scannedLotData) { + return false; + } + let payload: any = null; + try { + payload = JSON.parse(rawQr); + } catch { + payload = null; + } + const expectedStockInLineId = Number(selectedLotForQr.stockInLineId); + const mismatchedStockInLineId = Number(scannedLotData?.stockInLineId); + if (payload?.stockInLineId && payload?.itemId) { + const rescannedStockInLineId = Number(payload.stockInLineId); + if (Number.isFinite(expectedStockInLineId) && rescannedStockInLineId === expectedStockInLineId) { + clearLotConfirmationState(false); + if (processOutsideQrCodeRef.current) { + await processOutsideQrCodeRef.current(JSON.stringify(payload)); + } + return true; + } + if (Number.isFinite(mismatchedStockInLineId) && rescannedStockInLineId === mismatchedStockInLineId) { + await handleLotConfirmation(); + return true; + } + await handleLotConfirmation({ + lotNo: null, + itemCode: expectedLotData?.itemCode, + itemName: expectedLotData?.itemName, + inventoryLotLineId: null, + stockInLineId: rescannedStockInLineId + }); + return true; + } else { + const scannedText = rawQr?.trim(); + const expectedLotNo = expectedLotData?.lotNo?.trim(); + const mismatchedLotNo = scannedLotData?.lotNo?.trim(); + if (expectedLotNo && scannedText === expectedLotNo) { + clearLotConfirmationState(false); + if (processOutsideQrCodeRef.current) { + await processOutsideQrCodeRef.current(JSON.stringify({ + itemId: selectedLotForQr.itemId, + stockInLineId: selectedLotForQr.stockInLineId, + })); + } + return true; + } + if (mismatchedLotNo && scannedText === mismatchedLotNo) { + await handleLotConfirmation(); + return true; + } + } + return false; + }, [lotConfirmationOpen, selectedLotForQr, expectedLotData, scannedLotData, handleLotConfirmation, clearLotConfirmationState]); const processOutsideQrCode = useCallback(async (latestQr: string) => { // ✅ Only JSON QR supported for outside scanner (avoid false positive with lotNo) @@ -1676,8 +1783,33 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { return; } - // Skip processing if modal open for same QR - if (lotConfirmationOpen || manualLotConfirmationOpen) { + if (lotConfirmationOpen) { + if (isConfirmingLot) return; + if (lotConfirmSkipNextScanRef.current) { + lotConfirmSkipNextScanRef.current = false; + lotConfirmLastQrRef.current = latestQr || ''; + return; + } + if (!latestQr) return; + const sameQr = latestQr === lotConfirmLastQrRef.current; + const justOpened = + lotConfirmOpenedAtRef.current > 0 && (Date.now() - lotConfirmOpenedAtRef.current) < 800; + if (sameQr && justOpened) return; + lotConfirmLastQrRef.current = latestQr; + void (async () => { + try { + const handled = await handleLotConfirmationByRescan(latestQr); + if (handled && resetScanRef.current) { + resetScanRef.current(); + } + } catch (e) { + console.error("Lot confirmation rescan failed:", e); + } + })(); + return; + } + // Skip processing if manual modal open for same QR + if (manualLotConfirmationOpen) { if (latestQr === lastProcessedQrRef.current) return; } @@ -1712,7 +1844,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { qrProcessingTimeoutRef.current = null; } }; - }, [qrValues.length, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen]); + }, [qrValues.length, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen, handleLotConfirmationByRescan, isConfirmingLot]); const handlePickQtyChange = useCallback((lotKey: string, value: number | string) => { if (value === '' || value === null || value === undefined) { @@ -1753,6 +1885,9 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { if (fromPick !== undefined && fromPick !== null && !Number.isNaN(Number(fromPick))) { return Number(fromPick); } + if (isInventoryLotLineUnavailable(lot)) { + return 0; + } if (lot.noLot === true || !lot.lotId) { return 0; } @@ -1873,11 +2008,13 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { // Continue even if handler update fails } } + const unavailableLot = isInventoryLotLineUnavailable(lot); + const effectiveSubmitQty = unavailableLot ? 0 : submitQty; // ✅ 两步完成(与 DO 对齐): // 1) Skip/Submit0 只把 SOL 标记为 checked(不直接 completed) // 2) 之后由 batch submit 把 SOL 推到 completed(允许 0) - if (submitQty === 0) { + if (effectiveSubmitQty === 0) { console.log(`=== SUBMITTING ALL ZEROS CASE ===`); console.log(`Lot: ${lot.lotNo}`); console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); @@ -1921,7 +2058,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { // Normal case: Calculate cumulative quantity correctly const currentActualPickQty = lot.actualPickQty || 0; - const cumulativeQty = currentActualPickQty + submitQty; + const cumulativeQty = currentActualPickQty + effectiveSubmitQty; // 短拣一次 completed(對齊 GoodPickExecutiondetail) let newStatus = "partially_completed"; @@ -1937,7 +2074,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { console.log(`Lot: ${lot.lotNo}`); console.log(`Required Qty: ${lot.requiredQty}`); console.log(`Current Actual Pick Qty: ${currentActualPickQty}`); - console.log(`New Submitted Qty: ${submitQty}`); + console.log(`New Submitted Qty: ${effectiveSubmitQty}`); console.log(`Cumulative Qty: ${cumulativeQty}`); console.log(`New Status: ${newStatus}`); console.log(`=====================================`); @@ -1946,7 +2083,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { id: lot.stockOutLineId, status: newStatus, // 后端 updateStatus 的 qty 是“增量 delta”,不能传 cumulativeQty(否则会重复累加导致 out/hold 大幅偏移) - qty: submitQty + qty: effectiveSubmitQty }); if (solId > 0) { setIssuePickedQtyBySolId((prev) => { @@ -2028,7 +2165,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { return false; } // noLot / 過期批號:允許 pending(對齊 GoodPickExecutiondetail) - if (lot.noLot === true || !lot.lotId || isLotAvailabilityExpired(lot)) { + if (lot.noLot === true || !lot.lotId || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { return ( status === "checked" || status === "pending" || @@ -2075,11 +2212,15 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { const onlyComplete = lot.stockOutLineStatus === "partially_completed" || issuePicked !== undefined; const expired = isLotAvailabilityExpired(lot); + const unavailable = isInventoryLotLineUnavailable(lot); let targetActual: number; let newStatus: string; - if (expired && issuePicked === undefined) { + if (unavailable) { + targetActual = currentActualPickQty; + newStatus = "completed"; + } else if (expired && issuePicked === undefined) { targetActual = 0; newStatus = "completed"; } else if (onlyComplete) { @@ -2174,7 +2315,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { if (statusLower === "completed" || statusLower === "complete") { return false; } - if (lot.noLot === true || !lot.lotId || isLotAvailabilityExpired(lot)) { + if (lot.noLot === true || !lot.lotId || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { return ( status === "checked" || status === "pending" || @@ -2212,7 +2353,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { const nonPendingCount = data.filter((lot) => { const status = lot.stockOutLineStatus?.toLowerCase(); if (status !== "pending") return true; - if (lot.noLot === true || isLotAvailabilityExpired(lot)) return true; + if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) return true; return false; }).length; return { completed: nonPendingCount, total: data.length }; @@ -2418,24 +2559,9 @@ const sortedData = [...sourceData].sort((a, b) => { } }; }, [isManualScanning, stopScan, resetScan]); - useEffect(() => { - if (isManualScanning && combinedLotData.length === 0) { - console.log(" No data available, auto-stopping QR scan..."); - handleStopScan(); - } - }, [combinedLotData.length, isManualScanning, handleStopScan]); + // 勿在 combinedLotData 仍為空時自動停掃:API 未回傳前會誤觸,與 GoodPickExecutiondetail(已註解掉同段)一致。 + // 無資料時 qrValues effect 本來就不會處理掃碼;真正無單據可再手動按停止。 - // Cleanup effect - useEffect(() => { - return () => { - // Cleanup when component unmounts (e.g., when switching tabs) - if (isManualScanning) { - console.log("🧹 Component unmounting, stopping QR scanner..."); - stopScan(); - resetScan(); - } - }; - }, [isManualScanning, stopScan, resetScan]); const getStatusMessage = useCallback((lot: any) => { if (lot?.noLot === true || lot?.lotAvailability === 'insufficient_stock') { return t("This order is insufficient, please pick another lot."); @@ -2641,13 +2767,22 @@ const sortedData = [...sourceData].sort((a, b) => { {lot.lotNo ? ( - lot.lotAvailability === "expired" ? ( + isInventoryLotLineUnavailable(lot) ? ( + <> + {lot.lotNo}{" "} + {t( + "is unavable. Please check around have available QR code or not.", + )} + + ) : lot.lotAvailability === "expired" ? ( <> {lot.lotNo}{" "} {t( @@ -2765,7 +2900,9 @@ const sortedData = [...sourceData].sort((a, b) => { })()}
- {resolveSingleSubmitQty(lot)} + + {isInventoryLotLineUnavailable(lot) ? 0 : resolveSingleSubmitQty(lot)} + @@ -2773,6 +2910,7 @@ const sortedData = [...sourceData].sort((a, b) => { const status = lot.stockOutLineStatus?.toLowerCase(); const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; const isNoLot = !lot.lotNo; + const isUnavailableLot = isInventoryLotLineUnavailable(lot); // ✅ rejected lot:显示提示文本(换行显示) if (isRejected && !isNoLot) { @@ -2894,11 +3032,13 @@ const sortedData = [...sourceData].sort((a, b) => { lot.stockOutLineStatus === 'checked' || lot.stockOutLineStatus === 'partially_completed' || lot.lotAvailability === 'expired' || + isUnavailableLot || lot.noLot === true || !lot.lotId || (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) } + title={isUnavailableLot ? t("is unavable. Please check around have available QR code or not.") : undefined} sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '90px' }} > {t("Just Complete")} @@ -2938,7 +3078,7 @@ const sortedData = [...sourceData].sort((a, b) => { onClose={() => { setQrModalOpen(false); setSelectedLotForQr(null); - stopScan(); + // Keep scanner active like GoodPickExecutiondetail. resetScan(); }} lot={selectedLotForQr} @@ -2951,36 +3091,15 @@ const sortedData = [...sourceData].sort((a, b) => { { - console.log(`⏱️ [LOT CONFIRM MODAL] Closing modal, clearing state`); - setLotConfirmationOpen(false); - setLotConfirmationError(null); - setExpectedLotData(null); - setScannedLotData(null); - setSelectedLotForQr(null); - - // ✅ IMPORTANT: Clear refs and processedQrCombinations to allow reprocessing the same QR code - // This allows the modal to reopen if user cancels and scans the same QR again + console.log(`⏱️ [LOT CONFIRM MODAL] Closing modal, reset scanner and release raw-QR dedupe`); + if (resetScanRef.current) { + resetScanRef.current(); + } + clearLotConfirmationState(false); setTimeout(() => { lastProcessedQrRef.current = ''; processedQrCodesRef.current.clear(); - - // Clear processedQrCombinations for this itemId+stockInLineId combination - if (scannedLotData?.stockInLineId && selectedLotForQr?.itemId) { - setProcessedQrCombinations(prev => { - const newMap = new Map(prev); - const itemId = selectedLotForQr.itemId; - if (itemId && newMap.has(itemId)) { - newMap.get(itemId)!.delete(scannedLotData.stockInLineId); - if (newMap.get(itemId)!.size === 0) { - newMap.delete(itemId); - } - } - return newMap; - }); - } - - console.log(`⏱️ [LOT CONFIRM MODAL] Cleared refs and processedQrCombinations to allow reprocessing`); - }, 100); + }, 250); }} onConfirm={handleLotConfirmation} expectedLot={expectedLotData} diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index e55fc9d..50a512f 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -461,6 +461,7 @@ "No released pick order records found.": "目前沒有可用的提料單。", "EDT - Lane Code (Unassigned/Total)": "預計出發時間 - 貨車班次(未撳數/總單數)", "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.": "掃描的庫存批行為「不可用」,無法換批或綁定;揀貨行未更新。", + "is unavable. Please check around have available QR code or not.": "此批號不可用,請檢查周圍是否有可用的 QR 碼。", "Lot switch failed; pick line was not marked as checked.": "換批失敗;揀貨行未標為已核對。", "Lot confirmation failed. Please try again.": "確認批號失敗,請重試。", "Lot status is unavailable. Cannot switch or bind; pick line was not updated.": "批號狀態為「不可用」,無法換批或綁定;揀貨行未更新。"