| @@ -41,6 +41,9 @@ export interface InventoryLotDetailResponse { | |||||
| approverBadQty: number | null; | approverBadQty: number | null; | ||||
| finalQty: number | null; | finalQty: number | null; | ||||
| bookQty: number | null; | bookQty: number | null; | ||||
| stockTakeSection?: string | null; | |||||
| stockTakeSectionDescription?: string | null; | |||||
| stockTakerName?: string | null; | |||||
| } | } | ||||
| export const getInventoryLotDetailsBySection = async ( | export const getInventoryLotDetailsBySection = async ( | ||||
| @@ -58,6 +58,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| const formProps = useForm<CreateConsoDoInput>({ | const formProps = useForm<CreateConsoDoInput>({ | ||||
| defaultValues: {}, | defaultValues: {}, | ||||
| }); | }); | ||||
| const { setValue } = formProps; | |||||
| const errors = formProps.formState.errors; | const errors = formProps.formState.errors; | ||||
| @@ -68,8 +69,8 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| console.log("🔍 DoSearch - session:", session); | console.log("🔍 DoSearch - session:", session); | ||||
| console.log("🔍 DoSearch - currentUserId:", currentUserId); | console.log("🔍 DoSearch - currentUserId:", currentUserId); | ||||
| const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null); | const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null); | ||||
| const [rowSelectionModel, setRowSelectionModel] = | |||||
| useState<GridRowSelectionModel>([]); | |||||
| /** 使用者明確取消勾選的送貨單 id;未在此集合中的搜尋結果視為「已選」以便跨頁記憶 */ | |||||
| const [excludedRowIds, setExcludedRowIds] = useState<number[]>([]); | |||||
| const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | ||||
| const [totalCount, setTotalCount] = useState(0); | const [totalCount, setTotalCount] = useState(0); | ||||
| @@ -101,6 +102,37 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| const [hasSearched, setHasSearched] = useState(false); | const [hasSearched, setHasSearched] = useState(false); | ||||
| const [hasResults, setHasResults] = useState(false); | const [hasResults, setHasResults] = useState(false); | ||||
| const excludedIdSet = useMemo(() => new Set(excludedRowIds), [excludedRowIds]); | |||||
| const rowSelectionModel = useMemo<GridRowSelectionModel>(() => { | |||||
| return searchAllDos | |||||
| .map((r) => r.id) | |||||
| .filter((id) => !excludedIdSet.has(id)); | |||||
| }, [searchAllDos, excludedIdSet]); | |||||
| const applyRowSelectionChange = useCallback( | |||||
| (newModel: GridRowSelectionModel) => { | |||||
| const pageIds = searchAllDos.map((r) => r.id); | |||||
| const selectedSet = new Set( | |||||
| newModel.map((id) => (typeof id === "string" ? Number(id) : id)), | |||||
| ); | |||||
| setExcludedRowIds((prev) => { | |||||
| const next = new Set(prev); | |||||
| for (const id of pageIds) { | |||||
| next.delete(id); | |||||
| } | |||||
| for (const id of pageIds) { | |||||
| if (!selectedSet.has(id)) { | |||||
| next.add(id); | |||||
| } | |||||
| } | |||||
| return Array.from(next); | |||||
| }); | |||||
| setValue("ids", newModel); | |||||
| }, | |||||
| [searchAllDos, setValue], | |||||
| ); | |||||
| // 当搜索条件变化时,重置到第一页 | // 当搜索条件变化时,重置到第一页 | ||||
| useEffect(() => { | useEffect(() => { | ||||
| setPagingController(p => ({ | setPagingController(p => ({ | ||||
| @@ -140,6 +172,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| setTotalCount(0); | setTotalCount(0); | ||||
| setHasSearched(false); | setHasSearched(false); | ||||
| setHasResults(false); | setHasResults(false); | ||||
| setExcludedRowIds([]); | |||||
| setPagingController({ pageNum: 1, pageSize: 10 }); | setPagingController({ pageNum: 1, pageSize: 10 }); | ||||
| } | } | ||||
| catch (error) { | catch (error) { | ||||
| @@ -289,6 +322,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| setTotalCount(response.total); // 设置总记录数 | setTotalCount(response.total); // 设置总记录数 | ||||
| setHasSearched(true); | setHasSearched(true); | ||||
| setHasResults(response.records.length > 0); | setHasResults(response.records.length > 0); | ||||
| setExcludedRowIds([]); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error: ", error); | console.error("Error: ", error); | ||||
| @@ -296,6 +330,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| setTotalCount(0); | setTotalCount(0); | ||||
| setHasSearched(true); | setHasSearched(true); | ||||
| setHasResults(false); | setHasResults(false); | ||||
| setExcludedRowIds([]); | |||||
| } | } | ||||
| }, [pagingController]); | }, [pagingController]); | ||||
| @@ -494,6 +529,20 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| const idsToRelease = allMatchingDos | |||||
| .map((d) => d.id) | |||||
| .filter((id) => !excludedIdSet.has(id)); | |||||
| if (idsToRelease.length === 0) { | |||||
| await Swal.fire({ | |||||
| icon: "warning", | |||||
| title: t("No Records"), | |||||
| text: t("No delivery orders selected for batch release. Uncheck orders you want to exclude, or search again to reset selection."), | |||||
| confirmButtonText: t("OK"), | |||||
| }); | |||||
| return; | |||||
| } | |||||
| // 显示确认对话框 | // 显示确认对话框 | ||||
| const result = await Swal.fire({ | const result = await Swal.fire({ | ||||
| @@ -501,7 +550,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| title: t("Batch Release"), | title: t("Batch Release"), | ||||
| html: ` | html: ` | ||||
| <div> | <div> | ||||
| <p>${t("Selected Shop(s): ")}${allMatchingDos.length}</p> | |||||
| <p>${t("Selected Shop(s): ")}${idsToRelease.length}</p> | |||||
| <p style="font-size: 0.9em; color: #666; margin-top: 8px;"> | <p style="font-size: 0.9em; color: #666; margin-top: 8px;"> | ||||
| ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} | ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} | ||||
| @@ -519,8 +568,6 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| }); | }); | ||||
| if (result.isConfirmed) { | if (result.isConfirmed) { | ||||
| const idsToRelease = allMatchingDos.map(d => d.id); | |||||
| try { | try { | ||||
| const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | ||||
| const jobId = startRes?.entity?.jobId; | const jobId = startRes?.entity?.jobId; | ||||
| @@ -595,7 +642,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| confirmButtonText: t("OK") | confirmButtonText: t("OK") | ||||
| }); | }); | ||||
| } | } | ||||
| }, [t, currentUserId, currentSearchParams, handleSearch]); | |||||
| }, [t, currentUserId, currentSearchParams, handleSearch, excludedIdSet]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -629,10 +676,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||||
| columns={columns} | columns={columns} | ||||
| checkboxSelection | checkboxSelection | ||||
| rowSelectionModel={rowSelectionModel} | rowSelectionModel={rowSelectionModel} | ||||
| onRowSelectionModelChange={(newRowSelectionModel) => { | |||||
| setRowSelectionModel(newRowSelectionModel); | |||||
| formProps.setValue("ids", newRowSelectionModel); | |||||
| }} | |||||
| onRowSelectionModelChange={applyRowSelectionChange} | |||||
| slots={{ | slots={{ | ||||
| footer: FooterToolbar, | footer: FooterToolbar, | ||||
| noRowsOverlay: NoRowsOverlay, | noRowsOverlay: NoRowsOverlay, | ||||
| @@ -80,6 +80,23 @@ interface Props { | |||||
| onSwitchToRecordTab?: () => void; | onSwitchToRecordTab?: () => void; | ||||
| onRefreshReleasedOrderCount?: () => void; | onRefreshReleasedOrderCount?: () => void; | ||||
| } | } | ||||
| /** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */ | |||||
| function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null { | |||||
| if (!activeSuggestedLots?.length) return null; | |||||
| const withLotNo = activeSuggestedLots.filter( | |||||
| (l) => l.lotNo != null && String(l.lotNo).trim() !== "" | |||||
| ); | |||||
| if (withLotNo.length === 1) return withLotNo[0]; | |||||
| if (withLotNo.length > 1) { | |||||
| const pending = withLotNo.find( | |||||
| (l) => (l.stockOutLineStatus || "").toLowerCase() === "pending" | |||||
| ); | |||||
| return pending || withLotNo[0]; | |||||
| } | |||||
| return activeSuggestedLots[0]; | |||||
| } | |||||
| // QR Code Modal Component (from LotTable) | // QR Code Modal Component (from LotTable) | ||||
| const QrCodeModal: React.FC<{ | const QrCodeModal: React.FC<{ | ||||
| open: boolean; | open: boolean; | ||||
| @@ -513,6 +530,22 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||||
| const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]); | const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]); | ||||
| // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required) | // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required) | ||||
| const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | ||||
| const applyLocalStockOutLineUpdate = useCallback(( | |||||
| stockOutLineId: number, | |||||
| status: string, | |||||
| actualPickQty?: number | |||||
| ) => { | |||||
| setCombinedLotData(prev => prev.map((lot) => { | |||||
| if (Number(lot.stockOutLineId) !== Number(stockOutLineId)) return lot; | |||||
| return { | |||||
| ...lot, | |||||
| stockOutLineStatus: status, | |||||
| ...(typeof actualPickQty === "number" | |||||
| ? { actualPickQty, stockOutLineQty: actualPickQty } | |||||
| : {}), | |||||
| }; | |||||
| })); | |||||
| }, []); | |||||
| // 防止重复点击(Submit / Just Completed / Issue) | // 防止重复点击(Submit / Just Completed / Issue) | ||||
| const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | ||||
| @@ -571,12 +604,11 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); | |||||
| const lastProcessedQrRef = useRef<string>(''); | const lastProcessedQrRef = useRef<string>(''); | ||||
| // Store callbacks in refs to avoid useEffect dependency issues | // Store callbacks in refs to avoid useEffect dependency issues | ||||
| const processOutsideQrCodeRef = useRef<((latestQr: string) => Promise<void>) | null>(null); | |||||
| const processOutsideQrCodeRef = useRef< | |||||
| ((latestQr: string, qrScanCountAtInvoke?: number) => Promise<void>) | null | |||||
| >(null); | |||||
| const resetScanRef = useRef<(() => void) | null>(null); | const resetScanRef = useRef<(() => void) | null>(null); | ||||
| const lotConfirmOpenedQrCountRef = useRef<number>(0); | const lotConfirmOpenedQrCountRef = useRef<number>(0); | ||||
| const lotConfirmOpenedQrValueRef = useRef<string>(''); | |||||
| const lotConfirmInitialSameQrSkippedRef = useRef<boolean>(false); | |||||
| const autoConfirmInProgressRef = useRef<boolean>(false); | |||||
| @@ -651,11 +683,14 @@ const [isConfirmingLot, setIsConfirmingLot] = useState(false); | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| const handleLotMismatch = useCallback((expectedLot: any, scannedLot: any) => { | |||||
| const handleLotMismatch = useCallback((expectedLot: any, scannedLot: any, qrScanCountAtOpen?: number) => { | |||||
| const mismatchStartTime = performance.now(); | const mismatchStartTime = performance.now(); | ||||
| console.log(`⏱️ [HANDLE LOT MISMATCH START]`); | console.log(`⏱️ [HANDLE LOT MISMATCH START]`); | ||||
| console.log(`⏰ Start time: ${new Date().toISOString()}`); | console.log(`⏰ Start time: ${new Date().toISOString()}`); | ||||
| console.log("Lot mismatch detected:", { expectedLot, scannedLot }); | console.log("Lot mismatch detected:", { expectedLot, scannedLot }); | ||||
| lotConfirmOpenedQrCountRef.current = | |||||
| typeof qrScanCountAtOpen === "number" ? qrScanCountAtOpen : 1; | |||||
| // ✅ Use setTimeout to avoid flushSync warning - schedule modal update in next tick | // ✅ Use setTimeout to avoid flushSync warning - schedule modal update in next tick | ||||
| const setTimeoutStartTime = performance.now(); | const setTimeoutStartTime = performance.now(); | ||||
| @@ -1299,34 +1334,6 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| return false; | return false; | ||||
| }, [lotConfirmationOpen, selectedLotForQr, expectedLotData, scannedLotData, parseQrPayload, handleLotConfirmation, clearLotConfirmationState]); | }, [lotConfirmationOpen, selectedLotForQr, expectedLotData, scannedLotData, parseQrPayload, handleLotConfirmation, clearLotConfirmationState]); | ||||
| useEffect(() => { | |||||
| if (!lotConfirmationOpen || !expectedLotData || !scannedLotData || !selectedLotForQr) { | |||||
| autoConfirmInProgressRef.current = false; | |||||
| return; | |||||
| } | |||||
| if (autoConfirmInProgressRef.current || isConfirmingLot) { | |||||
| return; | |||||
| } | |||||
| autoConfirmInProgressRef.current = true; | |||||
| handleLotConfirmation() | |||||
| .catch((error) => { | |||||
| console.error("Auto confirm lot substitution failed:", error); | |||||
| }) | |||||
| .finally(() => { | |||||
| autoConfirmInProgressRef.current = false; | |||||
| }); | |||||
| }, [lotConfirmationOpen, expectedLotData, scannedLotData, selectedLotForQr, isConfirmingLot, handleLotConfirmation]); | |||||
| useEffect(() => { | |||||
| if (lotConfirmationOpen) { | |||||
| // 记录弹窗打开时的扫码数量,避免把“触发弹窗的同一次扫码”当作二次确认 | |||||
| lotConfirmOpenedQrCountRef.current = qrValues.length; | |||||
| lotConfirmOpenedQrValueRef.current = qrValues[qrValues.length - 1] || ''; | |||||
| lotConfirmInitialSameQrSkippedRef.current = true; | |||||
| } | |||||
| }, [lotConfirmationOpen, qrValues.length]); | |||||
| const handleQrCodeSubmit = useCallback(async (lotNo: string) => { | const handleQrCodeSubmit = useCallback(async (lotNo: string) => { | ||||
| console.log(` Processing QR Code for lot: ${lotNo}`); | console.log(` Processing QR Code for lot: ${lotNo}`); | ||||
| @@ -1624,7 +1631,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // Store resetScan in ref for immediate access (update on every render) | // Store resetScan in ref for immediate access (update on every render) | ||||
| resetScanRef.current = resetScan; | resetScanRef.current = resetScan; | ||||
| const processOutsideQrCode = useCallback(async (latestQr: string) => { | |||||
| const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => { | |||||
| const totalStartTime = performance.now(); | const totalStartTime = performance.now(); | ||||
| console.log(`⏱️ [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); | console.log(`⏱️ [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); | ||||
| console.log(`⏰ Start time: ${new Date().toISOString()}`); | console.log(`⏰ Start time: ${new Date().toISOString()}`); | ||||
| @@ -1742,7 +1749,14 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| lot.lotAvailability === 'rejected' || | lot.lotAvailability === 'rejected' || | ||||
| lot.lotAvailability === 'status_unavailable' | lot.lotAvailability === 'status_unavailable' | ||||
| ); | ); | ||||
| const expectedLot = rejectedLot || allLotsForItem[0]; // Use rejected lot if exists, otherwise first lot | |||||
| const expectedLot = | |||||
| rejectedLot || | |||||
| pickExpectedLotForSubstitution( | |||||
| allLotsForItem.filter( | |||||
| (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "" | |||||
| ) | |||||
| ) || | |||||
| allLotsForItem[0]; | |||||
| // ✅ Always open confirmation modal when no active lots (user needs to confirm switching) | // ✅ Always open confirmation modal when no active lots (user needs to confirm switching) | ||||
| // handleLotMismatch will fetch lotNo from backend using stockInLineId if needed | // handleLotMismatch will fetch lotNo from backend using stockInLineId if needed | ||||
| @@ -1760,7 +1774,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| itemName: expectedLot.itemName, | itemName: expectedLot.itemName, | ||||
| inventoryLotLineId: scannedLot?.lotId || null, | inventoryLotLineId: scannedLot?.lotId || null, | ||||
| stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | ||||
| } | |||||
| }, | |||||
| qrScanCountAtInvoke | |||||
| ); | ); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -1785,7 +1800,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) | // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) | ||||
| if (!exactMatch) { | if (!exactMatch) { | ||||
| // Scanned lot is not in active suggested lots, open confirmation modal | // Scanned lot is not in active suggested lots, open confirmation modal | ||||
| const expectedLot = activeSuggestedLots[0] || allLotsForItem[0]; // Use first active lot or first lot as expected | |||||
| const expectedLot = | |||||
| pickExpectedLotForSubstitution(activeSuggestedLots) || allLotsForItem[0]; | |||||
| if (expectedLot) { | if (expectedLot) { | ||||
| // Check if scanned lot is different from expected, or if scannedLot is undefined (not in allLotsForItem) | // Check if scanned lot is different from expected, or if scannedLot is undefined (not in allLotsForItem) | ||||
| const shouldOpenModal = !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); | const shouldOpenModal = !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); | ||||
| @@ -1804,7 +1820,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| itemName: expectedLot.itemName, | itemName: expectedLot.itemName, | ||||
| inventoryLotLineId: scannedLot?.lotId || null, | inventoryLotLineId: scannedLot?.lotId || null, | ||||
| stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | ||||
| } | |||||
| }, | |||||
| qrScanCountAtInvoke | |||||
| ); | ); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -1925,9 +1942,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| const mismatchCheckTime = performance.now() - mismatchCheckStartTime; | const mismatchCheckTime = performance.now() - mismatchCheckStartTime; | ||||
| console.log(`⏱️ [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); | console.log(`⏱️ [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); | ||||
| // 取第一个活跃的 lot 作为期望的 lot | |||||
| // 取应被替换的活跃行(同物料多行时优先有建议批次的行) | |||||
| const expectedLotStartTime = performance.now(); | const expectedLotStartTime = performance.now(); | ||||
| const expectedLot = activeSuggestedLots[0]; | |||||
| const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); | |||||
| if (!expectedLot) { | if (!expectedLot) { | ||||
| console.error("Could not determine expected lot for confirmation"); | console.error("Could not determine expected lot for confirmation"); | ||||
| startTransition(() => { | startTransition(() => { | ||||
| @@ -1963,7 +1980,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| itemName: expectedLot.itemName, | itemName: expectedLot.itemName, | ||||
| inventoryLotLineId: null, | inventoryLotLineId: null, | ||||
| stockInLineId: scannedStockInLineId // ✅ 传递 stockInLineId | stockInLineId: scannedStockInLineId // ✅ 传递 stockInLineId | ||||
| } | |||||
| }, | |||||
| qrScanCountAtInvoke | |||||
| ); | ); | ||||
| const handleMismatchTime = performance.now() - handleMismatchStartTime; | const handleMismatchTime = performance.now() - handleMismatchStartTime; | ||||
| console.log(`⏱️ [PERF] Handle mismatch call time: ${handleMismatchTime.toFixed(2)}ms`); | console.log(`⏱️ [PERF] Handle mismatch call time: ${handleMismatchTime.toFixed(2)}ms`); | ||||
| @@ -2048,7 +2066,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // ✅ Process immediately (bypass QR scanner delay) | // ✅ Process immediately (bypass QR scanner delay) | ||||
| if (processOutsideQrCodeRef.current) { | if (processOutsideQrCodeRef.current) { | ||||
| processOutsideQrCodeRef.current(simulatedQr).then(() => { | |||||
| processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => { | |||||
| const testTime = performance.now() - testStartTime; | const testTime = performance.now() - testStartTime; | ||||
| console.log(`⏱️ [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`); | console.log(`⏱️ [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`); | ||||
| console.log(`⏱️ [TEST QR] End time: ${new Date().toISOString()}`); | console.log(`⏱️ [TEST QR] End time: ${new Date().toISOString()}`); | ||||
| @@ -2074,9 +2092,24 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| } | } | ||||
| } | } | ||||
| // lot confirm 弹窗打开时,允许通过“再次扫码”决定走向(切换或继续原 lot) | |||||
| // 批次确认弹窗:须第二次扫码选择沿用建议批次或切换(不再自动确认) | |||||
| if (lotConfirmationOpen) { | if (lotConfirmationOpen) { | ||||
| // 已改回自动确认:弹窗打开时不再等待二次扫码 | |||||
| if (isConfirmingLot) { | |||||
| return; | |||||
| } | |||||
| if (qrValues.length <= lotConfirmOpenedQrCountRef.current) { | |||||
| return; | |||||
| } | |||||
| void (async () => { | |||||
| try { | |||||
| const handled = await handleLotConfirmationByRescan(latestQr); | |||||
| if (handled && resetScanRef.current) { | |||||
| resetScanRef.current(); | |||||
| } | |||||
| } catch (e) { | |||||
| console.error("Lot confirmation rescan failed:", e); | |||||
| } | |||||
| })(); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -2171,7 +2204,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // Use ref to avoid dependency issues | // Use ref to avoid dependency issues | ||||
| const processCallStartTime = performance.now(); | const processCallStartTime = performance.now(); | ||||
| if (processOutsideQrCodeRef.current) { | if (processOutsideQrCodeRef.current) { | ||||
| processOutsideQrCodeRef.current(latestQr).then(() => { | |||||
| processOutsideQrCodeRef.current(latestQr, qrValues.length).then(() => { | |||||
| const processCallTime = performance.now() - processCallStartTime; | const processCallTime = performance.now() - processCallStartTime; | ||||
| const totalProcessingTime = performance.now() - processingStartTime; | const totalProcessingTime = performance.now() - processingStartTime; | ||||
| console.log(`⏱️ [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`); | console.log(`⏱️ [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`); | ||||
| @@ -2203,7 +2236,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| qrProcessingTimeoutRef.current = null; | qrProcessingTimeoutRef.current = null; | ||||
| } | } | ||||
| }; | }; | ||||
| }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen, handleLotConfirmationByRescan]); | |||||
| }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, lotConfirmationOpen, manualLotConfirmationOpen, handleLotConfirmationByRescan, isConfirmingLot]); | |||||
| const renderCountRef = useRef(0); | const renderCountRef = useRef(0); | ||||
| const renderStartTimeRef = useRef<number | null>(null); | const renderStartTimeRef = useRef<number | null>(null); | ||||
| @@ -2550,16 +2583,16 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| try { | try { | ||||
| if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); | if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); | ||||
| // Special case: If submitQty is 0 and all values are 0, mark as completed with qty: 0 | |||||
| // Just Complete: mark checked only, real posting happens in batch submit | |||||
| if (submitQty === 0) { | if (submitQty === 0) { | ||||
| console.log(`=== SUBMITTING ALL ZEROS CASE ===`); | console.log(`=== SUBMITTING ALL ZEROS CASE ===`); | ||||
| console.log(`Lot: ${lot.lotNo}`); | console.log(`Lot: ${lot.lotNo}`); | ||||
| console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); | console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); | ||||
| console.log(`Setting status to 'completed' with qty: 0`); | |||||
| console.log(`Setting status to 'checked' with qty: 0`); | |||||
| const updateResult = await updateStockOutLineStatus({ | const updateResult = await updateStockOutLineStatus({ | ||||
| id: lot.stockOutLineId, | id: lot.stockOutLineId, | ||||
| status: 'completed', | |||||
| status: 'checked', | |||||
| qty: 0 | qty: 0 | ||||
| }); | }); | ||||
| @@ -2575,29 +2608,10 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| console.error('Failed to update stock out line status:', updateResult); | console.error('Failed to update stock out line status:', updateResult); | ||||
| throw new Error('Failed to update stock out line status'); | throw new Error('Failed to update stock out line status'); | ||||
| } | } | ||||
| applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), "checked", Number(lot.actualPickQty || 0)); | |||||
| // Check if pick order is completed | |||||
| if (lot.pickOrderConsoCode) { | |||||
| console.log(` Lot ${lot.lotNo} completed (all zeros), 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("All zeros submission completed successfully!"); | |||||
| void fetchAllCombinedLotData(); | |||||
| console.log("Just Complete marked as checked successfully (waiting for batch submit)."); | |||||
| setTimeout(() => { | setTimeout(() => { | ||||
| checkAndAutoAssignNext(); | checkAndAutoAssignNext(); | ||||
| @@ -2635,6 +2649,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| status: newStatus, | status: newStatus, | ||||
| qty: cumulativeQty // Use cumulative quantity | qty: cumulativeQty // Use cumulative quantity | ||||
| }); | }); | ||||
| applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), newStatus, cumulativeQty); | |||||
| if (submitQty > 0) { | if (submitQty > 0) { | ||||
| await updateInventoryLotLineQuantities({ | await updateInventoryLotLineQuantities({ | ||||
| @@ -2665,7 +2680,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| } | } | ||||
| } | } | ||||
| await fetchAllCombinedLotData(); | |||||
| void fetchAllCombinedLotData(); | |||||
| console.log("Pick quantity submitted successfully!"); | console.log("Pick quantity submitted successfully!"); | ||||
| setTimeout(() => { | setTimeout(() => { | ||||
| @@ -2677,16 +2692,31 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| } finally { | } finally { | ||||
| if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | ||||
| } | } | ||||
| }, [fetchAllCombinedLotData, checkAndAutoAssignNext, actionBusyBySolId]); | |||||
| }, [fetchAllCombinedLotData, checkAndAutoAssignNext, actionBusyBySolId, applyLocalStockOutLineUpdate]); | |||||
| const handleSkip = useCallback(async (lot: any) => { | const handleSkip = useCallback(async (lot: any) => { | ||||
| try { | try { | ||||
| console.log("Skip clicked, submit lot required qty for lot:", lot.lotNo); | |||||
| await handleSubmitPickQtyWithQty(lot, lot.requiredQty); | |||||
| console.log("Just Complete clicked, mark checked with 0 qty for lot:", lot.lotNo); | |||||
| await handleSubmitPickQtyWithQty(lot, 0); | |||||
| } catch (err) { | } catch (err) { | ||||
| console.error("Error in Skip:", err); | console.error("Error in Skip:", err); | ||||
| } | } | ||||
| }, [handleSubmitPickQtyWithQty]); | }, [handleSubmitPickQtyWithQty]); | ||||
| const hasPendingBatchSubmit = useMemo(() => { | |||||
| return combinedLotData.some((lot) => { | |||||
| const status = String(lot.stockOutLineStatus || "").toLowerCase(); | |||||
| return status === "checked" || status === "pending" || status === "partially_completed" || status === "partially_complete"; | |||||
| }); | |||||
| }, [combinedLotData]); | |||||
| useEffect(() => { | |||||
| if (!hasPendingBatchSubmit) return; | |||||
| const handler = (event: BeforeUnloadEvent) => { | |||||
| event.preventDefault(); | |||||
| event.returnValue = ""; | |||||
| }; | |||||
| window.addEventListener("beforeunload", handler); | |||||
| return () => window.removeEventListener("beforeunload", handler); | |||||
| }, [hasPendingBatchSubmit]); | |||||
| const handleStartScan = useCallback(() => { | const handleStartScan = useCallback(() => { | ||||
| const startTime = performance.now(); | const startTime = performance.now(); | ||||
| console.log(`⏱️ [START SCAN] Called at: ${new Date().toISOString()}`); | console.log(`⏱️ [START SCAN] Called at: ${new Date().toISOString()}`); | ||||
| @@ -2890,6 +2920,10 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| const scannedLots = combinedLotData.filter(lot => { | const scannedLots = combinedLotData.filter(lot => { | ||||
| const status = lot.stockOutLineStatus; | const status = lot.stockOutLineStatus; | ||||
| const statusLower = String(status || "").toLowerCase(); | |||||
| if (statusLower === "completed" || statusLower === "complete") { | |||||
| return false; | |||||
| } | |||||
| // ✅ noLot 情况:允许 checked / pending / partially_completed / PARTIALLY_COMPLETE | // ✅ noLot 情况:允许 checked / pending / partially_completed / PARTIALLY_COMPLETE | ||||
| if (lot.noLot === true) { | if (lot.noLot === true) { | ||||
| return status === 'checked' || | return status === 'checked' || | ||||
| @@ -3021,6 +3055,10 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| const scannedItemsCount = useMemo(() => { | const scannedItemsCount = useMemo(() => { | ||||
| const filtered = combinedLotData.filter(lot => { | const filtered = combinedLotData.filter(lot => { | ||||
| const status = lot.stockOutLineStatus; | const status = lot.stockOutLineStatus; | ||||
| const statusLower = String(status || "").toLowerCase(); | |||||
| if (statusLower === "completed" || statusLower === "complete") { | |||||
| return false; | |||||
| } | |||||
| // ✅ 与 handleSubmitAllScanned 完全保持一致 | // ✅ 与 handleSubmitAllScanned 完全保持一致 | ||||
| if (lot.noLot === true) { | if (lot.noLot === true) { | ||||
| return status === 'checked' || | return status === 'checked' || | ||||
| @@ -3528,6 +3566,9 @@ paginatedData.map((lot, index) => { | |||||
| onClick={() => handleSkip(lot)} | onClick={() => handleSkip(lot)} | ||||
| disabled={ | disabled={ | ||||
| lot.stockOutLineStatus === 'completed' || | lot.stockOutLineStatus === 'completed' || | ||||
| lot.stockOutLineStatus === 'checked' || | |||||
| lot.stockOutLineStatus === 'partially_completed' || | |||||
| // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交) | // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交) | ||||
| (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) || | (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) || | ||||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | ||||
| @@ -52,7 +52,7 @@ const LotConfirmationModal: React.FC<LotConfirmationModalProps> = ({ | |||||
| <DialogContent> | <DialogContent> | ||||
| <Stack spacing={3}> | <Stack spacing={3}> | ||||
| <Alert severity="warning"> | <Alert severity="warning"> | ||||
| {t("The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?")} | |||||
| {t("The scanned item matches the expected item, but the lot number is different. Scan again to confirm: scan the expected lot QR to keep the suggested lot, or scan the other lot QR again to switch.")} | |||||
| </Alert> | </Alert> | ||||
| <Box> | <Box> | ||||
| @@ -92,13 +92,10 @@ const LotConfirmationModal: React.FC<LotConfirmationModalProps> = ({ | |||||
| </Box> | </Box> | ||||
| <Alert severity="info"> | <Alert severity="info"> | ||||
| {t("If you confirm, the system will:")} | |||||
| <ul style={{ margin: '8px 0 0 16px' }}> | |||||
| <li>{t("Update your suggested lot to the this scanned lot")}</li> | |||||
| </ul> | |||||
| {t("After you scan to choose, the system will update the pick line to the lot you confirmed.")} | |||||
| </Alert> | </Alert> | ||||
| <Alert severity="info"> | <Alert severity="info"> | ||||
| {t("You can also scan again to confirm: scan the scanned lot again to switch, or scan the expected lot to continue with current lot.")} | |||||
| {t("Or use the Confirm button below if you cannot scan again (same as scanning the other lot again).")} | |||||
| </Alert> | </Alert> | ||||
| </Stack> | </Stack> | ||||
| </DialogContent> | </DialogContent> | ||||
| @@ -464,6 +464,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | ||||
| // issue form 里填的 actualPickQty(用于 submit/batch submit 不补拣到 required) | // issue form 里填的 actualPickQty(用于 submit/batch submit 不补拣到 required) | ||||
| const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | ||||
| const [localSolStatusById, setLocalSolStatusById] = useState<Record<number, string>>({}); | |||||
| // 防止同一行(以 stockOutLineId/solId 识别)被重复点击提交/完成 | // 防止同一行(以 stockOutLineId/solId 识别)被重复点击提交/完成 | ||||
| const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | ||||
| @@ -646,20 +647,22 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| // 前端覆盖:issue form/submit0 不会立刻改写后端 qty 时,用本地缓存让 UI 与 batch submit 计算一致 | // 前端覆盖:issue form/submit0 不会立刻改写后端 qty 时,用本地缓存让 UI 与 batch submit 计算一致 | ||||
| return lots.map((lot: any) => { | return lots.map((lot: any) => { | ||||
| const solId = Number(lot.stockOutLineId) || 0; | const solId = Number(lot.stockOutLineId) || 0; | ||||
| if (solId > 0 && Object.prototype.hasOwnProperty.call(issuePickedQtyBySolId, solId)) { | |||||
| const picked = Number(issuePickedQtyBySolId[solId] ?? 0); | |||||
| const status = String(lot.stockOutLineStatus || '').toLowerCase(); | |||||
| if (solId > 0) { | |||||
| const hasPickedOverride = Object.prototype.hasOwnProperty.call(issuePickedQtyBySolId, solId); | |||||
| const picked = Number(issuePickedQtyBySolId[solId] ?? lot.actualPickQty ?? 0); | |||||
| const statusRaw = localSolStatusById[solId] ?? lot.stockOutLineStatus ?? ""; | |||||
| const status = String(statusRaw).toLowerCase(); | |||||
| const isEnded = status === 'completed' || status === 'rejected'; | const isEnded = status === 'completed' || status === 'rejected'; | ||||
| return { | return { | ||||
| ...lot, | ...lot, | ||||
| actualPickQty: picked, | |||||
| stockOutLineQty: picked, | |||||
| stockOutLineStatus: isEnded ? lot.stockOutLineStatus : 'checked', | |||||
| actualPickQty: hasPickedOverride ? picked : lot.actualPickQty, | |||||
| stockOutLineQty: hasPickedOverride ? picked : lot.stockOutLineQty, | |||||
| stockOutLineStatus: isEnded ? statusRaw : (statusRaw || "checked"), | |||||
| }; | }; | ||||
| } | } | ||||
| return lot; | return lot; | ||||
| }); | }); | ||||
| }, [jobOrderData, getAllLotsFromHierarchical, issuePickedQtyBySolId]); | |||||
| }, [jobOrderData, getAllLotsFromHierarchical, issuePickedQtyBySolId, localSolStatusById]); | |||||
| const originalCombinedData = useMemo(() => { | const originalCombinedData = useMemo(() => { | ||||
| return getAllLotsFromHierarchical(jobOrderData); | return getAllLotsFromHierarchical(jobOrderData); | ||||
| @@ -1802,6 +1805,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| console.error("No stock out line found for this lot"); | console.error("No stock out line found for this lot"); | ||||
| return; | return; | ||||
| } | } | ||||
| const solId = Number(lot.stockOutLineId) || 0; | |||||
| try { | try { | ||||
| if (currentUserId && lot.pickOrderId && lot.itemId) { | if (currentUserId && lot.pickOrderId && lot.itemId) { | ||||
| @@ -1842,13 +1846,13 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| // 记录该 SOL 的“目标实际拣货量=0”,让 batch submit 走 onlyComplete(不补拣到 required) | // 记录该 SOL 的“目标实际拣货量=0”,让 batch submit 走 onlyComplete(不补拣到 required) | ||||
| const solId = Number(lot.stockOutLineId) || 0; | |||||
| if (solId > 0) { | if (solId > 0) { | ||||
| setIssuePickedQtyBySolId(prev => ({ ...prev, [solId]: 0 })); | setIssuePickedQtyBySolId(prev => ({ ...prev, [solId]: 0 })); | ||||
| setLocalSolStatusById(prev => ({ ...prev, [solId]: 'checked' })); | |||||
| } | } | ||||
| const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; | const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; | ||||
| await fetchJobOrderData(pickOrderId); | |||||
| void fetchJobOrderData(pickOrderId); | |||||
| console.log("All zeros submission marked as checked successfully (waiting for batch submit)."); | console.log("All zeros submission marked as checked successfully (waiting for batch submit)."); | ||||
| setTimeout(() => { | setTimeout(() => { | ||||
| @@ -1887,6 +1891,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| status: newStatus, | status: newStatus, | ||||
| qty: cumulativeQty | qty: cumulativeQty | ||||
| }); | }); | ||||
| if (solId > 0) { | |||||
| setIssuePickedQtyBySolId(prev => ({ ...prev, [solId]: cumulativeQty })); | |||||
| setLocalSolStatusById(prev => ({ ...prev, [solId]: newStatus })); | |||||
| } | |||||
| if (submitQty > 0) { | if (submitQty > 0) { | ||||
| await updateInventoryLotLineQuantities({ | await updateInventoryLotLineQuantities({ | ||||
| @@ -1923,7 +1931,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| } | } | ||||
| const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; | const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; | ||||
| await fetchJobOrderData(pickOrderId); | |||||
| void fetchJobOrderData(pickOrderId); | |||||
| console.log("Pick quantity submitted successfully!"); | console.log("Pick quantity submitted successfully!"); | ||||
| setTimeout(() => { | setTimeout(() => { | ||||
| @@ -1936,15 +1944,34 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| }, [fetchJobOrderData, checkAndAutoAssignNext, filterArgs]); | }, [fetchJobOrderData, checkAndAutoAssignNext, filterArgs]); | ||||
| 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); | |||||
| console.log("Just Complete clicked, mark checked with 0 qty for lot:", lot.lotNo); | |||||
| await handleSubmitPickQtyWithQty(lot, 0); | await handleSubmitPickQtyWithQty(lot, 0); | ||||
| } catch (err) { | } catch (err) { | ||||
| console.error("Error in Skip:", err); | console.error("Error in Skip:", err); | ||||
| } | } | ||||
| }, [handleSubmitPickQtyWithQty]); | }, [handleSubmitPickQtyWithQty]); | ||||
| const hasPendingBatchSubmit = useMemo(() => { | |||||
| return combinedLotData.some((lot) => { | |||||
| const status = String(lot.stockOutLineStatus || "").toLowerCase(); | |||||
| return status === "checked" || status === "pending" || status === "partially_completed" || status === "partially_complete"; | |||||
| }); | |||||
| }, [combinedLotData]); | |||||
| useEffect(() => { | |||||
| if (!hasPendingBatchSubmit) return; | |||||
| const handler = (event: BeforeUnloadEvent) => { | |||||
| event.preventDefault(); | |||||
| event.returnValue = ""; | |||||
| }; | |||||
| window.addEventListener("beforeunload", handler); | |||||
| return () => window.removeEventListener("beforeunload", handler); | |||||
| }, [hasPendingBatchSubmit]); | |||||
| const handleSubmitAllScanned = useCallback(async () => { | const handleSubmitAllScanned = useCallback(async () => { | ||||
| const scannedLots = combinedLotData.filter(lot => { | const scannedLots = combinedLotData.filter(lot => { | ||||
| const status = lot.stockOutLineStatus; | const status = lot.stockOutLineStatus; | ||||
| const statusLower = String(status || "").toLowerCase(); | |||||
| if (statusLower === "completed" || statusLower === "complete") { | |||||
| return false; | |||||
| } | |||||
| console.log("lot.noLot:", lot.noLot); | console.log("lot.noLot:", lot.noLot); | ||||
| console.log("lot.status:", lot.stockOutLineStatus); | console.log("lot.status:", lot.stockOutLineStatus); | ||||
| // ✅ no-lot:允許 pending / checked / partially_completed / PARTIALLY_COMPLETE | // ✅ no-lot:允許 pending / checked / partially_completed / PARTIALLY_COMPLETE | ||||
| @@ -2093,6 +2120,10 @@ if (onlyComplete) { | |||||
| const scannedItemsCount = useMemo(() => { | const scannedItemsCount = useMemo(() => { | ||||
| return combinedLotData.filter(lot => { | return combinedLotData.filter(lot => { | ||||
| const status = lot.stockOutLineStatus; | const status = lot.stockOutLineStatus; | ||||
| const statusLower = String(status || "").toLowerCase(); | |||||
| if (statusLower === "completed" || statusLower === "complete") { | |||||
| return false; | |||||
| } | |||||
| const isNoLot = lot.noLot === true || !lot.lotId; | const isNoLot = lot.noLot === true || !lot.lotId; | ||||
| if (isNoLot) { | if (isNoLot) { | ||||
| @@ -2722,7 +2753,7 @@ const sortedData = [...sourceData].sort((a, b) => { | |||||
| console.error("❌ Error updating handler (non-critical):", error); | console.error("❌ Error updating handler (non-critical):", error); | ||||
| } | } | ||||
| } | } | ||||
| await handleSubmitPickQtyWithQty(lot, lot.requiredQty || lot.pickOrderLineRequiredQty || 0); | |||||
| await handleSubmitPickQtyWithQty(lot, 0); | |||||
| } finally { | } finally { | ||||
| if (solId > 0) { | if (solId > 0) { | ||||
| setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | ||||
| @@ -31,6 +31,7 @@ type SearchQuery = { | |||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| const SearchPage: React.FC<Props> = ({ dataList }) => { | const SearchPage: React.FC<Props> = ({ dataList }) => { | ||||
| const BATCH_CHUNK_SIZE = 20; | |||||
| const { t } = useTranslation("inventory"); | const { t } = useTranslation("inventory"); | ||||
| const [tab, setTab] = useState<"miss" | "bad" | "expiry">("miss"); | const [tab, setTab] = useState<"miss" | "bad" | "expiry">("miss"); | ||||
| const [search, setSearch] = useState<SearchQuery>({ lotNo: "" }); | const [search, setSearch] = useState<SearchQuery>({ lotNo: "" }); | ||||
| @@ -53,6 +54,7 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); | const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); | ||||
| const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set()); | const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set()); | ||||
| const [batchSubmitting, setBatchSubmitting] = useState(false); | const [batchSubmitting, setBatchSubmitting] = useState(false); | ||||
| const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null); | |||||
| const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); | const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); | ||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| @@ -113,7 +115,9 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| // setExpiryItems(prev => prev.filter(i => i.id !== id)); | // setExpiryItems(prev => prev.filter(i => i.id !== id)); | ||||
| window.location.reload(); | window.location.reload(); | ||||
| } catch (e) { | } catch (e) { | ||||
| alert(t("Failed to submit expiry item")); | |||||
| console.error("submitExpiryItem failed:", e); | |||||
| const errMsg = e instanceof Error ? e.message : t("Unknown error"); | |||||
| alert(`${t("Failed to submit expiry item")}: ${errMsg}`); | |||||
| } | } | ||||
| return; // 记得 return,避免再走到下面的 lotId/itemId 分支 | return; // 记得 return,避免再走到下面的 lotId/itemId 分支 | ||||
| } | } | ||||
| @@ -160,26 +164,40 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| if (allIds.length === 0) return; | if (allIds.length === 0) return; | ||||
| setBatchSubmitting(true); | setBatchSubmitting(true); | ||||
| setBatchProgress({ done: 0, total: allIds.length }); | |||||
| try { | try { | ||||
| if (tab === "miss") { | |||||
| await batchSubmitMissItem(allIds, currentUserId); | |||||
| setMissItems((prev) => prev.filter((i) => !allIds.includes(i.id))); | |||||
| } else if (tab === "bad") { | |||||
| await batchSubmitBadItem(allIds, currentUserId); | |||||
| setBadItems((prev) => prev.filter((i) => !allIds.includes(i.id))); | |||||
| } else { | |||||
| await batchSubmitExpiryItem(allIds, currentUserId); | |||||
| setExpiryItems((prev) => prev.filter((i) => !allIds.includes(i.id))); | |||||
| for (let i = 0; i < allIds.length; i += BATCH_CHUNK_SIZE) { | |||||
| const chunkIds = allIds.slice(i, i + BATCH_CHUNK_SIZE); | |||||
| if (tab === "miss") { | |||||
| await batchSubmitMissItem(chunkIds, currentUserId); | |||||
| setMissItems((prev) => prev.filter((item) => !chunkIds.includes(item.id))); | |||||
| } else if (tab === "bad") { | |||||
| await batchSubmitBadItem(chunkIds, currentUserId); | |||||
| setBadItems((prev) => prev.filter((item) => !chunkIds.includes(item.id))); | |||||
| } else { | |||||
| await batchSubmitExpiryItem(chunkIds, currentUserId); | |||||
| setExpiryItems((prev) => prev.filter((item) => !chunkIds.includes(item.id))); | |||||
| } | |||||
| setBatchProgress({ | |||||
| done: Math.min(i + chunkIds.length, allIds.length), | |||||
| total: allIds.length, | |||||
| }); | |||||
| } | } | ||||
| setSelectedIds([]); | setSelectedIds([]); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Failed to submit selected items:", error); | console.error("Failed to submit selected items:", error); | ||||
| alert(`Failed to submit: ${error instanceof Error ? error.message : "Unknown error"}`); | |||||
| const partialDone = batchProgress?.done ?? 0; | |||||
| alert( | |||||
| `${t("Failed to submit")}: ${error instanceof Error ? error.message : "Unknown error"} (${partialDone}/${allIds.length})` | |||||
| ); | |||||
| } finally { | } finally { | ||||
| setBatchSubmitting(false); | setBatchSubmitting(false); | ||||
| setBatchProgress(null); | |||||
| } | } | ||||
| }, [tab, currentUserId, missItems, badItems, expiryItems, filterBySearch]); | |||||
| }, [tab, currentUserId, missItems, badItems, expiryItems, filterBySearch, batchProgress, t]); | |||||
| const missColumns = useMemo<Column<StockIssueResult>[]>( | const missColumns = useMemo<Column<StockIssueResult>[]>( | ||||
| () => [ | () => [ | ||||
| @@ -375,7 +393,9 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| onClick={handleSubmitSelected} | onClick={handleSubmitSelected} | ||||
| disabled={batchSubmitting || !currentUserId} | disabled={batchSubmitting || !currentUserId} | ||||
| > | > | ||||
| {batchSubmitting ? t("Disposing...") : t("Batch Disposed All")} | |||||
| {batchSubmitting | |||||
| ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | |||||
| : t("Batch Disposed All")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| )} | )} | ||||
| @@ -195,6 +195,14 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| calculateDifference, | calculateDifference, | ||||
| ]); | ]); | ||||
| const sortedDetails = useMemo(() => { | |||||
| return [...filteredDetails].sort((a, b) => { | |||||
| const sectionA = (a.stockTakeSection || "").trim(); | |||||
| const sectionB = (b.stockTakeSection || "").trim(); | |||||
| return sectionA.localeCompare(sectionB, undefined, { numeric: true, sensitivity: "base" }); | |||||
| }); | |||||
| }, [filteredDetails]); | |||||
| const handleSaveApproverStockTake = useCallback( | const handleSaveApproverStockTake = useCallback( | ||||
| async (detail: InventoryLotDetailResponse) => { | async (detail: InventoryLotDetailResponse) => { | ||||
| if (mode === "approved") return; | if (mode === "approved") return; | ||||
| @@ -493,22 +501,24 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| <TableCell> | <TableCell> | ||||
| {t("Stock Take Qty(include Bad Qty)= Available Qty")} | {t("Stock Take Qty(include Bad Qty)= Available Qty")} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{t("Remark")}</TableCell> | <TableCell>{t("Remark")}</TableCell> | ||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| <TableCell>{t("Picker")}</TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| {filteredDetails.length === 0 ? ( | |||||
| {sortedDetails.length === 0 ? ( | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={7} align="center"> | |||||
| <TableCell colSpan={8} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("No data")} | {t("No data")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| filteredDetails.map((detail) => { | |||||
| sortedDetails.map((detail) => { | |||||
| const hasFirst = | const hasFirst = | ||||
| detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0; | detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0; | ||||
| const hasSecond = | const hasSecond = | ||||
| @@ -519,8 +529,11 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| return ( | return ( | ||||
| <TableRow key={detail.id}> | <TableRow key={detail.id}> | ||||
| <TableCell> | <TableCell> | ||||
| {detail.warehouseArea || "-"} | |||||
| {detail.warehouseSlot || "-"} | |||||
| <Stack spacing={0.5}> | |||||
| <Typography variant="body2"><strong>{detail.stockTakeSection || "-"} {detail.stockTakeSectionDescription || "-"}</strong></Typography> | |||||
| <Typography variant="body2">{detail.warehouseCode || "-"}</Typography> | |||||
| </Stack> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell | <TableCell | ||||
| sx={{ | sx={{ | ||||
| @@ -792,6 +805,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| /> | /> | ||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.stockTakerName || "-"}</TableCell> | |||||
| <TableCell> | <TableCell> | ||||
| {mode === "pending" && detail.stockTakeRecordId && | {mode === "pending" && detail.stockTakeRecordId && | ||||
| detail.stockTakeRecordStatus !== "notMatch" && ( | detail.stockTakeRecordStatus !== "notMatch" && ( | ||||
| @@ -314,6 +314,7 @@ | |||||
| "QR code does not match any item in current orders.":"QR 碼不符合當前訂單中的任何貨品。", | "QR code does not match any item in current orders.":"QR 碼不符合當前訂單中的任何貨品。", | ||||
| "Lot Number Mismatch":"批次號碼不符", | "Lot Number Mismatch":"批次號碼不符", | ||||
| "The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?":"掃描的貨品與預期的貨品相同,但批次號碼不同。您是否要繼續使用不同的批次?", | "The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?":"掃描的貨品與預期的貨品相同,但批次號碼不同。您是否要繼續使用不同的批次?", | ||||
| "The scanned item matches the expected item, but the lot number is different. Scan again to confirm: scan the expected lot QR to keep the suggested lot, or scan the other lot QR again to switch.":"掃描貨品相同但批次不同。請再掃描一次以確認:掃描「建議批次」的 QR 可沿用該批次;再掃描「另一批次」的 QR 則切換為該批次。", | |||||
| "Expected Lot:":"預期批次:", | "Expected Lot:":"預期批次:", | ||||
| "Scanned Lot:":"掃描批次:", | "Scanned Lot:":"掃描批次:", | ||||
| "Confirm":"確認", | "Confirm":"確認", | ||||
| @@ -324,6 +325,8 @@ | |||||
| "Print DN Label":"列印送貨單標籤", | "Print DN Label":"列印送貨單標籤", | ||||
| "Print All Draft" : "列印全部草稿", | "Print All Draft" : "列印全部草稿", | ||||
| "If you confirm, the system will:":"如果您確認,系統將:", | "If you confirm, the system will:":"如果您確認,系統將:", | ||||
| "After you scan to choose, the system will update the pick line to the lot you confirmed.":"確認後,系統會將您選擇的批次套用到對應提料行。", | |||||
| "Or use the Confirm button below if you cannot scan again (same as scanning the other lot again).":"若無法再掃描,可按下「確認」以切換為剛才掃描到的批次(與再掃一次該批次 QR 相同)。", | |||||
| "QR code verified.":"QR 碼驗證成功。", | "QR code verified.":"QR 碼驗證成功。", | ||||
| "Order Finished":"訂單完成", | "Order Finished":"訂單完成", | ||||
| "Submitted Status":"提交狀態", | "Submitted Status":"提交狀態", | ||||