FPSMS-frontend
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 

418 Zeilen
14 KiB

  1. // FPSMS-frontend/src/components/PickOrderSearch/PickExecutionForm.tsx
  2. "use client";
  3. import {
  4. Box,
  5. Button,
  6. Dialog,
  7. DialogActions,
  8. DialogContent,
  9. DialogTitle,
  10. FormControl,
  11. Grid,
  12. InputLabel,
  13. MenuItem,
  14. Select,
  15. TextField,
  16. Typography,
  17. } from "@mui/material";
  18. import { useCallback, useEffect, useState } from "react";
  19. import { useTranslation } from "react-i18next";
  20. import { GetPickOrderLineInfo, PickExecutionIssueData } from "@/app/api/pickOrder/actions";
  21. import { fetchEscalationCombo } from "@/app/api/user/actions";
  22. import { useRef } from "react";
  23. import dayjs from 'dayjs';
  24. import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
  25. interface LotPickData {
  26. id: number;
  27. lotId: number;
  28. lotNo: string;
  29. expiryDate: string;
  30. location: string;
  31. stockUnit: string;
  32. inQty: number;
  33. outQty: number;
  34. holdQty: number;
  35. totalPickedByAllPickOrders: number;
  36. availableQty: number;
  37. requiredQty: number;
  38. actualPickQty: number;
  39. lotStatus: string;
  40. lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected';
  41. stockOutLineId?: number;
  42. stockOutLineStatus?: string;
  43. stockOutLineQty?: number;
  44. }
  45. interface PickExecutionFormProps {
  46. open: boolean;
  47. onClose: () => void;
  48. onSubmit: (data: PickExecutionIssueData) => Promise<void>;
  49. selectedLot: LotPickData | null;
  50. selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null;
  51. pickOrderId?: number;
  52. pickOrderCreateDate: any;
  53. // Remove these props since we're not handling normal cases
  54. // onNormalPickSubmit?: (lineId: number, lotId: number, qty: number) => Promise<void>;
  55. // selectedRowId?: number | null;
  56. }
  57. // 定义错误类型
  58. interface FormErrors {
  59. actualPickQty?: string;
  60. missQty?: string;
  61. badItemQty?: string;
  62. issueRemark?: string;
  63. handledBy?: string;
  64. }
  65. const PickExecutionForm: React.FC<PickExecutionFormProps> = ({
  66. open,
  67. onClose,
  68. onSubmit,
  69. selectedLot,
  70. selectedPickOrderLine,
  71. pickOrderId,
  72. pickOrderCreateDate,
  73. // Remove these props
  74. // onNormalPickSubmit,
  75. // selectedRowId,
  76. }) => {
  77. const { t } = useTranslation();
  78. const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({});
  79. const [errors, setErrors] = useState<FormErrors>({});
  80. const [loading, setLoading] = useState(false);
  81. const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]);
  82. // 计算剩余可用数量
  83. const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => {
  84. // 直接使用 availableQty,因为 API 没有返回 inQty 和 outQty
  85. return lot.availableQty || 0;
  86. }, []);
  87. const calculateRequiredQty = useCallback((lot: LotPickData) => {
  88. // Use the original required quantity, not subtracting actualPickQty
  89. // The actualPickQty in the form should be independent of the database value
  90. return lot.requiredQty || 0;
  91. }, []);
  92. const remaining = selectedLot ? calculateRemainingAvailableQty(selectedLot) : 0;
  93. const req = selectedLot ? calculateRequiredQty(selectedLot) : 0;
  94. const ap = Number(formData.actualPickQty) || 0;
  95. const miss = Number(formData.missQty) || 0;
  96. const bad = Number(formData.badItemQty) || 0;
  97. // Max the user can type
  98. const maxPick = Math.min(remaining, req);
  99. const maxIssueTotal = Math.max(0, req - ap); // remaining room for miss+bad
  100. const clamp0 = (v: any) => Math.max(0, Number(v) || 0);
  101. // 获取处理人员列表
  102. useEffect(() => {
  103. const fetchHandlers = async () => {
  104. try {
  105. const escalationCombo = await fetchEscalationCombo();
  106. setHandlers(escalationCombo);
  107. } catch (error) {
  108. console.error("Error fetching handlers:", error);
  109. }
  110. };
  111. fetchHandlers();
  112. }, []);
  113. const initKeyRef = useRef<string | null>(null);
  114. useEffect(() => {
  115. if (!open || !selectedLot || !selectedPickOrderLine || !pickOrderId) return;
  116. // Only initialize once per (pickOrderLineId + lotId) while dialog open
  117. const key = `${selectedPickOrderLine.id}-${selectedLot.lotId}`;
  118. if (initKeyRef.current === key) return;
  119. const getSafeDate = (dateValue: any): string => {
  120. if (!dateValue) return dayjs().format(INPUT_DATE_FORMAT);
  121. try {
  122. const date = dayjs(dateValue);
  123. if (!date.isValid()) {
  124. return dayjs().format(INPUT_DATE_FORMAT);
  125. }
  126. return date.format(INPUT_DATE_FORMAT);
  127. } catch {
  128. return dayjs().format(INPUT_DATE_FORMAT);
  129. }
  130. };
  131. setFormData({
  132. pickOrderId: pickOrderId,
  133. pickOrderCode: selectedPickOrderLine.pickOrderCode,
  134. pickOrderCreateDate: getSafeDate(pickOrderCreateDate),
  135. pickExecutionDate: dayjs().format(INPUT_DATE_FORMAT),
  136. pickOrderLineId: selectedPickOrderLine.id,
  137. itemId: selectedPickOrderLine.itemId,
  138. itemCode: selectedPickOrderLine.itemCode,
  139. itemDescription: selectedPickOrderLine.itemName,
  140. lotId: selectedLot.lotId,
  141. lotNo: selectedLot.lotNo,
  142. storeLocation: selectedLot.location,
  143. requiredQty: selectedLot.requiredQty,
  144. actualPickQty: selectedLot.actualPickQty || 0,
  145. missQty: 0,
  146. badItemQty: 0,
  147. issueRemark: '',
  148. pickerName: '',
  149. handledBy: undefined,
  150. });
  151. initKeyRef.current = key;
  152. }, [open, selectedPickOrderLine?.id, selectedLot?.lotId, pickOrderId, pickOrderCreateDate]);
  153. // Mutually exclusive inputs: picking vs reporting issues
  154. const handleInputChange = useCallback((field: keyof PickExecutionIssueData, value: any) => {
  155. setFormData(prev => ({ ...prev, [field]: value }));
  156. // 清除错误
  157. if (errors[field as keyof FormErrors]) {
  158. setErrors(prev => ({ ...prev, [field]: undefined }));
  159. }
  160. }, [errors]);
  161. // Update form validation to require either missQty > 0 OR badItemQty > 0
  162. const validateForm = (): boolean => {
  163. const newErrors: FormErrors = {};
  164. const req = selectedLot?.requiredQty || 0;
  165. const ap = Number(formData.actualPickQty) || 0;
  166. const miss = Number(formData.missQty) || 0;
  167. const bad = Number(formData.badItemQty) || 0;
  168. const total = ap + miss + bad;
  169. // 1. 检查 actualPickQty 不能为负数
  170. if (ap < 0) {
  171. newErrors.actualPickQty = t('Qty cannot be negative');
  172. }
  173. // 2. 检查 actualPickQty 不能超过可用数量或需求数量
  174. if (ap > Math.min(req)) {
  175. newErrors.actualPickQty = t('Qty is not allowed to be greater than required/available qty');
  176. }
  177. // 3. 检查 missQty 和 badItemQty 不能为负数
  178. if (miss < 0) {
  179. newErrors.missQty = t('Invalid qty');
  180. }
  181. if (bad < 0) {
  182. newErrors.badItemQty = t('Invalid qty');
  183. }
  184. // 4. 🔥 关键验证:总和必须等于 Required Qty(不能多也不能少)
  185. if (total !== req) {
  186. const diff = req - total;
  187. const errorMsg = diff > 0
  188. ? t('Total must equal Required Qty. Missing: {{diff}}', { diff })
  189. : t('Total must equal Required Qty. Exceeds by: {{diff}}', { diff: Math.abs(diff) });
  190. newErrors.actualPickQty = errorMsg;
  191. newErrors.missQty = errorMsg;
  192. newErrors.badItemQty = errorMsg;
  193. }
  194. // 5. 🔥 关键验证:如果只有 actualPickQty 有值,而 missQty 和 badItemQty 都为 0,不允许提交
  195. // 这意味着如果 actualPickQty < requiredQty,必须报告问题(missQty 或 badItemQty > 0)
  196. if (ap > 0 && miss === 0 && bad === 0 && ap < req) {
  197. newErrors.missQty = t('If Actual Pick Qty is less than Required Qty, you must report Missing Qty or Bad Item Qty');
  198. newErrors.badItemQty = t('If Actual Pick Qty is less than Required Qty, you must report Missing Qty or Bad Item Qty');
  199. }
  200. // 6. 如果所有值都为 0,不允许提交
  201. if (ap === 0 && miss === 0 && bad === 0) {
  202. newErrors.actualPickQty = t('Enter pick qty or issue qty');
  203. newErrors.missQty = t('Enter pick qty or issue qty');
  204. }
  205. // 7. 如果 actualPickQty = requiredQty,missQty 和 badItemQty 必须都为 0
  206. if (ap === req && (miss > 0 || bad > 0)) {
  207. newErrors.missQty = t('If Actual Pick Qty equals Required Qty, Missing Qty and Bad Item Qty must be 0');
  208. newErrors.badItemQty = t('If Actual Pick Qty equals Required Qty, Missing Qty and Bad Item Qty must be 0');
  209. }
  210. setErrors(newErrors);
  211. return Object.keys(newErrors).length === 0;
  212. };
  213. const handleSubmit = async () => {
  214. // First validate the form
  215. if (!validateForm()) {
  216. console.error('Form validation failed:', errors);
  217. return; // Prevent submission, show validation errors
  218. }
  219. if (!formData.pickOrderId) {
  220. console.error('Missing pickOrderId');
  221. return;
  222. }
  223. setLoading(true);
  224. try {
  225. await onSubmit(formData as PickExecutionIssueData);
  226. // Automatically closed when successful (handled by onClose)
  227. } catch (error: any) {
  228. console.error('Error submitting pick execution issue:', error);
  229. // Show error message (can be passed to parent component via props or state)
  230. // 或者在这里显示 toast/alert
  231. alert(t('Failed to submit issue. Please try again.') + (error.message ? `: ${error.message}` : ''));
  232. } finally {
  233. setLoading(false);
  234. }
  235. };
  236. const handleClose = () => {
  237. setFormData({});
  238. setErrors({});
  239. onClose();
  240. };
  241. if (!selectedLot || !selectedPickOrderLine) {
  242. return null;
  243. }
  244. const remainingAvailableQty = calculateRemainingAvailableQty(selectedLot);
  245. const requiredQty = calculateRequiredQty(selectedLot);
  246. return (
  247. <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
  248. <DialogTitle>
  249. {t('Pick Execution Issue Form')} {/* Always show issue form title */}
  250. </DialogTitle>
  251. <DialogContent>
  252. <Box sx={{ mt: 2 }}>
  253. {/* Add instruction text */}
  254. <Grid container spacing={2}>
  255. <Grid item xs={12}>
  256. <Box sx={{ p: 2, backgroundColor: '#fff3cd', borderRadius: 1, mb: 2 }}>
  257. <Typography variant="body2" color="warning.main">
  258. <strong>{t('Note:')}</strong> {t('This form is for reporting issues only. You must report either missing items or bad items.')}
  259. </Typography>
  260. </Box>
  261. </Grid>
  262. {/* Keep the existing form fields */}
  263. <Grid item xs={6}>
  264. <TextField
  265. fullWidth
  266. label={t('Required Qty')}
  267. value={selectedLot?.requiredQty || 0}
  268. disabled
  269. variant="outlined"
  270. // helperText={t('Still need to pick')}
  271. />
  272. </Grid>
  273. <Grid item xs={6}>
  274. <TextField
  275. fullWidth
  276. label={t('Remaining Available Qty')}
  277. value={remainingAvailableQty}
  278. disabled
  279. variant="outlined"
  280. // helperText={t('Available in warehouse')}
  281. />
  282. </Grid>
  283. <Grid item xs={12}>
  284. <TextField
  285. fullWidth
  286. label={t('Actual Pick Qty')}
  287. type="number"
  288. value={formData.actualPickQty ?? ''}
  289. onChange={(e) => handleInputChange('actualPickQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))}
  290. error={!!errors.actualPickQty}
  291. helperText={errors.actualPickQty || `${t('Max')}: ${Math.min(remainingAvailableQty, selectedLot?.requiredQty || 0)}`}
  292. variant="outlined"
  293. />
  294. </Grid>
  295. <Grid item xs={12}>
  296. <TextField
  297. fullWidth
  298. label={t('Missing item Qty')}
  299. type="number"
  300. value={formData.missQty || 0}
  301. onChange={(e) => handleInputChange('missQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))}
  302. error={!!errors.missQty}
  303. variant="outlined"
  304. //disabled={(formData.actualPickQty || 0) > 0}
  305. />
  306. </Grid>
  307. <Grid item xs={12}>
  308. <TextField
  309. fullWidth
  310. label={t('Bad Item Qty')}
  311. type="number"
  312. value={formData.badItemQty || 0}
  313. onChange={(e) => handleInputChange('badItemQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))}
  314. error={!!errors.badItemQty}
  315. variant="outlined"
  316. //disabled={(formData.actualPickQty || 0) > 0}
  317. />
  318. </Grid>
  319. {/* Show issue description and handler fields when bad items > 0 */}
  320. {(formData.badItemQty && formData.badItemQty > 0) ? (
  321. <>
  322. <Grid item xs={12}>
  323. <TextField
  324. fullWidth
  325. id="issueRemark"
  326. label={t('Issue Remark')}
  327. multiline
  328. rows={4}
  329. value={formData.issueRemark || ''}
  330. onChange={(e) => handleInputChange('issueRemark', e.target.value)}
  331. error={!!errors.issueRemark}
  332. helperText={errors.issueRemark}
  333. //placeholder={t('Describe the issue with bad items')}
  334. variant="outlined"
  335. />
  336. </Grid>
  337. <Grid item xs={12}>
  338. <FormControl fullWidth error={!!errors.handledBy}>
  339. <InputLabel>{t('handler')}</InputLabel>
  340. <Select
  341. value={formData.handledBy ? formData.handledBy.toString() : ''}
  342. onChange={(e) => handleInputChange('handledBy', e.target.value ? parseInt(e.target.value) : undefined)}
  343. label={t('handler')}
  344. >
  345. {handlers.map((handler) => (
  346. <MenuItem key={handler.id} value={handler.id.toString()}>
  347. {handler.name}
  348. </MenuItem>
  349. ))}
  350. </Select>
  351. {errors.handledBy && (
  352. <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}>
  353. {errors.handledBy}
  354. </Typography>
  355. )}
  356. </FormControl>
  357. </Grid>
  358. </>
  359. ) : (<></>)}
  360. </Grid>
  361. </Box>
  362. </DialogContent>
  363. <DialogActions>
  364. <Button onClick={handleClose} disabled={loading}>
  365. {t('Cancel')}
  366. </Button>
  367. <Button
  368. onClick={handleSubmit}
  369. variant="contained"
  370. disabled={loading}
  371. >
  372. {loading ? t('submitting') : t('submit')}
  373. </Button>
  374. </DialogActions>
  375. </Dialog>
  376. );
  377. };
  378. export default PickExecutionForm;