@@ -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<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,
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<number, any[]> },
/** Any pending unprocessed SOL for this item (e.g. scan different stockInLineId → auto-switch). */
function hasPendingUnprocessedRowForItem(
indexes: { b yItemId: Map<number, any[]> },
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, unknown>) => 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<string, unknown> {
const patch: Record<string, unknown> = { 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<FGPickOrderResponse[]>([]);
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 = hasPendingActive RowForStockInLine(
const hasPendingOnScannedSil = hasPendingUnprocessed RowForStockInLine(
indexes,
scannedItemId,
scannedStockInLineId,
processedQrCombinations,
);
const hasPendingOnItem = hasPendingActive RowForItem(
const hasPendingOnItem = hasPendingUnprocessed RowForItem(
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 &&
hasPendingActive RowForStockInLine(
hasPendingUnprocessed RowForStockInLine(
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 = targetUnavailabl e && submitQty > 0 ? 0 : submitQty;
const targetZeroComplete = isWorkbenchZeroCompleteLot (lot);
const effectiveSubmitQty = targetZeroComplet e && 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
isUnavailabl eForJustComplete || (
// expired / unavailable: Just Completed always submits qty=0, even without lotNo
isZeroComplet eForJustComplete || (
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 = isUnavailabl eForJustComplete
const qtyToSend = isZeroComplet eForJustComplete
? 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 =