From 4fc7e873757bdef39ae955f6c8eeb1ec5e6ce774 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 28 Jan 2026 16:56:37 +0800 Subject: [PATCH] update some jo qr --- .../GoodPickExecutiondetail.tsx | 107 --- .../Jodetail/JobPickExecutionForm.tsx | 182 ++++- .../Jodetail/newJobPickExecution.tsx | 765 +++++++++++++----- src/i18n/zh/jo.json | 2 + 4 files changed, 711 insertions(+), 345 deletions(-) diff --git a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx index 137ef52..3251015 100644 --- a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx +++ b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx @@ -2120,114 +2120,7 @@ useEffect(() => { } }, [currentUserId]); - // Handle submit pick quantity - const handleSubmitPickQty = useCallback(async (lot: any) => { - const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; - const newQty = pickQtyData[lotKey] || 0; - - if (!lot.stockOutLineId) { - console.error("No stock out line found for this lot"); - return; - } - - try { - // FIXED: Calculate cumulative quantity correctly - const currentActualPickQty = lot.actualPickQty || 0; - const cumulativeQty = currentActualPickQty + newQty; - - // FIXED: Determine status based on cumulative quantity vs required quantity - let newStatus = 'partially_completed'; - - if (cumulativeQty >= lot.requiredQty) { - newStatus = 'completed'; - } else if (cumulativeQty > 0) { - newStatus = 'partially_completed'; - } else { - newStatus = 'checked'; // QR scanned but no quantity submitted yet - } - - console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`); - console.log(`Lot: ${lot.lotNo}`); - console.log(`Required Qty: ${lot.requiredQty}`); - console.log(`Current Actual Pick Qty: ${currentActualPickQty}`); - console.log(`New Submitted Qty: ${newQty}`); - console.log(`Cumulative Qty: ${cumulativeQty}`); - console.log(`New Status: ${newStatus}`); - console.log(`=====================================`); - - await updateStockOutLineStatus({ - id: lot.stockOutLineId, - status: newStatus, - qty: cumulativeQty // Use cumulative quantity - }); - - if (newQty > 0) { - await updateInventoryLotLineQuantities({ - inventoryLotLineId: lot.lotId, - qty: newQty, - status: 'available', - operation: 'pick' - }); - } - - // Check if pick order is completed when lot status becomes 'completed' - if (newStatus === 'completed' && lot.pickOrderConsoCode) { - console.log(` Lot ${lot.lotNo} completed, checking if pick order ${lot.pickOrderConsoCode} is complete...`); - - try { - const completionResponse = await checkAndCompletePickOrderByConsoCode(lot.pickOrderConsoCode); - console.log(` Pick order completion check result:`, completionResponse); - - if (completionResponse.code === "SUCCESS") { - console.log(`�� Pick order ${lot.pickOrderConsoCode} completed successfully!`); - } else if (completionResponse.message === "not completed") { - console.log(`⏳ Pick order not completed yet, more lines remaining`); - } else { - console.error(` Error checking completion: ${completionResponse.message}`); - } - } catch (error) { - console.error("Error checking pick order completion:", error); - } - } - - await fetchAllCombinedLotData(); - console.log("Pick quantity submitted successfully!"); - - setTimeout(() => { - checkAndAutoAssignNext(); - }, 1000); - - } catch (error) { - console.error("Error submitting pick quantity:", error); - } - }, [pickQtyData, fetchAllCombinedLotData, checkAndAutoAssignNext]); - // Handle reject lot - const handleRejectLot = useCallback(async (lot: any) => { - if (!lot.stockOutLineId) { - console.error("No stock out line found for this lot"); - return; - } - - try { - await updateStockOutLineStatus({ - id: lot.stockOutLineId, - status: 'rejected', - qty: 0 - }); - - await fetchAllCombinedLotData(); - console.log("Lot rejected successfully!"); - - setTimeout(() => { - checkAndAutoAssignNext(); - }, 1000); - - } catch (error) { - console.error("Error rejecting lot:", error); - } - }, [fetchAllCombinedLotData, checkAndAutoAssignNext]); - // Handle pick execution form const handlePickExecutionForm = useCallback((lot: any) => { console.log("=== Pick Execution Form ==="); diff --git a/src/components/Jodetail/JobPickExecutionForm.tsx b/src/components/Jodetail/JobPickExecutionForm.tsx index 3cf7b27..03b16f1 100644 --- a/src/components/Jodetail/JobPickExecutionForm.tsx +++ b/src/components/Jodetail/JobPickExecutionForm.tsx @@ -70,6 +70,7 @@ interface FormErrors { badItemQty?: string; issueRemark?: string; handledBy?: string; + badReason?: string; } const PickExecutionForm: React.FC = ({ @@ -163,8 +164,12 @@ useEffect(() => { actualPickQty: initialVerifiedQty, missQty: 0, badItemQty: 0, - issueRemark: '', + badPackageQty: 0, // Bad Package Qty (frontend only) + issueRemark: "", + pickerName: "", handledBy: undefined, + reason: "", + badReason: "", }); } // 只在 open 状态改变时重新初始化,移除其他依赖 @@ -185,30 +190,51 @@ useEffect(() => { } }, [errors]); - // Update form validation to require either missQty > 0 OR badItemQty > 0 + // Updated validation logic (same as GoodPickExecutionForm) const validateForm = (): boolean => { const newErrors: FormErrors = {}; - - const requiredQty = selectedLot?.requiredQty || 0; - const badItemQty = formData.badItemQty || 0; - const missQty = formData.missQty || 0; - - if (verifiedQty === undefined || verifiedQty < 0) { - newErrors.actualPickQty = t('Qty is required'); + const ap = Number(verifiedQty) || 0; + const miss = Number(formData.missQty) || 0; + const badItem = Number(formData.badItemQty) || 0; + const badPackage = Number((formData as any).badPackageQty) || 0; + const totalBad = badItem + badPackage; + const total = ap + miss + totalBad; + const availableQty = selectedLot?.availableQty || 0; + + // 1. Check actualPickQty cannot be negative + if (ap < 0) { + newErrors.actualPickQty = t("Qty cannot be negative"); } - - const totalQty = verifiedQty + badItemQty + missQty; - const hasAnyValue = verifiedQty > 0 || badItemQty > 0 || missQty > 0; - - // ✅ 新增:必须至少有一个 > 0 - if (!hasAnyValue) { - newErrors.actualPickQty = t('At least one of Verified / Missing / Bad must be greater than 0'); + + // 2. Check actualPickQty cannot exceed available quantity + if (ap > availableQty) { + newErrors.actualPickQty = t("Actual pick qty cannot exceed available qty"); } - - if (hasAnyValue && totalQty !== requiredQty) { - newErrors.actualPickQty = t('Total (Verified + Bad + Missing) must equal Required quantity'); + + // 3. Check missQty and both bad qtys cannot be negative + if (miss < 0) { + newErrors.missQty = t("Invalid qty"); } - + if (badItem < 0 || badPackage < 0) { + newErrors.badItemQty = t("Invalid qty"); + } + + // 4. Total (actualPickQty + missQty + badItemQty + badPackageQty) cannot exceed lot available qty + if (total > availableQty) { + const errorMsg = t( + "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}", + { available: availableQty } + ); + newErrors.actualPickQty = errorMsg; + newErrors.missQty = errorMsg; + newErrors.badItemQty = errorMsg; + } + + // 5. At least one field must have a value + if (ap === 0 && miss === 0 && totalBad === 0) { + newErrors.actualPickQty = t("Enter pick qty or issue qty"); + } + setErrors(newErrors); return Object.keys(newErrors).length === 0; }; @@ -244,22 +270,38 @@ useEffect(() => { if (!validateForm() || !formData.pickOrderId) { return; } + + const badItem = Number(formData.badItemQty) || 0; + const badPackage = Number((formData as any).badPackageQty) || 0; + const totalBadQty = badItem + badPackage; + + let badReason: string | undefined; + if (totalBadQty > 0) { + // assumption: only one of them is > 0 + badReason = badPackage > 0 ? "package_problem" : "quantity_problem"; + } setLoading(true); try { - const submissionData = { - ...formData, + const submissionData: PickExecutionIssueData = { + ...(formData as PickExecutionIssueData), actualPickQty: verifiedQty, lotId: formData.lotId || selectedLot?.lotId || 0, lotNo: formData.lotNo || selectedLot?.lotNo || '', pickOrderCode: formData.pickOrderCode || selectedPickOrderLine?.pickOrderCode || '', - pickerName: session?.user?.name || '' - } as PickExecutionIssueData; + pickerName: session?.user?.name || '', + badItemQty: totalBadQty, + badReason, + }; await onSubmit(submissionData); onClose(); - } catch (error) { + } catch (error: any) { console.error('Error submitting pick execution issue:', error); + alert( + t("Failed to submit issue. Please try again.") + + (error.message ? `: ${error.message}` : "") + ); } finally { setLoading(false); } @@ -321,16 +363,24 @@ useEffect(() => { { - const newValue = parseFloat(e.target.value) || 0; - setVerifiedQty(newValue); - // handleInputChange('actualPickQty', newValue); + const newValue = e.target.value === "" + ? undefined + : Math.max(0, Number(e.target.value) || 0); + setVerifiedQty(newValue || 0); }} error={!!errors.actualPickQty} - helperText={errors.actualPickQty || `${t('Max')}: ${selectedLot?.actualPickQty || 0}`} // 使用原始接收数量 + helperText={ + errors.actualPickQty || `${t("Max")}: ${remainingAvailableQty}` + } variant="outlined" /> @@ -340,14 +390,21 @@ useEffect(() => { fullWidth label={t('Missing item Qty')} type="number" + inputProps={{ + inputMode: "numeric", + pattern: "[0-9]*", + min: 0, + }} value={formData.missQty || 0} onChange={(e) => { - const newMissQty = parseFloat(e.target.value) || 0; - handleInputChange('missQty', newMissQty); - // 不要自动修改其他字段 + handleInputChange( + "missQty", + e.target.value === "" + ? undefined + : Math.max(0, Number(e.target.value) || 0) + ); }} error={!!errors.missQty} - helperText={errors.missQty} variant="outlined" /> @@ -357,22 +414,67 @@ useEffect(() => { fullWidth label={t('Bad Item Qty')} type="number" + inputProps={{ + inputMode: "numeric", + pattern: "[0-9]*", + min: 0, + }} value={formData.badItemQty || 0} onChange={(e) => { - const newBadItemQty = parseFloat(e.target.value) || 0; + const newBadItemQty = e.target.value === "" + ? undefined + : Math.max(0, Number(e.target.value) || 0); handleInputChange('badItemQty', newBadItemQty); - // 不要自动修改其他字段 }} error={!!errors.badItemQty} helperText={errors.badItemQty} variant="outlined" /> + + + { + handleInputChange( + "badPackageQty", + e.target.value === "" + ? undefined + : Math.max(0, Number(e.target.value) || 0) + ); + }} + error={!!errors.badItemQty} + variant="outlined" + /> + + + + + {t("Remark")} + + + {/* Show issue description and handler fields when bad items > 0 */} - {(formData.badItemQty && formData.badItemQty > 0) ? ( + {(formData.badItemQty && formData.badItemQty > 0) || ((formData as any).badPackageQty && (formData as any).badPackageQty > 0) ? ( <> - + { - ) : (<>)} + ) : null} diff --git a/src/components/Jodetail/newJobPickExecution.tsx b/src/components/Jodetail/newJobPickExecution.tsx index 9e0590d..b59ac0e 100644 --- a/src/components/Jodetail/newJobPickExecution.tsx +++ b/src/components/Jodetail/newJobPickExecution.tsx @@ -20,7 +20,7 @@ import { Modal, } from "@mui/material"; import TestQrCodeProvider from '../QrCodeScannerProvider/TestQrCodeProvider'; -import { useCallback, useEffect, useState, useRef, useMemo } from "react"; +import { useCallback, useEffect, useState, useRef, useMemo, startTransition } from "react"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/navigation"; import { @@ -69,6 +69,110 @@ interface Props { onBackToList?: () => void; } +// Manual Lot Confirmation Modal (align with GoodPickExecutiondetail, opened by {2fic}) +const ManualLotConfirmationModal: React.FC<{ + open: boolean; + onClose: () => void; + onConfirm: (expectedLotNo: string, scannedLotNo: string) => void; + expectedLot: { lotNo: string; itemCode: string; itemName: string } | null; + scannedLot: { lotNo: string; itemCode: string; itemName: string } | null; + isLoading?: boolean; +}> = ({ open, onClose, onConfirm, expectedLot, scannedLot, isLoading = false }) => { + const { t } = useTranslation("jo"); + const [expectedLotInput, setExpectedLotInput] = useState(''); + const [scannedLotInput, setScannedLotInput] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + setExpectedLotInput(expectedLot?.lotNo || ''); + setScannedLotInput(scannedLot?.lotNo || ''); + setError(''); + } + }, [open, expectedLot, scannedLot]); + + const handleConfirm = () => { + if (!expectedLotInput.trim() || !scannedLotInput.trim()) { + setError(t("Please enter both expected and scanned lot numbers.")); + return; + } + if (expectedLotInput.trim() === scannedLotInput.trim()) { + setError(t("Expected and scanned lot numbers cannot be the same.")); + return; + } + onConfirm(expectedLotInput.trim(), scannedLotInput.trim()); + }; + + return ( + + + + {t("Manual Lot Confirmation")} + + + + + {t("Expected Lot Number")}: + + { setExpectedLotInput(e.target.value); setError(''); }} + sx={{ mb: 2 }} + error={!!error && !expectedLotInput.trim()} + /> + + + + + {t("Scanned Lot Number")}: + + { setScannedLotInput(e.target.value); setError(''); }} + sx={{ mb: 2 }} + error={!!error && !scannedLotInput.trim()} + /> + + + {error && ( + + + {error} + + + )} + + + + + + + + ); +}; + // QR Code Modal Component (from GoodPickExecution) const QrCodeModal: React.FC<{ open: boolean; @@ -345,6 +449,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { const [isConfirmingLot, setIsConfirmingLot] = useState(false); const [qrScanInput, setQrScanInput] = useState(''); const [qrScanError, setQrScanError] = useState(false); + const [qrScanErrorMsg, setQrScanErrorMsg] = useState(''); const [qrScanSuccess, setQrScanSuccess] = useState(false); const [jobOrderData, setJobOrderData] = useState(null); const [pickQtyData, setPickQtyData] = useState>({}); @@ -379,6 +484,25 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { const [processedQrCodes, setProcessedQrCodes] = useState>(new Set()); const [lastProcessedQr, setLastProcessedQr] = useState(''); const [isRefreshingData, setIsRefreshingData] = useState(false); + // Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling + const [processedQrCombinations, setProcessedQrCombinations] = useState>>(new Map()); + + // Cache for fetchStockInLineInfo API calls to avoid redundant requests + const stockInLineInfoCache = useRef>(new Map()); + const CACHE_TTL = 60000; // 60 seconds cache TTL + const abortControllerRef = useRef(null); + const qrProcessingTimeoutRef = useRef(null); + + // Use refs for processed QR tracking to avoid useEffect dependency issues and delays + const processedQrCodesRef = useRef>(new Set()); + const lastProcessedQrRef = useRef(''); + + // Store callbacks in refs to avoid useEffect dependency issues + const processOutsideQrCodeRef = useRef<((latestQr: string) => Promise) | null>(null); + const resetScanRef = useRef<(() => void) | null>(null); + + // Manual lot confirmation modal state (test shortcut {2fic}) + const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false); const getAllLotsFromHierarchical = useCallback(( data: JobOrderLotsHierarchicalResponse | null ): any[] => { @@ -426,6 +550,74 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { const originalCombinedData = useMemo(() => { return getAllLotsFromHierarchical(jobOrderData); }, [jobOrderData, getAllLotsFromHierarchical]); + + // Enhanced lotDataIndexes with cached active lots for better performance (align with GoodPickExecutiondetail) + const lotDataIndexes = useMemo(() => { + const byItemId = new Map(); + const byItemCode = new Map(); + const byLotId = new Map(); + const byLotNo = new Map(); + const byStockInLineId = new Map(); + const activeLotsByItemId = new Map(); + const rejectedStatuses = new Set(['rejected']); + + 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'; + + if (lot.itemId) { + if (!byItemId.has(lot.itemId)) { + byItemId.set(lot.itemId, []); + activeLotsByItemId.set(lot.itemId, []); + } + byItemId.get(lot.itemId)!.push(lot); + if (isActive) activeLotsByItemId.get(lot.itemId)!.push(lot); + } + if (lot.itemCode) { + if (!byItemCode.has(lot.itemCode)) byItemCode.set(lot.itemCode, []); + byItemCode.get(lot.itemCode)!.push(lot); + } + if (lot.lotId) byLotId.set(lot.lotId, lot); + if (lot.lotNo) { + if (!byLotNo.has(lot.lotNo)) byLotNo.set(lot.lotNo, []); + byLotNo.get(lot.lotNo)!.push(lot); + } + if (lot.stockInLineId) { + if (!byStockInLineId.has(lot.stockInLineId)) byStockInLineId.set(lot.stockInLineId, []); + byStockInLineId.get(lot.stockInLineId)!.push(lot); + } + } + + return { byItemId, byItemCode, byLotId, byLotNo, byStockInLineId, activeLotsByItemId }; + }, [combinedLotData]); + + // Cached version of fetchStockInLineInfo to avoid redundant API calls + const fetchStockInLineInfoCached = useCallback(async (stockInLineId: number): Promise<{ lotNo: string | null }> => { + const now = Date.now(); + const cached = stockInLineInfoCache.current.get(stockInLineId); + if (cached && (now - cached.timestamp) < CACHE_TTL) { + return { lotNo: cached.lotNo }; + } + + if (abortControllerRef.current) abortControllerRef.current.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + const stockInLineInfo = await fetchStockInLineInfo(stockInLineId); + stockInLineInfoCache.current.set(stockInLineId, { + lotNo: stockInLineInfo.lotNo || null, + timestamp: now + }); + if (stockInLineInfoCache.current.size > 100) { + const firstKey = stockInLineInfoCache.current.keys().next().value; + if (firstKey !== undefined) stockInLineInfoCache.current.delete(firstKey); + } + return { lotNo: stockInLineInfo.lotNo || null }; + }, []); // 修改:加载未分配的 Job Order 订单 const loadUnassignedOrders = useCallback(async () => { setIsLoadingUnassigned(true); @@ -719,53 +911,119 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { } }, [combinedLotData, fetchJobOrderData]); const handleLotMismatch = useCallback((expectedLot: any, scannedLot: any) => { - console.log("Lot mismatch detected:", { expectedLot, scannedLot }); - setExpectedLotData(expectedLot); - setScannedLotData(scannedLot); - setLotConfirmationOpen(true); - }, []); + console.log("⚠️ [LOT MISMATCH] Lot mismatch detected:", { expectedLot, scannedLot }); + console.log("⚠️ [LOT MISMATCH] Opening confirmation modal - NO lot will be marked as scanned until user confirms"); + + // ✅ schedule modal open in next tick (avoid flushSync warnings on some builds) + // ✅ IMPORTANT: This function ONLY opens the modal. It does NOT process any lot. + setTimeout(() => { + setExpectedLotData(expectedLot); + setScannedLotData({ + ...scannedLot, + lotNo: scannedLot.lotNo || null, + }); + setLotConfirmationOpen(true); + console.log("⚠️ [LOT MISMATCH] Modal opened - waiting for user confirmation"); + }, 0); + + // ✅ Fetch lotNo in background for display purposes (cached) + // ✅ This is ONLY for display - it does NOT process any lot + if (!scannedLot.lotNo && scannedLot.stockInLineId) { + console.log(`⚠️ [LOT MISMATCH] Fetching lotNo for display (stockInLineId: ${scannedLot.stockInLineId})`); + fetchStockInLineInfoCached(scannedLot.stockInLineId) + .then((info) => { + console.log(`⚠️ [LOT MISMATCH] Fetched lotNo for display: ${info.lotNo}`); + startTransition(() => { + setScannedLotData((prev: any) => ({ + ...prev, + lotNo: info.lotNo || null, + })); + }); + }) + .catch((error) => { + console.error(`❌ [LOT MISMATCH] Error fetching lotNo for display (stockInLineId may not exist):`, error); + // ignore display fetch errors - this does NOT affect processing + }); + } + }, [fetchStockInLineInfoCached]); // Add handleLotConfirmation function const handleLotConfirmation = useCallback(async () => { - if (!expectedLotData || !scannedLotData || !selectedLotForQr) return; + if (!expectedLotData || !scannedLotData || !selectedLotForQr) { + console.error("❌ [LOT CONFIRM] Missing required data for lot confirmation"); + return; + } + + console.log("✅ [LOT CONFIRM] User confirmed lot substitution - processing now"); + console.log("✅ [LOT CONFIRM] Expected lot:", expectedLotData); + console.log("✅ [LOT CONFIRM] Scanned lot:", scannedLotData); + console.log("✅ [LOT CONFIRM] Selected lot for QR:", selectedLotForQr); + setIsConfirmingLot(true); try { let newLotLineId = scannedLotData?.inventoryLotLineId; if (!newLotLineId && scannedLotData?.stockInLineId) { - const ld = await fetchLotDetail(scannedLotData.stockInLineId); - newLotLineId = ld.inventoryLotLineId; + try { + console.log(`🔍 [LOT CONFIRM] Fetching lot detail for stockInLineId: ${scannedLotData.stockInLineId}`); + const ld = await fetchLotDetail(scannedLotData.stockInLineId); + newLotLineId = ld.inventoryLotLineId; + console.log(`✅ [LOT CONFIRM] Fetched lot detail: inventoryLotLineId=${newLotLineId}`); + } catch (error) { + console.error("❌ [LOT CONFIRM] Error fetching lot detail (stockInLineId may not exist):", error); + // If stockInLineId doesn't exist, we can still proceed with lotNo substitution + // The backend confirmLotSubstitution should handle this case + } } if (!newLotLineId) { - console.error("No inventory lot line id for scanned lot"); - return; + console.warn("⚠️ [LOT CONFIRM] No inventory lot line id for scanned lot, proceeding with lotNo only"); + // Continue anyway - backend may handle lotNo substitution without inventoryLotLineId } - console.log("=== Lot Confirmation Debug ==="); + console.log("=== [LOT CONFIRM] Lot Confirmation Debug ==="); console.log("Selected Lot:", selectedLotForQr); console.log("Pick Order Line ID:", selectedLotForQr.pickOrderLineId); console.log("Stock Out Line ID:", selectedLotForQr.stockOutLineId); 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); // Call confirmLotSubstitution to update the suggested lot + console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution..."); const substitutionResult = await confirmLotSubstitution({ pickOrderLineId: selectedLotForQr.pickOrderLineId, stockOutLineId: selectedLotForQr.stockOutLineId, originalSuggestedPickLotId: selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId, - newInventoryLotNo: scannedLotData.lotNo + newInventoryLotNo: scannedLotData.lotNo || '', + // ✅ required by LotSubstitutionConfirmRequest + newStockInLineId: scannedLotData?.stockInLineId ?? null, }); - console.log(" Lot substitution result:", substitutionResult); + 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") { + console.error("❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status."); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + substitutionResult?.message || + `换批失败:stockInLineId ${scannedLotData?.stockInLineId ?? ""} 不存在或无法匹配` + ); + return; + } // Update stock out line status to 'checked' after substitution if(selectedLotForQr?.stockOutLineId){ + console.log(`🔄 [LOT CONFIRM] Updating stockOutLine ${selectedLotForQr.stockOutLineId} to 'checked'`); await updateStockOutLineStatus({ id: selectedLotForQr.stockOutLineId, status: 'checked', qty: 0 }); - console.log(" Stock out line status updated to 'checked'"); + console.log(`✅ [LOT CONFIRM] Stock out line ${selectedLotForQr.stockOutLineId} status updated to 'checked'`); } // Close modal and clean up state BEFORE refreshing @@ -777,6 +1035,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { // Clear QR processing state but DON'T clear processedQrCodes yet setQrScanError(false); setQrScanSuccess(true); + setQrScanErrorMsg(''); setQrScanInput(''); // Set refreshing flag to prevent QR processing during refresh @@ -796,12 +1055,27 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { setLastProcessedQr(''); setQrScanSuccess(false); setIsRefreshingData(false); + // ✅ Clear processedQrCombinations to allow reprocessing the same QR if needed + 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; + }); + } }, 500); // Reduced from 3000ms to 500ms - just enough for UI update } catch (error) { console.error("Error confirming lot substitution:", error); setQrScanError(true); setQrScanSuccess(false); + setQrScanErrorMsg('换批发生异常,请重试或联系管理员'); // Clear refresh flag on error setIsRefreshingData(false); } finally { @@ -810,182 +1084,178 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { }, [expectedLotData, scannedLotData, selectedLotForQr, fetchJobOrderData]); const processOutsideQrCode = useCallback(async (latestQr: string) => { - // Don't process if confirmation modal is open - if (lotConfirmationOpen) { - console.log("⏸️ Confirmation modal is open, skipping QR processing"); - return; - } - + // ✅ Only JSON QR supported for outside scanner (avoid false positive with lotNo) let qrData: any = null; try { qrData = JSON.parse(latestQr); } catch { - console.log("QR is not JSON format"); - // Handle non-JSON QR codes as direct lot numbers - const directLotNo = latestQr.replace(/[{}]/g, ''); - if (directLotNo) { - console.log(`Processing direct lot number: ${directLotNo}`); - await handleQrCodeSubmit(directLotNo); - } + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); return; } - try { - // Only use the new API when we have JSON with stockInLineId + itemId - if (!(qrData?.stockInLineId && qrData?.itemId)) { - console.log("QR JSON missing required fields (itemId, stockInLineId)."); - setQrScanError(true); - setQrScanSuccess(false); - return; - } - - // First, fetch stock in line info to get the lot number - let stockInLineInfo: any; - try { - stockInLineInfo = await fetchStockInLineInfo(qrData.stockInLineId); - console.log("Stock in line info:", stockInLineInfo); - } catch (error) { - console.error("Error fetching stock in line info:", error); + if (!(qrData?.stockInLineId && qrData?.itemId)) { + startTransition(() => { setQrScanError(true); setQrScanSuccess(false); - return; - } - - // Call new analyze-qr-code API - const analysis = await analyzeQrCode({ - itemId: qrData.itemId, - stockInLineId: qrData.stockInLineId }); - - if (!analysis) { - console.error("analyzeQrCode returned no data"); - setQrScanError(true); - setQrScanSuccess(false); - return; - } - - const { - itemId: analyzedItemId, - itemCode: analyzedItemCode, - itemName: analyzedItemName, - scanned, - } = analysis || {}; - - // 1) Find all lots for the same item from current expected list - const sameItemLotsInExpected = combinedLotData.filter(l => - (l.itemId && analyzedItemId && l.itemId === analyzedItemId) || - (l.itemCode && analyzedItemCode && l.itemCode === analyzedItemCode) - ); - - if (!sameItemLotsInExpected || sameItemLotsInExpected.length === 0) { - // Case 3: No item code match - console.error("No item match in expected lots for scanned code"); - setQrScanError(true); - setQrScanSuccess(false); - return; - } - - // Find the ACTIVE suggested lot (not rejected lots) - const activeSuggestedLots = sameItemLotsInExpected.filter(lot => - lot.lotAvailability !== 'rejected' && - lot.stockOutLineStatus !== 'rejected' && - lot.stockOutLineStatus !== 'completed' - ); - - if (activeSuggestedLots.length === 0) { - console.warn("All lots for this item are rejected or completed"); + return; + } + + const scannedItemId = Number(qrData.itemId); + const scannedStockInLineId = Number(qrData.stockInLineId); + + // ✅ avoid duplicate processing by itemId+stockInLineId + const itemProcessedSet = processedQrCombinations.get(scannedItemId); + if (itemProcessedSet?.has(scannedStockInLineId)) return; + + const indexes = lotDataIndexes; + const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; + if (activeSuggestedLots.length === 0) { + startTransition(() => { setQrScanError(true); setQrScanSuccess(false); - return; + }); + return; + } + + // ✅ direct stockInLineId match (O(1)) + const stockInLineLots = indexes.byStockInLineId.get(scannedStockInLineId) || []; + let exactMatch: any = null; + for (let i = 0; i < stockInLineLots.length; i++) { + const lot = stockInLineLots[i]; + if (lot.itemId === scannedItemId && activeSuggestedLots.includes(lot)) { + exactMatch = lot; + break; } - - // Use the first active suggested lot as the "expected" lot - const expectedLot = activeSuggestedLots[0]; - - if (scanned?.lotNo === expectedLot.lotNo) { - console.log(`✅ Exact lot match found for ${scanned.lotNo}, using fast API`); - - if (!expectedLot.stockOutLineId) { - console.warn("No stockOutLineId on expectedLot, cannot update status by QR."); + } + + console.log(`🔍 [QR PROCESS] Scanned stockInLineId: ${scannedStockInLineId}, itemId: ${scannedItemId}`); + console.log(`🔍 [QR PROCESS] Found ${stockInLineLots.length} lots with stockInLineId ${scannedStockInLineId}`); + console.log(`🔍 [QR PROCESS] Exact match found: ${exactMatch ? `YES (lotNo: ${exactMatch.lotNo}, stockOutLineId: ${exactMatch.stockOutLineId})` : 'NO'}`); + + if (exactMatch) { + if (!exactMatch.stockOutLineId) { + console.error(`❌ [QR PROCESS] Exact match found but no stockOutLineId`); + startTransition(() => { setQrScanError(true); setQrScanSuccess(false); - return; - } - - try { - const res = await updateStockOutLineStatusByQRCodeAndLotNo({ - pickOrderLineId: expectedLot.pickOrderLineId, - inventoryLotNo: scanned.lotNo, - stockOutLineId: expectedLot.stockOutLineId, - itemId: expectedLot.itemId, - status: "checked", + }); + return; + } + + console.log(`✅ [QR PROCESS] Processing exact match: lotNo=${exactMatch.lotNo}, stockOutLineId=${exactMatch.stockOutLineId}`); + try { + const res = await updateStockOutLineStatusByQRCodeAndLotNo({ + pickOrderLineId: exactMatch.pickOrderLineId, + inventoryLotNo: exactMatch.lotNo, + stockOutLineId: exactMatch.stockOutLineId, + itemId: exactMatch.itemId, + status: "checked", + }); + + if (res.code === "checked" || res.code === "SUCCESS") { + console.log(`✅ [QR PROCESS] Successfully updated stockOutLine ${exactMatch.stockOutLineId} to checked`); + const entity = res.entity as any; + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); }); - - const updateOk = - res?.type === "checked" || - typeof res?.id === "number" || - (res?.message && res.message.includes("success")); - - if (updateOk) { - setQrScanError(false); - setQrScanSuccess(true); - - if ( - expectedLot.pickOrderId && - expectedLot.itemId && - (expectedLot.stockOutLineStatus?.toLowerCase?.() === "pending" || - !expectedLot.stockOutLineStatus) && - !expectedLot.handler - ) { - await updateHandledBy(expectedLot.pickOrderId, expectedLot.itemId); - } - - const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; - await fetchJobOrderData(pickOrderId); - } else if (res?.code === "LOT_NUMBER_MISMATCH" || res?.code === "ITEM_MISMATCH") { - setQrScanError(true); - setQrScanSuccess(false); - } else { - console.warn("Unexpected response from backend:", res); + // mark combination processed + setProcessedQrCombinations(prev => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + + // refresh to keep consistency with server & handler updates + const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; + await fetchJobOrderData(pickOrderId); + } else { + console.error(`❌ [QR PROCESS] Update failed: ${res.code}`); + startTransition(() => { setQrScanError(true); setQrScanSuccess(false); - } - } catch (e) { - console.error("Error calling updateStockOutLineStatusByQRCodeAndLotNo:", e); + }); + } + } catch (error) { + console.error(`❌ [QR PROCESS] Error updating stockOutLine:`, error); + startTransition(() => { setQrScanError(true); setQrScanSuccess(false); - } - - return; // ✅ 直接返回,不再调用后面的分支 + }); } - - // Case 2: Same item, different lot - show confirmation modal - console.log(`🔍 Lot mismatch: Expected ${expectedLot.lotNo}, Scanned ${scanned?.lotNo}`); + return; + } + + // ✅ mismatch: validate scanned stockInLineId exists before opening confirmation modal + console.log(`⚠️ [QR PROCESS] No exact match found. Validating scanned stockInLineId ${scannedStockInLineId} for itemId ${scannedItemId}`); + console.log(`⚠️ [QR PROCESS] Active suggested lots for itemId ${scannedItemId}:`, activeSuggestedLots.map(l => ({ lotNo: l.lotNo, stockInLineId: l.stockInLineId }))); + + if (activeSuggestedLots.length === 0) { + console.error(`❌ [QR PROCESS] No active suggested lots found for itemId ${scannedItemId}`); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(`当前订单中没有 itemId ${scannedItemId} 的可用批次`); + }); + return; + } + + const expectedLot = activeSuggestedLots[0]; + console.log(`⚠️ [QR PROCESS] Expected lot: ${expectedLot.lotNo} (stockInLineId: ${expectedLot.stockInLineId}), Scanned stockInLineId: ${scannedStockInLineId}`); + + // ✅ Validate scanned stockInLineId exists before opening modal + // This ensures the backend can find the lot when user confirms + try { + console.log(`🔍 [QR PROCESS] Validating scanned stockInLineId ${scannedStockInLineId} exists...`); + const stockInLineInfo = await fetchStockInLineInfoCached(scannedStockInLineId); + console.log(`✅ [QR PROCESS] Scanned stockInLineId ${scannedStockInLineId} exists, lotNo: ${stockInLineInfo.lotNo}`); - // DON'T stop scanning - just pause QR processing by showing modal + // ✅ stockInLineId exists, open confirmation modal + console.log(`⚠️ [QR PROCESS] Opening confirmation modal - user must confirm before any lot is marked as scanned`); setSelectedLotForQr(expectedLot); handleLotMismatch( { lotNo: expectedLot.lotNo, - itemCode: analyzedItemCode || expectedLot.itemCode, - itemName: analyzedItemName || expectedLot.itemName + itemCode: expectedLot.itemCode, + itemName: expectedLot.itemName }, { - lotNo: scanned?.lotNo || '', - itemCode: analyzedItemCode || expectedLot.itemCode, - itemName: analyzedItemName || expectedLot.itemName, - inventoryLotLineId: scanned?.inventoryLotLineId, - stockInLineId: qrData.stockInLineId + lotNo: stockInLineInfo.lotNo || null, // Use fetched lotNo for display + itemCode: expectedLot.itemCode, + itemName: expectedLot.itemName, + inventoryLotLineId: null, + stockInLineId: scannedStockInLineId } ); } catch (error) { - console.error("Error during analyzeQrCode flow:", error); - setQrScanError(true); - setQrScanSuccess(false); - return; + // ✅ stockInLineId does NOT exist, show error immediately (don't open modal) + console.error(`❌ [QR PROCESS] Scanned stockInLineId ${scannedStockInLineId} does NOT exist:`, error); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + `扫描的 stockInLineId ${scannedStockInLineId} 不存在。请检查 QR 码是否正确,或联系管理员。` + ); + }); + // 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; + }); } - }, [combinedLotData, handleQrCodeSubmit, handleLotMismatch, lotConfirmationOpen, updateHandledBy]); + }, [filterArgs?.pickOrderId, fetchJobOrderData, handleLotMismatch, lotDataIndexes, processedQrCombinations]); + + // Store in refs for immediate access in qrValues effect + processOutsideQrCodeRef.current = processOutsideQrCode; + resetScanRef.current = resetScan; const handleManualInputSubmit = useCallback(() => { @@ -1039,26 +1309,84 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { useEffect(() => { - // Add isManualScanning check - if (!isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData || lotConfirmationOpen) { - return; - } - + // Skip if scanner not active or no data or currently refreshing + if (!isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData) return; + const latestQr = qrValues[qrValues.length - 1]; - - if (processedQrCodes.has(latestQr) || lastProcessedQr === latestQr) { - console.log("QR code already processed, skipping..."); - return; + + // ✅ Test shortcut: {2fitestx,y} or {2fittestx,y} where x=itemId, y=stockInLineId + if ((latestQr.startsWith("{2fitest") || latestQr.startsWith("{2fittest")) && latestQr.endsWith("}")) { + let content = ''; + if (latestQr.startsWith("{2fittest")) content = latestQr.substring(9, latestQr.length - 1); + else content = latestQr.substring(8, latestQr.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 (!isNaN(itemId) && !isNaN(stockInLineId)) { + const simulatedQr = JSON.stringify({ itemId, stockInLineId }); + + lastProcessedQrRef.current = latestQr; + processedQrCodesRef.current.add(latestQr); + setLastProcessedQr(latestQr); + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + + processOutsideQrCodeRef.current?.(simulatedQr); + resetScanRef.current?.(); + return; + } + } } - - if (latestQr && latestQr !== lastProcessedQr) { - console.log(`🔍 Processing new QR code with enhanced validation: ${latestQr}`); + + // ✅ Shortcut: {2fic} open manual lot confirmation modal + if (latestQr === "{2fic}") { + setManualLotConfirmationOpen(true); + resetScanRef.current?.(); + lastProcessedQrRef.current = latestQr; + processedQrCodesRef.current.add(latestQr); setLastProcessedQr(latestQr); - setProcessedQrCodes(prev => new Set(prev).add(latestQr)); - - processOutsideQrCode(latestQr); + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + return; + } + + // Skip processing if modal open for same QR + if (lotConfirmationOpen || manualLotConfirmationOpen) { + if (latestQr === lastProcessedQrRef.current) return; + } + + // Skip if already processed (refs) + if (processedQrCodesRef.current.has(latestQr) || lastProcessedQrRef.current === latestQr) return; + + // Mark processed immediately + lastProcessedQrRef.current = latestQr; + processedQrCodesRef.current.add(latestQr); + if (processedQrCodesRef.current.size > 100) { + const firstValue = processedQrCodesRef.current.values().next().value; + if (firstValue !== undefined) processedQrCodesRef.current.delete(firstValue); } - }, [qrValues, processedQrCodes, lastProcessedQr, isRefreshingData, processOutsideQrCode, combinedLotData, isManualScanning, lotConfirmationOpen]); + + // Process immediately + if (qrProcessingTimeoutRef.current) { + clearTimeout(qrProcessingTimeoutRef.current); + qrProcessingTimeoutRef.current = null; + } + + processOutsideQrCodeRef.current?.(latestQr); + + // UI state updates (non-blocking) + startTransition(() => { + setLastProcessedQr(latestQr); + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + }); + + return () => { + if (qrProcessingTimeoutRef.current) { + clearTimeout(qrProcessingTimeoutRef.current); + qrProcessingTimeoutRef.current = null; + } + }; + }, [qrValues.length, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen]); const handlePickQtyChange = useCallback((lotKey: string, value: number | string) => { if (value === '' || value === null || value === undefined) { @@ -1715,7 +2043,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { backgroundColor: "error.light", }} > - {t("QR code does not match any item in current orders.")} + {qrScanErrorMsg || t("QR code does not match any item in current orders.")} )} {qrScanSuccess && ( @@ -1878,34 +2206,32 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { @@ -1953,11 +2279,35 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { { + console.log(`⏱️ [LOT CONFIRM MODAL] Closing modal, clearing state`); setLotConfirmationOpen(false); 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 + 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); }} onConfirm={handleLotConfirmation} expectedLot={expectedLotData} @@ -1965,6 +2315,25 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { isLoading={isConfirmingLot} /> )} + + {/* Manual Lot Confirmation Modal (test shortcut {2fic}) */} + setManualLotConfirmationOpen(false)} + // Reuse existing handler: expectedLotInput=current lot, scannedLotInput=new lot + onConfirm={(currentLotNo, newLotNo) => { + // Use existing manual flow from handleManualLotConfirmation in other screens: + // Here we route through updateStockOutLineStatusByQRCodeAndLotNo via handleManualLotConfirmation-like inline logic. + // For now: open LotConfirmationModal path by setting expected/scanned and letting user confirm substitution. + setExpectedLotData({ lotNo: currentLotNo, itemCode: '', itemName: '' }); + setScannedLotData({ lotNo: newLotNo, itemCode: '', itemName: '', inventoryLotLineId: null, stockInLineId: null }); + setManualLotConfirmationOpen(false); + setLotConfirmationOpen(true); + }} + expectedLot={expectedLotData} + scannedLot={scannedLotData} + isLoading={isConfirmingLot} + /> {/* Pick Execution Form Modal */} {pickExecutionFormOpen && selectedLotForExecutionForm && (