"use client"; import { Box, Button, Stack, TextField, Typography, Alert, CircularProgress, Autocomplete, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Checkbox, TablePagination, Modal, } from "@mui/material"; import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; import { useCallback, useEffect, useState, useRef, useMemo, startTransition, } from "react"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/navigation"; import { updateStockOutLineStatus, createStockOutLine, //applyPickExecutionHoldAndChecked, fetchFGPickOrdersByUserIdWorkbench, FGPickOrderResponse, autoAssignAndReleasePickOrder, AutoAssignReleaseResponse, checkPickOrderCompletion, PickOrderCompletionResponse, confirmLotSubstitution, updateStockOutLineStatusByQRCodeAndLotNo, // ✅ 添加 } from "@/app/api/pickOrder/actions"; // 修改:使用 Job Order API import { fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench, updateJoPickOrderHandledBy, JobOrderLotsHierarchicalWorkbenchResponse, applyPickExecutionHoldAndChecked, PrintPickRecord, } from "@/app/api/jo/actions"; import { assignJobOrderPickOrderForWorkbench } from "@/app/api/jo/workbenchActions"; import { fetchNameList, NameList } from "@/app/api/user/actions"; import { FormProvider, useForm } from "react-hook-form"; import SearchBox, { Criterion } from "../SearchBox"; import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; import { updateInventoryLotLineQuantities, analyzeQrCode, fetchLotDetail, } from "@/app/api/inventory/actions"; import QrCodeIcon from "@mui/icons-material/QrCode"; import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { fetchStockInLineInfo } from "@/app/api/po/actions"; import GoodPickExecutionForm from "../Jodetail/JobPickExecutionForm"; import FGPickOrderCard from "../Jodetail/FGPickOrderCard"; import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; import LinearProgressWithLabel from "../common/LinearProgressWithLabel"; import ScanStatusAlert from "../common/ScanStatusAlert"; import { workbenchBatchScanPick, workbenchScanPick, } from "@/app/api/doworkbench/actions"; import type { PrinterCombo } from "@/app/api/settings/printer"; import { msg, msgError } from "@/components/Swal/CustomAlerts"; import { buildPrintPickRecordRequest, promptAllFloorsPlasticBoxCartonQty, promptPlasticBoxCartonQty, type PickRecordFloor, } from "@/components/Jodetail/pickRecordHelpers"; interface Props { filterArgs: Record; //onSwitchToRecordTab: () => void; onBackToList?: () => void; printerCombo?: PrinterCombo[]; } /** 過期批號:與 noLot 類似——單筆/批量預設 0,除非 Issue 改數(對齊 GoodPickExecutiondetail) */ function isLotAvailabilityExpired(lot: any): boolean { return String(lot?.lotAvailability || "").toLowerCase() === "expired"; } /** inventory_lot_line.status = unavailable(API 可能用 lotAvailability 或 lotStatus) */ function isInventoryLotLineUnavailable(lot: any): boolean { if (!lot) return false; if (lot.lotAvailability === "status_unavailable") return true; return String(lot.lotStatus || "").toLowerCase() === "unavailable"; } /** 同 DO Workbench:多行時優先替換有建議批號的行 */ function pickExpectedLotForSubstitution( activeSuggestedLots: any[], ): any | null { if (!activeSuggestedLots?.length) return null; const withLotNo = activeSuggestedLots.filter( (l) => l.lotNo != null && String(l.lotNo).trim() !== "", ); if (withLotNo.length === 1) return withLotNo[0]; if (withLotNo.length > 1) { const pending = withLotNo.find( (l) => String(l.stockOutLineStatus || "").toLowerCase() === "pending", ); return pending || withLotNo[0]; } return activeSuggestedLots[0]; } type PickOrderT = (key: string, options?: Record) => string; /** 與 DO Workbench:優先顯示 scan-pick 暫存拒絕訊息 */ function buildLotRejectDisplayMessage( lot: any, scanRejectBySolId: Record, t: PickOrderT, ): string | undefined { const solId = Number(lot.stockOutLineId) || 0; const fromScan = solId > 0 ? scanRejectBySolId[solId]?.trim() : ""; if (fromScan) return fromScan; const fromApi = typeof lot.stockOutLineRejectMessage === "string" ? lot.stockOutLineRejectMessage.trim() : ""; if (fromApi) return fromApi; const st = String(lot.stockOutLineStatus || "").toLowerCase(); const av = String(lot.lotAvailability || "").toLowerCase(); const isRejected = st === "rejected" || av === "rejected"; if (!isRejected) return undefined; if (isLotAvailabilityExpired(lot) || av === "expired") { return t("Rejected: lot expired or no longer valid."); } if (av === "insufficient_stock") { return t("Rejected: no remaining quantity / empty lot."); } if (isInventoryLotLineUnavailable(lot) || av === "status_unavailable") { return t("Rejected: lot unavailable or not yet putaway."); } return t("Pick was rejected. Please scan another lot or check stock."); } const JO_ISSUE_PICKED_KEY = (pickOrderId: number) => `fpsms-jo-issuePickedQty:${pickOrderId}`; function loadIssuePickedMapJo(pickOrderId: number): Record { if (typeof window === "undefined" || !pickOrderId) return {}; try { const raw = sessionStorage.getItem(JO_ISSUE_PICKED_KEY(pickOrderId)); if (!raw) return {}; const parsed = JSON.parse(raw) as Record; const out: Record = {}; Object.entries(parsed).forEach(([k, v]) => { const n = Number(v); if (!Number.isNaN(n)) out[Number(k)] = n; }); return out; } catch { return {}; } } function saveIssuePickedMapJo( pickOrderId: number, map: Record, ) { if (typeof window === "undefined" || !pickOrderId) return; try { sessionStorage.setItem( JO_ISSUE_PICKED_KEY(pickOrderId), JSON.stringify(map), ); } catch { // ignore quota / private mode } } // Manual Lot Confirmation Modal (align with GoodPickExecutiondetail, opened by {2fic}) const ManualLotConfirmationModal: React.FC<{ open: boolean; onClose: () => void; onConfirm: (expectedLotNo: string, scannedLotNo: string) => void; expectedLot: { lotNo: string; itemCode: string; itemName: string } | null; scannedLot: { lotNo: string; itemCode: string; itemName: string } | null; isLoading?: boolean; }> = ({ open, onClose, onConfirm, expectedLot, scannedLot, isLoading = false, }) => { const { t } = useTranslation("jo"); const [expectedLotInput, setExpectedLotInput] = useState(""); const [scannedLotInput, setScannedLotInput] = useState(""); const [error, setError] = useState(""); useEffect(() => { if (open) { setExpectedLotInput(expectedLot?.lotNo || ""); setScannedLotInput(scannedLot?.lotNo || ""); setError(""); } }, [open, expectedLot, scannedLot]); const handleConfirm = () => { if (!expectedLotInput.trim() || !scannedLotInput.trim()) { setError(t("Please enter both expected and scanned lot numbers.")); return; } if (expectedLotInput.trim() === scannedLotInput.trim()) { setError(t("Expected and scanned lot numbers cannot be the same.")); return; } onConfirm(expectedLotInput.trim(), scannedLotInput.trim()); }; return ( {t("Manual Lot Confirmation")} {t("Expected Lot Number")}: { setExpectedLotInput(e.target.value); setError(""); }} sx={{ mb: 2 }} error={!!error && !expectedLotInput.trim()} /> {t("Scanned Lot Number")}: { setScannedLotInput(e.target.value); setError(""); }} sx={{ mb: 2 }} error={!!error && !scannedLotInput.trim()} /> {error && ( {error} )} ); }; // QR Code Modal Component (from GoodPickExecution) const QrCodeModal: React.FC<{ open: boolean; onClose: () => void; lot: any | null; onQrCodeSubmit: (lotNo: string) => void; combinedLotData: any[]; }> = ({ open, onClose, lot, onQrCodeSubmit, combinedLotData }) => { const { t } = useTranslation("jo"); const { values: qrValues, isScanning, startScan, stopScan, resetScan, } = useQrCodeScannerContext(); const [manualInput, setManualInput] = useState(""); const [selectedFloor, setSelectedFloor] = useState(null); const [manualInputSubmitted, setManualInputSubmitted] = useState(false); const [manualInputError, setManualInputError] = useState(false); const [isProcessingQr, setIsProcessingQr] = useState(false); const [qrScanFailed, setQrScanFailed] = useState(false); const [qrScanSuccess, setQrScanSuccess] = useState(false); const [processedQrCodes, setProcessedQrCodes] = useState>( new Set(), ); const [scannedQrResult, setScannedQrResult] = useState(""); // Process scanned QR codes useEffect(() => { if (qrValues.length > 0 && lot && !isProcessingQr && !qrScanSuccess) { const latestQr = qrValues[qrValues.length - 1]; if (processedQrCodes.has(latestQr)) { console.log("QR code already processed, skipping..."); return; } setProcessedQrCodes((prev) => new Set(prev).add(latestQr)); try { const qrData = JSON.parse(latestQr); if (qrData.stockInLineId && qrData.itemId) { setIsProcessingQr(true); setQrScanFailed(false); fetchStockInLineInfo(qrData.stockInLineId) .then((stockInLineInfo) => { console.log("Stock in line info:", stockInLineInfo); setScannedQrResult(stockInLineInfo.lotNo || "Unknown lot number"); if (stockInLineInfo.lotNo === lot.lotNo) { console.log(` QR Code verified for lot: ${lot.lotNo}`); setQrScanSuccess(true); onQrCodeSubmit(lot.lotNo); onClose(); resetScan(); } else { console.log( `❌ QR Code mismatch. Expected: ${lot.lotNo}, Got: ${stockInLineInfo.lotNo}`, ); setQrScanFailed(true); setManualInputError(true); setManualInputSubmitted(true); } }) .catch((error) => { console.error("Error fetching stock in line info:", error); setScannedQrResult("Error fetching data"); setQrScanFailed(true); setManualInputError(true); setManualInputSubmitted(true); }) .finally(() => { setIsProcessingQr(false); }); } else { const qrContent = latestQr.replace(/[{}]/g, ""); setScannedQrResult(qrContent); if (qrContent === lot.lotNo) { setQrScanSuccess(true); onQrCodeSubmit(lot.lotNo); onClose(); resetScan(); } else { setQrScanFailed(true); setManualInputError(true); setManualInputSubmitted(true); } } } catch (error) { console.log("QR code is not JSON format, trying direct comparison"); const qrContent = latestQr.replace(/[{}]/g, ""); setScannedQrResult(qrContent); if (qrContent === lot.lotNo) { setQrScanSuccess(true); onQrCodeSubmit(lot.lotNo); onClose(); resetScan(); } else { setQrScanFailed(true); setManualInputError(true); setManualInputSubmitted(true); } } } }, [ qrValues, lot, onQrCodeSubmit, onClose, resetScan, isProcessingQr, qrScanSuccess, processedQrCodes, ]); // Clear states when modal opens useEffect(() => { if (open) { setManualInput(""); setManualInputSubmitted(false); setManualInputError(false); setIsProcessingQr(false); setQrScanFailed(false); setQrScanSuccess(false); setScannedQrResult(""); setProcessedQrCodes(new Set()); } }, [open]); useEffect(() => { if (lot) { setManualInput(""); setManualInputSubmitted(false); setManualInputError(false); setIsProcessingQr(false); setQrScanFailed(false); setQrScanSuccess(false); setScannedQrResult(""); setProcessedQrCodes(new Set()); } }, [lot]); // Auto-submit manual input when it matches useEffect(() => { if ( manualInput.trim() === lot?.lotNo && manualInput.trim() !== "" && !qrScanFailed && !qrScanSuccess ) { console.log(" Auto-submitting manual input:", manualInput.trim()); const timer = setTimeout(() => { setQrScanSuccess(true); onQrCodeSubmit(lot.lotNo); onClose(); setManualInput(""); setManualInputError(false); setManualInputSubmitted(false); }, 200); return () => clearTimeout(timer); } }, [manualInput, lot, onQrCodeSubmit, onClose, qrScanFailed, qrScanSuccess]); const handleManualSubmit = () => { if (manualInput.trim() === lot?.lotNo) { setQrScanSuccess(true); onQrCodeSubmit(lot.lotNo); onClose(); setManualInput(""); } else { setQrScanFailed(true); setManualInputError(true); setManualInputSubmitted(true); } }; useEffect(() => { if (open) { startScan(); } }, [open, startScan]); return ( {t("QR Code Scan for Lot")}: {lot?.lotNo} {isProcessingQr && ( {t("Processing QR code...")} )} {t("Manual Input")}: { setManualInput(e.target.value); if (qrScanFailed || manualInputError) { setQrScanFailed(false); setManualInputError(false); setManualInputSubmitted(false); } }} sx={{ mb: 1 }} error={manualInputSubmitted && manualInputError} helperText={ manualInputSubmitted && manualInputError ? `${t( "The input is not the same as the expected lot number.", )}` : "" } /> {qrValues.length > 0 && ( {t("QR Scan Result:")} {scannedQrResult} {qrScanSuccess && ( {t("Verified successfully!")} )} )} ); }; const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCombo = [] }) => { const workbenchMode = true; const { t } = useTranslation("jo"); const { t: tPick } = useTranslation("pickOrder"); const router = useRouter(); const { data: session } = useSession() as { data: SessionWithTokens | null }; const currentUserId = session?.id ? parseInt(session.id) : undefined; // 修改:使用 Job Order 数据结构 const [combinedDataLoading, setCombinedDataLoading] = useState(false); // 添加未分配订单状态 const [unassignedOrders, setUnassignedOrders] = useState([]); const [isLoadingUnassigned, setIsLoadingUnassigned] = useState(false); const { values: qrValues, isScanning, startScan, stopScan, resetScan, } = useQrCodeScannerContext(); const [expectedLotData, setExpectedLotData] = useState(null); const [scannedLotData, setScannedLotData] = useState(null); const [isConfirmingLot, setIsConfirmingLot] = useState(false); const [qrScanInput, setQrScanInput] = useState(""); const [qrScanError, setQrScanError] = useState(false); const [qrScanErrorMsg, setQrScanErrorMsg] = useState(""); const [qrScanSuccess, setQrScanSuccess] = useState(false); const [jobOrderData, setJobOrderData] = useState(null); const a4Printers = useMemo( () => (printerCombo || []).filter((p) => String(p.type || "") .trim() .toUpperCase() .includes("A4"), ), [printerCombo], ); const printerOptions = useMemo( () => (a4Printers.length > 0 ? a4Printers : printerCombo || []), [a4Printers, printerCombo], ); const isPrinterComboMissing = printerCombo.length === 0; const [selectedPrinter, setSelectedPrinter] = useState( printerOptions.length > 0 ? printerOptions[0] : null, ); const [printQty, setPrintQty] = useState(1); const pickRecordPrintInFlightRef = useRef(false); useEffect(() => { // Keep selected printer valid when combo list changes. if (!printerOptions.length) { setSelectedPrinter(null); return; } setSelectedPrinter((prev) => { if (!prev) return printerOptions[0]; const stillExists = printerOptions.some((p) => p.id === prev.id); return stillExists ? prev : printerOptions[0]; }); }, [printerOptions]); useEffect(() => { console.log("[JO Workbench] printerCombo:", printerCombo); console.log("[JO Workbench] a4Printers:", a4Printers); console.log("[JO Workbench] printerOptions:", printerOptions); }, [printerCombo, a4Printers, printerOptions]); const handlePickRecord = useCallback( async (floor: PickRecordFloor) => { if (pickRecordPrintInFlightRef.current) return; try { const pickOrderId = jobOrderData?.pickOrder?.id; if (!pickOrderId) { msgError(t("Pick Order not found")); return; } if (!selectedPrinter) { msgError(t("Please select a printer first")); return; } if (!printQty || printQty < 1) { msgError(t("Please enter a valid print quantity (at least 1)")); return; } let printRequest; if (floor === "ALL") { const allFloorsQty = await promptAllFloorsPlasticBoxCartonQty(t, pickOrderId); if (allFloorsQty === null) { return; } printRequest = buildPrintPickRecordRequest({ pickOrderId, printerId: selectedPrinter.id, printQty, floor, allFloorsQty, }); } else { const plasticBoxCartonQty = await promptPlasticBoxCartonQty(t); if (plasticBoxCartonQty === null) { return; } printRequest = buildPrintPickRecordRequest({ pickOrderId, printerId: selectedPrinter.id, printQty, floor, plasticBoxCartonQty, }); } pickRecordPrintInFlightRef.current = true; const response = await PrintPickRecord(printRequest); if (response?.success) { msg(t("Printed Successfully.")); } else { msgError(response?.message || t("Print failed")); } } catch (e) { console.error(e); msgError(t("An error occurred while printing")); } finally { pickRecordPrintInFlightRef.current = false; } }, [jobOrderData, printQty, selectedPrinter, t], ); const workbenchStoreId = useMemo(() => { const po = jobOrderData?.pickOrder as | { storeId?: string | null } | undefined; const s = po?.storeId; return typeof s === "string" && s.trim() !== "" ? s.trim() : null; }, [jobOrderData]); const [pickQtyData, setPickQtyData] = useState>({}); /** 與 DO Workbench 一致:false = 數量只讀;Edit 切換為可輸入「將提交數量」,不開 Issue 表單 */ const [ workbenchSubmitQtyFieldEnabledByLotKey, setWorkbenchSubmitQtyFieldEnabledByLotKey, ] = useState>({}); const [searchQuery, setSearchQuery] = useState>({}); // issue form 里填的 actualPickQty(用于 submit/batch submit 不补拣到 required) const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState< Record >({}); const [localSolStatusById, setLocalSolStatusById] = useState< Record >({}); // 防止同一行(以 stockOutLineId/solId 识别)被重复点击提交/完成 const [actionBusyBySolId, setActionBusyBySolId] = useState< Record >({}); /** DO Workbench:scan-pick 失敗訊息按 SOL 顯示 */ const [scanRejectMessageBySolId, setScanRejectMessageBySolId] = useState< Record >({}); const rememberWorkbenchScanReject = useCallback( (stockOutLineId: number, message: string | undefined | null) => { const id = Number(stockOutLineId); const m = String(message ?? "").trim(); if (!id || !m) return; setScanRejectMessageBySolId((prev) => ({ ...prev, [id]: m })); }, [], ); const clearWorkbenchScanReject = useCallback((stockOutLineId: number) => { const id = Number(stockOutLineId); if (!id) return; setScanRejectMessageBySolId((prev) => { if (!(id in prev)) return prev; const next = { ...prev }; delete next[id]; return next; }); }, []); const [paginationController, setPaginationController] = useState({ pageNum: 0, pageSize: 10, }); const [usernameList, setUsernameList] = useState([]); const initializationRef = useRef(false); const scannerInitializedRef = useRef(false); const autoAssignRef = useRef(false); const formProps = useForm(); const errors = formProps.formState.errors; const [isSubmittingAll, setIsSubmittingAll] = useState(false); const [autoAssignStatus, setAutoAssignStatus] = useState< "idle" | "checking" | "assigned" | "no_orders" >("idle"); const [completionStatus, setCompletionStatus] = useState(null); const [autoAssignMessage, setAutoAssignMessage] = useState(""); // Add QR modal states const [qrModalOpen, setQrModalOpen] = useState(false); const [selectedLotForQr, setSelectedLotForQr] = useState(null); const [selectedFloor, setSelectedFloor] = useState(null); // Add GoodPickExecutionForm states const [pickExecutionFormOpen, setPickExecutionFormOpen] = useState(false); const [selectedLotForExecutionForm, setSelectedLotForExecutionForm] = useState(null); const [fgPickOrders, setFgPickOrders] = useState([]); const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false); const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] = useState(false); const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] = useState<{ itemId: number; stockInLineId: number } | null>(null); const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] = useState(null); const [workbenchLotLabelReminderText, setWorkbenchLotLabelReminderText] = useState(null); useEffect(() => { if (!qrScanSuccess || !workbenchLotLabelModalOpen) return; setWorkbenchLotLabelModalOpen(false); setWorkbenchLotLabelInitialPayload(null); setWorkbenchLotLabelContextLot(null); setWorkbenchLotLabelReminderText(null); }, [qrScanSuccess, workbenchLotLabelModalOpen]); // Add these missing state variables const [isManualScanning, setIsManualScanning] = useState(false); const [processedQrCodes, setProcessedQrCodes] = useState>( new Set(), ); const [lastProcessedQr, setLastProcessedQr] = useState(""); const [isRefreshingData, setIsRefreshingData] = useState(false); // Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling const [processedQrCombinations, setProcessedQrCombinations] = useState< Map> >(new Map()); // Cache for fetchStockInLineInfo API calls to avoid redundant requests const stockInLineInfoCache = useRef< Map >(new Map()); const CACHE_TTL = 60000; // 60 seconds cache TTL const abortControllerRef = useRef(null); const qrProcessingTimeoutRef = useRef(null); // Use refs for processed QR tracking to avoid useEffect dependency issues and delays const processedQrCodesRef = useRef>(new Set()); const lastProcessedQrRef = useRef(""); // Store callbacks in refs to avoid useEffect dependency issues const processOutsideQrCodeRef = useRef< ((latestQr: string, qrScanCountAtInvoke?: number) => Promise) | null >(null); const resetScanRef = useRef<(() => void) | null>(null); // Manual lot confirmation modal state (test shortcut {2fic}) const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false); const getAllLotsFromHierarchical = useCallback( (data: JobOrderLotsHierarchicalWorkbenchResponse | null): any[] => { if (!data || !data.pickOrder || !data.pickOrderLines) { return []; } const allLots: any[] = []; data.pickOrderLines.forEach((line) => { // 用来记录这一行已经通过 lots 出现过的 lotId(避免 stockouts 再渲染一次) const lotIdSet = new Set(); /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數),對齊 DO Workbench */ let lotsAllocatedSumForLine = 0; // lots:按 lotId 去重并合并 requiredQty(对齐 GoodPickExecutiondetail) if (line.lots && line.lots.length > 0) { const lotMap = new Map(); line.lots.forEach((lot: any) => { const lotId = lot.lotId; if (lotId == null) return; if (lotMap.has(lotId)) { const existingLot = lotMap.get(lotId); existingLot.requiredQty = (existingLot.requiredQty || 0) + (lot.requiredQty || 0); } else { lotMap.set(lotId, { ...lot }); } }); lotMap.forEach((lot: any) => { lotsAllocatedSumForLine += Number(lot.requiredQty) || 0; if (lot.lotId != null) lotIdSet.add(lot.lotId); allLots.push({ ...lot, pickOrderLineId: line.id, itemId: line.itemId, itemCode: line.itemCode, itemName: line.itemName, uomCode: line.uomCode, uomDesc: line.uomDesc, itemTotalAvailableQty: line.totalAvailableQty ?? null, pickOrderLineRequiredQty: line.requiredQty, pickOrderLineStatus: line.status, jobOrderId: data.pickOrder.jobOrder.id, jobOrderCode: data.pickOrder.jobOrder.code, pickOrderId: data.pickOrder.id, pickOrderCode: data.pickOrder.code, pickOrderConsoCode: data.pickOrder.consoCode, pickOrderTargetDate: data.pickOrder.targetDate, pickOrderType: data.pickOrder.type, pickOrderStatus: data.pickOrder.status, pickOrderAssignTo: data.pickOrder.assignTo, handler: line.handler, noLot: false, }); }); } /** 工單 API 常在有揀貨後仍回傳 lots: [],缺口只在 stockouts;此時用非 noLot 的已揀量扣 POL(對齊實際剩餘) */ const stockoutsPickedSumNonNoLot = (line.stockouts ?? []).reduce( (acc: number, s: any) => { if (!s || s.noLot) return acc; return acc + (Number(s.qty) || 0); }, 0, ); const noLotRemainingBasis = lotsAllocatedSumForLine > 0 ? lotsAllocatedSumForLine : stockoutsPickedSumNonNoLot; // stockouts:用于“无 suggested lot / noLot”场景也显示并可 submit 0 闭环 if (line.stockouts && line.stockouts.length > 0) { line.stockouts.forEach((stockout: any) => { const hasLot = stockout.lotId != null; const lotAlreadyInLots = hasLot && lotIdSet.has(stockout.lotId as number); // 有批次 & 已经通过 lots 渲染过 → 跳过,避免一条变两行 if (!stockout.noLot && lotAlreadyInLots) { return; } allLots.push({ pickOrderLineId: line.id, itemId: line.itemId, itemCode: line.itemCode, itemName: line.itemName, uomCode: line.uomCode, uomDesc: line.uomDesc, itemTotalAvailableQty: line.totalAvailableQty ?? null, pickOrderLineRequiredQty: line.requiredQty, pickOrderLineStatus: line.status, jobOrderId: data.pickOrder.jobOrder.id, jobOrderCode: data.pickOrder.jobOrder.code, pickOrderId: data.pickOrder.id, pickOrderCode: data.pickOrder.code, pickOrderConsoCode: data.pickOrder.consoCode, pickOrderTargetDate: data.pickOrder.targetDate, pickOrderType: data.pickOrder.type, pickOrderStatus: data.pickOrder.status, pickOrderAssignTo: data.pickOrder.assignTo, handler: line.handler, lotId: stockout.lotId || null, lotNo: stockout.lotNo || null, expiryDate: null, location: stockout.location || null, availableQty: stockout.availableQty ?? 0, // 無批次列:有 SPL 時扣 suggested 合計;僅有 stockouts(lots 空)時扣已揀量(對齊 DO + workbench 僅 SOL 情境) requiredQty: stockout.noLot ? Math.max( 0, (Number(line.requiredQty) || 0) - noLotRemainingBasis, ) : Number(line.requiredQty) || 0, actualPickQty: stockout.qty ?? 0, processingStatus: stockout.status || "pending", lotAvailability: stockout.noLot ? "insufficient_stock" : "available", suggestedPickLotId: null, stockOutLineId: stockout.id || null, stockOutLineQty: stockout.qty ?? 0, stockOutLineStatus: stockout.status || null, stockInLineId: null, routerIndex: stockout.noLot ? 999999 : null, routerArea: null, routerRoute: null, noLot: !!stockout.noLot, }); }); } }); return allLots; }, [], ); const extractFloor = (lot: any): string => { const raw = lot.routerRoute || lot.routerArea || lot.location || ""; const match = raw.match(/^(\d+F?)/i) || raw.split("-")[0]; return (match?.[1] || match || raw || "") .toUpperCase() .replace(/(\d)F?/i, "$1F"); }; // 楼层排序权重:4F > 3F > 2F(数字越大越靠前) const floorSortOrder = (floor: string): number => { const n = parseInt(floor.replace(/\D/g, ""), 10); return isNaN(n) ? 0 : n; }; const combinedLotData = useMemo(() => { const lots = getAllLotsFromHierarchical(jobOrderData); // 前端覆盖:issue form/submit0 不会立刻改写后端 qty 时,用本地缓存让 UI 与 batch submit 计算一致 return lots.map((lot: any) => { const solId = Number(lot.stockOutLineId) || 0; if (solId > 0) { const hasPickedOverride = Object.prototype.hasOwnProperty.call( issuePickedQtyBySolId, solId, ); const picked = Number( issuePickedQtyBySolId[solId] ?? lot.actualPickQty ?? 0, ); const statusRaw = localSolStatusById[solId] ?? lot.stockOutLineStatus ?? ""; const status = String(statusRaw).toLowerCase(); const isEnded = status === "completed" || status === "rejected"; return { ...lot, actualPickQty: hasPickedOverride ? picked : lot.actualPickQty, stockOutLineQty: hasPickedOverride ? picked : lot.stockOutLineQty, stockOutLineStatus: isEnded ? statusRaw : statusRaw || "checked", }; } return lot; }); }, [ jobOrderData, getAllLotsFromHierarchical, issuePickedQtyBySolId, localSolStatusById, ]); /** 單筆將提交數量:Issue 改數 → pickQtyData → 已完成行用實際量 → noLot/過期/unavailable → 0 → 否則 required(對齊 DO Workbench) */ const resolveSingleSubmitQty = useCallback( (lot: any) => { const required = Number( lot.requiredQty || lot.pickOrderLineRequiredQty || 0, ); const solId = Number(lot.stockOutLineId) || 0; const lotId = lot.lotId; const lotKey = Number.isFinite(solId) && solId > 0 ? `sol:${solId}` : `${lot.pickOrderLineId}-${lotId}`; const issuePicked = solId > 0 ? issuePickedQtyBySolId[solId] : undefined; if (issuePicked !== undefined && !Number.isNaN(Number(issuePicked))) { return Number(issuePicked); } const st = String(lot.stockOutLineStatus || "").toLowerCase(); if ( st === "completed" || st === "partially_completed" || st === "partially_complete" ) { return Number(lot.stockOutLineQty ?? lot.actualPickQty ?? 0); } if (Object.prototype.hasOwnProperty.call(pickQtyData, lotKey)) { const fromPick = pickQtyData[lotKey]; if ( fromPick !== undefined && fromPick !== null && !Number.isNaN(Number(fromPick)) ) { return Number(fromPick); } } if (isInventoryLotLineUnavailable(lot)) { return 0; } if (lot.noLot === true || !lot.lotId) { return 0; } if (isLotAvailabilityExpired(lot)) { return 0; } return required; }, [issuePickedQtyBySolId, pickQtyData], ); const getWorkbenchQtyLotKey = useCallback((lot: any) => { const solId = Number(lot?.stockOutLineId) || 0; if (Number.isFinite(solId) && solId > 0) return `sol:${solId}`; return `${lot?.pickOrderLineId}-${lot?.lotId}`; }, []); const workbenchScanPickQtyFromLot = useCallback( (lot: any) => { const solId = Number(lot?.stockOutLineId); const sourceLot = Number.isFinite(solId) && solId > 0 ? combinedLotData.find((r) => Number(r.stockOutLineId) === solId) ?? lot : lot; const lotKey = getWorkbenchQtyLotKey(sourceLot); const hasExplicitPickOverride = Object.prototype.hasOwnProperty.call( pickQtyData, lotKey, ); const n = Number(resolveSingleSubmitQty(sourceLot)); if (hasExplicitPickOverride && Number.isFinite(n) && n === 0) return { qty: 0 } as const; if (!Number.isFinite(n) || n <= 0) return {}; return { qty: n } as const; }, [resolveSingleSubmitQty, combinedLotData, pickQtyData, getWorkbenchQtyLotKey], ); const lotFloorPrefixFilter = useMemo(() => { const storeId = String(workbenchStoreId ?? "") .trim() .toUpperCase() .replace(/\s/g, ""); const floorKey = storeId.replace(/\//g, ""); return floorKey ? `${floorKey}-` : ""; }, [workbenchStoreId]); const defaultLabelPrinterName = useMemo(() => { const storeId = String(workbenchStoreId ?? "") .trim() .toUpperCase() .replace(/\s/g, ""); const floorKey = storeId.replace(/\//g, ""); if (floorKey === "2F") return "Label機 2F A+B"; if (floorKey === "4F") return "Label機 4F 乾貨 C, D"; return undefined; }, [workbenchStoreId]); const openWorkbenchLotLabelModalForLot = useCallback((lot: any, reminderText?: string | null) => { const itemId = Number(lot?.itemId); const stockInLineId = Number(lot?.stockInLineId); const solId = Number(lot?.stockOutLineId); if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) { return; } setWorkbenchLotLabelContextLot(lot); if (Number.isFinite(stockInLineId) && stockInLineId > 0) { setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId }); } else { setWorkbenchLotLabelInitialPayload(null); } setWorkbenchLotLabelReminderText(reminderText ?? null); // Clear latched success so the lot-label modal effect cannot instantly re-close on open. setQrScanSuccess(false); setWorkbenchLotLabelModalOpen(true); }, []); const shouldOpenWorkbenchLotLabelModalForFailure = useCallback( (code?: string | null, msg?: string | null): boolean => { const normalizedCode = String(code || "").toUpperCase(); if ( normalizedCode.includes("UNAVAILABLE") || normalizedCode.includes("EXPIRED") ) { return true; } const normalizedMsg = String(msg || "").toLowerCase(); if (!normalizedMsg) return false; return ( normalizedMsg.includes("unavailable") || normalizedMsg.includes("not available") || normalizedMsg.includes("expired") || normalizedMsg.includes("不可用") || normalizedMsg.includes("無法使用") || normalizedMsg.includes("过期") ); }, [], ); const handleWorkbenchLotLabelScanPick = useCallback( async ({ inventoryLotLineId, lotNo, qty, }: { inventoryLotLineId: number; lotNo: string; qty?: number; }) => { const lot = workbenchLotLabelContextLot; if (!lot?.stockOutLineId) { throw new Error(t("Missing stock out line for this row.")); } const qtyPayload = Number.isFinite(Number(qty)) ? { qty: Number(qty) } : workbenchScanPickQtyFromLot(lot); const res = await workbenchScanPick({ stockOutLineId: Number(lot.stockOutLineId), lotNo: String(lotNo || "").trim(), inventoryLotLineId, storeId: workbenchStoreId, userId: currentUserId ?? 1, ...qtyPayload, }); if (res.code !== "SUCCESS") { const errMsg = (res as { message?: string })?.message || t("Workbench scan-pick failed."); rememberWorkbenchScanReject(Number(lot.stockOutLineId), errMsg); throw new Error(errMsg); } clearWorkbenchScanReject(Number(lot.stockOutLineId)); const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; if (pickOrderId) { const latest = await fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId); setJobOrderData(latest); setIssuePickedQtyBySolId(loadIssuePickedMapJo(pickOrderId)); getAllLotsFromHierarchical(latest); } setWorkbenchLotLabelModalOpen(false); setWorkbenchLotLabelContextLot(null); setWorkbenchLotLabelInitialPayload(null); }, [ workbenchLotLabelContextLot, currentUserId, workbenchScanPickQtyFromLot, workbenchStoreId, rememberWorkbenchScanReject, clearWorkbenchScanReject, filterArgs?.pickOrderId, getAllLotsFromHierarchical, t, ], ); const workbenchLotLabelSubmitQty = useMemo(() => { if (!workbenchLotLabelContextLot) return 0; return Number(resolveSingleSubmitQty(workbenchLotLabelContextLot)) || 0; }, [workbenchLotLabelContextLot, resolveSingleSubmitQty]); const handleWorkbenchLotLabelSubmitQtyChange = useCallback( (qty: number) => { if (!workbenchLotLabelContextLot) return; const lotKey = getWorkbenchQtyLotKey(workbenchLotLabelContextLot); setPickQtyData((prev) => ({ ...prev, [lotKey]: qty })); }, [workbenchLotLabelContextLot, getWorkbenchQtyLotKey], ); const workbenchLotLabelScanPickDisabled = useMemo(() => { if (!workbenchLotLabelModalOpen || !workbenchLotLabelContextLot) return true; const lot = workbenchLotLabelContextLot; const status = String(lot.stockOutLineStatus || "").toLowerCase(); const isNoLot = !lot.lotNo || String(lot.lotNo).trim() === ""; if (isNoLot) return false; if (status === "pending" || status === "rejected") return false; return true; }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]); const originalCombinedData = useMemo(() => { return getAllLotsFromHierarchical(jobOrderData); }, [jobOrderData, getAllLotsFromHierarchical]); // Enhanced lotDataIndexes with cached active lots for better performance (align with GoodPickExecutiondetail) const lotDataIndexes = useMemo(() => { const byItemId = new Map(); const byItemCode = new Map(); const byLotId = new Map(); const byLotNo = new Map(); const byStockInLineId = new Map(); const activeLotsByItemId = new Map(); const rejectedStatuses = new Set(["rejected"]); for (let i = 0; i < combinedLotData.length; i++) { const lot = combinedLotData[i]; const solStatus = String(lot.stockOutLineStatus || "").toLowerCase(); const lotAvailability = String(lot.lotAvailability || "").toLowerCase(); const processingStatus = String(lot.processingStatus || "").toLowerCase(); const isUnavailable = isInventoryLotLineUnavailable(lot); const isExpired = isLotAvailabilityExpired(lot); const isRejected = rejectedStatuses.has(lotAvailability) || rejectedStatuses.has(solStatus) || rejectedStatuses.has(processingStatus); const isEnded = solStatus === "checked" || solStatus === "completed"; const isPartially = solStatus === "partially_completed" || solStatus === "partially_complete"; const isPending = solStatus === "pending" || solStatus === ""; const isActive = !isRejected && !isUnavailable && !isExpired && !isEnded && (isPending || isPartially); if (lot.itemId) { if (!byItemId.has(lot.itemId)) { byItemId.set(lot.itemId, []); activeLotsByItemId.set(lot.itemId, []); } byItemId.get(lot.itemId)!.push(lot); if (isActive) activeLotsByItemId.get(lot.itemId)!.push(lot); } if (lot.itemCode) { if (!byItemCode.has(lot.itemCode)) byItemCode.set(lot.itemCode, []); byItemCode.get(lot.itemCode)!.push(lot); } if (lot.lotId) byLotId.set(lot.lotId, lot); if (lot.lotNo) { if (!byLotNo.has(lot.lotNo)) byLotNo.set(lot.lotNo, []); byLotNo.get(lot.lotNo)!.push(lot); } if (lot.stockInLineId) { if (!byStockInLineId.has(lot.stockInLineId)) byStockInLineId.set(lot.stockInLineId, []); byStockInLineId.get(lot.stockInLineId)!.push(lot); } } return { byItemId, byItemCode, byLotId, byLotNo, byStockInLineId, activeLotsByItemId, }; }, [combinedLotData]); // Cached version of fetchStockInLineInfo to avoid redundant API calls const fetchStockInLineInfoCached = useCallback( async (stockInLineId: number): Promise<{ lotNo: string | null }> => { const now = Date.now(); const cached = stockInLineInfoCache.current.get(stockInLineId); if (cached && now - cached.timestamp < CACHE_TTL) { return { lotNo: cached.lotNo }; } if (abortControllerRef.current) abortControllerRef.current.abort(); const abortController = new AbortController(); abortControllerRef.current = abortController; const stockInLineInfo = await fetchStockInLineInfo(stockInLineId); stockInLineInfoCache.current.set(stockInLineId, { lotNo: stockInLineInfo.lotNo || null, timestamp: now, }); if (stockInLineInfoCache.current.size > 100) { const firstKey = stockInLineInfoCache.current.keys().next().value; if (firstKey !== undefined) stockInLineInfoCache.current.delete(firstKey); } return { lotNo: stockInLineInfo.lotNo || null }; }, [], ); // 修改:加载未分配的 Job Order 订单 const loadUnassignedOrders = useCallback(async () => { setIsLoadingUnassigned(true); try { //const orders = await fetchUnassignedJobOrderPickOrders(); //setUnassignedOrders(orders); } catch (error) { console.error("Error loading unassigned orders:", error); } finally { setIsLoadingUnassigned(false); } }, []); // 修改:分配订单给当前用户 const handleAssignOrder = useCallback( async (pickOrderId: number) => { if (!currentUserId) { console.error("Missing user id in session"); return; } try { const result = await assignJobOrderPickOrderForWorkbench( pickOrderId, currentUserId, ); const msg = String(result?.message || ""); const assignSkippedByStatus = msg.toLowerCase().startsWith("skipped assign:"); if (msg === "Successfully assigned" || assignSkippedByStatus) { console.log(" Successfully assigned pick order"); if (!assignSkippedByStatus) { try { /* const refreshed = await fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench( pickOrderId, ); const uniqueItemIds = Array.from( new Set( (refreshed?.pickOrderLines ?? []) .map((line) => line?.itemId) .filter( (itemId): itemId is number => typeof itemId === "number" && Number.isFinite(itemId) && itemId > 0, ), ), ); await Promise.all( uniqueItemIds.map((itemId) => updateJoPickOrderHandledBy({ pickOrderId, itemId, userId: currentUserId, }), ), ); */ } catch (handledBySyncError) { console.warn( "⚠️ Assigned but failed to sync JoPickOrder.handledBy for some lines:", handledBySyncError, ); } } // 刷新数据 window.dispatchEvent(new CustomEvent("pickOrderAssigned")); // 重新加载未分配订单列表 loadUnassignedOrders(); } else { console.warn("⚠️ Assignment failed:", result.message); alert(`Assignment failed: ${result.message}`); } } catch (error) { console.error("❌ Error assigning order:", error); alert("Error occurred during assignment"); } }, [currentUserId, loadUnassignedOrders], ); const fetchFgPickOrdersData = useCallback(async () => { if (!currentUserId) return; setFgPickOrdersLoading(true); try { const allFgPickOrders = await fetchFGPickOrdersByUserIdWorkbench( currentUserId, ); setFgPickOrders(Array.isArray(allFgPickOrders) ? allFgPickOrders : []); console.log(" Fetched FG pick orders(workbench):", allFgPickOrders); } catch (error) { console.error("❌ Error fetching FG pick orders:", error); setFgPickOrders([]); } finally { setFgPickOrdersLoading(false); } }, [currentUserId]); useEffect(() => { if (combinedLotData.length > 0) { fetchFgPickOrdersData(); } }, [combinedLotData, fetchFgPickOrdersData]); // Handle QR code button click const handleQrCodeClick = (pickOrderId: number) => { console.log(`QR Code clicked for pick order ID: ${pickOrderId}`); // TODO: Implement QR code functionality }; // 修改:使用 Job Order API 获取数据 const fetchJobOrderData = useCallback( async (pickOrderId?: number) => { setCombinedDataLoading(true); try { if (!pickOrderId) { console.warn("⚠️ No pickOrderId provided, skipping API call"); return; } // 直接使用类型化的响应 const jobOrderData = await fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId); console.log("✅ Job Order data (hierarchical):", jobOrderData); setJobOrderData(jobOrderData); setIssuePickedQtyBySolId(loadIssuePickedMapJo(pickOrderId)); // 使用辅助函数获取所有 lots(不再扁平化) getAllLotsFromHierarchical(jobOrderData); } catch (error) { console.error("❌ Error fetching job order data:", error); setJobOrderData(null); setIssuePickedQtyBySolId({}); } finally { setCombinedDataLoading(false); } }, [getAllLotsFromHierarchical], ); const applyLocalStockOutLineUpdate = useCallback((..._args: unknown[]) => { // JO Workbench:以伺服器刷新為主;保留呼叫點與 DO Workbench 一致(不更新本地 SOL 鏡像)。 }, []); const refreshWorkbenchAfterScanPick = useCallback(async () => { const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; await fetchJobOrderData(pickOrderId); }, [fetchJobOrderData, filterArgs?.pickOrderId]); const updateHandledBy = useCallback( async (pickOrderId: number, itemId: number) => { if (!currentUserId || !pickOrderId || !itemId) { return; } try { console.log( `Updating JoPickOrder.handledBy for pickOrderId: ${pickOrderId}, itemId: ${itemId}, userId: ${currentUserId}`, ); await updateJoPickOrderHandledBy({ pickOrderId: pickOrderId, itemId: itemId, userId: currentUserId, }); console.log("✅ JoPickOrder.handledBy updated successfully"); } catch (error) { console.error("❌ Error updating JoPickOrder.handledBy:", error); // Don't throw - this is not critical for the main flow } }, [currentUserId], ); // 修改:初始化 — Workbench 先 prime SPL/SOL(後端不寫 pick_order.assignTo),再載入階層資料 useEffect(() => { if (session && currentUserId && !initializationRef.current) { console.log("✅ Session loaded, initializing job order..."); initializationRef.current = true; const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; if (pickOrderId) { void (async () => { try { await handleAssignOrder(pickOrderId); } finally { await fetchJobOrderData(pickOrderId); } })(); } loadUnassignedOrders(); } }, [ session, currentUserId, fetchJobOrderData, loadUnassignedOrders, filterArgs?.pickOrderId, handleAssignOrder, ]); // 與 GoodPickExecutiondetail 一致:session 就緒後自動開啟背景掃碼(平板現場用) useEffect(() => { if (session && currentUserId && !scannerInitializedRef.current) { scannerInitializedRef.current = true; console.log("✅ [JO] Auto-starting QR scanner in background mode"); setIsManualScanning(true); startScan(); } }, [session, currentUserId, startScan]); // 僅在元件卸載時重置,讓 React Strict Mode 二次掛載仍能再走一次自動開掃(不因 startScan 引用變化而重複開掃) useEffect(() => { return () => { scannerInitializedRef.current = false; }; }, []); // Add event listener for manual assignment useEffect(() => { const handlePickOrderAssigned = () => { console.log("🔄 Pick order assigned event received, refreshing data..."); const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; if (pickOrderId) { fetchJobOrderData(pickOrderId); } }; window.addEventListener("pickOrderAssigned", handlePickOrderAssigned); return () => { window.removeEventListener("pickOrderAssigned", handlePickOrderAssigned); }; }, [fetchJobOrderData, filterArgs?.pickOrderId]); /** 純 lotNo 手動輸入:與 DO Workbench 相同,不寫 checked,改提示用 scan-pick / Just Completed */ const handleQrCodeSubmit = useCallback( async (lotNo: string) => { console.log(` Processing QR Code for lot: ${lotNo}`); if (!lotNo || lotNo === "null" || lotNo.trim() === "") { console.error(" Invalid lotNo: null, undefined, or empty"); return; } const currentLotData = combinedLotData; console.log( ` Available lots:`, currentLotData.map((lot) => lot.lotNo), ); const lotNoLower = lotNo.toLowerCase(); const matchingLots = currentLotData.filter((lot) => { if (!lot.lotNo) return false; return lot.lotNo === lotNo || lot.lotNo.toLowerCase() === lotNoLower; }); if (matchingLots.length === 0) { console.error(` Lot not found: ${lotNo}`); setQrScanError(true); setQrScanSuccess(false); const availableLotNos = currentLotData .map((lot) => lot.lotNo) .join(", "); console.log( ` QR Code "${lotNo}" does not match any expected lots. Available lots: ${availableLotNos}`, ); return; } const hasExpiredLot = matchingLots.some( (lot: any) => String(lot.lotAvailability || "").toLowerCase() === "expired", ); if (hasExpiredLot) { console.warn(`⚠️ [QR PROCESS] Scanned lot ${lotNo} is expired`); setQrScanError(true); setQrScanSuccess(false); return; } setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg( tPick( "Workbench uses scan-pick. Please use Just Completed / submit via scan-pick instead of checked status.", ), ); }, [combinedLotData, tPick], ); const clearLotConfirmationState = useCallback( (clearProcessedRefs: boolean = false) => { setExpectedLotData(null); setScannedLotData(null); setSelectedLotForQr(null); if (clearProcessedRefs) { setTimeout(() => { lastProcessedQrRef.current = ""; processedQrCodesRef.current.clear(); }, 100); } }, [], ); // Add handleLotConfirmation function const handleLotConfirmation = useCallback( async (overrideScannedLot?: any) => { const effectiveScannedLot = overrideScannedLot ?? scannedLotData; if (!expectedLotData || !effectiveScannedLot || !selectedLotForQr) { console.error( "❌ [LOT CONFIRM] Missing required data for lot confirmation", ); return; } console.log( "✅ [LOT CONFIRM] User confirmed lot substitution - processing now", ); console.log("✅ [LOT CONFIRM] Expected lot:", expectedLotData); console.log("✅ [LOT CONFIRM] Scanned lot:", scannedLotData); console.log("✅ [LOT CONFIRM] Selected lot for QR:", selectedLotForQr); setIsConfirmingLot(true); try { let newLotLineId = effectiveScannedLot?.inventoryLotLineId; if (!newLotLineId && effectiveScannedLot?.stockInLineId) { try { if ( currentUserId && selectedLotForQr.pickOrderId && selectedLotForQr.itemId ) { try { await updateHandledBy( selectedLotForQr.pickOrderId, selectedLotForQr.itemId, ); console.log( `✅ [LOT CONFIRM] Handler updated for itemId ${selectedLotForQr.itemId}`, ); } catch (error) { console.error( `❌ [LOT CONFIRM] Error updating handler (non-critical):`, error, ); } } console.log( `🔍 [LOT CONFIRM] Fetching lot detail for stockInLineId: ${effectiveScannedLot.stockInLineId}`, ); const ld = await fetchLotDetail(effectiveScannedLot.stockInLineId); newLotLineId = ld.inventoryLotLineId; console.log( `✅ [LOT CONFIRM] Fetched lot detail: inventoryLotLineId=${newLotLineId}`, ); } catch (error) { console.error( "❌ [LOT CONFIRM] Error fetching lot detail (stockInLineId may not exist):", error, ); // If stockInLineId doesn't exist, we can still proceed with lotNo substitution // The backend confirmLotSubstitution should handle this case } } if (!newLotLineId) { console.warn( "⚠️ [LOT CONFIRM] No inventory lot line id for scanned lot, proceeding with lotNo only", ); // Continue anyway - backend may handle lotNo substitution without inventoryLotLineId } console.log("=== [LOT CONFIRM] Lot Confirmation Debug ==="); console.log("Selected Lot:", selectedLotForQr); console.log("Pick Order Line ID:", selectedLotForQr.pickOrderLineId); console.log("Stock Out Line ID:", selectedLotForQr.stockOutLineId); console.log( "Suggested Pick Lot ID:", selectedLotForQr.suggestedPickLotId, ); console.log("Lot ID (fallback):", selectedLotForQr.lotId); console.log("New Inventory Lot Line ID:", newLotLineId); console.log("Scanned Lot No:", effectiveScannedLot.lotNo); console.log( "Scanned StockInLineId:", effectiveScannedLot.stockInLineId, ); const originalSuggestedPickLotId = selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId; let switchedToUnavailable = false; // noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo if (!originalSuggestedPickLotId) { if (!selectedLotForQr?.stockOutLineId) { throw new Error("Missing stockOutLineId for noLot line"); } console.log( "🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...", ); const res = await updateStockOutLineStatusByQRCodeAndLotNo({ pickOrderLineId: selectedLotForQr.pickOrderLineId, inventoryLotNo: effectiveScannedLot.lotNo || "", stockInLineId: effectiveScannedLot?.stockInLineId ?? null, stockOutLineId: selectedLotForQr.stockOutLineId, itemId: selectedLotForQr.itemId, status: "checked", }); console.log( "✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:", res, ); switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE"; const ok = res?.code === "checked" || res?.code === "SUCCESS" || switchedToUnavailable; if (!ok) { const errMsg = res?.code === "LOT_UNAVAILABLE" ? tPick( "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", ) : res?.message || tPick( "Lot switch failed; pick line was not marked as checked.", ); setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(errMsg); return; } } else { // Call confirmLotSubstitution to update the suggested lot console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution..."); const substitutionResult = await confirmLotSubstitution({ pickOrderLineId: selectedLotForQr.pickOrderLineId, stockOutLineId: selectedLotForQr.stockOutLineId, originalSuggestedPickLotId, newInventoryLotNo: effectiveScannedLot.lotNo || "", // ✅ required by LotSubstitutionConfirmRequest newStockInLineId: effectiveScannedLot?.stockInLineId ?? null, }); console.log( "✅ [LOT CONFIRM] Lot substitution result:", substitutionResult, ); // ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked. // Keep modal open so user can cancel/rescan. switchedToUnavailable = substitutionResult?.code === "SUCCESS_UNAVAILABLE" || substitutionResult?.code === "BOUND_UNAVAILABLE"; if ( !substitutionResult || (substitutionResult.code !== "SUCCESS" && !switchedToUnavailable) ) { console.error( "❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.", ); const errMsg = substitutionResult?.code === "LOT_UNAVAILABLE" ? tPick( "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", ) : substitutionResult?.message || `换批失败:stockInLineId ${ effectiveScannedLot?.stockInLineId ?? "" } 不存在或无法匹配`; setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(errMsg); return; } } // Workbench:以 scan-pick 過帳(與 DO Workbench 一致);非 workbench 仍寫 checked if ( workbenchMode && selectedLotForQr?.stockOutLineId && !switchedToUnavailable ) { const lotNoToScan = String(effectiveScannedLot.lotNo || "").trim(); const silId = effectiveScannedLot?.stockInLineId; if (!lotNoToScan) { const errMsg = tPick("Cannot resolve lot number for confirmation."); setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(errMsg); return; } const res = await workbenchScanPick({ stockOutLineId: selectedLotForQr.stockOutLineId, lotNo: lotNoToScan, ...(typeof silId === "number" && Number.isFinite(silId) && silId > 0 ? { stockInLineId: silId } : {}), ...workbenchScanPickQtyFromLot(selectedLotForQr), storeId: workbenchStoreId, userId: currentUserId ?? 1, }); if (res.code !== "SUCCESS") { const errMsg = (res as { message?: string })?.message || tPick("Workbench scan-pick failed."); setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(errMsg); if (selectedLotForQr.stockOutLineId != null) { rememberWorkbenchScanReject( Number(selectedLotForQr.stockOutLineId), errMsg, ); } return; } clearWorkbenchScanReject(Number(selectedLotForQr.stockOutLineId)); } else if (selectedLotForQr?.stockOutLineId && !switchedToUnavailable) { console.log( `🔄 [LOT CONFIRM] Updating stockOutLine ${selectedLotForQr.stockOutLineId} to 'checked'`, ); await updateStockOutLineStatus({ id: selectedLotForQr.stockOutLineId, status: "checked", qty: 0, }); console.log( `✅ [LOT CONFIRM] Stock out line ${selectedLotForQr.stockOutLineId} status updated to 'checked'`, ); } // Close modal and clean up state BEFORE refreshing clearLotConfirmationState(false); // Clear QR processing state but DON'T clear processedQrCodes yet setQrScanError(false); setQrScanSuccess(true); setQrScanErrorMsg(""); setQrScanInput(""); // Set refreshing flag to prevent QR processing during refresh setIsRefreshingData(true); // Refresh data to show updated lot console.log("🔄 Refreshing job order data..."); const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; await fetchJobOrderData(pickOrderId); console.log(" Lot substitution confirmed and data refreshed"); // Clear processed QR codes and flags immediately after refresh // This allows new QR codes to be processed right away setTimeout(() => { console.log(" Clearing processed QR codes and resuming scan"); setProcessedQrCodes(new Set()); setLastProcessedQr(""); setQrScanSuccess(false); setIsRefreshingData(false); // ✅ Clear processedQrCombinations to allow reprocessing the same QR if needed if (effectiveScannedLot?.stockInLineId && selectedLotForQr?.itemId) { setProcessedQrCombinations((prev) => { const newMap = new Map(prev); const itemId = selectedLotForQr.itemId; if (itemId && newMap.has(itemId)) { newMap.get(itemId)!.delete(effectiveScannedLot.stockInLineId); if (newMap.get(itemId)!.size === 0) { newMap.delete(itemId); } } return newMap; }); } }, 500); // Reduced from 3000ms to 500ms - just enough for UI update } catch (error) { console.error("Error confirming lot substitution:", error); const errMsg = tPick("Lot confirmation failed. Please try again."); setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(errMsg); // Clear refresh flag on error setIsRefreshingData(false); } finally { setIsConfirmingLot(false); } }, [ expectedLotData, scannedLotData, selectedLotForQr, fetchJobOrderData, currentUserId, updateHandledBy, tPick, clearLotConfirmationState, workbenchMode, workbenchScanPickQtyFromLot, workbenchStoreId, rememberWorkbenchScanReject, clearWorkbenchScanReject, ], ); const processOutsideQrCode = useCallback( async (latestQr: string, _qrScanCountAtInvoke?: number) => { const totalStartTime = performance.now(); console.log( ` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`, ); console.log(` Start time: ${new Date().toISOString()}`); // ✅ Measure index access time const indexAccessStart = performance.now(); const indexes = lotDataIndexes; // Access the memoized indexes const indexAccessTime = performance.now() - indexAccessStart; console.log( ` [PERF] Index access time: ${indexAccessTime.toFixed(2)}ms`, ); // 1) Parse JSON safely (parse once, reuse) const parseStartTime = performance.now(); let qrData: any = null; let parseTime = 0; try { qrData = JSON.parse(latestQr); parseTime = performance.now() - parseStartTime; console.log(` [PERF] JSON parse time: ${parseTime.toFixed(2)}ms`); } catch { console.log( "QR content is not JSON; skipping lotNo direct submit to avoid false matches.", ); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); }); return; } try { const validationStartTime = performance.now(); if (!(qrData?.stockInLineId && qrData?.itemId)) { console.log( "QR JSON missing required fields (itemId, stockInLineId).", ); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); }); return; } const validationTime = performance.now() - validationStartTime; console.log(` [PERF] Validation time: ${validationTime.toFixed(2)}ms`); const scannedItemId = qrData.itemId; const scannedStockInLineId = qrData.stockInLineId; // ✅ Check if this combination was already processed const duplicateCheckStartTime = performance.now(); const itemProcessedSet = processedQrCombinations.get(scannedItemId); if (itemProcessedSet?.has(scannedStockInLineId)) { const duplicateCheckTime = performance.now() - duplicateCheckStartTime; console.log( ` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed( 2, )}ms)`, ); return; } const duplicateCheckTime = performance.now() - duplicateCheckStartTime; console.log( ` [PERF] Duplicate check time: ${duplicateCheckTime.toFixed(2)}ms`, ); // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) const lookupStartTime = performance.now(); const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; const lookupTime = performance.now() - lookupStartTime; console.log( ` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${ activeSuggestedLots.length } active lots, ${allLotsForItem.length} total lots`, ); // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots // This allows users to scan other lots even when all suggested lots are rejected const scannedLot = allLotsForItem.find( (lot: any) => lot.stockInLineId === scannedStockInLineId, ); if (scannedLot) { const isRejected = scannedLot.stockOutLineStatus?.toLowerCase() === "rejected" || scannedLot.lotAvailability === "rejected" || isInventoryLotLineUnavailable(scannedLot); if (isRejected) { console.warn( `⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected or unavailable`, ); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg( `此批次(${ scannedLot.lotNo || scannedStockInLineId })已被拒绝,无法使用。请扫描其他批次。`, ); }); // Mark as processed to prevent re-processing setProcessedQrCombinations((prev) => { const newMap = new Map(prev); if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); newMap.get(scannedItemId)!.add(scannedStockInLineId); return newMap; }); return; } const isExpired = String(scannedLot.lotAvailability || "").toLowerCase() === "expired"; if (isExpired) { console.warn( `⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired`, ); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg( `此批次(${ scannedLot.lotNo || scannedStockInLineId })已过期,无法使用。请扫描其他批次。`, ); }); // Mark as processed to prevent re-processing the same expired QR repeatedly setProcessedQrCombinations((prev) => { const newMap = new Map(prev); if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); newMap.get(scannedItemId)!.add(scannedStockInLineId); return newMap; }); return; } } // ✅ If no active suggested lots, auto-switch to scanned lot (no modal) if (activeSuggestedLots.length === 0) { // Check if there are any lots for this item (even if all are rejected) if (allLotsForItem.length === 0) { console.error("No lots found for this item"); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg("当前订单中没有此物品的批次信息"); }); return; } console.log( `⚠️ [QR PROCESS] No active suggested lots, auto-switching to scanned lot.`, ); // Find a rejected lot as expected lot (the one that was rejected) const rejectedLot = allLotsForItem.find( (lot: any) => lot.stockOutLineStatus?.toLowerCase() === "rejected" || lot.lotAvailability === "rejected" || isInventoryLotLineUnavailable(lot), ); const expectedLot = rejectedLot || pickExpectedLotForSubstitution( allLotsForItem.filter( (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "", ), ) || allLotsForItem[0]; let scannedLotNo: string | null = scannedLot?.lotNo || null; if (!scannedLotNo) { try { const info = await fetchStockInLineInfoCached(scannedStockInLineId); scannedLotNo = info?.lotNo || null; } catch (e) { console.warn( "Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e, ); } } if (!scannedLotNo) { startTransition(() => { setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg( tPick( "Cannot resolve lot number from QR. Please rescan or use manual confirmation.", ), ); }); return; } if (!workbenchMode) { const substitutionResult = await confirmLotSubstitution({ pickOrderLineId: expectedLot.pickOrderLineId, stockOutLineId: expectedLot.stockOutLineId, originalSuggestedPickLotId: expectedLot.suggestedPickLotId, newInventoryLotNo: "", newStockInLineId: scannedStockInLineId, }); const substitutionCode = (substitutionResult as any)?.code; const switchedToUnavailable = substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE"; if ( !substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable) ) { const errMsg = substitutionResult?.message || tPick("Lot switch failed; pick line was not updated."); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(errMsg); }); if (expectedLot.stockOutLineId != null) { rememberWorkbenchScanReject( Number(expectedLot.stockOutLineId), errMsg, ); } return; } } const res = await workbenchScanPick({ stockOutLineId: expectedLot.stockOutLineId, lotNo: scannedLotNo, ...(typeof scannedStockInLineId === "number" && Number.isFinite(scannedStockInLineId) && scannedStockInLineId > 0 ? { stockInLineId: scannedStockInLineId } : {}), ...workbenchScanPickQtyFromLot(expectedLot), storeId: workbenchStoreId, userId: currentUserId ?? 1, }); const ok = res.code === "SUCCESS"; if (!ok) { const failMsg = (res as { message?: string })?.message || tPick("Workbench scan-pick failed."); if ( shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && expectedLot ) { openWorkbenchLotLabelModalForLot(expectedLot, failMsg); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { rememberWorkbenchScanReject( Number(expectedLot.stockOutLineId), failMsg, ); } startTransition(() => { setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(failMsg); }); return; } clearWorkbenchScanReject(Number(expectedLot.stockOutLineId)); startTransition(() => { setQrScanError(false); setQrScanSuccess(true); }); setProcessedQrCombinations((prev) => { const newMap = new Map(prev); if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); newMap.get(scannedItemId)!.add(scannedStockInLineId); return newMap; }); if (workbenchMode) { await refreshWorkbenchAfterScanPick(); } return; } // ✅ OPTIMIZATION: Direct Map lookup for stockInLineId match (O(1)) const matchStartTime = performance.now(); let exactMatch: any = null; const stockInLineLots = indexes.byStockInLineId.get(scannedStockInLineId) || []; // Find exact match from stockInLineId index, then verify it's in active lots for (let i = 0; i < stockInLineLots.length; i++) { const lot = stockInLineLots[i]; if ( lot.itemId === scannedItemId && activeSuggestedLots.includes(lot) ) { exactMatch = lot; break; } } const matchTime = performance.now() - matchStartTime; console.log( ` [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${ exactMatch ? "yes" : "no" }`, ); // ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots // This handles the case where Lot A is rejected and user scans Lot B // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) if (!exactMatch) { const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots) || allLotsForItem[0]; if (expectedLot) { const shouldAutoSwitch = !scannedLot || scannedLot.stockInLineId !== expectedLot.stockInLineId; if (shouldAutoSwitch) { console.log( `⚠️ [QR PROCESS] Auto-switching (scanned lot ${ scannedLot?.lotNo || "not in data" } is not in active suggested lots)`, ); let scannedLotNo: string | null = scannedLot?.lotNo || null; if (!scannedLotNo) { try { const info = await fetchStockInLineInfoCached(scannedStockInLineId); scannedLotNo = info?.lotNo || null; } catch (e) { console.warn( "Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e, ); } } if (!scannedLotNo) { startTransition(() => { setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg( tPick( "Cannot resolve lot number from QR. Please rescan or use manual confirmation.", ), ); }); return; } if (!workbenchMode) { const substitutionResult = await confirmLotSubstitution({ pickOrderLineId: expectedLot.pickOrderLineId, stockOutLineId: expectedLot.stockOutLineId, originalSuggestedPickLotId: expectedLot.suggestedPickLotId, newInventoryLotNo: "", newStockInLineId: scannedStockInLineId, }); const substitutionCode = (substitutionResult as any)?.code; const switchedToUnavailable = substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE"; if ( !substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable) ) { const errMsg = substitutionResult?.message || tPick("Lot switch failed; pick line was not updated."); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(errMsg); }); if (expectedLot.stockOutLineId != null) { rememberWorkbenchScanReject( Number(expectedLot.stockOutLineId), errMsg, ); } return; } } const res = await workbenchScanPick({ stockOutLineId: expectedLot.stockOutLineId, lotNo: scannedLotNo, ...(typeof scannedStockInLineId === "number" && Number.isFinite(scannedStockInLineId) && scannedStockInLineId > 0 ? { stockInLineId: scannedStockInLineId } : {}), ...workbenchScanPickQtyFromLot(expectedLot), storeId: workbenchStoreId, userId: currentUserId ?? 1, }); const ok = res.code === "SUCCESS"; if (!ok) { const failMsg = (res as { message?: string })?.message || tPick("Workbench scan-pick failed."); if ( shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && expectedLot ) { openWorkbenchLotLabelModalForLot(expectedLot, failMsg); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { rememberWorkbenchScanReject( Number(expectedLot.stockOutLineId), failMsg, ); } startTransition(() => { setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(failMsg); }); return; } clearWorkbenchScanReject(Number(expectedLot.stockOutLineId)); startTransition(() => { setQrScanError(false); setQrScanSuccess(true); }); setProcessedQrCombinations((prev) => { const newMap = new Map(prev); if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); newMap.get(scannedItemId)!.add(scannedStockInLineId); return newMap; }); if (workbenchMode) { await refreshWorkbenchAfterScanPick(); } return; } } } if (exactMatch) { // ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认 console.log( `✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`, ); if (!exactMatch.stockOutLineId) { console.warn( "No stockOutLineId on exactMatch, cannot update status by QR.", ); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); }); return; } try { const apiStartTime = performance.now(); console.log( workbenchMode ? ` [API CALL START] workbenchScanPick` : ` [API CALL START] Calling updateStockOutLineStatusByQRCodeAndLotNo`, ); console.log( ` [API CALL] API start time: ${new Date().toISOString()}`, ); const res = await workbenchScanPick({ stockOutLineId: exactMatch.stockOutLineId, lotNo: exactMatch.lotNo, ...(typeof scannedStockInLineId === "number" && Number.isFinite(scannedStockInLineId) && scannedStockInLineId > 0 ? { stockInLineId: scannedStockInLineId } : typeof exactMatch.stockInLineId === "number" && Number.isFinite(exactMatch.stockInLineId) && exactMatch.stockInLineId > 0 ? { stockInLineId: exactMatch.stockInLineId } : {}), ...workbenchScanPickQtyFromLot(exactMatch), storeId: workbenchStoreId, userId: currentUserId ?? 1, }); const apiTime = performance.now() - apiStartTime; console.log( ` [API CALL END] Total API time: ${apiTime.toFixed(2)}ms (${( apiTime / 1000 ).toFixed(3)}s)`, ); console.log( ` [API CALL] API end time: ${new Date().toISOString()}`, ); const ok = res.code === "SUCCESS"; if (ok) { clearWorkbenchScanReject(Number(exactMatch.stockOutLineId)); // ✅ Batch state updates using startTransition const stateUpdateStartTime = performance.now(); startTransition(() => { setQrScanError(false); setQrScanSuccess(true); }); const stateUpdateTime = performance.now() - stateUpdateStartTime; console.log( ` [PERF] State update time: ${stateUpdateTime.toFixed(2)}ms`, ); // Mark this combination as processed const markProcessedStartTime = performance.now(); setProcessedQrCombinations((prev) => { const newMap = new Map(prev); if (!newMap.has(scannedItemId)) { newMap.set(scannedItemId, new Set()); } newMap.get(scannedItemId)!.add(scannedStockInLineId); return newMap; }); const markProcessedTime = performance.now() - markProcessedStartTime; console.log( ` [PERF] Mark processed time: ${markProcessedTime.toFixed( 2, )}ms`, ); if (workbenchMode) { await refreshWorkbenchAfterScanPick(); } const totalTime = performance.now() - totalStartTime; console.log( `✅ [PROCESS OUTSIDE QR END] Total time: ${totalTime.toFixed( 2, )}ms (${(totalTime / 1000).toFixed(3)}s)`, ); console.log(` End time: ${new Date().toISOString()}`); console.log( `📊 Breakdown: parse=${parseTime.toFixed( 2, )}ms, validation=${validationTime.toFixed( 2, )}ms, duplicateCheck=${duplicateCheckTime.toFixed( 2, )}ms, lookup=${lookupTime.toFixed( 2, )}ms, match=${matchTime.toFixed(2)}ms, api=${apiTime.toFixed( 2, )}ms, stateUpdate=${stateUpdateTime.toFixed( 2, )}ms, markProcessed=${markProcessedTime.toFixed(2)}ms`, ); console.log( workbenchMode ? "✅ Workbench scan-pick: list refreshed from server" : "✅ Status updated locally, no full data refresh needed", ); } else { console.warn("Unexpected response code from backend:", res.code); const failMsg = (res as { message?: string })?.message || tPick("Workbench scan-pick failed."); if ( shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && exactMatch ) { openWorkbenchLotLabelModalForLot(exactMatch, failMsg); return; } if (workbenchMode && exactMatch.stockOutLineId != null) { rememberWorkbenchScanReject( Number(exactMatch.stockOutLineId), failMsg, ); } startTransition(() => { setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(failMsg); }); } } catch (e) { const totalTime = performance.now() - totalStartTime; console.error( `❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed( 2, )}ms`, ); console.error( "Error calling updateStockOutLineStatusByQRCodeAndLotNo:", e, ); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); }); } return; // ✅ 直接返回,不需要确认表单 } // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配 // Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描 const mismatchCheckStartTime = performance.now(); const itemProcessedSet2 = processedQrCombinations.get(scannedItemId); if (itemProcessedSet2?.has(scannedStockInLineId)) { const mismatchCheckTime = performance.now() - mismatchCheckStartTime; console.log( ` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed( 2, )}ms)`, ); return; } const mismatchCheckTime = performance.now() - mismatchCheckStartTime; console.log( ` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`, ); const expectedLotStartTime = performance.now(); const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); if (!expectedLot) { console.error("Could not determine expected lot for auto-switch"); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); }); return; } const expectedLotTime = performance.now() - expectedLotStartTime; console.log( ` [PERF] Get expected lot time: ${expectedLotTime.toFixed(2)}ms`, ); console.log( `⚠️ Lot mismatch (auto): Expected stockInLineId=${expectedLot.stockInLineId}, Scanned stockInLineId=${scannedStockInLineId}`, ); // 1) 先把掃到的 stockInLineId 轉成 lotNo(workbenchScanPick 需要 lotNo) let scannedLotNo: string | null = null; try { const info = await fetchStockInLineInfoCached(scannedStockInLineId); scannedLotNo = info?.lotNo || null; } catch (e) { console.warn( "Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e, ); } if (!scannedLotNo) { const msg = tPick( "Cannot resolve lot number from QR. Please rescan or use manual confirmation.", ); setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(msg); return; } // 2) 非 workbench:先 confirmLotSubstitution;workbench 僅依 scan-pick 規則與錯誤訊息 if (!workbenchMode) { const substitutionResult = await confirmLotSubstitution({ pickOrderLineId: expectedLot.pickOrderLineId, stockOutLineId: expectedLot.stockOutLineId, originalSuggestedPickLotId: expectedLot.suggestedPickLotId, newInventoryLotNo: "", newStockInLineId: scannedStockInLineId, }); const substitutionCode = (substitutionResult as any)?.code; const switchedToUnavailable = substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE"; if ( !substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable) ) { const errMsg = substitutionResult?.message || tPick("Lot switch failed; pick line was not updated."); setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(errMsg); if (expectedLot.stockOutLineId != null) { rememberWorkbenchScanReject( Number(expectedLot.stockOutLineId), errMsg, ); } return; } } // 3) 提交掃描(workbench:直接 workbenchScanPick) try { const res = await workbenchScanPick({ stockOutLineId: expectedLot.stockOutLineId, lotNo: scannedLotNo, ...(typeof scannedStockInLineId === "number" && Number.isFinite(scannedStockInLineId) && scannedStockInLineId > 0 ? { stockInLineId: scannedStockInLineId } : {}), ...workbenchScanPickQtyFromLot(expectedLot), storeId: workbenchStoreId, userId: currentUserId ?? 1, }); const ok = res.code === "SUCCESS"; if (!ok) { const failMsg = (res as { message?: string })?.message || tPick("Workbench scan-pick failed."); if ( shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && expectedLot ) { openWorkbenchLotLabelModalForLot(expectedLot, failMsg); return; } if (workbenchMode && expectedLot.stockOutLineId != null) { rememberWorkbenchScanReject( Number(expectedLot.stockOutLineId), failMsg, ); } setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg(failMsg); return; } clearWorkbenchScanReject(Number(expectedLot.stockOutLineId)); startTransition(() => { setQrScanError(false); setQrScanSuccess(true); }); setProcessedQrCombinations((prev) => { const newMap = new Map(prev); if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); newMap.get(scannedItemId)!.add(scannedStockInLineId); return newMap; }); if (workbenchMode) { await refreshWorkbenchAfterScanPick(); } } catch (e) { console.error("Auto-switch scanPick failed:", e); setQrScanError(true); setQrScanSuccess(false); return; } const totalTime = performance.now() - totalStartTime; console.log( `✅ [PROCESS OUTSIDE QR AUTO-SWITCH] Total time: ${totalTime.toFixed( 2, )}ms (${(totalTime / 1000).toFixed(3)}s)`, ); console.log(` End time: ${new Date().toISOString()}`); console.log( `📊 Breakdown: parse=${parseTime.toFixed( 2, )}ms, validation=${validationTime.toFixed( 2, )}ms, duplicateCheck=${duplicateCheckTime.toFixed( 2, )}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed( 2, )}ms, mismatchCheck=${mismatchCheckTime.toFixed( 2, )}ms, expectedLot=${expectedLotTime.toFixed(2)}ms`, ); } catch (error) { const totalTime = performance.now() - totalStartTime; console.error( `❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`, ); console.error("Error during QR code processing:", error); startTransition(() => { setQrScanError(true); setQrScanSuccess(false); }); return; } }, [ lotDataIndexes, processedQrCombinations, combinedLotData, fetchStockInLineInfoCached, workbenchMode, currentUserId, clearWorkbenchScanReject, rememberWorkbenchScanReject, refreshWorkbenchAfterScanPick, workbenchScanPickQtyFromLot, tPick, workbenchStoreId, confirmLotSubstitution, openWorkbenchLotLabelModalForLot, shouldOpenWorkbenchLotLabelModalForFailure, ], ); // Store in refs for immediate access in qrValues effect processOutsideQrCodeRef.current = processOutsideQrCode; resetScanRef.current = resetScan; const handleManualInputSubmit = useCallback(() => { if (qrScanInput.trim() !== "") { handleQrCodeSubmit(qrScanInput.trim()); } }, [qrScanInput, handleQrCodeSubmit]); // Handle QR code submission from modal (internal scanning) const handleQrCodeSubmitFromModal = useCallback( async (lotNo: string) => { if (selectedLotForQr && selectedLotForQr.lotNo === lotNo) { console.log(` QR Code verified for lot: ${lotNo}`); const requiredQty = selectedLotForQr.requiredQty; const lotId = selectedLotForQr.lotId; // Create stock out line const stockOutLineData: CreateStockOutLine = { consoCode: selectedLotForQr.pickOrderConsoCode, pickOrderLineId: selectedLotForQr.pickOrderLineId, inventoryLotLineId: selectedLotForQr.lotId, qty: 0.0, }; try { await createStockOutLine(stockOutLineData); console.log("Stock out line created successfully!"); // Close modal setQrModalOpen(false); setSelectedLotForQr(null); // Set pick quantity const lotKey = `${selectedLotForQr.pickOrderLineId}-${lotId}`; setTimeout(() => { setPickQtyData((prev) => ({ ...prev, [lotKey]: requiredQty, })); console.log( ` Auto-set pick quantity to ${requiredQty} for lot ${lotNo}`, ); }, 500); // Refresh data const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; await fetchJobOrderData(pickOrderId); } catch (error) { console.error("Error creating stock out line:", error); } } }, [selectedLotForQr, fetchJobOrderData], ); useEffect(() => { // Skip if scanner not active or no data or currently refreshing if ( !isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData ) return; const latestQr = qrValues[qrValues.length - 1]; // ✅ Test shortcut: {2fitestx,y} or {2fittestx,y} where x=itemId, y=stockInLineId if ( (latestQr.startsWith("{2fitest") || latestQr.startsWith("{2fittest")) && latestQr.endsWith("}") ) { let content = ""; if (latestQr.startsWith("{2fittest")) content = latestQr.substring(9, latestQr.length - 1); else content = latestQr.substring(8, latestQr.length - 1); const parts = content.split(","); if (parts.length === 2) { const itemId = parseInt(parts[0].trim(), 10); const stockInLineId = parseInt(parts[1].trim(), 10); if (!isNaN(itemId) && !isNaN(stockInLineId)) { const simulatedQr = JSON.stringify({ itemId, stockInLineId }); lastProcessedQrRef.current = latestQr; processedQrCodesRef.current.add(latestQr); setLastProcessedQr(latestQr); setProcessedQrCodes(new Set(processedQrCodesRef.current)); processOutsideQrCodeRef.current?.(simulatedQr); resetScanRef.current?.(); return; } } } // ✅ Shortcut: {2fic} open manual lot confirmation modal if (latestQr === "{2fic}") { setManualLotConfirmationOpen(true); resetScanRef.current?.(); lastProcessedQrRef.current = latestQr; processedQrCodesRef.current.add(latestQr); setLastProcessedQr(latestQr); setProcessedQrCodes(new Set(processedQrCodesRef.current)); return; } // Skip processing if manual modal open for same QR if (manualLotConfirmationOpen) { if (latestQr === lastProcessedQrRef.current) return; } // Skip if already processed (refs) if ( processedQrCodesRef.current.has(latestQr) || lastProcessedQrRef.current === latestQr ) return; // Mark processed immediately lastProcessedQrRef.current = latestQr; processedQrCodesRef.current.add(latestQr); if (processedQrCodesRef.current.size > 100) { const firstValue = processedQrCodesRef.current.values().next().value; if (firstValue !== undefined) processedQrCodesRef.current.delete(firstValue); } // Process immediately if (qrProcessingTimeoutRef.current) { clearTimeout(qrProcessingTimeoutRef.current); qrProcessingTimeoutRef.current = null; } processOutsideQrCodeRef.current?.(latestQr); // UI state updates (non-blocking) startTransition(() => { setLastProcessedQr(latestQr); setProcessedQrCodes(new Set(processedQrCodesRef.current)); }); return () => { if (qrProcessingTimeoutRef.current) { clearTimeout(qrProcessingTimeoutRef.current); qrProcessingTimeoutRef.current = null; } }; }, [ qrValues.length, isManualScanning, isRefreshingData, combinedLotData.length, manualLotConfirmationOpen, isConfirmingLot, ]); const handlePickQtyChange = useCallback( (lotKey: string, value: number | string) => { if (value === "" || value === null || value === undefined) { setPickQtyData((prev) => ({ ...prev, [lotKey]: 0, })); return; } const numericValue = typeof value === "string" ? parseFloat(value) : value; if (isNaN(numericValue)) { setPickQtyData((prev) => ({ ...prev, [lotKey]: 0, })); return; } setPickQtyData((prev) => ({ ...prev, [lotKey]: numericValue, })); }, [], ); const checkAndAutoAssignNext = useCallback(async () => { if (!currentUserId) return; try { const completionResponse = await checkPickOrderCompletion(currentUserId); if ( completionResponse.code === "SUCCESS" && completionResponse.entity?.hasCompletedOrders ) { console.log("Found completed pick orders, auto-assigning next..."); // 移除前端的自动分配逻辑,因为后端已经处理了 // await handleAutoAssignAndRelease(); // 删除这个函数 } } catch (error) { console.error("Error checking pick order completion:", error); } }, [currentUserId]); const handleSubmitPickQtyWithQty = useCallback( async ( lot: any, submitQty: number, source: "justComplete" | "singleSubmit", ) => { if (!lot.stockOutLineId) { console.error("No stock out line found for this lot"); return; } const solId = Number(lot.stockOutLineId); if (solId > 0 && actionBusyBySolId[solId]) { console.warn("Action already in progress for stockOutLineId:", solId); return; } const pickOrderIdForRefresh = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; try { if (solId > 0) setActionBusyBySolId((prev) => ({ ...prev, [solId]: true })); const targetUnavailable = isInventoryLotLineUnavailable(lot); const effectiveSubmitQty = targetUnavailable && submitQty > 0 ? 0 : submitQty; const canonicalLotForSol = solId > 0 ? combinedLotData.find((r) => Number(r.stockOutLineId) === solId) ?? lot : lot; if (workbenchMode && source === "justComplete") { const solIdForOverride = Number(canonicalLotForSol.stockOutLineId) || 0; const lotIdForOverride = canonicalLotForSol.lotId; const lotKeyForOverride = Number.isFinite(solIdForOverride) && solIdForOverride > 0 ? `sol:${solIdForOverride}` : `${canonicalLotForSol.pickOrderLineId}-${lotIdForOverride}`; const hasExplicitSubmitOverride = Object.prototype.hasOwnProperty.call( pickQtyData, lotKeyForOverride, ); const explicitSubmitOverride = hasExplicitSubmitOverride ? Number(pickQtyData[lotKeyForOverride]) : NaN; const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol); const wbJustQty = qtyPayload.qty; const isUnavailableForJustComplete = isInventoryLotLineUnavailable(canonicalLotForSol); const isNoLotForJustComplete = canonicalLotForSol.noLot === true || !String(canonicalLotForSol.lotNo ?? "").trim(); const canPostScanPick = // unavailable lot: Just Completed must always submit qty=0, even without lotNo isUnavailableForJustComplete || isLotAvailabilityExpired(canonicalLotForSol) || // noLot row: Just Completed always submit qty=0 isNoLotForJustComplete || (canonicalLotForSol.lotNo && String(canonicalLotForSol.lotNo).trim() !== "" && ((hasExplicitSubmitOverride && Number.isFinite(explicitSubmitOverride) && explicitSubmitOverride === 0) || (wbJustQty != null && wbJustQty > 0))); if (canPostScanPick) { const qtyToSend = isUnavailableForJustComplete ? 0 : isLotAvailabilityExpired(canonicalLotForSol) ? 0 : isNoLotForJustComplete ? 0 : hasExplicitSubmitOverride && explicitSubmitOverride === 0 ? 0 : Number(wbJustQty); const res = await workbenchScanPick({ stockOutLineId: Number(canonicalLotForSol.stockOutLineId), lotNo: String(canonicalLotForSol.lotNo).trim(), ...(typeof canonicalLotForSol.stockInLineId === "number" && Number.isFinite(canonicalLotForSol.stockInLineId) && canonicalLotForSol.stockInLineId > 0 ? { stockInLineId: canonicalLotForSol.stockInLineId } : {}), qty: qtyToSend, storeId: workbenchStoreId, userId: currentUserId ?? 1, }); const scanOk = res.code === "SUCCESS"; if (!scanOk) { rememberWorkbenchScanReject( Number(canonicalLotForSol.stockOutLineId), (res as { message?: string })?.message, ); throw new Error( (res as { message?: string })?.message || "Workbench scan-pick failed", ); } clearWorkbenchScanReject(Number(canonicalLotForSol.stockOutLineId)); setPickQtyData((prev) => { if (!Object.prototype.hasOwnProperty.call(prev, lotKeyForOverride)) return prev; const next = { ...prev }; delete next[lotKeyForOverride]; return next; }); await refreshWorkbenchAfterScanPick(); setTimeout(() => { checkAndAutoAssignNext(); }, 1000); console.log( "Just Completed (workbench): workbenchScanPick posted without QR.", ); return; } const justCompleteErr = t( "Just Completed (workbench): requires valid quantity; expired rows must not use this button.", ); if (solId > 0) { rememberWorkbenchScanReject(solId, justCompleteErr); } setQrScanErrorMsg(justCompleteErr); throw new Error(justCompleteErr); } if (effectiveSubmitQty === 0 && source === "singleSubmit") { console.log(`=== SUBMITTING ALL ZEROS CASE ===`); console.log(`Lot: ${lot.lotNo}`); console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); if (workbenchMode) { const res = await workbenchScanPick({ stockOutLineId: Number(lot.stockOutLineId), lotNo: String(lot.lotNo ?? "").trim(), ...(typeof lot.stockInLineId === "number" && Number.isFinite(lot.stockInLineId) && lot.stockInLineId > 0 ? { stockInLineId: lot.stockInLineId } : {}), qty: 0, storeId: workbenchStoreId, userId: currentUserId ?? 1, }); const scanOk = res.code === "SUCCESS"; if (!scanOk) { rememberWorkbenchScanReject( Number(lot.stockOutLineId), (res as { message?: string })?.message, ); throw new Error( (res as { message?: string })?.message || "Workbench scan-pick failed (qty=0)", ); } clearWorkbenchScanReject(Number(lot.stockOutLineId)); await refreshWorkbenchAfterScanPick(); setTimeout(() => { checkAndAutoAssignNext(); }, 1000); return; } throw new Error("Unsupported legacy checked flow on workbench page"); } if ( workbenchMode && effectiveSubmitQty > 0 && lot.lotNo && String(lot.lotNo).trim() !== "" && !isLotAvailabilityExpired(lot) && !isInventoryLotLineUnavailable(lot) ) { const res = await workbenchScanPick({ stockOutLineId: Number(lot.stockOutLineId), lotNo: String(lot.lotNo).trim(), ...(typeof lot.stockInLineId === "number" && Number.isFinite(lot.stockInLineId) && lot.stockInLineId > 0 ? { stockInLineId: lot.stockInLineId } : {}), qty: Number(effectiveSubmitQty), storeId: workbenchStoreId, userId: currentUserId ?? 1, }); const scanOk = res.code === "SUCCESS"; if (!scanOk) { rememberWorkbenchScanReject( Number(lot.stockOutLineId), (res as { message?: string })?.message, ); throw new Error( (res as { message?: string })?.message || "Workbench scan-pick failed", ); } clearWorkbenchScanReject(Number(lot.stockOutLineId)); const successLotKey = getWorkbenchQtyLotKey(lot); setPickQtyData((prev) => { if (!Object.prototype.hasOwnProperty.call(prev, successLotKey)) return prev; const next = { ...prev }; delete next[successLotKey]; return next; }); await refreshWorkbenchAfterScanPick(); setTimeout(() => { checkAndAutoAssignNext(); }, 1000); return; } const currentActualPickQty = lot.actualPickQty || 0; const cumulativeQty = currentActualPickQty + effectiveSubmitQty; let newStatus = "partially_completed"; if (cumulativeQty >= lot.requiredQty) { newStatus = "completed"; } else if (cumulativeQty > 0) { newStatus = "partially_completed"; } else { newStatus = "pending"; } console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`); console.log(`Lot: ${lot.lotNo}`); console.log(`Required Qty: ${lot.requiredQty}`); console.log(`Current Actual Pick Qty: ${currentActualPickQty}`); console.log(`New Submitted Qty: ${effectiveSubmitQty}`); console.log(`Cumulative Qty: ${cumulativeQty}`); console.log(`New Status: ${newStatus}`); console.log(`=====================================`); if (!workbenchMode) { await updateStockOutLineStatus({ id: lot.stockOutLineId, status: newStatus, qty: effectiveSubmitQty, }); applyLocalStockOutLineUpdate( Number(lot.stockOutLineId), newStatus, cumulativeQty, ); } // Workbench completion is handled in backend scan-pick flow. void fetchJobOrderData(pickOrderIdForRefresh); console.log("Pick quantity submitted successfully!"); setTimeout(() => { checkAndAutoAssignNext(); }, 1000); } catch (error) { console.error("Error submitting pick quantity:", error); setQrScanError(true); setQrScanSuccess(false); } finally { if (solId > 0) setActionBusyBySolId((prev) => ({ ...prev, [solId]: false })); } }, [ fetchJobOrderData, checkAndAutoAssignNext, actionBusyBySolId, applyLocalStockOutLineUpdate, workbenchMode, currentUserId, rememberWorkbenchScanReject, clearWorkbenchScanReject, refreshWorkbenchAfterScanPick, combinedLotData, workbenchScanPickQtyFromLot, pickQtyData, tPick, workbenchStoreId, filterArgs?.pickOrderId, ], ); const handleSkip = useCallback( async (lot: any) => { try { console.log( "Just Complete clicked (workbench: scan-pick without QR when possible):", lot.lotNo, ); await handleSubmitPickQtyWithQty(lot, 0, "justComplete"); } catch (err) { console.error("Error in Skip:", err); } }, [handleSubmitPickQtyWithQty], ); const hasPendingBatchSubmit = useMemo(() => { return combinedLotData.some((lot) => { const status = String(lot.stockOutLineStatus || "").toLowerCase(); return ( status === "pending" || status === "partially_completed" || status === "partially_complete" ); }); }, [combinedLotData]); useEffect(() => { if (!hasPendingBatchSubmit) return; const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [hasPendingBatchSubmit]); const handleSubmitAllScanned = useCallback(async () => { const startTime = performance.now(); console.log(` [BATCH SUBMIT START]`); console.log(` Start time: ${new Date().toISOString()}`); const scannedLots = combinedLotData.filter((lot) => { const status = lot.stockOutLineStatus; const statusLower = String(status || "").toLowerCase(); if (statusLower === "completed" || statusLower === "complete") { return false; } if ( lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot) ) { return true; } return false; }); if (scannedLots.length === 0) { console.log("No scanned items to submit"); return; } setIsSubmittingAll(true); console.log( `📦 Submitting ${scannedLots.length} items using workbench batch scan-pick (qty=0)...`, ); try { const submitStartTime = performance.now(); const result = await workbenchBatchScanPick({ lines: scannedLots.map((lot) => ({ stockOutLineId: Number(lot.stockOutLineId) || 0, lotNo: "", qty: 0, storeId: workbenchStoreId, userId: currentUserId ?? 1, })), }); const submitTime = performance.now() - submitStartTime; console.log( ` Batch submit API call completed in ${submitTime.toFixed(2)}ms (${( submitTime / 1000 ).toFixed(3)}s)`, ); console.log(`📥 Batch submit result:`, result); const refreshStartTime = performance.now(); const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; await fetchJobOrderData(pickOrderId); const refreshTime = performance.now() - refreshStartTime; console.log( ` Data refresh time: ${refreshTime.toFixed(2)}ms (${( refreshTime / 1000 ).toFixed(3)}s)`, ); const totalTime = performance.now() - startTime; console.log(` [BATCH SUBMIT END]`); console.log( ` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed( 3, )}s)`, ); console.log(` End time: ${new Date().toISOString()}`); if (result && result.code === "SUCCESS") { setQrScanSuccess(true); setTimeout(() => { setQrScanSuccess(false); checkAndAutoAssignNext(); if (onBackToList) { onBackToList(); } }, 2000); } else { console.error("Batch submit failed:", result); setQrScanError(true); } } catch (error) { console.error("Error submitting all scanned items:", error); setQrScanError(true); } finally { setIsSubmittingAll(false); } }, [ combinedLotData, fetchJobOrderData, checkAndAutoAssignNext, currentUserId, onBackToList, workbenchStoreId, filterArgs?.pickOrderId, ]); const scannedItemsCount = useMemo(() => { const filtered = combinedLotData.filter((lot) => { const status = lot.stockOutLineStatus; const statusLower = String(status || "").toLowerCase(); if (statusLower === "completed" || statusLower === "complete") { return false; } if ( lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot) ) { return true; } return false; }); const noLotCount = filtered.filter((l) => l.noLot === true).length; const normalCount = filtered.filter((l) => l.noLot !== true).length; console.log( `📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`, ); console.log(`📊 All items breakdown:`, { total: combinedLotData.length, noLot: combinedLotData.filter((l) => l.noLot === true).length, normal: combinedLotData.filter((l) => l.noLot !== true).length, }); return filtered.length; }, [combinedLotData]); // 先定义 filteredByFloor 和 availableFloors const availableFloors = useMemo(() => { const floors = new Set(); combinedLotData.forEach((lot) => { const f = extractFloor(lot); if (f) floors.add(f); }); return Array.from(floors).sort( (a, b) => floorSortOrder(b) - floorSortOrder(a), ); }, [combinedLotData]); const filteredByFloor = useMemo(() => { if (!selectedFloor) return combinedLotData; return combinedLotData.filter((lot) => extractFloor(lot) === selectedFloor); }, [combinedLotData, selectedFloor]); // 與批量篩選一致:noLot / 過期 的 pending 也算已處理(對齊 GoodPickExecutiondetail) const progress = useMemo(() => { const data = selectedFloor ? filteredByFloor : combinedLotData; if (data.length === 0) return { completed: 0, total: 0 }; const nonPendingCount = data.filter((lot) => { const status = lot.stockOutLineStatus?.toLowerCase(); if (status !== "pending") return true; if ( lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot) ) return true; return false; }).length; return { completed: nonPendingCount, total: data.length }; }, [selectedFloor, filteredByFloor, combinedLotData]); // Handle reject lot const handleRejectLot = useCallback( async (lot: any) => { if (!lot.stockOutLineId) { console.error("No stock out line found for this lot"); return; } try { await updateStockOutLineStatus({ id: lot.stockOutLineId, status: "rejected", qty: 0, }); const pickOrderId = filterArgs?.pickOrderId ? Number(filterArgs.pickOrderId) : undefined; await fetchJobOrderData(pickOrderId); console.log("Lot rejected successfully!"); setTimeout(() => { checkAndAutoAssignNext(); }, 1000); } catch (error) { console.error("Error rejecting lot:", error); } }, [fetchJobOrderData, checkAndAutoAssignNext], ); // Handle pick execution form const handlePickExecutionForm = useCallback((lot: any) => { console.log("=== Pick Execution Form ==="); console.log("Lot data:", lot); if (!lot) { console.warn("No lot data provided for pick execution form"); return; } console.log("Opening pick execution form for lot:", lot.lotNo); setSelectedLotForExecutionForm(lot); setPickExecutionFormOpen(true); console.log("Pick execution form opened for lot ID:", lot.lotId); }, []); // Calculate remaining required quantity const calculateRemainingRequiredQty = useCallback((lot: any) => { const requiredQty = lot.requiredQty || 0; const stockOutLineQty = lot.stockOutLineQty || 0; return Math.max(0, requiredQty - stockOutLineQty); }, []); const handlePageChange = useCallback((event: unknown, newPage: number) => { setPaginationController((prev) => ({ ...prev, pageNum: newPage, })); }, []); const handlePageSizeChange = useCallback( (event: React.ChangeEvent) => { const newPageSize = parseInt(event.target.value, 10); setPaginationController({ pageNum: 0, pageSize: newPageSize, }); }, [], ); // Pagination data: align DO workbench grouping display const paginatedData = useMemo(() => { const sourceData = selectedFloor ? filteredByFloor : combinedLotData; /** 同 pick_order_line 內「無庫位 / no-lot」列 extractFloor 為空,若仍用 0 會被排到全表最後,編號欄也變成新群組。繼承該行最大樓層權重並以 polId 相鄰排序。 */ const lineMaxFloorOrder = new Map(); for (const lot of sourceData) { const polId = Number(lot.pickOrderLineId); if (!Number.isFinite(polId) || polId <= 0) continue; const o = floorSortOrder(extractFloor(lot)); const prev = lineMaxFloorOrder.get(polId) ?? 0; if (o > prev) lineMaxFloorOrder.set(polId, o); } const effectiveFloorOrder = (lot: any): number => { const own = floorSortOrder(extractFloor(lot)); if (own > 0) return own; const polId = Number(lot.pickOrderLineId); if (Number.isFinite(polId) && polId > 0) { const inherited = lineMaxFloorOrder.get(polId); if (inherited != null && inherited > 0) return inherited; } return 0; }; const isNoLotTailRow = (lot: any) => lot.noLot === true || lot.lotId == null || lot.lotId === undefined; const sortedData = [...sourceData].sort((a, b) => { const efA = effectiveFloorOrder(a); const efB = effectiveFloorOrder(b); if (efA !== efB) return efB - efA; // 4F, 3F, 2F(含繼承樓層的缺口列) const polA = Number(a.pickOrderLineId); const polB = Number(b.pickOrderLineId); const hasPolA = Number.isFinite(polA) && polA > 0; const hasPolB = Number.isFinite(polB) && polB > 0; if (hasPolA && hasPolB && polA !== polB) return polA - polB; const aItem = String(a.itemCode || ""); const bItem = String(b.itemCode || ""); if (aItem !== bItem) return aItem.localeCompare(bItem); const aName = String(a.itemName || ""); const bName = String(b.itemName || ""); if (aName !== bName) return aName.localeCompare(bName); const tailA = isNoLotTailRow(a) ? 1 : 0; const tailB = isNoLotTailRow(b) ? 1 : 0; if (tailA !== tailB) return tailA - tailB; const aIndex = Number(a.routerIndex ?? 0); const bIndex = Number(b.routerIndex ?? 0); if (aIndex !== bIndex) return aIndex - bIndex; return (a.lotNo || "").localeCompare(b.lotNo || ""); }); const flattened = sortedData.map((lot, idx, arr) => { const key = `${lot.itemId ?? "null"}-${lot.itemCode ?? ""}`; const prev = idx > 0 ? arr[idx - 1] : undefined; const prevKey = prev ? `${prev.itemId ?? "null"}-${prev.itemCode ?? ""}` : null; const isGroupFirst = key !== prevKey; const groupDisplayIndex = arr .slice(0, idx + 1) .filter((row, rowIdx, all) => { const rowKey = `${row.itemId ?? "null"}-${row.itemCode ?? ""}`; const before = rowIdx > 0 ? all[rowIdx - 1] : undefined; const beforeKey = before ? `${before.itemId ?? "null"}-${before.itemCode ?? ""}` : null; return rowKey !== beforeKey; }).length; return { lot, isGroupFirst, groupDisplayIndex }; }); const startIndex = paginationController.pageNum * paginationController.pageSize; const endIndex = startIndex + paginationController.pageSize; return flattened.slice(startIndex, endIndex); }, [selectedFloor, filteredByFloor, combinedLotData, paginationController]); // Add these functions for manual scanning const handleStartScan = useCallback(() => { console.log(" Starting manual QR scan..."); setIsManualScanning(true); setProcessedQrCodes(new Set()); setLastProcessedQr(""); setQrScanError(false); setQrScanSuccess(false); startScan(); }, [startScan]); const handleStopScan = useCallback(() => { console.log(" Stopping manual QR scan..."); setIsManualScanning(false); setQrScanError(false); setQrScanSuccess(false); stopScan(); resetScan(); }, [stopScan, resetScan]); useEffect(() => { return () => { // Cleanup when component unmounts (e.g., when switching tabs) if (isManualScanning) { console.log("🧹 Component unmounting, stopping QR scanner..."); stopScan(); resetScan(); } }; }, [isManualScanning, stopScan, resetScan]); // 勿在 combinedLotData 仍為空時自動停掃:API 未回傳前會誤觸,與 GoodPickExecutiondetail(已註解掉同段)一致。 // 無資料時 qrValues effect 本來就不會處理掃碼;真正無單據可再手動按停止。 const getStatusMessage = useCallback( (lot: any) => { if ( lot?.noLot === true || lot?.lotAvailability === "insufficient_stock" ) { return t("This order is insufficient, please pick another lot."); } switch (lot.stockOutLineStatus?.toLowerCase()) { case "pending": return t("Please finish QR code scan and pick order."); case "checked": return t("Please submit the pick order."); case "partially_completed": return t( "Partial quantity submitted. Please submit more or complete the order.", ); case "completed": return t("Pick order completed successfully!"); case "rejected": return t("Lot has been rejected and marked as unavailable."); case "unavailable": return t("This order is insufficient, please pick another lot."); default: return t("Please finish QR code scan and pick order."); } }, [t], ); return ( lot.lotAvailability !== "rejected" && lot.stockOutLineStatus !== "rejected" && lot.stockOutLineStatus !== "completed" } > {/* Progress bar + scan status fixed at top */} {availableFloors.map((floor) => ( ))} {t("Select Printer")}: option.name || option.label || option.code || `Printer ${option.id}` } value={selectedPrinter} onChange={(_, newValue) => setSelectedPrinter(newValue)} sx={{ minWidth: 220 }} size="small" renderInput={(params) => ( )} /> {t("Print Quantity")}: { const value = parseInt(e.target.value) || 1; setPrintQty(Math.max(1, value)); }} inputProps={{ min: 1, step: 1 }} sx={{ width: 120 }} size="small" /> {isPrinterComboMissing && ( {t("Printer list is empty")} )} {/* Job Order Header */} {jobOrderData && ( {t("Item Name")}:{" "} {jobOrderData.pickOrder.jobOrder.itemCode || "-"}{" "}{jobOrderData.pickOrder.jobOrder.itemName || "-"} {t("Job Order")}:{" "} {jobOrderData.pickOrder?.jobOrder?.code || "-"} {t("Pick Order Code")}:{" "} {jobOrderData.pickOrder?.code || "-"} {t("Target Date")}:{" "} {jobOrderData.pickOrder?.targetDate || "-"} )} {/* Combined Lot Table */} {!isManualScanning ? ( ) : ( )} {/* ADD THIS: Submit All Scanned Button */} {t("Index")} {t("Item Code")} {t("Route")} {t("Handler")} {t("Lot No")} {t("Lot Required Pick Qty")} {t("Available Qty")} {t("Scan Result")} {t("Qty will submit")} {t("Submit Required Pick Qty")} {paginatedData.length === 0 ? ( {t("No data available")} ) : ( paginatedData.map((row) => { const lot = row.lot; const solIdForKey = Number(lot.stockOutLineId) || 0; const lotKeyForSubmitQty = Number.isFinite(solIdForKey) && solIdForKey > 0 ? `sol:${solIdForKey}` : `${lot.pickOrderLineId}-${lot.lotId}`; const submitQtyStatus = String( lot.stockOutLineStatus || "", ).toLowerCase(); const isSubmitQtyCompleted = submitQtyStatus === "completed" || submitQtyStatus === "partially_completed" || submitQtyStatus === "partially_complete"; const lockedSubmitQtyDisplay = isInventoryLotLineUnavailable(lot) && !isSubmitQtyCompleted ? 0 : resolveSingleSubmitQty(lot); const hasPickOverride = Object.prototype.hasOwnProperty.call( pickQtyData, lotKeyForSubmitQty, ); const fromPickRow = hasPickOverride ? pickQtyData[lotKeyForSubmitQty] : undefined; const workbenchSubmitQtyDisplay = hasPickOverride && fromPickRow !== undefined && fromPickRow !== null && !Number.isNaN(Number(fromPickRow)) ? Number(fromPickRow) : lockedSubmitQtyDisplay; const totalAvail = Number(lot.itemTotalAvailableQty ?? 0); const isLastLotUnavailable = Number.isFinite(totalAvail) && totalAvail === 0; return ( {row.isGroupFirst ? row.groupDisplayIndex : ""} {row.isGroupFirst ? ( <> {lot.itemCode}
{lot.itemName}
{lot.uomDesc} ) : ""}
{lot.routerRoute || "-"} {lot.handler || "-"} {lot.lotNo ? ( /* */ lot.lotAvailability === "expired" ? ( <> {lot.lotNo}{" "} {t( "is expired. Please check around have available QR code or not.", )} {isLastLotUnavailable && ( {t("This is last lot, so no available lot.")} )} ) : isInventoryLotLineUnavailable(lot) && !( String( lot.stockOutLineStatus || "", ).toLowerCase() === "completed" || String( lot.stockOutLineStatus || "", ).toLowerCase() === "partially_completed" || String( lot.stockOutLineStatus || "", ).toLowerCase() === "partially_complete" ) ? ( <> {lot.lotNo}{" "} {t( "is unavable. Please check around have available QR code or not.", )} ) : ( lot.lotNo ) ) : ( {t( "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.", )} )} {Number(lot.stockOutLineId) > 0 && Number(lot.itemId) > 0 ? ( ) : null} {(() => { const requiredQty = lot.requiredQty || 0; const unit = lot.noLot === true || !lot.lotId ? lot.uomDesc || "" : lot.uomDesc || ""; return `${requiredQty.toLocaleString()}(${unit})`; })()} {(() => { const avail = lot.itemTotalAvailableQty; if (avail == null) return "-"; const unit = lot.uomDesc || ""; return `${Number( avail, ).toLocaleString()}(${unit})`; })()} {(() => { const status = lot.stockOutLineStatus?.toLowerCase(); const isRejected = status === "rejected" || lot.lotAvailability === "rejected"; const isNoLot = !lot.lotNo; if (isRejected && !isNoLot) { return ( ); } if ( isLotAvailabilityExpired(lot) && status !== "rejected" ) { return ( ); } if ( !isNoLot && status !== "pending" && status !== "rejected" ) { return ( ); } if ( isNoLot && (status === "partially_completed" || status === "completed") ) { return ( ); } return null; })()} {workbenchSubmitQtyDisplay} {(() => { const status = lot.stockOutLineStatus?.toLowerCase(); const isRejected = status === "rejected" || lot.lotAvailability === "rejected"; const isNoLot = !lot.lotNo; const isUnavailableLot = isInventoryLotLineUnavailable(lot); if (isRejected && !isNoLot) { const rejectDisplay = buildLotRejectDisplayMessage( lot, scanRejectMessageBySolId, tPick, ); return ( {rejectDisplay ?? t( "This lot is rejected, please scan another lot.", )} ); } const lotKey = lotKeyForSubmitQty; const qtyFieldEnabled = workbenchSubmitQtyFieldEnabledByLotKey[ lotKey ] === true; const displayedSubmitQty = workbenchSubmitQtyDisplay; const hasPickOverrideRow = Object.prototype.hasOwnProperty.call( pickQtyData, lotKey, ); const textFieldValue = qtyFieldEnabled ? hasPickOverrideRow ? String(pickQtyData[lotKey]) : String(displayedSubmitQty) : String(displayedSubmitQty); return ( { if (!qtyFieldEnabled) return; if (e.key !== "{") return; e.preventDefault(); setWorkbenchSubmitQtyFieldEnabledByLotKey( (prev) => ({ ...prev, [lotKey]: false, }), ); ( e.currentTarget as HTMLInputElement ).blur(); }} onChange={(e) => { if (!qtyFieldEnabled) return; handlePickQtyChange( lotKey, e.target.value, ); }} inputProps={{ min: 0, step: 1 }} sx={{ width: 96, "& .MuiInputBase-input": { fontSize: "0.75rem", py: 0.5, textAlign: "center", }, }} /> ); })()}
); }) )}
`${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` } />
{/* QR Code Modal */} { setQrModalOpen(false); setSelectedLotForQr(null); // Keep scanner active like GoodPickExecutiondetail. resetScan(); }} lot={selectedLotForQr} combinedLotData={combinedLotData} onQrCodeSubmit={handleQrCodeSubmitFromModal} /> { setWorkbenchLotLabelModalOpen(false); setWorkbenchLotLabelContextLot(null); setWorkbenchLotLabelInitialPayload(null); setWorkbenchLotLabelReminderText(null); }} initialPayload={workbenchLotLabelInitialPayload} initialItemId={ workbenchLotLabelContextLot != null ? Number(workbenchLotLabelContextLot.itemId) : null } defaultPrinterName={defaultLabelPrinterName} hideScanSection={ workbenchLotLabelInitialPayload != null || workbenchLotLabelContextLot != null } warehouseCodePrefixFilter={lotFloorPrefixFilter} triggerLotAvailableQty={ workbenchLotLabelContextLot != null ? Number(workbenchLotLabelContextLot.availableQty) : null } triggerLotUom={ workbenchLotLabelContextLot != null ? String(workbenchLotLabelContextLot.uomDesc ?? "").trim() || null : null } disableScanPick={workbenchLotLabelScanPickDisabled} onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} submitQty={workbenchLotLabelSubmitQty} onSubmitQtyChange={handleWorkbenchLotLabelSubmitQtyChange} reminderText={workbenchLotLabelReminderText ?? undefined} /> {/* Manual Lot Confirmation Modal (test shortcut {2fic}); JO Workbench 不使用 LotConfirmationModal,直接走 handleLotConfirmation */} setManualLotConfirmationOpen(false)} onConfirm={(currentLotNo, newLotNo) => { setManualLotConfirmationOpen(false); const row = selectedLotForQr; if ( !row?.stockOutLineId || !row?.pickOrderLineId || row.itemId == null ) { alert( t( "Open 挑號 QR 碼 on a pick line first, then scan {2fic} to use manual lot substitution.", ), ); return; } setExpectedLotData({ ...row, lotNo: currentLotNo, }); setScannedLotData({ lotNo: newLotNo, itemCode: String(row.itemCode ?? ""), itemName: String(row.itemName ?? ""), inventoryLotLineId: null, stockInLineId: null, }); window.setTimeout(() => { void handleLotConfirmation(); }, 0); }} expectedLot={expectedLotData} scannedLot={scannedLotData} isLoading={isConfirmingLot} />
); }; export default JobPickExecution;