| @@ -210,6 +210,8 @@ export interface PickExecutionIssueData { | |||||
| issueRemark: string; | issueRemark: string; | ||||
| pickerName: string; | pickerName: string; | ||||
| handledBy?: number; | handledBy?: number; | ||||
| badReason?: string; | |||||
| reason?: string; | |||||
| } | } | ||||
| export type AutoAssignReleaseResponse = { | export type AutoAssignReleaseResponse = { | ||||
| id: number | null; | id: number | null; | ||||
| @@ -1,4 +1,4 @@ | |||||
| // FPSMS-frontend/src/components/PickOrderSearch/PickExecutionForm.tsx | |||||
| // FPSMS-frontend/src/components/FinishedGoodSearch/GoodPickExecutionForm.tsx | |||||
| "use client"; | "use client"; | ||||
| import { | import { | ||||
| @@ -53,16 +53,13 @@ interface PickExecutionFormProps { | |||||
| selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | ||||
| pickOrderId?: number; | pickOrderId?: number; | ||||
| pickOrderCreateDate: any; | 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 { | interface FormErrors { | ||||
| actualPickQty?: string; | actualPickQty?: string; | ||||
| missQty?: string; | missQty?: string; | ||||
| badItemQty?: string; | badItemQty?: string; | ||||
| badReason?: string; | |||||
| issueRemark?: string; | issueRemark?: string; | ||||
| handledBy?: string; | handledBy?: string; | ||||
| } | } | ||||
| @@ -75,38 +72,21 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||||
| selectedPickOrderLine, | selectedPickOrderLine, | ||||
| pickOrderId, | pickOrderId, | ||||
| pickOrderCreateDate, | pickOrderCreateDate, | ||||
| // Remove these props | |||||
| // onNormalPickSubmit, | |||||
| // selectedRowId, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | ||||
| const [errors, setErrors] = useState<FormErrors>({}); | const [errors, setErrors] = useState<FormErrors>({}); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]); | const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]); | ||||
| // 计算剩余可用数量 | |||||
| const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { | const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { | ||||
| // 直接使用 availableQty,因为 API 没有返回 inQty 和 outQty | |||||
| return lot.availableQty || 0; | return lot.availableQty || 0; | ||||
| }, []); | }, []); | ||||
| const calculateRequiredQty = useCallback((lot: LotPickData) => { | 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; | 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(() => { | useEffect(() => { | ||||
| const fetchHandlers = async () => { | const fetchHandlers = async () => { | ||||
| try { | try { | ||||
| @@ -120,12 +100,11 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||||
| fetchHandlers(); | fetchHandlers(); | ||||
| }, []); | }, []); | ||||
| const initKeyRef = useRef<string | null>(null); | |||||
| const initKeyRef = useRef<string | null>(null); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (!open || !selectedLot || !selectedPickOrderLine || !pickOrderId) return; | if (!open || !selectedLot || !selectedPickOrderLine || !pickOrderId) return; | ||||
| // Only initialize once per (pickOrderLineId + lotId) while dialog open | |||||
| const key = `${selectedPickOrderLine.id}-${selectedLot.lotId}`; | const key = `${selectedPickOrderLine.id}-${selectedLot.lotId}`; | ||||
| if (initKeyRef.current === key) return; | if (initKeyRef.current === key) return; | ||||
| @@ -161,86 +140,75 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||||
| issueRemark: '', | issueRemark: '', | ||||
| pickerName: '', | pickerName: '', | ||||
| handledBy: undefined, | handledBy: undefined, | ||||
| reason: '', | |||||
| badReason: '', | |||||
| }); | }); | ||||
| initKeyRef.current = key; | initKeyRef.current = key; | ||||
| }, [open, selectedPickOrderLine?.id, selectedLot?.lotId, pickOrderId, pickOrderCreateDate]); | }, [open, selectedPickOrderLine?.id, selectedLot?.lotId, pickOrderId, pickOrderCreateDate]); | ||||
| // Mutually exclusive inputs: picking vs reporting issues | |||||
| const handleInputChange = useCallback((field: keyof PickExecutionIssueData, value: any) => { | const handleInputChange = useCallback((field: keyof PickExecutionIssueData, value: any) => { | ||||
| setFormData(prev => ({ ...prev, [field]: value })); | setFormData(prev => ({ ...prev, [field]: value })); | ||||
| // 清除错误 | |||||
| if (errors[field as keyof FormErrors]) { | if (errors[field as keyof FormErrors]) { | ||||
| setErrors(prev => ({ ...prev, [field]: undefined })); | setErrors(prev => ({ ...prev, [field]: undefined })); | ||||
| } | } | ||||
| }, [errors]); | }, [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 () => { | const handleSubmit = async () => { | ||||
| // First validate the form | |||||
| if (!validateForm()) { | if (!validateForm()) { | ||||
| console.error('Form validation failed:', errors); | console.error('Form validation failed:', errors); | ||||
| return; // Prevent submission, show validation errors | |||||
| return; | |||||
| } | } | ||||
| if (!formData.pickOrderId) { | if (!formData.pickOrderId) { | ||||
| @@ -251,11 +219,8 @@ const validateForm = (): boolean => { | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| await onSubmit(formData as PickExecutionIssueData); | await onSubmit(formData as PickExecutionIssueData); | ||||
| // Automatically closed when successful (handled by onClose) | |||||
| } catch (error: any) { | } catch (error: any) { | ||||
| console.error('Error submitting pick execution issue:', error); | 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}` : '')); | alert(t('Failed to submit issue. Please try again.') + (error.message ? `: ${error.message}` : '')); | ||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| @@ -278,21 +243,11 @@ const validateForm = (): boolean => { | |||||
| return ( | return ( | ||||
| <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> | <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> | ||||
| <DialogTitle> | <DialogTitle> | ||||
| {t('Pick Execution Issue Form')} {/* Always show issue form title */} | |||||
| {t('Pick Execution Issue Form')} | |||||
| </DialogTitle> | </DialogTitle> | ||||
| <DialogContent> | <DialogContent> | ||||
| <Box sx={{ mt: 2 }}> | <Box sx={{ mt: 2 }}> | ||||
| {/* Add instruction text */} | |||||
| <Grid container spacing={2}> | <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}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| @@ -300,7 +255,6 @@ const validateForm = (): boolean => { | |||||
| value={selectedLot?.requiredQty || 0} | value={selectedLot?.requiredQty || 0} | ||||
| disabled | disabled | ||||
| variant="outlined" | variant="outlined" | ||||
| // helperText={t('Still need to pick')} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| @@ -311,7 +265,6 @@ const validateForm = (): boolean => { | |||||
| value={remainingAvailableQty} | value={remainingAvailableQty} | ||||
| disabled | disabled | ||||
| variant="outlined" | variant="outlined" | ||||
| // helperText={t('Available in warehouse')} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| @@ -320,88 +273,81 @@ const validateForm = (): boolean => { | |||||
| fullWidth | fullWidth | ||||
| label={t('Actual Pick Qty')} | label={t('Actual Pick Qty')} | ||||
| type="number" | type="number" | ||||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} | |||||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*', min: 0 }} | |||||
| value={formData.actualPickQty ?? ''} | value={formData.actualPickQty ?? ''} | ||||
| onChange={(e) => handleInputChange('actualPickQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))} | onChange={(e) => handleInputChange('actualPickQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))} | ||||
| error={!!errors.actualPickQty} | error={!!errors.actualPickQty} | ||||
| helperText={errors.actualPickQty || `${t('Max')}: ${Math.min(remainingAvailableQty, selectedLot?.requiredQty || 0)}`} | |||||
| helperText={errors.actualPickQty || `${t('Max')}: ${remainingAvailableQty}`} | |||||
| variant="outlined" | 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> | ||||
| <Grid item xs={12}> | <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> | ||||
| <Grid item xs={12}> | <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> | </Grid> | ||||
| {/* Show issue description and handler fields when bad items > 0 */} | |||||
| {/* Show bad reason dropdown when badItemQty > 0 */} | |||||
| {(formData.badItemQty && formData.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> | </Grid> | ||||
| </Box> | </Box> | ||||
| </DialogContent> | </DialogContent> | ||||
| @@ -690,7 +690,7 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||||
| stockOutLineId: lot.stockOutLineId, | stockOutLineId: lot.stockOutLineId, | ||||
| stockOutLineStatus: lot.stockOutLineStatus, | stockOutLineStatus: lot.stockOutLineStatus, | ||||
| stockOutLineQty: lot.stockOutLineQty, | stockOutLineQty: lot.stockOutLineQty, | ||||
| stockInLineId: lot.stockInLineId, | |||||
| routerId: lot.router?.id, | routerId: lot.router?.id, | ||||
| routerIndex: lot.router?.index, | routerIndex: lot.router?.index, | ||||
| routerRoute: lot.router?.route, | routerRoute: lot.router?.route, | ||||
| @@ -1217,7 +1217,50 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||||
| console.log(`⏱️ Total time: ${totalTime.toFixed(2)}ms`); | console.log(`⏱️ Total time: ${totalTime.toFixed(2)}ms`); | ||||
| } | } | ||||
| }, [combinedLotData, updateStockOutLineStatusByQRCodeAndLotNo]); | }, [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) => { | const processOutsideQrCode = useCallback(async (latestQr: string) => { | ||||
| // 1) Parse JSON safely | // 1) Parse JSON safely | ||||
| @@ -1232,7 +1275,7 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||||
| } | } | ||||
| try { | try { | ||||
| // Only use the new API when we have JSON with stockInLineId + itemId | |||||
| // ✅ OPTIMIZATION: 直接使用 QR 数据,不需要调用 analyzeQrCode API | |||||
| if (!(qrData?.stockInLineId && qrData?.itemId)) { | if (!(qrData?.stockInLineId && qrData?.itemId)) { | ||||
| console.log("QR JSON missing required fields (itemId, stockInLineId)."); | console.log("QR JSON missing required fields (itemId, stockInLineId)."); | ||||
| setQrScanError(true); | setQrScanError(true); | ||||
| @@ -1240,45 +1283,30 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||||
| return; | 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"); | console.error("No item match in expected lots for scanned code"); | ||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| return; | 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) { | if (activeSuggestedLots.length === 0) { | ||||
| @@ -1288,77 +1316,63 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||||
| return; | 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); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| return; | return; | ||||
| } | } | ||||
| try { | try { | ||||
| // ✅ 直接调用后端 API,后端会处理所有匹配逻辑 | |||||
| const res = await updateStockOutLineStatusByQRCodeAndLotNo({ | 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", | status: "checked", | ||||
| }); | }); | ||||
| console.log("updateStockOutLineStatusByQRCodeAndLotNo result:", res); | |||||
| // 后端返回三种 code:checked / LOT_NUMBER_MISMATCH / ITEM_MISMATCH | |||||
| if (res.code === "checked" || res.code === "SUCCESS") { | if (res.code === "checked" || res.code === "SUCCESS") { | ||||
| // ✅ 完全匹配 - 只更新本地状态,不调用 fetchAllCombinedLotData | |||||
| setQrScanError(false); | setQrScanError(false); | ||||
| setQrScanSuccess(true); | setQrScanSuccess(true); | ||||
| // ✅ 更新本地状态 | |||||
| const entity = res.entity as any; | const entity = res.entity as any; | ||||
| setCombinedLotData(prev => prev.map(lot => { | setCombinedLotData(prev => prev.map(lot => { | ||||
| if (lot.stockOutLineId === exactLotMatch.stockOutLineId && | |||||
| lot.pickOrderLineId === exactLotMatch.pickOrderLineId) { | |||||
| if (lot.stockOutLineId === exactMatch.stockOutLineId && | |||||
| lot.pickOrderLineId === exactMatch.pickOrderLineId) { | |||||
| return { | return { | ||||
| ...lot, | ...lot, | ||||
| stockOutLineStatus: 'checked', | stockOutLineStatus: 'checked', | ||||
| stockOutLineQty: entity?.qty ? Number(entity.qty) : lot.stockOutLineQty, | |||||
| stockOutLineQty: entity?.qty ?? lot.stockOutLineQty, | |||||
| }; | }; | ||||
| } | } | ||||
| return lot; | return lot; | ||||
| })); | })); | ||||
| setOriginalCombinedData(prev => prev.map(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 { | return { | ||||
| ...lot, | ...lot, | ||||
| stockOutLineStatus: 'checked', | stockOutLineStatus: 'checked', | ||||
| stockOutLineQty: entity?.qty ? Number(entity.qty) : lot.stockOutLineQty, | |||||
| stockOutLineQty: entity?.qty ?? lot.stockOutLineQty, | |||||
| }; | }; | ||||
| } | } | ||||
| return lot; | return lot; | ||||
| })); | })); | ||||
| console.log("✅ Status updated locally, no full data refresh needed"); | 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 { | } else { | ||||
| console.warn("Unexpected response code from backend:", res.code); | console.warn("Unexpected response code from backend:", res.code); | ||||
| setQrScanError(true); | setQrScanError(true); | ||||
| @@ -1370,10 +1384,11 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||||
| setQrScanSuccess(false); | 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]; | const expectedLot = activeSuggestedLots[0]; | ||||
| if (!expectedLot) { | if (!expectedLot) { | ||||
| console.error("Could not determine expected lot for confirmation"); | console.error("Could not determine expected lot for confirmation"); | ||||
| @@ -1382,39 +1397,38 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||||
| return; | 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); | setSelectedLotForQr(expectedLot); | ||||
| // ✅ 获取扫描的 lot 信息(从 QR 数据中提取,或使用默认值) | |||||
| handleLotMismatch( | handleLotMismatch( | ||||
| { | { | ||||
| lotNo: expectedLot.lotNo, | 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) { | } catch (error) { | ||||
| console.error("Error during analyzeQrCode flow:", error); | |||||
| console.error("Error during QR code processing:", error); | |||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| return; | 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(() => { | useEffect(() => { | ||||
| if (lotConfirmationOpen || manualLotConfirmationOpen) { | |||||
| console.log("Confirmation modal is open, skipping QR processing..."); | |||||
| return; | |||||
| } | |||||
| if (!isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData) { | if (!isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData) { | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -1965,8 +1979,8 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| const handleSkip = useCallback(async (lot: any) => { | const handleSkip = useCallback(async (lot: any) => { | ||||
| try { | 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) { | } catch (err) { | ||||
| console.error("Error in Skip:", err); | console.error("Error in Skip:", err); | ||||
| } | } | ||||
| @@ -2749,27 +2763,27 @@ paginatedData.map((lot, index) => { | |||||
| /> | /> | ||||
| {/* 保留:Lot Confirmation Modal */} | {/* 保留:Lot Confirmation Modal */} | ||||
| {lotConfirmationOpen && expectedLotData && scannedLotData && ( | {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 */} | {/* 保留:Good Pick Execution Form Modal */} | ||||
| {pickExecutionFormOpen && selectedLotForExecutionForm && ( | {pickExecutionFormOpen && selectedLotForExecutionForm && ( | ||||