From c530b43b70f4275b2dce8f3e199fddacba2968f6 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 8 Jun 2026 12:28:51 +0800 Subject: [PATCH 1/5] m18 --- src/i18n/en/pickOrder.json | 4 +++- src/i18n/zh/do.json | 35 +++++++++++++++++++++++++++++++---- src/i18n/zh/doWorkbench.json | 2 +- src/i18n/zh/navigation.json | 2 +- src/i18n/zh/pickOrder.json | 4 +++- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/i18n/en/pickOrder.json b/src/i18n/en/pickOrder.json index 4e6026c..a79dcfc 100644 --- a/src/i18n/en/pickOrder.json +++ b/src/i18n/en/pickOrder.json @@ -36,6 +36,7 @@ "Submit Qty": "Submit Qty", "Just Completed": "Just Completed", "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; expired rows must not use this button.", + "Just Completed (workbench): requires a valid lot number and quantity.": "Just Completed (workbench): requires a valid lot number and quantity.", "Do you want to start?": "Do you want to start?", "Start": "Start", "Pick Order Code(s)": "Pick Order Code(s)", @@ -517,5 +518,6 @@ "Report missing or bad items": "Report missing or bad items", "passed": "Passed", "failed": "Failed", - "confirm_accept_with_fail": "There are failed QC items. Confirm to accept stock out?" + "confirm_accept_with_fail": "There are failed QC items. Confirm to accept stock out?", + "No pending pick line left for this item. It may already be completed or fully processed.": "No pending pick line left for this item. It may already be completed or fully processed." } diff --git a/src/i18n/zh/do.json b/src/i18n/zh/do.json index a22423c..824d84f 100644 --- a/src/i18n/zh/do.json +++ b/src/i18n/zh/do.json @@ -1,4 +1,5 @@ { + "Delivery Order": "送貨訂單", "Shop Name": "店鋪名稱", "Delivery Order No.": "送貨訂單編號", @@ -15,11 +16,8 @@ "No Records": "沒有找到記錄", "OK": "確認", "Truck X": "車線-X", - "DO Workbench": "新版成品出倉", "Order Date From": "訂單日期", "Workbench Batch Release": "批量放單", - "do workbench": "新版成品出倉", - "Do Workbench": "新版成品出倉", "Delivery Order Code": "送貨訂單編號", "Floor": "樓層", "Truck lane search requires date title": "需選擇預計送貨日期", @@ -94,5 +92,34 @@ "Release 2/F": "放單2/F", "Release 4/F": "放單4/F", "Stock Status": "庫存狀態", - "Delivery": "送貨訂單" + "Delivery": "送貨訂單", + "Replenishment page title": "送貨單補貨", + "Replenishment demo banner": "示範模式:候選送貨單與補貨提交均使用假資料,後端 API 完成後會切換為真實資料。", + "Replenishment input section": "補貨資料", + "Replenishment item code": "貨品編號", + "Replenishment search candidates": "搜尋候選送貨單", + "Replenishment reset": "重設", + "Replenishment candidate section": "候選送貨單(待放單)", + "Replenishment candidate hint": "輸入店鋪與貨品編號後搜尋;僅顯示預計送貨日不早於今天且狀態為待處理的送貨單。", + "Replenishment candidate count": "共 {{count}} 張候選送貨單", + "Replenishment select": "選擇", + "Replenishment existing lines": "已有同品項行", + "Replenishment apply": "加入補貨行", + "Replenishment shop required title": "請輸入店鋪", + "Replenishment shop required message": "請輸入店鋪名稱以搜尋候選送貨單。", + "Replenishment item required title": "請輸入貨品編號", + "Replenishment item required message": "請輸入要補貨的貨品編號。", + "Replenishment demo items title": "示範貨品編號", + "Replenishment demo items message": "目前假資料僅接受以下貨品編號:", + "Replenishment no candidates message": "沒有符合條件的待放單送貨單,請調整店鋪名稱或稍後再試。", + "Replenishment qty invalid title": "數量不正確", + "Replenishment qty invalid message": "請輸入大於 0 的補貨數量後再提交。", + "Replenishment confirm title": "確認補貨", + "Replenishment success title": "補貨行已加入(示範)", + "Replenishment success message": "已模擬新增 delivery_order_line(is_replenishment = 1)。", + "Replenishment line id": "補貨行 ID", + "Replenishment demo note": "此為前端假資料回應;正式環境將呼叫後端 API。", + "Search Delivery Order": "搜尋送貨單", + "DO Replenishment": "送貨單補貨", + "Error": "錯誤" } diff --git a/src/i18n/zh/doWorkbench.json b/src/i18n/zh/doWorkbench.json index d918e48..647829a 100644 --- a/src/i18n/zh/doWorkbench.json +++ b/src/i18n/zh/doWorkbench.json @@ -1,5 +1,5 @@ { - "DO Workbench": "新版成品出倉", + "DO Workbench": "成品出倉", "Confirm": "確認", "Cancel": "取消", "Shop Name": "店鋪名稱", diff --git a/src/i18n/zh/navigation.json b/src/i18n/zh/navigation.json index 8a48e09..3e271b4 100644 --- a/src/i18n/zh/navigation.json +++ b/src/i18n/zh/navigation.json @@ -86,7 +86,7 @@ "nav.settings.shopAndTruck": "車線店鋪管理", "nav.settings.user": "用戶", "nav.settings.warehouse": "倉庫", - "nav.store.doWorkbench": "新版成品出倉", + "nav.store.doWorkbench": "成品出倉", "nav.store.finishedGoodManagement": "成品出倉管理", "nav.store.inventoryLedger": "查看物品出入庫及庫存日誌", "nav.store.pickOrder": "提料單", diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index 846ddbd..f489aa2 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -36,6 +36,7 @@ "Submit Qty": "提交數量", "Just Completed": "已完成", "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.": "已完成(工作台):需有效批號與可提交數量。", "Do you want to start?": "確定開始嗎?", "Start": "開始", "Pick Order Code(s)": "提料單編號", @@ -517,5 +518,6 @@ "Report missing or bad items": "報告缺失或不良物品", "passed": "合格", "failed": "不合格", - "confirm_accept_with_fail": "有不合格檢查項目,確認接受出庫?" + "confirm_accept_with_fail": "有不合格檢查項目,確認接受出庫?", + "No pending pick line left for this item. It may already be completed or fully processed.": "此貨品已無待處理的提貨行(可能已完成或已處理完畢)。" } From 5ca2461c07318b32d806f19b4b9f4d40a8f1d633 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 8 Jun 2026 12:30:13 +0800 Subject: [PATCH 2/5] 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 = From ba510b8808f90b2e8c3ede31768cde8a18efcba5 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 8 Jun 2026 13:15:04 +0800 Subject: [PATCH 3/5] update do is extra same order --- src/app/api/pickOrder/actions.ts | 3 + .../GoodPickExecutionWorkbenchRecord.tsx | 6 +- .../DoWorkbench/WorkbenchFloorLanePanel.tsx | 3 +- src/utils/workbenchPickLotUtils.ts | 167 ++++++++++++++++++ src/utils/workbenchReleaseType.ts | 14 ++ 5 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 src/utils/workbenchPickLotUtils.ts create mode 100644 src/utils/workbenchReleaseType.ts diff --git a/src/app/api/pickOrder/actions.ts b/src/app/api/pickOrder/actions.ts index 4e8f6f9..2cc0bdc 100644 --- a/src/app/api/pickOrder/actions.ts +++ b/src/app/api/pickOrder/actions.ts @@ -297,6 +297,7 @@ export interface FGPickOrderResponse { truckLanceCode: string; storeId: string; qrCodeData: number; + releaseType?: string; } export interface DoPickOrderDetail { @@ -372,6 +373,8 @@ export interface CompletedDoPickOrderResponse { deliveryNoteCode: number; /** Legacy: do_pick_order_record.handler_name; workbench: delivery_order_pick_order.handlerName */ handlerName?: string | null; + /** Workbench: delivery_order_pick_order.releaseType (e.g. isExtrabatch). */ + releaseType?: string | null; } // 新增:搜索参数接口 diff --git a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx index d3419cb..3465d0e 100644 --- a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx +++ b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx @@ -39,6 +39,7 @@ import { printDNWorkbench, printDNLabelsWorkbench, printDNLabelsReprintWorkbench import { fetchWorkbenchCompletedLotDetails } from "@/app/api/doworkbench/actions"; import SearchBox, { Criterion } from "../SearchBox"; import { resolveWorkbenchRecordTargetDate } from "@/utils/workbenchTargetDate"; +import { isWorkbenchExtraTicket } from "@/utils/workbenchReleaseType"; type Props = { printerCombo: PrinterCombo[]; @@ -522,6 +523,9 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ {t("Pick Order Details")}: {selectedRecord.ticketNo} + {isWorkbenchExtraTicket(selectedRecord.releaseType, selectedRecord.ticketNo) && ( + + )} @@ -674,7 +678,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ {row.deliveryNoteCode || "-"} - {(row.ticketNo ?? "").trim().toUpperCase().startsWith("TI-E-") && ( + {isWorkbenchExtraTicket(row.releaseType, row.ticketNo) && ( = ({ truckDepartureTime, loadingSequence, requiredDate: dateParam, + releaseType: inEtraUi ? "isExtra" : releaseType, }); console.log("assignByLane result:", res); if (res.code === "SUCCESS") { @@ -309,7 +310,7 @@ const WorkbenchFloorLanePanel: React.FC = ({ setIsAssigning(false); } }, - [currentUserId, inEtraUi, loadEtraSummaries, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, t], + [currentUserId, inEtraUi, loadEtraSummaries, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, releaseType, t], ); const handleLaneButtonClick = useCallback( diff --git a/src/utils/workbenchPickLotUtils.ts b/src/utils/workbenchPickLotUtils.ts new file mode 100644 index 0000000..6718db0 --- /dev/null +++ b/src/utils/workbenchPickLotUtils.ts @@ -0,0 +1,167 @@ +import dayjs from "dayjs"; + +export type WorkbenchPickLotLike = { + status?: string; + stockOutLineStatus?: string; + lotAvailability?: string; + lotStatus?: string; + expiryDate?: string; + noLot?: boolean; + lotNo?: string; + availableQty?: number; +}; + +export type PickOrderT = (key: string, options?: Record) => string; + +function solStatusOf(lot: WorkbenchPickLotLike | null | undefined): string { + return String(lot?.stockOutLineStatus || lot?.status || "").toLowerCase(); +} + +/** lotAvailability === expired(後端標記) */ +export function isLotAvailabilityExpired( + lot: WorkbenchPickLotLike | null | undefined, +): boolean { + return String(lot?.lotAvailability || "").toLowerCase() === "expired"; +} + +/** inventory_lot_line.status = unavailable */ +export function isInventoryLotLineUnavailable( + lot: WorkbenchPickLotLike | null | undefined, +): boolean { + if (!lot) return false; + const solSt = solStatusOf(lot); + if (solSt === "completed" || solSt === "partially_completed" || solSt === "partially_complete") { + return false; + } + if (String(lot.lotAvailability || "").toLowerCase() === "status_unavailable") return true; + return String(lot.lotStatus || "").toLowerCase() === "unavailable"; +} + +/** 含 expiryDate 日期判斷 */ +export function isWorkbenchSourceLotExpired( + lot: WorkbenchPickLotLike | null | undefined, +): boolean { + if (!lot) return false; + if (isLotAvailabilityExpired(lot)) return true; + if (String(lot.lotAvailability || "").toLowerCase() === "expired") return true; + if (lot.expiryDate) { + const d = dayjs(lot.expiryDate).startOf("day"); + if (d.isValid() && d.isBefore(dayjs().startOf("day"))) return true; + } + return false; +} + +/** 過期或不可用:單筆 Just Complete / 顯示數量與批量提交一致,固定 qty=0 */ +export function isWorkbenchZeroCompleteLot( + lot: WorkbenchPickLotLike | null | undefined, +): boolean { + return isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot); +} + +export function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string { + const msg = raw.trim(); + if (!msg) return msg; + + const expiredMatch = msg.match(/^Lot is expired \(expiry=([^)]+)\)\.?$/i); + if (expiredMatch) { + return t("Lot is expired (expiry={{expiry}})", { + expiry: expiredMatch[1], + }); + } + + return t(msg); +} + +export 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); +} + +export type UnpickableScanAvailability = "expired" | "status_unavailable"; + +export 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; +} + +export function buildUnpickableScanRowPatch( + scannedLot: WorkbenchPickLotLike | null | undefined, + availability: UnpickableScanAvailability, +): Record { + const patch: Record = { lotAvailability: availability }; + if (availability === "status_unavailable") { + patch.lotStatus = "unavailable"; + } + if (scannedLot && "lotNo" in scannedLot && scannedLot.lotNo) { + patch.lotNo = scannedLot.lotNo; + } + if (scannedLot && "stockInLineId" in scannedLot && scannedLot.stockInLineId) { + patch.stockInLineId = scannedLot.stockInLineId; + } + if (scannedLot?.expiryDate) patch.expiryDate = scannedLot.expiryDate; + return patch; +} + +export function getWorkbenchSourceLotStatusSummary(lot: WorkbenchPickLotLike | null | undefined): { + severity: "success" | "warning" | "error"; + text: string; +} { + if (!lot) { + return { severity: "warning", text: "無法判斷此批號狀態" }; + } + if (isWorkbenchSourceLotExpired(lot)) { + return { severity: "error", text: "此批號狀態:已過期" }; + } + const solSt = solStatusOf(lot); + if (solSt === "rejected") { + return { severity: "warning", text: "此出庫行:已拒絕,請改掃其他批號" }; + } + if (solSt === "completed" || solSt === "partially_completed" || solSt === "partially_complete") { + return { severity: "warning", text: "此出庫行:已完成,無需再提貨" }; + } + const isNoLotRow = + lot.noLot === true || !lot.lotNo || String(lot.lotNo || "").trim() === ""; + if (isNoLotRow) { + return { + severity: "warning", + text: "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR", + }; + } + const av = String(lot.lotAvailability || "").toLowerCase(); + if (av === "insufficient_stock") { + return { severity: "warning", text: "此批號狀態:已用畢(無剩餘庫存)" }; + } + const avail = Number(lot.availableQty); + if (lot.lotNo && Number.isFinite(avail) && avail <= 0) { + return { severity: "warning", text: "此批號狀態:已用畢(可用量為 0)" }; + } + if (isInventoryLotLineUnavailable(lot)) { + return { + severity: "warning", + text: "此批號狀態:庫存不可用(未上架或行狀態不可用)", + }; + } + return { severity: "success", text: "此批號狀態:可提貨" }; +} diff --git a/src/utils/workbenchReleaseType.ts b/src/utils/workbenchReleaseType.ts new file mode 100644 index 0000000..0868803 --- /dev/null +++ b/src/utils/workbenchReleaseType.ts @@ -0,0 +1,14 @@ +/** Workbench ticket carries extra DOs when releaseType or legacy TI-E- ticket. */ +export function isWorkbenchExtraTicket( + releaseType?: string | null, + ticketNo?: string | null, +): boolean { + const rt = (releaseType ?? "").trim().toLowerCase(); + const tn = (ticketNo ?? "").trim().toUpperCase(); + return ( + rt === "isextrabatch" || + rt === "isextrasingle" || + rt === "isextra" || + tn.startsWith("TI-E-") + ); +} From cd6c2ca450f1b6cd63ba69ffc64767d6b68c0ca3 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 8 Jun 2026 16:43:30 +0800 Subject: [PATCH 4/5] do scan fix --- .../WorkbenchGoodPickExecutionDetail.tsx | 101 ++++-------------- 1 file changed, 20 insertions(+), 81 deletions(-) diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index c32cbba..a6dbe2c 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -70,6 +70,12 @@ import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { fetchStockInLineInfo } from "@/app/api/po/actions"; import GoodPickExecutionForm from "@/components/FinishedGoodSearch/GoodPickExecutionForm"; +import { WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE } from "./workbenchTabConstants"; +import { + inferUnpickableScanAvailability, + translateWorkbenchRejectMessage, + type UnpickableScanAvailability, +} from "@/utils/workbenchPickLotUtils"; import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; import FGPickOrderCard from "@/components/FinishedGoodSearch/FGPickOrderCard"; import LinearProgressWithLabel from "@/components/common/LinearProgressWithLabel"; @@ -423,20 +429,6 @@ function getWorkbenchSourceLotStatusSummary(lot: any): { type PickOrderT = (key: string, options?: Record) => string; -function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string { - const msg = raw.trim(); - if (!msg) return msg; - - const expiredMatch = msg.match(/^Lot is expired \(expiry=([^)]+)\)\.?$/i); - if (expiredMatch) { - return t("Lot is expired (expiry={{expiry}})", { - expiry: expiredMatch[1], - }); - } - - return t(msg); -} - function isExpiredWorkbenchReminderMessage(msg: string): boolean { const trimmed = msg.trim(); if (!trimmed) return false; @@ -444,33 +436,6 @@ function isExpiredWorkbenchReminderMessage(msg: string): boolean { 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, @@ -937,7 +902,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO ) { workbenchFinishNavigateDoneRef.current = true; const redirectParams = new URLSearchParams(); - redirectParams.set("tab", "2"); + redirectParams.set("tab", String(WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE)); redirectParams.set("ticketNo", ticketForRedirect); if (targetDateForRedirect) { redirectParams.set("targetDate", targetDateForRedirect); @@ -1434,7 +1399,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const event = new CustomEvent('pickOrderCompletionStatus', { detail: { allLotsCompleted, - tabIndex: 2 // DO workbench「Finished Good Record (mine)」分頁索引 + tabIndex: WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE, } }); window.dispatchEvent(event); @@ -1669,16 +1634,11 @@ 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), - ); - } + /** + * Stop QR effect re-entry after unpickable modal (expired/unavailable API fail). + * Do NOT mark stock-out line as processed — pick was not completed; user may scan another lot. + */ + const markUnpickableScanSessionHandled = () => { recordHandledQrScanCount(qrScanCountAtInvoke); }; @@ -1778,10 +1738,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` ); }); - markUnpickableScanSessionHandled( - scannedItemId, - scannedLot.stockOutLineId, - ); + markUnpickableScanSessionHandled(); return; } @@ -1796,10 +1753,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO scannedLot, t("This lot is not available, please scan another lot."), ); - markUnpickableScanSessionHandled( - scannedItemId, - scannedLot.stockOutLineId, - ); + markUnpickableScanSessionHandled(); return; } @@ -1814,10 +1768,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO scannedLot, `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`, ); - markUnpickableScanSessionHandled( - scannedItemId, - scannedLot.stockOutLineId, - ); + markUnpickableScanSessionHandled(); return; } } @@ -1929,10 +1880,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO null, failMsg, ); - markUnpickableScanSessionHandled( - scannedItemId, - expectedLot.stockOutLineId, - ); + markUnpickableScanSessionHandled(); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -2109,10 +2057,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO }, failMsg, ); - markUnpickableScanSessionHandled( - scannedItemId, - expectedLot.stockOutLineId, - ); + markUnpickableScanSessionHandled(); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -2300,10 +2245,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO exactMatch ) { openUnpickableScanLotLabelModal(exactMatch, exactMatch, failMsg); - markUnpickableScanSessionHandled( - scannedItemId, - exactMatch.stockOutLineId, - ); + markUnpickableScanSessionHandled(); return; } if (workbenchMode && exactMatch.stockOutLineId != null) { @@ -2428,10 +2370,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO }, failMsg, ); - markUnpickableScanSessionHandled( - scannedItemId, - expectedLot.stockOutLineId, - ); + markUnpickableScanSessionHandled(); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { From 752857e74dbb6ac41d6f9df678df747096bf5594 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 8 Jun 2026 18:36:51 +0800 Subject: [PATCH 5/5] update --- .../DoWorkbench/workbenchTabConstants.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/components/DoWorkbench/workbenchTabConstants.ts diff --git a/src/components/DoWorkbench/workbenchTabConstants.ts b/src/components/DoWorkbench/workbenchTabConstants.ts new file mode 100644 index 0000000..07b31e6 --- /dev/null +++ b/src/components/DoWorkbench/workbenchTabConstants.ts @@ -0,0 +1,19 @@ +/** Tab index for「Finished Good Record (mine)」— used by post-pick redirect. */ +export const WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE = 2; + +/** Map legacy tab indices to current 7-tab layout (Etra at 1). */ +export function normalizeWorkbenchTabFromUrl(raw: number): number | null { + const allowed = new Set([0, 1, 2, 3, 4, 5, 6]); + if (allowed.has(raw)) return raw; + /** Hidden-Etra 6-tab layout: mine was at 1 */ + const legacyWithoutEtra: Record = { + 0: 0, + 1: WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE, + 2: 3, + 3: 4, + 4: 5, + 5: 6, + }; + const mapped = legacyWithoutEtra[raw]; + return mapped != null && allowed.has(mapped) ? mapped : null; +}