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/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 4a8c863..a6dbe2c 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"; @@ -69,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"; @@ -131,37 +138,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 +362,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; @@ -405,18 +429,26 @@ 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; +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); +} - const expiredMatch = msg.match(/^Lot is expired \(expiry=([^)]+)\)\.?$/i); - if (expiredMatch) { - return t("Lot is expired (expiry={{expiry}})", { - expiry: expiredMatch[1], - }); +function buildUnpickableScanRowPatch( + scannedLot: any | null | undefined, + availability: UnpickableScanAvailability, +): Record { + const patch: Record = { lotAvailability: availability }; + if (availability === "status_unavailable") { + patch.lotStatus = "unavailable"; } - - return t(msg); + 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; } /** @@ -646,10 +678,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 ?? "") @@ -870,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); @@ -894,6 +926,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 +1196,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 +1338,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; @@ -1306,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); @@ -1541,6 +1634,14 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO } }; + /** + * 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); + }; + try { // 1) Parse JSON safely (parse once, reuse) const parseStartTime = performance.now(); @@ -1575,13 +1676,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 +1691,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 +1738,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` ); }); - if (scannedLot?.stockOutLineId != null) { - const nextProcessed = markProcessedStockOutLine( - processedQrCombinations, - scannedItemId, - scannedLot.stockOutLineId, - ); - setProcessedQrCombinations(nextProcessed); - } + markUnpickableScanSessionHandled(); return; } @@ -1645,25 +1748,27 @@ 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(); 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(); return; } } @@ -1766,7 +1871,16 @@ 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(); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -1932,7 +2046,18 @@ 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(); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -2119,7 +2244,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && exactMatch ) { - openWorkbenchLotLabelModalForLot(exactMatch, failMsg); + openUnpickableScanLotLabelModal(exactMatch, exactMatch, failMsg); + markUnpickableScanSessionHandled(); return; } if (workbenchMode && exactMatch.stockOutLineId != null) { @@ -2233,7 +2359,18 @@ 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(); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { @@ -2340,6 +2477,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO refreshWorkbenchAfterScanPick, workbenchScanPickQtyFromLot, openWorkbenchLotLabelModalForLot, + openUnpickableScanLotLabelModal, shouldOpenWorkbenchLotLabelModalForFailure, t, ]); @@ -2445,6 +2583,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 +2601,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const canRetrySamePhysicalLot = qrPayload != null && isNewScanEvent && - hasPendingActiveRowForStockInLine( + hasPendingUnprocessedRowForStockInLine( lotDataIndexes, qrPayload.itemId, qrPayload.stockInLineId, @@ -2578,6 +2722,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO isRefreshingData, combinedLotData.length, manualLotConfirmationOpen, + workbenchLotLabelModalOpen, lotDataIndexes, processedQrCombinations, ]); @@ -2925,8 +3070,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 +3096,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 +3111,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe ); if (canPostScanPick) { - const qtyToSend = isUnavailableForJustComplete + const qtyToSend = isZeroCompleteForJustComplete ? 0 : hasExplicitSubmitOverride && explicitSubmitOverride === 0 ? 0 @@ -3011,7 +3156,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 +3945,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 = 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; +} diff --git a/src/i18n/en/pickOrder.json b/src/i18n/en/pickOrder.json index 81ddc36..fcc98ff 100644 --- a/src/i18n/en/pickOrder.json +++ b/src/i18n/en/pickOrder.json @@ -39,6 +39,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)", @@ -520,5 +521,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 6e2bded..6494faf 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": "需選擇預計送貨日期", @@ -96,5 +94,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 5d1e83b..360b857 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -39,6 +39,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)": "提料單編號", @@ -520,5 +521,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.": "此貨品已無待處理的提貨行(可能已完成或已處理完畢)。" } 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-") + ); +}