| @@ -22,6 +22,7 @@ import { | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import dayjs from 'dayjs'; | import dayjs from 'dayjs'; | ||||
| import { normalizeTargetDateInput } from "@/utils/workbenchTargetDate"; | import { normalizeTargetDateInput } from "@/utils/workbenchTargetDate"; | ||||
| import { isWorkbenchExtraTicket } from "@/utils/workbenchReleaseType"; | |||||
| import TestQrCodeProvider from "@/components/QrCodeScannerProvider/TestQrCodeProvider"; | import TestQrCodeProvider from "@/components/QrCodeScannerProvider/TestQrCodeProvider"; | ||||
| import { fetchLotDetail } from "@/app/api/inventory/actions"; | import { fetchLotDetail } from "@/app/api/inventory/actions"; | ||||
| import { formatDepartureTime } from "@/app/utils/formatUtil"; | import { formatDepartureTime } from "@/app/utils/formatUtil"; | ||||
| @@ -131,37 +132,49 @@ function parseWorkbenchQrPayload( | |||||
| } | } | ||||
| } | } | ||||
| function hasPendingActiveRowForStockInLine( | |||||
| indexes: { | |||||
| byStockInLineId: Map<number, any[]>; | |||||
| activeLotsByItemId: Map<number, any[]>; | |||||
| }, | |||||
| /** | |||||
| * 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<number, any[]> }, | |||||
| itemId: number, | itemId: number, | ||||
| stockInLineId: number, | stockInLineId: number, | ||||
| processedByItemId: ProcessedStockOutLinesByItemId, | processedByItemId: ProcessedStockOutLinesByItemId, | ||||
| ): boolean { | ): boolean { | ||||
| const rows = indexes.byStockInLineId.get(stockInLineId) ?? []; | 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<number, any[]> }, | |||||
| /** Any pending unprocessed SOL for this item (e.g. scan different stockInLineId → auto-switch). */ | |||||
| function hasPendingUnprocessedRowForItem( | |||||
| indexes: { byItemId: Map<number, any[]> }, | |||||
| itemId: number, | itemId: number, | ||||
| processedByItemId: ProcessedStockOutLinesByItemId, | processedByItemId: ProcessedStockOutLinesByItemId, | ||||
| ): boolean { | ): 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"; | 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 { | function isWorkbenchSourceLotExpired(lot: any): boolean { | ||||
| if (!lot) return false; | if (!lot) return false; | ||||
| @@ -419,6 +437,55 @@ function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string { | |||||
| return t(msg); | 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<string, unknown> { | |||||
| const patch: Record<string, unknown> = { 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), | * 顯示後端拒絕原因:優先 workbench scan API 的 message(暫存於 scanRejectBySolId), | ||||
| * 其次階層 API 若帶 stockOutLineRejectMessage,最後依 rejected + lotAvailability 推斷(與後端語意對齊)。 | * 其次階層 API 若帶 stockOutLineRejectMessage,最後依 rejected + lotAvailability 推斷(與後端語意對齊)。 | ||||
| @@ -646,10 +713,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||||
| const [fgPickOrders, setFgPickOrders] = useState<FGPickOrderResponse[]>([]); | const [fgPickOrders, setFgPickOrders] = useState<FGPickOrderResponse[]>([]); | ||||
| const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false); | 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 lotFloorPrefixFilter = useMemo(() => { | ||||
| const storeId = String(fgPickOrders?.[0]?.storeId ?? "") | const storeId = String(fgPickOrders?.[0]?.storeId ?? "") | ||||
| @@ -894,6 +961,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| shopName: hierarchicalData.fgInfo.shopName, | shopName: hierarchicalData.fgInfo.shopName, | ||||
| truckLanceCode: hierarchicalData.fgInfo.truckLanceCode, | truckLanceCode: hierarchicalData.fgInfo.truckLanceCode, | ||||
| DepartureTime: hierarchicalData.fgInfo.departureTime, | DepartureTime: hierarchicalData.fgInfo.departureTime, | ||||
| releaseType: hierarchicalData.fgInfo.releaseType, | |||||
| shopAddress: "", | shopAddress: "", | ||||
| pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", | pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", | ||||
| @@ -1163,12 +1231,64 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| } else { | } else { | ||||
| setWorkbenchLotLabelInitialPayload(null); | 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. | // Clear latched success so the lot-label modal effect cannot instantly re-close on open. | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setWorkbenchLotLabelModalOpen(true); | 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( | const shouldOpenWorkbenchLotLabelModalForFailure = useCallback( | ||||
| @@ -1253,9 +1373,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| severity: undefined as "success" | "warning" | "error" | undefined, | 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); | const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot); | ||||
| return { text: s.text, severity: s.severity }; | return { text: s.text, severity: s.severity }; | ||||
| }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]); | |||||
| }, [ | |||||
| workbenchLotLabelModalOpen, | |||||
| workbenchLotLabelContextLot, | |||||
| workbenchLotLabelReminderText, | |||||
| ]); | |||||
| const workbenchLotLabelSubmitQty = useMemo(() => { | const workbenchLotLabelSubmitQty = useMemo(() => { | ||||
| if (!workbenchLotLabelContextLot) return 0; | 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 { | try { | ||||
| // 1) Parse JSON safely (parse once, reuse) | // 1) Parse JSON safely (parse once, reuse) | ||||
| const parseStartTime = performance.now(); | const parseStartTime = performance.now(); | ||||
| @@ -1575,13 +1716,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| const scannedItemId = qrData.itemId; | const scannedItemId = qrData.itemId; | ||||
| const scannedStockInLineId = qrData.stockInLineId; | const scannedStockInLineId = qrData.stockInLineId; | ||||
| const hasPendingOnScannedSil = hasPendingActiveRowForStockInLine( | |||||
| const hasPendingOnScannedSil = hasPendingUnprocessedRowForStockInLine( | |||||
| indexes, | indexes, | ||||
| scannedItemId, | scannedItemId, | ||||
| scannedStockInLineId, | scannedStockInLineId, | ||||
| processedQrCombinations, | processedQrCombinations, | ||||
| ); | ); | ||||
| const hasPendingOnItem = hasPendingActiveRowForItem( | |||||
| const hasPendingOnItem = hasPendingUnprocessedRowForItem( | |||||
| indexes, | indexes, | ||||
| scannedItemId, | scannedItemId, | ||||
| processedQrCombinations, | processedQrCombinations, | ||||
| @@ -1590,6 +1731,15 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| console.log( | console.log( | ||||
| ` [SKIP] No pending stock-out line left for itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId}`, | ` [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; | return; | ||||
| } | } | ||||
| @@ -1628,14 +1778,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` | `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` | ||||
| ); | ); | ||||
| }); | }); | ||||
| if (scannedLot?.stockOutLineId != null) { | |||||
| const nextProcessed = markProcessedStockOutLine( | |||||
| processedQrCombinations, | |||||
| scannedItemId, | |||||
| scannedLot.stockOutLineId, | |||||
| ); | |||||
| setProcessedQrCombinations(nextProcessed); | |||||
| } | |||||
| markUnpickableScanSessionHandled( | |||||
| scannedItemId, | |||||
| scannedLot.stockOutLineId, | |||||
| ); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -1645,25 +1791,33 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| setQrScanError(false); | setQrScanError(false); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| }); | }); | ||||
| openWorkbenchLotLabelModalForLot( | |||||
| openUnpickableScanLotLabelModal( | |||||
| scannedLot, | |||||
| scannedLot, | scannedLot, | ||||
| t("This lot is not available, please scan another lot."), | t("This lot is not available, please scan another lot."), | ||||
| ); | ); | ||||
| markUnpickableScanSessionHandled( | |||||
| scannedItemId, | |||||
| scannedLot.stockOutLineId, | |||||
| ); | |||||
| return; | 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`); | console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired; opening lot-label modal`); | ||||
| startTransition(() => { | startTransition(() => { | ||||
| setQrScanError(false); | setQrScanError(false); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| }); | }); | ||||
| openWorkbenchLotLabelModalForLot( | |||||
| openUnpickableScanLotLabelModal( | |||||
| scannedLot, | |||||
| scannedLot, | scannedLot, | ||||
| `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`, | `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`, | ||||
| ); | ); | ||||
| markUnpickableScanSessionHandled( | |||||
| scannedItemId, | |||||
| scannedLot.stockOutLineId, | |||||
| ); | |||||
| return; | return; | ||||
| } | } | ||||
| } | } | ||||
| @@ -1766,7 +1920,19 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | ||||
| expectedLot | expectedLot | ||||
| ) { | ) { | ||||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg); | |||||
| openUnpickableScanLotLabelModal( | |||||
| expectedLot, | |||||
| scannedLot ?? | |||||
| allLotsForItem.find( | |||||
| (lot: any) => lot.stockInLineId === scannedStockInLineId, | |||||
| ) ?? | |||||
| null, | |||||
| failMsg, | |||||
| ); | |||||
| markUnpickableScanSessionHandled( | |||||
| scannedItemId, | |||||
| expectedLot.stockOutLineId, | |||||
| ); | |||||
| return; | return; | ||||
| } | } | ||||
| if (workbenchMode && expectedLot.stockOutLineId != null) { | if (workbenchMode && expectedLot.stockOutLineId != null) { | ||||
| @@ -1932,7 +2098,21 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | ||||
| expectedLot | expectedLot | ||||
| ) { | ) { | ||||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg); | |||||
| openUnpickableScanLotLabelModal( | |||||
| expectedLot, | |||||
| scannedLot ?? | |||||
| allLotsForItem.find( | |||||
| (lot: any) => lot.stockInLineId === scannedStockInLineId, | |||||
| ) ?? { | |||||
| stockInLineId: scannedStockInLineId, | |||||
| lotNo: scannedLotNo, | |||||
| }, | |||||
| failMsg, | |||||
| ); | |||||
| markUnpickableScanSessionHandled( | |||||
| scannedItemId, | |||||
| expectedLot.stockOutLineId, | |||||
| ); | |||||
| return; | return; | ||||
| } | } | ||||
| if (workbenchMode && expectedLot.stockOutLineId != null) { | if (workbenchMode && expectedLot.stockOutLineId != null) { | ||||
| @@ -2119,7 +2299,11 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | ||||
| exactMatch | exactMatch | ||||
| ) { | ) { | ||||
| openWorkbenchLotLabelModalForLot(exactMatch, failMsg); | |||||
| openUnpickableScanLotLabelModal(exactMatch, exactMatch, failMsg); | |||||
| markUnpickableScanSessionHandled( | |||||
| scannedItemId, | |||||
| exactMatch.stockOutLineId, | |||||
| ); | |||||
| return; | return; | ||||
| } | } | ||||
| if (workbenchMode && exactMatch.stockOutLineId != null) { | if (workbenchMode && exactMatch.stockOutLineId != null) { | ||||
| @@ -2233,7 +2417,21 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && | ||||
| expectedLot | expectedLot | ||||
| ) { | ) { | ||||
| openWorkbenchLotLabelModalForLot(expectedLot, failMsg); | |||||
| openUnpickableScanLotLabelModal( | |||||
| expectedLot, | |||||
| scannedLot ?? | |||||
| allLotsForItem.find( | |||||
| (lot: any) => lot.stockInLineId === scannedStockInLineId, | |||||
| ) ?? { | |||||
| stockInLineId: scannedStockInLineId, | |||||
| lotNo: scannedLotNo, | |||||
| }, | |||||
| failMsg, | |||||
| ); | |||||
| markUnpickableScanSessionHandled( | |||||
| scannedItemId, | |||||
| expectedLot.stockOutLineId, | |||||
| ); | |||||
| return; | return; | ||||
| } | } | ||||
| if (workbenchMode && expectedLot.stockOutLineId != null) { | if (workbenchMode && expectedLot.stockOutLineId != null) { | ||||
| @@ -2340,6 +2538,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| refreshWorkbenchAfterScanPick, | refreshWorkbenchAfterScanPick, | ||||
| workbenchScanPickQtyFromLot, | workbenchScanPickQtyFromLot, | ||||
| openWorkbenchLotLabelModalForLot, | openWorkbenchLotLabelModalForLot, | ||||
| openUnpickableScanLotLabelModal, | |||||
| shouldOpenWorkbenchLotLabelModalForFailure, | shouldOpenWorkbenchLotLabelModalForFailure, | ||||
| t, | t, | ||||
| ]); | ]); | ||||
| @@ -2445,6 +2644,12 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| // If it's a different QR, allow processing | // If it's a different QR, allow processing | ||||
| console.log(` [QR PROCESS] Different QR detected while manual modal open, allowing 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(); | const qrDetectionStartTime = performance.now(); | ||||
| console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); | console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); | ||||
| @@ -2457,7 +2662,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| const canRetrySamePhysicalLot = | const canRetrySamePhysicalLot = | ||||
| qrPayload != null && | qrPayload != null && | ||||
| isNewScanEvent && | isNewScanEvent && | ||||
| hasPendingActiveRowForStockInLine( | |||||
| hasPendingUnprocessedRowForStockInLine( | |||||
| lotDataIndexes, | lotDataIndexes, | ||||
| qrPayload.itemId, | qrPayload.itemId, | ||||
| qrPayload.stockInLineId, | qrPayload.stockInLineId, | ||||
| @@ -2578,6 +2783,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||||
| isRefreshingData, | isRefreshingData, | ||||
| combinedLotData.length, | combinedLotData.length, | ||||
| manualLotConfirmationOpen, | manualLotConfirmationOpen, | ||||
| workbenchLotLabelModalOpen, | |||||
| lotDataIndexes, | lotDataIndexes, | ||||
| processedQrCombinations, | processedQrCombinations, | ||||
| ]); | ]); | ||||
| @@ -2925,8 +3131,8 @@ 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 })); | ||||
| const targetUnavailable = isInventoryLotLineUnavailable(lot); | |||||
| const effectiveSubmitQty = targetUnavailable && submitQty > 0 ? 0 : submitQty; | |||||
| const targetZeroComplete = isWorkbenchZeroCompleteLot(lot); | |||||
| const effectiveSubmitQty = targetZeroComplete && submitQty > 0 ? 0 : submitQty; | |||||
| const canonicalLotForSol = | const canonicalLotForSol = | ||||
| solId > 0 | solId > 0 | ||||
| @@ -2951,10 +3157,10 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol); | const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol); | ||||
| const wbJustQty = qtyPayload.qty; | const wbJustQty = qtyPayload.qty; | ||||
| const isUnavailableForJustComplete = isInventoryLotLineUnavailable(canonicalLotForSol); | |||||
| const isZeroCompleteForJustComplete = isWorkbenchZeroCompleteLot(canonicalLotForSol); | |||||
| const canPostScanPick = | 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() !== "" && ( | canonicalLotForSol.lotNo && String(canonicalLotForSol.lotNo).trim() !== "" && ( | ||||
| // explicit short submit: user typed 0 (must send qty=0 to backend) | // explicit short submit: user typed 0 (must send qty=0 to backend) | ||||
| (hasExplicitSubmitOverride && | (hasExplicitSubmitOverride && | ||||
| @@ -2966,7 +3172,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| ); | ); | ||||
| if (canPostScanPick) { | if (canPostScanPick) { | ||||
| const qtyToSend = isUnavailableForJustComplete | |||||
| const qtyToSend = isZeroCompleteForJustComplete | |||||
| ? 0 | ? 0 | ||||
| : hasExplicitSubmitOverride && explicitSubmitOverride === 0 | : hasExplicitSubmitOverride && explicitSubmitOverride === 0 | ||||
| ? 0 | ? 0 | ||||
| @@ -3011,7 +3217,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| return; | return; | ||||
| } | } | ||||
| const justCompleteErr = t( | 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) { | if (solId > 0) { | ||||
| rememberWorkbenchScanReject(solId, justCompleteErr); | rememberWorkbenchScanReject(solId, justCompleteErr); | ||||
| @@ -3800,7 +4006,7 @@ paginatedData.map((row, index) => { | |||||
| const solIdForKey = Number(lot.stockOutLineId) || 0; | const solIdForKey = Number(lot.stockOutLineId) || 0; | ||||
| const lotKeyForSubmitQty = | const lotKeyForSubmitQty = | ||||
| Number.isFinite(solIdForKey) && solIdForKey > 0 ? `sol:${solIdForKey}` : `${lot.pickOrderLineId}-${lot.lotId}`; | 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 hasPickOverride = Object.prototype.hasOwnProperty.call(pickQtyData, lotKeyForSubmitQty); | ||||
| const fromPickRow = hasPickOverride ? pickQtyData[lotKeyForSubmitQty] : undefined; | const fromPickRow = hasPickOverride ? pickQtyData[lotKeyForSubmitQty] : undefined; | ||||
| const workbenchSubmitQtyDisplay = | const workbenchSubmitQtyDisplay = | ||||