| @@ -210,6 +210,8 @@ export interface PickExecutionIssueData { | |||
| issueRemark: string; | |||
| pickerName: string; | |||
| handledBy?: number; | |||
| badReason?: string; | |||
| reason?: string; | |||
| } | |||
| export type AutoAssignReleaseResponse = { | |||
| id: number | null; | |||
| @@ -1,4 +1,4 @@ | |||
| // FPSMS-frontend/src/components/PickOrderSearch/PickExecutionForm.tsx | |||
| // FPSMS-frontend/src/components/FinishedGoodSearch/GoodPickExecutionForm.tsx | |||
| "use client"; | |||
| import { | |||
| @@ -53,16 +53,13 @@ interface PickExecutionFormProps { | |||
| selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | |||
| pickOrderId?: number; | |||
| pickOrderCreateDate: any; | |||
| // Remove these props since we're not handling normal cases | |||
| // onNormalPickSubmit?: (lineId: number, lotId: number, qty: number) => Promise<void>; | |||
| // selectedRowId?: number | null; | |||
| } | |||
| // 定义错误类型 | |||
| interface FormErrors { | |||
| actualPickQty?: string; | |||
| missQty?: string; | |||
| badItemQty?: string; | |||
| badReason?: string; | |||
| issueRemark?: string; | |||
| handledBy?: string; | |||
| } | |||
| @@ -75,38 +72,21 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||
| selectedPickOrderLine, | |||
| pickOrderId, | |||
| pickOrderCreateDate, | |||
| // Remove these props | |||
| // onNormalPickSubmit, | |||
| // selectedRowId, | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | |||
| const [errors, setErrors] = useState<FormErrors>({}); | |||
| const [loading, setLoading] = useState(false); | |||
| const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]); | |||
| // 计算剩余可用数量 | |||
| const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { | |||
| // 直接使用 availableQty,因为 API 没有返回 inQty 和 outQty | |||
| return lot.availableQty || 0; | |||
| }, []); | |||
| const calculateRequiredQty = useCallback((lot: LotPickData) => { | |||
| // Use the original required quantity, not subtracting actualPickQty | |||
| // The actualPickQty in the form should be independent of the database value | |||
| return lot.requiredQty || 0; | |||
| }, []); | |||
| const remaining = selectedLot ? calculateRemainingAvailableQty(selectedLot) : 0; | |||
| const req = selectedLot ? calculateRequiredQty(selectedLot) : 0; | |||
| const ap = Number(formData.actualPickQty) || 0; | |||
| const miss = Number(formData.missQty) || 0; | |||
| const bad = Number(formData.badItemQty) || 0; | |||
| // Max the user can type | |||
| const maxPick = Math.min(remaining, req); | |||
| const maxIssueTotal = Math.max(0, req - ap); // remaining room for miss+bad | |||
| const clamp0 = (v: any) => Math.max(0, Number(v) || 0); | |||
| // 获取处理人员列表 | |||
| useEffect(() => { | |||
| const fetchHandlers = async () => { | |||
| try { | |||
| @@ -120,12 +100,11 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||
| fetchHandlers(); | |||
| }, []); | |||
| const initKeyRef = useRef<string | null>(null); | |||
| const initKeyRef = useRef<string | null>(null); | |||
| useEffect(() => { | |||
| if (!open || !selectedLot || !selectedPickOrderLine || !pickOrderId) return; | |||
| // Only initialize once per (pickOrderLineId + lotId) while dialog open | |||
| const key = `${selectedPickOrderLine.id}-${selectedLot.lotId}`; | |||
| if (initKeyRef.current === key) return; | |||
| @@ -161,86 +140,75 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||
| issueRemark: '', | |||
| pickerName: '', | |||
| handledBy: undefined, | |||
| reason: '', | |||
| badReason: '', | |||
| }); | |||
| initKeyRef.current = key; | |||
| }, [open, selectedPickOrderLine?.id, selectedLot?.lotId, pickOrderId, pickOrderCreateDate]); | |||
| // Mutually exclusive inputs: picking vs reporting issues | |||
| const handleInputChange = useCallback((field: keyof PickExecutionIssueData, value: any) => { | |||
| setFormData(prev => ({ ...prev, [field]: value })); | |||
| // 清除错误 | |||
| if (errors[field as keyof FormErrors]) { | |||
| setErrors(prev => ({ ...prev, [field]: undefined })); | |||
| } | |||
| }, [errors]); | |||
| // Update form validation to require either missQty > 0 OR badItemQty > 0 | |||
| const validateForm = (): boolean => { | |||
| const newErrors: FormErrors = {}; | |||
| const req = selectedLot?.requiredQty || 0; | |||
| const ap = Number(formData.actualPickQty) || 0; | |||
| const miss = Number(formData.missQty) || 0; | |||
| const bad = Number(formData.badItemQty) || 0; | |||
| const total = ap + miss + bad; | |||
| // Updated validation logic | |||
| const validateForm = (): boolean => { | |||
| const newErrors: FormErrors = {}; | |||
| const req = selectedLot?.requiredQty || 0; | |||
| const ap = Number(formData.actualPickQty) || 0; | |||
| const miss = Number(formData.missQty) || 0; | |||
| const bad = Number(formData.badItemQty) || 0; | |||
| const total = ap + miss + bad; | |||
| const availableQty = selectedLot?.availableQty || 0; | |||
| // 1. 检查 actualPickQty 不能为负数 | |||
| if (ap < 0) { | |||
| newErrors.actualPickQty = t('Qty cannot be negative'); | |||
| } | |||
| // 2. 检查 actualPickQty 不能超过可用数量或需求数量 | |||
| if (ap > Math.min(req)) { | |||
| newErrors.actualPickQty = t('Qty is not allowed to be greater than required/available qty'); | |||
| } | |||
| // 3. 检查 missQty 和 badItemQty 不能为负数 | |||
| if (miss < 0) { | |||
| newErrors.missQty = t('Invalid qty'); | |||
| } | |||
| if (bad < 0) { | |||
| newErrors.badItemQty = t('Invalid qty'); | |||
| } | |||
| // 4. 🔥 关键验证:总和必须等于 Required Qty(不能多也不能少) | |||
| if (total !== req) { | |||
| const diff = req - total; | |||
| const errorMsg = diff > 0 | |||
| ? t('Total must equal Required Qty. Missing: {diff}', { diff }) | |||
| : t('Total must equal Required Qty. Exceeds by: {diff}', { diff: Math.abs(diff) }); | |||
| newErrors.actualPickQty = errorMsg; | |||
| newErrors.missQty = errorMsg; | |||
| newErrors.badItemQty = errorMsg; | |||
| } | |||
| // 5. 🔥 关键验证:如果只有 actualPickQty 有值,而 missQty 和 badItemQty 都为 0,不允许提交 | |||
| // 这意味着如果 actualPickQty < requiredQty,必须报告问题(missQty 或 badItemQty > 0) | |||
| if (ap > 0 && miss === 0 && bad === 0 && ap < req) { | |||
| newErrors.missQty = t('If Actual Pick Qty is less than Required Qty, you must report Missing Qty or Bad Item Qty'); | |||
| newErrors.badItemQty = t('If Actual Pick Qty is less than Required Qty, you must report Missing Qty or Bad Item Qty'); | |||
| } | |||
| // 6. 如果所有值都为 0,不允许提交 | |||
| if (ap === 0 && miss === 0 && bad === 0) { | |||
| newErrors.actualPickQty = t('Enter pick qty or issue qty'); | |||
| newErrors.missQty = t('Enter pick qty or issue qty'); | |||
| } | |||
| // 7. 如果 actualPickQty = requiredQty,missQty 和 badItemQty 必须都为 0 | |||
| if (ap === req && (miss > 0 || bad > 0)) { | |||
| newErrors.missQty = t('If Actual Pick Qty equals Required Qty, Missing Qty and Bad Item Qty must be 0'); | |||
| newErrors.badItemQty = t('If Actual Pick Qty equals Required Qty, Missing Qty and Bad Item Qty must be 0'); | |||
| } | |||
| setErrors(newErrors); | |||
| return Object.keys(newErrors).length === 0; | |||
| }; | |||
| // 1. Check actualPickQty cannot be negative | |||
| if (ap < 0) { | |||
| newErrors.actualPickQty = t('Qty cannot be negative'); | |||
| } | |||
| // 2. Check actualPickQty cannot exceed available quantity | |||
| if (ap > availableQty) { | |||
| newErrors.actualPickQty = t('Actual pick qty cannot exceed available qty'); | |||
| } | |||
| // 3. Check missQty and badItemQty cannot be negative | |||
| if (miss < 0) { | |||
| newErrors.missQty = t('Invalid qty'); | |||
| } | |||
| if (bad < 0) { | |||
| newErrors.badItemQty = t('Invalid qty'); | |||
| } | |||
| // 4. NEW: Total (actualPickQty + missQty + badItemQty) 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. If badItemQty > 0, badReason is required | |||
| if (bad > 0 && !formData.badReason) { | |||
| newErrors.badReason = t('Bad reason is required when bad item qty > 0'); | |||
| newErrors.badItemQty = t('Bad reason is required'); | |||
| } | |||
| // 6. At least one field must have a value | |||
| if (ap === 0 && miss === 0 && bad === 0) { | |||
| newErrors.actualPickQty = t('Enter pick qty or issue qty'); | |||
| } | |||
| setErrors(newErrors); | |||
| return Object.keys(newErrors).length === 0; | |||
| }; | |||
| const handleSubmit = async () => { | |||
| // First validate the form | |||
| if (!validateForm()) { | |||
| console.error('Form validation failed:', errors); | |||
| return; // Prevent submission, show validation errors | |||
| return; | |||
| } | |||
| if (!formData.pickOrderId) { | |||
| @@ -251,11 +219,8 @@ const validateForm = (): boolean => { | |||
| setLoading(true); | |||
| try { | |||
| await onSubmit(formData as PickExecutionIssueData); | |||
| // Automatically closed when successful (handled by onClose) | |||
| } catch (error: any) { | |||
| console.error('Error submitting pick execution issue:', error); | |||
| // Show error message (can be passed to parent component via props or state) | |||
| // 或者在这里显示 toast/alert | |||
| alert(t('Failed to submit issue. Please try again.') + (error.message ? `: ${error.message}` : '')); | |||
| } finally { | |||
| setLoading(false); | |||
| @@ -278,21 +243,11 @@ const validateForm = (): boolean => { | |||
| return ( | |||
| <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> | |||
| <DialogTitle> | |||
| {t('Pick Execution Issue Form')} {/* Always show issue form title */} | |||
| {t('Pick Execution Issue Form')} | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <Box sx={{ mt: 2 }}> | |||
| {/* Add instruction text */} | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12}> | |||
| <Box sx={{ p: 2, backgroundColor: '#fff3cd', borderRadius: 1, mb: 2 }}> | |||
| <Typography variant="body2" color="warning.main"> | |||
| <strong>{t('Note:')}</strong> {t('This form is for reporting issues only. You must report either missing items or bad items.')} | |||
| </Typography> | |||
| </Box> | |||
| </Grid> | |||
| {/* Keep the existing form fields */} | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| fullWidth | |||
| @@ -300,7 +255,6 @@ const validateForm = (): boolean => { | |||
| value={selectedLot?.requiredQty || 0} | |||
| disabled | |||
| variant="outlined" | |||
| // helperText={t('Still need to pick')} | |||
| /> | |||
| </Grid> | |||
| @@ -311,7 +265,6 @@ const validateForm = (): boolean => { | |||
| value={remainingAvailableQty} | |||
| disabled | |||
| variant="outlined" | |||
| // helperText={t('Available in warehouse')} | |||
| /> | |||
| </Grid> | |||
| @@ -320,88 +273,81 @@ const validateForm = (): boolean => { | |||
| fullWidth | |||
| label={t('Actual Pick Qty')} | |||
| type="number" | |||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} | |||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', min: 0 }} | |||
| value={formData.actualPickQty ?? ''} | |||
| onChange={(e) => handleInputChange('actualPickQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))} | |||
| error={!!errors.actualPickQty} | |||
| helperText={errors.actualPickQty || `${t('Max')}: ${Math.min(remainingAvailableQty, selectedLot?.requiredQty || 0)}`} | |||
| helperText={errors.actualPickQty || `${t('Max')}: ${remainingAvailableQty}`} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t('Reason')}</InputLabel> | |||
| <Select | |||
| value={formData.reason || ''} | |||
| onChange={(e) => handleInputChange('reason', e.target.value)} | |||
| label={t('Reason')} | |||
| > | |||
| <MenuItem value="">{t('Select Reason')}</MenuItem> | |||
| <MenuItem value="miss">{t('Edit')}</MenuItem> | |||
| <MenuItem value="bad">{t('Just Complete')}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| label={t('Missing item Qty')} | |||
| type="number" | |||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} | |||
| value={formData.missQty || 0} | |||
| onChange={(e) => handleInputChange('missQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))} | |||
| error={!!errors.missQty} | |||
| variant="outlined" | |||
| //disabled={(formData.actualPickQty || 0) > 0} | |||
| /> | |||
| <TextField | |||
| fullWidth | |||
| label={t('Missing item Qty')} | |||
| type="number" | |||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', min: 0 }} | |||
| value={formData.missQty || 0} | |||
| onChange={(e) => handleInputChange('missQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))} | |||
| error={!!errors.missQty} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| label={t('Bad Item Qty')} | |||
| type="number" | |||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} | |||
| value={formData.badItemQty || 0} | |||
| onChange={(e) => handleInputChange('badItemQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))} | |||
| error={!!errors.badItemQty} | |||
| variant="outlined" | |||
| //disabled={(formData.actualPickQty || 0) > 0} | |||
| /> | |||
| <TextField | |||
| fullWidth | |||
| label={t('Bad Item Qty')} | |||
| type="number" | |||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', min: 0 }} | |||
| value={formData.badItemQty || 0} | |||
| onChange={(e) => handleInputChange('badItemQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))} | |||
| error={!!errors.badItemQty} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| {/* Show issue description and handler fields when bad items > 0 */} | |||
| {/* Show bad reason dropdown when badItemQty > 0 */} | |||
| {(formData.badItemQty && formData.badItemQty > 0) ? ( | |||
| <> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| id="issueRemark" | |||
| label={t('Issue Remark')} | |||
| multiline | |||
| rows={4} | |||
| value={formData.issueRemark || ''} | |||
| onChange={(e) => handleInputChange('issueRemark', e.target.value)} | |||
| error={!!errors.issueRemark} | |||
| helperText={errors.issueRemark} | |||
| //placeholder={t('Describe the issue with bad items')} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <FormControl fullWidth error={!!errors.handledBy}> | |||
| <InputLabel>{t('handler')}</InputLabel> | |||
| <Select | |||
| value={formData.handledBy ? formData.handledBy.toString() : ''} | |||
| onChange={(e) => handleInputChange('handledBy', e.target.value ? parseInt(e.target.value) : undefined)} | |||
| label={t('handler')} | |||
| > | |||
| {handlers.map((handler) => ( | |||
| <MenuItem key={handler.id} value={handler.id.toString()}> | |||
| {handler.name} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| {errors.handledBy && ( | |||
| <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}> | |||
| {errors.handledBy} | |||
| </Typography> | |||
| )} | |||
| </FormControl> | |||
| </Grid> | |||
| </> | |||
| ) : (<></>)} | |||
| <Grid item xs={12}> | |||
| <FormControl fullWidth error={!!errors.badReason}> | |||
| <InputLabel>{t('Bad Reason')}</InputLabel> | |||
| <Select | |||
| value={formData.badReason || ''} | |||
| onChange={(e) => handleInputChange('badReason', e.target.value)} | |||
| label={t('Bad Reason')} | |||
| > | |||
| <MenuItem value="">{t('Select Bad Reason')}</MenuItem> | |||
| <MenuItem value="quantity_problem">{t('Quantity Problem')}</MenuItem> | |||
| <MenuItem value="package_problem">{t('Package Problem')}</MenuItem> | |||
| </Select> | |||
| {errors.badReason && ( | |||
| <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}> | |||
| {errors.badReason} | |||
| </Typography> | |||
| )} | |||
| </FormControl> | |||
| </Grid> | |||
| ) : null} | |||
| </Grid> | |||
| </Box> | |||
| </DialogContent> | |||
| @@ -690,7 +690,7 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||
| stockOutLineId: lot.stockOutLineId, | |||
| stockOutLineStatus: lot.stockOutLineStatus, | |||
| stockOutLineQty: lot.stockOutLineQty, | |||
| stockInLineId: lot.stockInLineId, | |||
| routerId: lot.router?.id, | |||
| routerIndex: lot.router?.index, | |||
| routerRoute: lot.router?.route, | |||
| @@ -1217,7 +1217,50 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||
| console.log(`⏱️ Total time: ${totalTime.toFixed(2)}ms`); | |||
| } | |||
| }, [combinedLotData, updateStockOutLineStatusByQRCodeAndLotNo]); | |||
| const lotDataIndexes = useMemo(() => { | |||
| const byItemId = new Map<number, any[]>(); | |||
| const byItemCode = new Map<string, any[]>(); | |||
| const byLotId = new Map<number, any>(); | |||
| const byLotNo = new Map<string, any[]>(); | |||
| const byStockInLineId = new Map<number, any[]>(); // ✅ 新增:按 stockInLineId 索引 | |||
| combinedLotData.forEach(lot => { | |||
| if (lot.itemId) { | |||
| if (!byItemId.has(lot.itemId)) { | |||
| byItemId.set(lot.itemId, []); | |||
| } | |||
| byItemId.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); | |||
| } | |||
| // ✅ 新增:按 stockInLineId 索引 | |||
| if (lot.stockInLineId) { | |||
| if (!byStockInLineId.has(lot.stockInLineId)) { | |||
| byStockInLineId.set(lot.stockInLineId, []); | |||
| } | |||
| byStockInLineId.get(lot.stockInLineId)!.push(lot); | |||
| } | |||
| }); | |||
| return { byItemId, byItemCode, byLotId, byLotNo, byStockInLineId }; // ✅ 添加 byStockInLineId | |||
| }, [combinedLotData]); | |||
| const processOutsideQrCode = useCallback(async (latestQr: string) => { | |||
| // 1) Parse JSON safely | |||
| @@ -1232,7 +1275,7 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||
| } | |||
| try { | |||
| // Only use the new API when we have JSON with stockInLineId + itemId | |||
| // ✅ OPTIMIZATION: 直接使用 QR 数据,不需要调用 analyzeQrCode API | |||
| if (!(qrData?.stockInLineId && qrData?.itemId)) { | |||
| console.log("QR JSON missing required fields (itemId, stockInLineId)."); | |||
| setQrScanError(true); | |||
| @@ -1240,45 +1283,30 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||
| return; | |||
| } | |||
| // Call new analyze-qr-code API | |||
| const analysis = await analyzeQrCode({ | |||
| itemId: qrData.itemId, | |||
| stockInLineId: qrData.stockInLineId | |||
| }); | |||
| const scannedItemId = qrData.itemId; | |||
| const scannedStockInLineId = qrData.stockInLineId; | |||
| if (!analysis) { | |||
| console.error("analyzeQrCode returned no data"); | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| return; | |||
| // ✅ OPTIMIZATION: 使用索引快速查找相同 item 的 lots | |||
| const sameItemLots: any[] = []; | |||
| // 使用索引快速查找 | |||
| if (lotDataIndexes.byItemId.has(scannedItemId)) { | |||
| sameItemLots.push(...lotDataIndexes.byItemId.get(scannedItemId)!); | |||
| } | |||
| 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 | |||
| if (sameItemLots.length === 0) { | |||
| console.error("No item match in expected lots for scanned code"); | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| return; | |||
| } | |||
| // FIXED: Find the ACTIVE suggested lot (not rejected lots) | |||
| const activeSuggestedLots = sameItemLotsInExpected.filter(lot => | |||
| lot.lotAvailability !== 'rejected' && | |||
| lot.stockOutLineStatus !== 'rejected' && | |||
| lot.processingStatus !== 'rejected' | |||
| // ✅ OPTIMIZATION: 过滤出活跃的 lots(非 rejected) | |||
| const rejectedStatuses = new Set(['rejected']); | |||
| const activeSuggestedLots = sameItemLots.filter(lot => | |||
| !rejectedStatuses.has(lot.lotAvailability) && | |||
| !rejectedStatuses.has(lot.stockOutLineStatus) && | |||
| !rejectedStatuses.has(lot.processingStatus) | |||
| ); | |||
| if (activeSuggestedLots.length === 0) { | |||
| @@ -1288,77 +1316,63 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||
| return; | |||
| } | |||
| // 2) Check if scanned lot is exactly in active suggested lots | |||
| const exactLotMatch = activeSuggestedLots.find(l => | |||
| (scanned?.inventoryLotLineId && l.lotId === scanned.inventoryLotLineId) || | |||
| (scanned?.lotNo && l.lotNo === scanned.lotNo) | |||
| // ✅ OPTIMIZATION: 按优先级查找匹配的 lot | |||
| // 1. 首先查找 stockInLineId 完全匹配的(正确的 lot) | |||
| let exactMatch = activeSuggestedLots.find(lot => | |||
| lot.stockInLineId === scannedStockInLineId | |||
| ); | |||
| if (exactLotMatch && scanned?.lotNo) { | |||
| // ✅ Case 1: 使用 updateStockOutLineStatusByQRCodeAndLotNo API(更快) | |||
| console.log(`✅ Exact lot match found for ${scanned.lotNo}, using fast API`); | |||
| if (exactMatch) { | |||
| // ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认 | |||
| console.log(`✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`); | |||
| if (!exactLotMatch.stockOutLineId) { | |||
| console.warn("No stockOutLineId on exactLotMatch, cannot update status by QR."); | |||
| if (!exactMatch.stockOutLineId) { | |||
| console.warn("No stockOutLineId on exactMatch, cannot update status by QR."); | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| return; | |||
| } | |||
| try { | |||
| // ✅ 直接调用后端 API,后端会处理所有匹配逻辑 | |||
| const res = await updateStockOutLineStatusByQRCodeAndLotNo({ | |||
| pickOrderLineId: exactLotMatch.pickOrderLineId, | |||
| inventoryLotNo: scanned.lotNo, | |||
| stockOutLineId: exactLotMatch.stockOutLineId, | |||
| itemId: exactLotMatch.itemId, | |||
| pickOrderLineId: exactMatch.pickOrderLineId, | |||
| inventoryLotNo: exactMatch.lotNo, | |||
| stockOutLineId: exactMatch.stockOutLineId, | |||
| itemId: exactMatch.itemId, | |||
| status: "checked", | |||
| }); | |||
| console.log("updateStockOutLineStatusByQRCodeAndLotNo result:", res); | |||
| // 后端返回三种 code:checked / LOT_NUMBER_MISMATCH / ITEM_MISMATCH | |||
| if (res.code === "checked" || res.code === "SUCCESS") { | |||
| // ✅ 完全匹配 - 只更新本地状态,不调用 fetchAllCombinedLotData | |||
| setQrScanError(false); | |||
| setQrScanSuccess(true); | |||
| // ✅ 更新本地状态 | |||
| const entity = res.entity as any; | |||
| setCombinedLotData(prev => prev.map(lot => { | |||
| if (lot.stockOutLineId === exactLotMatch.stockOutLineId && | |||
| lot.pickOrderLineId === exactLotMatch.pickOrderLineId) { | |||
| if (lot.stockOutLineId === exactMatch.stockOutLineId && | |||
| lot.pickOrderLineId === exactMatch.pickOrderLineId) { | |||
| return { | |||
| ...lot, | |||
| stockOutLineStatus: 'checked', | |||
| stockOutLineQty: entity?.qty ? Number(entity.qty) : lot.stockOutLineQty, | |||
| stockOutLineQty: entity?.qty ?? lot.stockOutLineQty, | |||
| }; | |||
| } | |||
| return lot; | |||
| })); | |||
| setOriginalCombinedData(prev => prev.map(lot => { | |||
| if (lot.stockOutLineId === exactLotMatch.stockOutLineId && | |||
| lot.pickOrderLineId === exactLotMatch.pickOrderLineId) { | |||
| if (lot.stockOutLineId === exactMatch.stockOutLineId && | |||
| lot.pickOrderLineId === exactMatch.pickOrderLineId) { | |||
| return { | |||
| ...lot, | |||
| stockOutLineStatus: 'checked', | |||
| stockOutLineQty: entity?.qty ? Number(entity.qty) : lot.stockOutLineQty, | |||
| stockOutLineQty: entity?.qty ?? lot.stockOutLineQty, | |||
| }; | |||
| } | |||
| return lot; | |||
| })); | |||
| console.log("✅ Status updated locally, no full data refresh needed"); | |||
| } else if (res.code === "LOT_NUMBER_MISMATCH") { | |||
| console.warn("Backend reported LOT_NUMBER_MISMATCH:", res.message); | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| } else if (res.code === "ITEM_MISMATCH") { | |||
| console.warn("Backend reported ITEM_MISMATCH:", res.message); | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| } else { | |||
| console.warn("Unexpected response code from backend:", res.code); | |||
| setQrScanError(true); | |||
| @@ -1370,10 +1384,11 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||
| setQrScanSuccess(false); | |||
| } | |||
| return; // ✅ 直接返回,不再调用 handleQrCodeSubmit | |||
| return; // ✅ 直接返回,不需要确认表单 | |||
| } | |||
| // Case 2: Item matches but lot number differs -> open confirmation modal | |||
| // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配 - 显示确认表单 | |||
| // 取第一个活跃的 lot 作为期望的 lot | |||
| const expectedLot = activeSuggestedLots[0]; | |||
| if (!expectedLot) { | |||
| console.error("Could not determine expected lot for confirmation"); | |||
| @@ -1382,39 +1397,38 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||
| return; | |||
| } | |||
| // Check if the expected lot is already the scanned lot (after substitution) | |||
| if (expectedLot.lotNo === scanned?.lotNo) { | |||
| console.log(`Lot already substituted, proceeding with ${scanned.lotNo}`); | |||
| handleQrCodeSubmit(scanned.lotNo); | |||
| return; | |||
| } | |||
| console.log(` Lot mismatch: Expected ${expectedLot.lotNo}, Scanned ${scanned?.lotNo}`); | |||
| // ✅ 立即打开确认模态框,不等待其他操作 | |||
| console.log(`⚠️ Lot mismatch: Expected stockInLineId=${expectedLot.stockInLineId}, Scanned stockInLineId=${scannedStockInLineId}`); | |||
| setSelectedLotForQr(expectedLot); | |||
| // ✅ 获取扫描的 lot 信息(从 QR 数据中提取,或使用默认值) | |||
| 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: null, // 扫描的 lotNo 未知,需要从后端获取或显示为未知 | |||
| itemCode: expectedLot.itemCode, | |||
| itemName: expectedLot.itemName, | |||
| inventoryLotLineId: null, | |||
| stockInLineId: scannedStockInLineId // ✅ 传递 stockInLineId | |||
| } | |||
| ); | |||
| } catch (error) { | |||
| console.error("Error during analyzeQrCode flow:", error); | |||
| console.error("Error during QR code processing:", error); | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| return; | |||
| } | |||
| }, [combinedLotData, handleQrCodeSubmit, handleLotMismatch]); | |||
| // Update the outside QR scanning effect to use enhanced processing | |||
| // Update the outside QR scanning effect to use enhanced processing | |||
| }, [combinedLotData, handleQrCodeSubmit, handleLotMismatch, lotDataIndexes, updateStockOutLineStatusByQRCodeAndLotNo]); | |||
| useEffect(() => { | |||
| if (lotConfirmationOpen || manualLotConfirmationOpen) { | |||
| console.log("Confirmation modal is open, skipping QR processing..."); | |||
| return; | |||
| } | |||
| if (!isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData) { | |||
| return; | |||
| } | |||
| @@ -1965,8 +1979,8 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||
| const handleSkip = useCallback(async (lot: any) => { | |||
| try { | |||
| console.log("Skip clicked, submit 0 qty for lot:", lot.lotNo); | |||
| await handleSubmitPickQtyWithQty(lot, 0); | |||
| console.log("Skip clicked, submit lot required qty for lot:", lot.lotNo); | |||
| await handleSubmitPickQtyWithQty(lot, lot.requiredQty); | |||
| } catch (err) { | |||
| console.error("Error in Skip:", err); | |||
| } | |||
| @@ -2749,27 +2763,27 @@ paginatedData.map((lot, index) => { | |||
| /> | |||
| {/* 保留:Lot Confirmation Modal */} | |||
| {lotConfirmationOpen && expectedLotData && scannedLotData && ( | |||
| <LotConfirmationModal | |||
| open={lotConfirmationOpen} | |||
| onClose={() => { | |||
| setLotConfirmationOpen(false); | |||
| setExpectedLotData(null); | |||
| setScannedLotData(null); | |||
| if (lastProcessedQr) { | |||
| setProcessedQrCodes(prev => { | |||
| const newSet = new Set(prev); | |||
| newSet.delete(lastProcessedQr); | |||
| return newSet; | |||
| }); | |||
| setLastProcessedQr(''); | |||
| } | |||
| }} | |||
| onConfirm={handleLotConfirmation} | |||
| expectedLot={expectedLotData} | |||
| scannedLot={scannedLotData} | |||
| isLoading={isConfirmingLot} | |||
| /> | |||
| )} | |||
| <LotConfirmationModal | |||
| open={lotConfirmationOpen} | |||
| onClose={() => { | |||
| setLotConfirmationOpen(false); | |||
| setExpectedLotData(null); | |||
| setScannedLotData(null); | |||
| setSelectedLotForQr(null); // ✅ 新增:清除选中的 lot | |||
| // ✅ 修复:不要清除 processedQrCodes,而是保留它,避免重复处理 | |||
| // 或者,如果确实需要清除,应该在清除后立即重新标记为已处理 | |||
| if (lastProcessedQr) { | |||
| setLastProcessedQr(''); | |||
| } | |||
| }} | |||
| onConfirm={handleLotConfirmation} | |||
| expectedLot={expectedLotData} | |||
| scannedLot={scannedLotData} | |||
| isLoading={isConfirmingLot} | |||
| /> | |||
| )} | |||
| {/* 保留:Good Pick Execution Form Modal */} | |||
| {pickExecutionFormOpen && selectedLotForExecutionForm && ( | |||