From cb0f899fc5625e3477ccf0832d68bba3de6e5af1 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 6 Jul 2026 17:04:28 +0800 Subject: [PATCH] Put away fix --- src/components/PutAwayScan/PutAwayModal.tsx | 223 +++++++++++------- src/components/PutAwayScan/PutAwayScan.tsx | 119 +++++++--- .../QrCodeScannerProvider.tsx | 39 ++- 3 files changed, 254 insertions(+), 127 deletions(-) diff --git a/src/components/PutAwayScan/PutAwayModal.tsx b/src/components/PutAwayScan/PutAwayModal.tsx index f14cb8d..1cfdaac 100644 --- a/src/components/PutAwayScan/PutAwayModal.tsx +++ b/src/components/PutAwayScan/PutAwayModal.tsx @@ -15,7 +15,7 @@ import { import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; import { Result } from "@/app/api/settings/item"; // 只导入类型 -import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import ReactQrCodeScanner, { ScannerConfig, } from "../ReactQrCodeScanner/ReactQrCodeScanner"; @@ -36,20 +36,24 @@ import LoadingComponent from "../General/LoadingComponent"; import StockInForm from "../StockIn/StockInForm"; import { arrayToDateString, INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; import { QrCodeScanner } from "../QrCodeScannerProvider/QrCodeScannerProvider"; -import { msg } from "../Swal/CustomAlerts"; +import { msg, msgError } from "../Swal/CustomAlerts"; import { PutAwayRecord } from "."; import FgStockInForm from "../StockIn/FgStockInForm"; import Swal from "sweetalert2"; -interface Props extends Omit { +interface Props extends Omit { warehouse: WarehouseResult[]; stockInLineId: number; warehouseId: number; scanner: QrCodeScanner; addPutAwayHistory: (putAwayData: PutAwayRecord) => void; - onSetDefaultWarehouseId?: (warehouseId: number) => void; // 新增回调 + onSetDefaultWarehouseId?: (warehouseId: number) => void; + /** Parent toggles lot vs warehouse scan routing. */ + onScanPhaseChange?: (acceptWarehouse: boolean) => void; + /** When false, parent keeps scanner stopped (e.g. until alert is dismissed). */ + onClose: (resumeScan?: boolean) => void; } const style = { position: "absolute", @@ -80,7 +84,7 @@ const scannerStyle = { maxWidth: "600px", }; -const PutAwayModal: React.FC = ({ open, onClose, warehouse, stockInLineId, warehouseId, scanner, addPutAwayHistory, onSetDefaultWarehouseId }) => { +const PutAwayModal: React.FC = ({ open, onClose, warehouse, stockInLineId, warehouseId, scanner, addPutAwayHistory, onSetDefaultWarehouseId, onScanPhaseChange }) => { const { t } = useTranslation("putAway"); const [serverError, setServerError] = useState(""); const params = useSearchParams(); @@ -101,6 +105,40 @@ const PutAwayModal: React.FC = ({ open, onClose, warehouse, stockInLineId const [putQty, setPutQty] = useState(itemDetail?.acceptedQty ?? 0); const [verified, setVerified] = useState(false); const [qtyError, setQtyError] = useState(""); + const fetchRequestIdRef = useRef(0); + const processedItemKeyRef = useRef(null); + const onCloseRef = useRef(onClose); + const onScanPhaseChangeRef = useRef(onScanPhaseChange); + const scannerRef = useRef(scanner); + onCloseRef.current = onClose; + onScanPhaseChangeRef.current = onScanPhaseChange; + scannerRef.current = scanner; + + const setWarehouseScanPhase = useCallback((accept: boolean) => { + onScanPhaseChangeRef.current?.(accept); + }, []); + + const getRejectMessage = (status: string): string => { + switch (status) { + case "pending": return "此貨品有待品檢"; + case "rejected": return "此貨品已被拒收"; + case "escalated": return "此貨品已被上報"; + case "partially_completed": return "此貨品已部分上架"; + case "completed": return "此貨品已上架"; + default: return "此貨品暫時無法上架"; + } + }; + + const resetModalState = useCallback(() => { + fetchRequestIdRef.current += 1; + setVerified(false); + setItemDetail(undefined); + setTotalPutAwayQty(0); + setItemDefaultWarehouseId(null); + setFirstWarehouseId(null); + setFirstWarehouseInfo(null); + setWarehouseMismatchError(""); + }, []); const defaultNewValue = useMemo(() => { // console.log("%c ItemDetail", "color:purple", itemDetail); @@ -128,54 +166,48 @@ const PutAwayModal: React.FC = ({ open, onClose, warehouse, stockInLineId ...defaultNewValue, }, }); - const { isSubmitting } = formProps.formState; + const { reset: resetForm, formState: { isSubmitting } } = formProps; const errors = formProps.formState.errors; useEffect(() => { - if (itemDetail) { - startScanner(); + if (itemDetail?.status === "received") { + scannerRef.current.startScan(); + console.log("%c Scanning started ", "color:cyan"); } - }, [itemDetail]) + }, [itemDetail?.id, itemDetail?.status]); const closeHandler = useCallback>( (...args) => { - setVerified(false); - setItemDetail(undefined); - setTotalPutAwayQty(0); - setItemDefaultWarehouseId(null); - setFirstWarehouseId(null); - setFirstWarehouseInfo(null); - onClose?.(...args); - // reset(); + setWarehouseScanPhase(false); + onCloseRef.current(true); + resetModalState(); }, - [onClose], + [resetModalState, setWarehouseScanPhase], ); const scannerCloseHandler = useCallback>( (...args) => { setIsOpenScanner(false); - scanner.stopScan(); + scannerRef.current.stopScan(); console.log("%c Scanning stopped ", "color:cyan"); }, [], ); const startScanner = () => { - // setIsOpenScanner(true); - scanner.startScan(); + scannerRef.current.startScan(); console.log("%c Scanning started ", "color:cyan"); }; const openScanner = () => { setIsOpenScanner(true); - scanner.startScan(); + scannerRef.current.startScan(); console.log("%c Scanning started ", "color:cyan"); }; - // 根据 item 的 locationCode 设置默认 warehouseId + // 根据 item 的 locationCode 设置默认 warehouseId(仅 received 状态) useEffect(() => { - // 直接使用 fetchStockInLineInfo 返回的 locationCode - // 只在第一次上架时(firstWarehouseId === null)设置默认值 + if (itemDetail?.status !== "received") return; if (itemDetail?.locationCode && warehouse.length > 0 && firstWarehouseId === null) { const locationCode = itemDetail.locationCode; @@ -198,9 +230,11 @@ const PutAwayModal: React.FC = ({ open, onClose, warehouse, stockInLineId } } } - }, [itemDetail?.locationCode, warehouse, firstWarehouseId]); + }, [itemDetail?.status, itemDetail?.locationCode, warehouse, firstWarehouseId]); useEffect(() => { + if (itemDetail?.status !== "received") return; + // 只使用实际扫描的 warehouseId,不使用默认值进行验证 if (warehouseId > 0 && firstWarehouseId !== null) { // 第二次及后续上架:必须使用第一次的仓库 @@ -215,7 +249,7 @@ const PutAwayModal: React.FC = ({ open, onClose, warehouse, stockInLineId setIsOpenScanner(false); setVerified(true); msg("貨倉掃瞄成功!"); - scanner.resetScan(); + scannerRef.current.resetScan(); } } } else if (warehouseId > 0 && firstWarehouseId === null) { @@ -224,10 +258,10 @@ const PutAwayModal: React.FC = ({ open, onClose, warehouse, stockInLineId setIsOpenScanner(false); setVerified(true); msg("貨倉掃瞄成功!"); - scanner.resetScan(); + scannerRef.current.resetScan(); } } - }, [warehouseId, firstWarehouseId, scanner.isScanning]); + }, [warehouseId, firstWarehouseId, scanner.isScanning, itemDetail?.status, warehouse]); const warehouseDisplay = useMemo(() => { // 优先使用扫描的 warehouseId,如果没有扫描则显示默认值作为建议 @@ -247,69 +281,86 @@ const PutAwayModal: React.FC = ({ open, onClose, warehouse, stockInLineId // }, [isOpenScanner]) useLayoutEffect(() => { - if (itemDetail !== undefined) { - if (itemDetail.status == "received") { - formProps.reset({ - ...defaultNewValue - }) - const total = itemDetail.putAwayLines?.reduce((sum, p) => sum + (p.stockQty ?? p.qty ?? 0), 0) ?? 0; - setPutQty(itemDetail?.acceptedQty - total); - - // ✅ Get first warehouse from existing put away lines - const firstPutAwayLine = itemDetail.putAwayLines?.[0]; - if (firstPutAwayLine?.warehouseId) { - setFirstWarehouseId(firstPutAwayLine.warehouseId); - // ✅ Store first warehouse info for display - const firstWh = warehouse.find((w) => w.id == firstPutAwayLine.warehouseId); - if (firstWh) { - setFirstWarehouseInfo({ - name: firstWh.name || "", - code: firstWh.code || "" - }); - } - } else { - setFirstWarehouseId(null); - setFirstWarehouseInfo(null); + if (itemDetail === undefined) { + processedItemKeyRef.current = null; + return; + } + + const itemKey = `${itemDetail.id}:${itemDetail.status}`; + if (processedItemKeyRef.current === itemKey) { + return; + } + processedItemKeyRef.current = itemKey; + + if (itemDetail.status == "received") { + resetForm({ + ...defaultNewValue, + }); + const total = itemDetail.putAwayLines?.reduce((sum, p) => sum + (p.stockQty ?? p.qty ?? 0), 0) ?? 0; + setPutQty(itemDetail?.acceptedQty - total); + + const firstPutAwayLine = itemDetail.putAwayLines?.[0]; + if (firstPutAwayLine?.warehouseId) { + setFirstWarehouseId(firstPutAwayLine.warehouseId); + const firstWh = warehouse.find((w) => w.id == firstPutAwayLine.warehouseId); + if (firstWh) { + setFirstWarehouseInfo({ + name: firstWh.name || "", + code: firstWh.code || "", + }); } - - console.log("%c Loaded data:", "color:lime", defaultNewValue); } else { - switch (itemDetail.status) { - case "pending": alert("此貨品有待品檢"); break; - case "rejected": alert("此貨品已被拒收"); break; - case "escalated": alert("此貨品已被上報"); break; - case "partially_completed": alert("此貨品已部分上架"); break; - case "completed": alert("此貨品已上架"); break; - default: alert("此貨品暫時無法上架"); break; - } - - closeHandler({}, "backdropClick"); + setFirstWarehouseId(null); + setFirstWarehouseInfo(null); } - } - }, [open, itemDetail, defaultNewValue]) - - const fetchStockInLine = useCallback( - async (stockInLineId: number) => { - setUnavailableText(undefined); - try { - const res = await fetchStockInLineInfo(stockInLineId); - console.log("%c Fetched Stock In Line Info:", "color:gold", res); - const total = res.putAwayLines?.reduce((sum, p) => sum + (p.stockQty ?? p.qty ?? 0), 0) ?? 0; - setTotalPutAwayQty(total); - setItemDetail(res); - } catch (e) { - console.log("%c Error when fetching Stock In Line: ", "color:red", e); - alert("錯誤的二維碼"); - closeHandler({}, "backdropClick"); - } - }, - [formProps, itemDetail, fetchStockInLineInfo], - ); + console.log("%c Loaded data:", "color:lime", defaultNewValue); + setWarehouseScanPhase(true); + } else { + processedItemKeyRef.current = null; + setWarehouseScanPhase(false); + resetModalState(); + onCloseRef.current(true); + msgError(getRejectMessage(itemDetail.status)); + } + }, [itemDetail, defaultNewValue, warehouse, resetModalState, resetForm, setWarehouseScanPhase]); + + const fetchStockInLine = useCallback(async (stockInLineId: number) => { + const requestId = ++fetchRequestIdRef.current; + processedItemKeyRef.current = null; + setWarehouseScanPhase(false); + setUnavailableText(undefined); + setItemDetail(undefined); + try { + const res = await fetchStockInLineInfo(stockInLineId); + if (requestId !== fetchRequestIdRef.current) return; + + console.log("%c Fetched Stock In Line Info:", "color:gold", res); + + const total = res.putAwayLines?.reduce((sum, p) => sum + (p.stockQty ?? p.qty ?? 0), 0) ?? 0; + setTotalPutAwayQty(total); + setItemDetail(res); + } catch (e) { + if (requestId !== fetchRequestIdRef.current) return; + + console.log("%c Error when fetching Stock In Line: ", "color:red", e); + setWarehouseScanPhase(false); + resetModalState(); + onCloseRef.current(true); + msgError("錯誤的二維碼"); + } + }, [resetModalState, setWarehouseScanPhase]); useEffect(() => { - if (stockInLineId) { fetchStockInLine(stockInLineId); } - }, [stockInLineId]); + if (stockInLineId) { + fetchStockInLine(stockInLineId); + return; + } + fetchRequestIdRef.current += 1; + processedItemKeyRef.current = null; + setWarehouseScanPhase(false); + setItemDetail(undefined); + }, [stockInLineId, fetchStockInLine, setWarehouseScanPhase]); const validateQty = useCallback((qty : number = putQty) => { // if (isNaN(putQty) || putQty === undefined || putQty === null || typeof(putQty) != "number") { diff --git a/src/components/PutAwayScan/PutAwayScan.tsx b/src/components/PutAwayScan/PutAwayScan.tsx index 96a932e..96b4325 100644 --- a/src/components/PutAwayScan/PutAwayScan.tsx +++ b/src/components/PutAwayScan/PutAwayScan.tsx @@ -45,14 +45,21 @@ const PutAwayScan: React.FC = ({ warehouse }) => { const [scannedWareHouseId, setScannedWareHouseId] = useState(0); // TODO use QR code info const [putAwayHistory, setPutAwayHistory] = useState([]); + const openPutAwayModalRef = useRef(false); + const scannedSilIdRef = useRef(0); + /** Only true after modal loaded a received item — gates warehouse vs lot routing. */ + const acceptWarehouseScansRef = useRef(false); + // QR Code Scanner const scanner = useQrCodeScannerContext(); + const scannerRef = useRef(scanner); + scannerRef.current = scanner; + useEffect(() => { - if (!scanner.isScanning) { - scanner.startScan(); - } else if (scanner.isScanning) { - scanner.stopScan(); - } + scannerRef.current.startScan(); + return () => { + scannerRef.current.stopScan(); + }; }, []); const resetScan = (error : string = "") => { @@ -62,26 +69,49 @@ const PutAwayScan: React.FC = ({ warehouse }) => { } else { console.log("%c Scan reset", "color:red"); } - scanner.resetScan(); + scannerRef.current.resetScan(); }; const openModal = () => { console.log("Scanned successfully and open modal"); - scanner.stopScan(); + openPutAwayModalRef.current = true; + acceptWarehouseScansRef.current = false; + scannerRef.current.stopScan(); console.log("%c Scanning stopped ", "color:cyan"); setOpenPutAwayModal(true); setScanDisplay("pending"); }; + + const applyLotScan = useCallback((lotId: number) => { + acceptWarehouseScansRef.current = false; + scannedSilIdRef.current = lotId; + setScannedWareHouseId(0); + setScannedSilId(lotId); + }, []); + + const handleScanPhaseChange = useCallback((acceptWarehouse: boolean) => { + acceptWarehouseScansRef.current = acceptWarehouse; + if (!acceptWarehouse) { + setScannedWareHouseId(0); + } + }, []); - const closeModal = () => { + const closeModal = useCallback((resumeScan = true) => { + openPutAwayModalRef.current = false; + acceptWarehouseScansRef.current = false; + scannedSilIdRef.current = 0; setScannedSilId(0); setScannedWareHouseId(0); setScanDisplay("pending"); - console.log("Modal Closed"); - scanner.startScan(); - console.log("%c Scanning started ", "color:cyan"); setOpenPutAwayModal(false); - } + console.log("Modal Closed"); + scannerRef.current.resetScan(); + scannerRef.current.stopScan(); + if (resumeScan) { + scannerRef.current.startScan(); + console.log("%c Scanning started ", "color:cyan"); + } + }, []); const addPutAwayHistory = (putAwayData: PutAwayRecord) => { console.log("%c Added new data to Putaway history: ", "color:orange", putAwayData); @@ -92,6 +122,7 @@ const PutAwayScan: React.FC = ({ warehouse }) => { // 处理默认 warehouseId 的回调 const handleSetDefaultWarehouseId = useCallback((warehouseId: number) => { + if (!acceptWarehouseScansRef.current) return; if (scannedWareHouseId === 0) { setScannedWareHouseId(warehouseId); console.log("%c Set default warehouseId from item locationCode:", "color:green", warehouseId); @@ -112,7 +143,11 @@ const PutAwayScan: React.FC = ({ warehouse }) => { const id = parseInt(stockInLineIdQ, 10); if (!Number.isFinite(id) || id <= 0) return; deepLinkConsumedRef.current = true; - setScannedSilId((prev) => (prev === id ? prev : id)); + setScannedSilId((prev) => { + if (prev === id) return prev; + scannedSilIdRef.current = id; + return id; + }); // Consume deep link once, then remove it from URL const nextParams = new URLSearchParams(searchParams.toString()); @@ -121,24 +156,51 @@ const PutAwayScan: React.FC = ({ warehouse }) => { router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname); }, [stockInLineIdQ, pathname, router, searchParams]); - // Get Scanned Values + const parseLotId = useCallback((data: QrCodeInfo): number | null => { + const fromValue = Number(data.value); + if (Number.isFinite(fromValue) && fromValue > 0) return fromValue; + const fromField = Number(data.stockInLineId); + if (Number.isFinite(fromField) && fromField > 0) return fromField; + return null; + }, []); + + const parseWarehouseId = useCallback((data: QrCodeInfo): number | null => { + const fromValue = Number(data.value); + if (Number.isFinite(fromValue) && fromValue > 0) return fromValue; + const fromField = Number(data.warehouseId); + if (Number.isFinite(fromField) && fromField > 0) return fromField; + return null; + }, []); + + // Route scans: lot unless modal has a received item ready for warehouse scan. useEffect(() => { - if (scanner.result) { - const data = scanner.result; - if (scannedSilId == 0) { // Initial State - if (!isNaN(Number(data.value))) { setScannedSilId(Number(data.value)); } - else if (data.stockInLineId) { - setScannedSilId(Number(data.stockInLineId)); - } else resetScan("Cannot read Stock In Line Id"); - } else { // Processing - if (!isNaN(Number(data.value))) { setScannedWareHouseId(Number(data.value)); } - else if (data.warehouseId) { - setScannedWareHouseId(Number(data.warehouseId)); - } else resetScan("Cannot read Warehouse Id"); + if (!scanner.result) return; + + const data = scanner.result; + scannerRef.current.resetScan(); + + const modalOpen = openPutAwayModalRef.current; + const acceptWarehouse = acceptWarehouseScansRef.current; + + // Lot scan when modal closed, or still loading / rejecting (not yet received). + if (!modalOpen || !acceptWarehouse) { + const lotId = parseLotId(data); + if (lotId != null) { + applyLotScan(lotId); + } else if (!modalOpen) { + resetScan("Cannot read Stock In Line Id"); } - scanner.resetScan(); + // Warehouse-only scan during loading/reject: silently discard. + return; + } + + const whId = parseWarehouseId(data); + if (whId != null) { + setScannedWareHouseId(whId); + } else { + resetScan("Cannot read Warehouse Id"); } - }, [scanner.result]); + }, [scanner.result, parseLotId, parseWarehouseId, applyLotScan]); // Get Scanner State useEffect(() => { @@ -196,6 +258,7 @@ const PutAwayScan: React.FC = ({ warehouse }) => { scanner={scanner} addPutAwayHistory={addPutAwayHistory} onSetDefaultWarehouseId={handleSetDefaultWarehouseId} + onScanPhaseChange={handleScanPhaseChange} /> ) } diff --git a/src/components/QrCodeScannerProvider/QrCodeScannerProvider.tsx b/src/components/QrCodeScannerProvider/QrCodeScannerProvider.tsx index bc2130b..8a3523f 100644 --- a/src/components/QrCodeScannerProvider/QrCodeScannerProvider.tsx +++ b/src/components/QrCodeScannerProvider/QrCodeScannerProvider.tsx @@ -1,12 +1,13 @@ "use client"; import { QrCodeInfo } from "@/app/api/qrcode"; -import { useRef } from "react"; import { ReactNode, createContext, useCallback, useContext, useEffect, + useMemo, + useRef, useState, startTransition, } from "react"; @@ -352,19 +353,31 @@ useEffect(() => { } }, [qrCodeScannerValues]); + const contextValue = useMemo( + () => ({ + values: qrCodeScannerValues, + isScanning: isScanning, + startScan: startQrCodeScanner, + stopScan: endQrCodeScanner, + resetScan: resetQrCodeScanner, + result: scanResult, + state: scanState, + error: scanError, + }), + [ + qrCodeScannerValues, + isScanning, + startQrCodeScanner, + endQrCodeScanner, + resetQrCodeScanner, + scanResult, + scanState, + scanError, + ], + ); + return ( - + {children} );