From 5ca2461c07318b32d806f19b4b9f4d40a8f1d633 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 8 Jun 2026 12:30:13 +0800 Subject: [PATCH] do scan fix --- .../WorkbenchGoodPickExecutionDetail.tsx | 316 +++++++++++++++--- 1 file changed, 261 insertions(+), 55 deletions(-) diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 4a8c863..c32cbba 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -22,6 +22,7 @@ import { } from "@mui/material"; import dayjs from 'dayjs'; import { normalizeTargetDateInput } from "@/utils/workbenchTargetDate"; +import { isWorkbenchExtraTicket } from "@/utils/workbenchReleaseType"; import TestQrCodeProvider from "@/components/QrCodeScannerProvider/TestQrCodeProvider"; import { fetchLotDetail } from "@/app/api/inventory/actions"; import { formatDepartureTime } from "@/app/utils/formatUtil"; @@ -131,37 +132,49 @@ function parseWorkbenchQrPayload( } } -function hasPendingActiveRowForStockInLine( - indexes: { - byStockInLineId: Map; - activeLotsByItemId: Map; - }, +/** + * QR entry gate: pending / partial SOL not yet processed this session. + * Includes expired & unavailable rows (still need modal /换批); excludes completed/rejected/checked. + * `processedQrCombinations` still prevents re-picking the same SOL (e.g. two lines, same lot). + */ +function isLotRowEligibleForQrEntryGate( + lot: any, + itemId: number, + processedByItemId: ProcessedStockOutLinesByItemId, +): boolean { + if (Number(lot?.itemId) !== itemId) return false; + if (!isLotRowPending(lot)) return false; + const st = String(lot?.stockOutLineStatus ?? "").toLowerCase(); + if (st === "rejected" || st === "completed" || st === "checked") return false; + if (String(lot?.lotAvailability ?? "").toLowerCase() === "rejected") return false; + return !isStockOutLineAlreadyProcessed( + processedByItemId, + itemId, + lot.stockOutLineId, + ); +} + +function hasPendingUnprocessedRowForStockInLine( + indexes: { byStockInLineId: Map }, itemId: number, stockInLineId: number, processedByItemId: ProcessedStockOutLinesByItemId, ): boolean { const rows = indexes.byStockInLineId.get(stockInLineId) ?? []; - const activeSet = new Set(indexes.activeLotsByItemId.get(itemId) ?? []); - return rows.some( - (lot) => - lot.itemId === itemId && - activeSet.has(lot) && - isLotRowPending(lot) && - !isStockOutLineAlreadyProcessed(processedByItemId, itemId, lot.stockOutLineId), + return rows.some((lot) => + isLotRowEligibleForQrEntryGate(lot, itemId, processedByItemId), ); } -/** Any pending active SOL for this item (e.g. scan different location, same lot no → auto-switch). */ -function hasPendingActiveRowForItem( - indexes: { activeLotsByItemId: Map }, +/** Any pending unprocessed SOL for this item (e.g. scan different stockInLineId → auto-switch). */ +function hasPendingUnprocessedRowForItem( + indexes: { byItemId: Map }, itemId: number, processedByItemId: ProcessedStockOutLinesByItemId, ): boolean { - const activeLots = indexes.activeLotsByItemId.get(itemId) ?? []; - return activeLots.some( - (lot) => - isLotRowPending(lot) && - !isStockOutLineAlreadyProcessed(processedByItemId, itemId, lot.stockOutLineId), + const rows = indexes.byItemId.get(itemId) ?? []; + return rows.some((lot) => + isLotRowEligibleForQrEntryGate(lot, itemId, processedByItemId), ); } @@ -343,6 +356,11 @@ function isInventoryLotLineUnavailable(lot: any): boolean { return String(lot.lotStatus || "").toLowerCase() === "unavailable"; } +/** 過期或不可用:單筆 Just Complete / 顯示數量與批量提交一致,固定 qty=0 */ +function isWorkbenchZeroCompleteLot(lot: any): boolean { + return isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot); +} + /** 提貨台「列印標籤」彈窗頂部:依目前表格列判斷可提貨/已用畢/已過期等 */ function isWorkbenchSourceLotExpired(lot: any): boolean { if (!lot) return false; @@ -419,6 +437,55 @@ function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string { return t(msg); } +function isExpiredWorkbenchReminderMessage(msg: string): boolean { + const trimmed = msg.trim(); + if (!trimmed) return false; + if (/^lot is expired \(expiry=/i.test(trimmed)) return true; + return /已過期/.test(trimmed) || /掃描批號已過期/.test(trimmed); +} + +type UnpickableScanAvailability = "expired" | "status_unavailable"; + +function inferUnpickableScanAvailability( + failMsg: string | null | undefined, +): UnpickableScanAvailability | null { + const m = String(failMsg ?? "").trim().toLowerCase(); + if (!m) return null; + if ( + m.includes("expired") || + m.includes("过期") || + m.includes("已過期") || + /^lot is expired/.test(m) + ) { + return "expired"; + } + if ( + m.includes("unavailable") || + m.includes("not available") || + m.includes("not yet putaway") || + m.includes("不可用") || + m.includes("未上架") + ) { + return "status_unavailable"; + } + return null; +} + +function buildUnpickableScanRowPatch( + scannedLot: any | null | undefined, + availability: UnpickableScanAvailability, +): Record { + const patch: Record = { lotAvailability: availability }; + if (availability === "status_unavailable") { + patch.lotStatus = "unavailable"; + } + if (scannedLot?.lotNo) patch.lotNo = scannedLot.lotNo; + if (scannedLot?.stockInLineId) patch.stockInLineId = scannedLot.stockInLineId; + if (scannedLot?.expiryDate) patch.expiryDate = scannedLot.expiryDate; + if (scannedLot?.lotId) patch.lotId = scannedLot.lotId; + return patch; +} + /** * 顯示後端拒絕原因:優先 workbench scan API 的 message(暫存於 scanRejectBySolId), * 其次階層 API 若帶 stockOutLineRejectMessage,最後依 rejected + lotAvailability 推斷(與後端語意對齊)。 @@ -646,10 +713,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); const [fgPickOrders, setFgPickOrders] = useState([]); const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false); - const isExtraTicket = useMemo(() => { - const ticketNo = String(fgPickOrders?.[0]?.ticketNo ?? "").trim().toUpperCase(); - return ticketNo.startsWith("TI-E-"); - }, [fgPickOrders]); + const isExtraTicket = useMemo( + () => isWorkbenchExtraTicket(fgPickOrders?.[0]?.releaseType, fgPickOrders?.[0]?.ticketNo), + [fgPickOrders], + ); const lotFloorPrefixFilter = useMemo(() => { const storeId = String(fgPickOrders?.[0]?.storeId ?? "") @@ -894,6 +961,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO shopName: hierarchicalData.fgInfo.shopName, truckLanceCode: hierarchicalData.fgInfo.truckLanceCode, DepartureTime: hierarchicalData.fgInfo.departureTime, + releaseType: hierarchicalData.fgInfo.releaseType, shopAddress: "", pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", @@ -1163,12 +1231,64 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO } else { setWorkbenchLotLabelInitialPayload(null); } - setWorkbenchLotLabelReminderText(reminderText ?? null); + setWorkbenchLotLabelReminderText( + reminderText ? translateWorkbenchRejectMessage(reminderText, t) : null, + ); // Clear latched success so the lot-label modal effect cannot instantly re-close on open. setQrScanSuccess(false); setWorkbenchLotLabelModalOpen(true); }, - [], + [t], + ); + + /** Patch pick row locally so table shows 已過期/不可用 without full refresh. */ + const patchWorkbenchRowForUnpickableScan = useCallback( + ( + pickRow: any, + scannedLot: any | null | undefined, + availability: UnpickableScanAvailability, + ) => { + const solId = Number(pickRow?.stockOutLineId); + if (!solId) return; + const rowPatch = buildUnpickableScanRowPatch(scannedLot, availability); + const mapRows = (prev: any[]) => + prev.map((lot) => + Number(lot.stockOutLineId) === solId ? { ...lot, ...rowPatch } : lot, + ); + setCombinedLotData(mapRows); + setOriginalCombinedData(mapRows); + clearWorkbenchScanReject(solId); + }, + [clearWorkbenchScanReject], + ); + + const openUnpickableScanLotLabelModal = useCallback( + ( + pickRow: any, + scannedLot: any | null | undefined, + reminderText: string, + ) => { + const fromMsg = inferUnpickableScanAvailability(reminderText); + const availability = + fromMsg ?? + (isWorkbenchSourceLotExpired(scannedLot ?? pickRow) + ? "expired" + : isInventoryLotLineUnavailable(scannedLot ?? pickRow) + ? "status_unavailable" + : null); + const mergedPickRow = + availability != null + ? { ...pickRow, ...buildUnpickableScanRowPatch(scannedLot, availability) } + : pickRow; + if (availability != null) { + patchWorkbenchRowForUnpickableScan(pickRow, scannedLot, availability); + } + openWorkbenchLotLabelModalForLot(mergedPickRow, reminderText); + }, + [ + patchWorkbenchRowForUnpickableScan, + openWorkbenchLotLabelModalForLot, + ], ); const shouldOpenWorkbenchLotLabelModalForFailure = useCallback( @@ -1253,9 +1373,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO severity: undefined as "success" | "warning" | "error" | undefined, }; } + const reminder = workbenchLotLabelReminderText?.trim() ?? ""; + if (reminder && isExpiredWorkbenchReminderMessage(reminder)) { + return { text: "此批號狀態:已過期", severity: "error" as const }; + } const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot); return { text: s.text, severity: s.severity }; - }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]); + }, [ + workbenchLotLabelModalOpen, + workbenchLotLabelContextLot, + workbenchLotLabelReminderText, + ]); const workbenchLotLabelSubmitQty = useMemo(() => { if (!workbenchLotLabelContextLot) return 0; @@ -1541,6 +1669,19 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO } }; + /** Stop QR effect re-entry after unpickable modal (expired/unavailable API fail). */ + const markUnpickableScanSessionHandled = ( + itemId: number, + stockOutLineId: number | null | undefined, + ) => { + if (stockOutLineId != null) { + setProcessedQrCombinations((prev) => + markProcessedStockOutLine(prev, itemId, stockOutLineId), + ); + } + recordHandledQrScanCount(qrScanCountAtInvoke); + }; + try { // 1) Parse JSON safely (parse once, reuse) const parseStartTime = performance.now(); @@ -1575,13 +1716,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const scannedItemId = qrData.itemId; const scannedStockInLineId = qrData.stockInLineId; - const hasPendingOnScannedSil = hasPendingActiveRowForStockInLine( + const hasPendingOnScannedSil = hasPendingUnprocessedRowForStockInLine( indexes, scannedItemId, scannedStockInLineId, processedQrCombinations, ); - const hasPendingOnItem = hasPendingActiveRowForItem( + const hasPendingOnItem = hasPendingUnprocessedRowForItem( indexes, scannedItemId, processedQrCombinations, @@ -1590,6 +1731,15 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO console.log( ` [SKIP] No pending stock-out line left for itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId}`, ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + t( + "No pending pick line left for this item. It may already be completed or fully processed.", + ), + ); + }); return; } @@ -1628,14 +1778,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` ); }); - if (scannedLot?.stockOutLineId != null) { - const nextProcessed = markProcessedStockOutLine( - processedQrCombinations, - scannedItemId, - scannedLot.stockOutLineId, - ); - setProcessedQrCombinations(nextProcessed); - } + markUnpickableScanSessionHandled( + scannedItemId, + scannedLot.stockOutLineId, + ); return; } @@ -1645,25 +1791,33 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO setQrScanError(false); setQrScanSuccess(false); }); - openWorkbenchLotLabelModalForLot( + openUnpickableScanLotLabelModal( + scannedLot, scannedLot, t("This lot is not available, please scan another lot."), ); + markUnpickableScanSessionHandled( + scannedItemId, + scannedLot.stockOutLineId, + ); return; } - const isExpired = - String(scannedLot.lotAvailability || '').toLowerCase() === 'expired'; - if (isExpired) { + if (isWorkbenchSourceLotExpired(scannedLot)) { console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired; opening lot-label modal`); startTransition(() => { setQrScanError(false); setQrScanSuccess(false); }); - openWorkbenchLotLabelModalForLot( + openUnpickableScanLotLabelModal( + scannedLot, scannedLot, `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`, ); + markUnpickableScanSessionHandled( + scannedItemId, + scannedLot.stockOutLineId, + ); return; } } @@ -1766,7 +1920,19 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && expectedLot ) { - openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + openUnpickableScanLotLabelModal( + expectedLot, + scannedLot ?? + allLotsForItem.find( + (lot: any) => lot.stockInLineId === scannedStockInLineId, + ) ?? + null, + failMsg, + ); + markUnpickableScanSessionHandled( + scannedItemId, + expectedLot.stockOutLineId, + ); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -1932,7 +2098,21 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && expectedLot ) { - openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + openUnpickableScanLotLabelModal( + expectedLot, + scannedLot ?? + allLotsForItem.find( + (lot: any) => lot.stockInLineId === scannedStockInLineId, + ) ?? { + stockInLineId: scannedStockInLineId, + lotNo: scannedLotNo, + }, + failMsg, + ); + markUnpickableScanSessionHandled( + scannedItemId, + expectedLot.stockOutLineId, + ); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -2119,7 +2299,11 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && exactMatch ) { - openWorkbenchLotLabelModalForLot(exactMatch, failMsg); + openUnpickableScanLotLabelModal(exactMatch, exactMatch, failMsg); + markUnpickableScanSessionHandled( + scannedItemId, + exactMatch.stockOutLineId, + ); return; } if (workbenchMode && exactMatch.stockOutLineId != null) { @@ -2233,7 +2417,21 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && expectedLot ) { - openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + openUnpickableScanLotLabelModal( + expectedLot, + scannedLot ?? + allLotsForItem.find( + (lot: any) => lot.stockInLineId === scannedStockInLineId, + ) ?? { + stockInLineId: scannedStockInLineId, + lotNo: scannedLotNo, + }, + failMsg, + ); + markUnpickableScanSessionHandled( + scannedItemId, + expectedLot.stockOutLineId, + ); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -2340,6 +2538,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO refreshWorkbenchAfterScanPick, workbenchScanPickQtyFromLot, openWorkbenchLotLabelModalForLot, + openUnpickableScanLotLabelModal, shouldOpenWorkbenchLotLabelModalForFailure, t, ]); @@ -2445,6 +2644,12 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO // If it's a different QR, allow processing console.log(` [QR PROCESS] Different QR detected while manual modal open, allowing processing`); } + + // Skip re-processing while lot-label modal is already open for this scan + if (workbenchLotLabelModalOpen && latestQr === lastProcessedQrRef.current) { + console.log(` [QR PROCESS] Skipping - lot-label modal open for same QR`); + return; + } const qrDetectionStartTime = performance.now(); console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); @@ -2457,7 +2662,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const canRetrySamePhysicalLot = qrPayload != null && isNewScanEvent && - hasPendingActiveRowForStockInLine( + hasPendingUnprocessedRowForStockInLine( lotDataIndexes, qrPayload.itemId, qrPayload.stockInLineId, @@ -2578,6 +2783,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO isRefreshingData, combinedLotData.length, manualLotConfirmationOpen, + workbenchLotLabelModalOpen, lotDataIndexes, processedQrCombinations, ]); @@ -2925,8 +3131,8 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe try { if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); - const targetUnavailable = isInventoryLotLineUnavailable(lot); - const effectiveSubmitQty = targetUnavailable && submitQty > 0 ? 0 : submitQty; + const targetZeroComplete = isWorkbenchZeroCompleteLot(lot); + const effectiveSubmitQty = targetZeroComplete && submitQty > 0 ? 0 : submitQty; const canonicalLotForSol = solId > 0 @@ -2951,10 +3157,10 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol); const wbJustQty = qtyPayload.qty; - const isUnavailableForJustComplete = isInventoryLotLineUnavailable(canonicalLotForSol); + const isZeroCompleteForJustComplete = isWorkbenchZeroCompleteLot(canonicalLotForSol); const canPostScanPick = - // unavailable lot: Just Completed must always submit qty=0, even without lotNo - isUnavailableForJustComplete || ( + // expired / unavailable: Just Completed always submits qty=0, even without lotNo + isZeroCompleteForJustComplete || ( canonicalLotForSol.lotNo && String(canonicalLotForSol.lotNo).trim() !== "" && ( // explicit short submit: user typed 0 (must send qty=0 to backend) (hasExplicitSubmitOverride && @@ -2966,7 +3172,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe ); if (canPostScanPick) { - const qtyToSend = isUnavailableForJustComplete + const qtyToSend = isZeroCompleteForJustComplete ? 0 : hasExplicitSubmitOverride && explicitSubmitOverride === 0 ? 0 @@ -3011,7 +3217,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe return; } const justCompleteErr = t( - "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.", + "Just Completed (workbench): requires a valid lot number and quantity.", ); if (solId > 0) { rememberWorkbenchScanReject(solId, justCompleteErr); @@ -3800,7 +4006,7 @@ paginatedData.map((row, index) => { const solIdForKey = Number(lot.stockOutLineId) || 0; const lotKeyForSubmitQty = Number.isFinite(solIdForKey) && solIdForKey > 0 ? `sol:${solIdForKey}` : `${lot.pickOrderLineId}-${lot.lotId}`; - const lockedSubmitQtyDisplay = isInventoryLotLineUnavailable(lot) ? 0 : resolveSingleSubmitQty(lot); + const lockedSubmitQtyDisplay = isWorkbenchZeroCompleteLot(lot) ? 0 : resolveSingleSubmitQty(lot); const hasPickOverride = Object.prototype.hasOwnProperty.call(pickQtyData, lotKeyForSubmitQty); const fromPickRow = hasPickOverride ? pickQtyData[lotKeyForSubmitQty] : undefined; const workbenchSubmitQtyDisplay =