|
- "use client";
-
- import {
- Box,
- Button,
- Stack,
- TextField,
- Typography,
- Alert,
- CircularProgress,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- Paper,
- Checkbox,
- TablePagination,
- Modal,
- Chip,
- } from "@mui/material";
- import dayjs from 'dayjs';
- import TestQrCodeProvider from "@/components/QrCodeScannerProvider/TestQrCodeProvider";
- import { fetchLotDetail } from "@/app/api/inventory/actions";
- import React, { useCallback, useEffect, useState, useRef, useMemo, startTransition } from "react";
- import { useTranslation } from "react-i18next";
- import { useRouter } from "next/navigation";
- import {
- updateStockOutLineStatus,
- createStockOutLine,
- updateStockOutLine,
- recordPickExecutionIssue,
- fetchFGPickOrders, // Add this import
- FGPickOrderResponse,
- stockReponse,
- PickExecutionIssueData,
- checkPickOrderCompletion,
- fetchAllPickOrderLotsHierarchicalWorkbench,
- PickOrderCompletionResponse,
- updateSuggestedLotLineId,
- updateStockOutLineStatusByQRCodeAndLotNo,
- confirmLotSubstitution,
- fetchDoPickOrderDetail, // 必须添加
- DoPickOrderDetail, // 必须添加
- batchScan,
- BatchScanRequest,
- BatchScanLineRequest,
- } from "@/app/api/pickOrder/actions";
- import { workbenchBatchScanPick, workbenchScanPick } from "@/app/api/doworkbench/actions";
- import { workbenchScanPickResponseNeedsFullRefresh } from "@/app/api/doworkbench/workbenchScanPickUtils";
-
- import FGPickOrderInfoCard from "@/components/FinishedGoodSearch/FGPickOrderInfoCard";
- //import { fetchItem } from "@/app/api/settings/item";
- import { updateInventoryLotLineStatus, analyzeQrCode } from "@/app/api/inventory/actions";
- import { fetchNameList, NameList } from "@/app/api/user/actions";
- import {
- FormProvider,
- useForm,
- } from "react-hook-form";
- import SearchBox, { Criterion } from "@/components/SearchBox";
- import { CreateStockOutLine } from "@/app/api/pickOrder/actions";
- import { updateInventoryLotLineQuantities } from "@/app/api/inventory/actions";
- import QrCodeIcon from '@mui/icons-material/QrCode';
- import { useQrCodeScannerContext } from "@/components/QrCodeScannerProvider/QrCodeScannerProvider";
- import { useSession } from "next-auth/react";
- import { SessionWithTokens } from "@/config/authConfig";
- import { fetchStockInLineInfo } from "@/app/api/po/actions";
- import GoodPickExecutionForm from "@/components/FinishedGoodSearch/GoodPickExecutionForm";
- import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal";
- import FGPickOrderCard from "@/components/FinishedGoodSearch/FGPickOrderCard";
- import LinearProgressWithLabel from "@/components/common/LinearProgressWithLabel";
- import ScanStatusAlert from "@/components/common/ScanStatusAlert";
-
- interface Props {
- filterArgs: Record<string, any>;
- onSwitchToRecordTab?: () => void;
- onRefreshReleasedOrderCount?: () => void;
- /** 階層揀貨資料已無(例如訂單已完成/釋放)時通知外層,以便重新檢查是否仍為「已指派」狀態 */
- onWorkbenchHierarchyEmpty?: () => void;
- }
-
- /** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */
- 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) => (l.stockOutLineStatus || "").toLowerCase() === "pending"
- );
- return pending || withLotNo[0];
- }
- return activeSuggestedLots[0];
- }
-
- 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("pickOrder");
- const [expectedLotInput, setExpectedLotInput] = useState<string>('');
- const [scannedLotInput, setScannedLotInput] = useState<string>('');
- const [error, setError] = useState<string>('');
-
- // 当模态框打开时,预填充输入框
- 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 (
- <Modal open={open} onClose={onClose}>
- <Box sx={{
- position: 'absolute',
- top: '50%',
- left: '50%',
- transform: 'translate(-50%, -50%)',
- bgcolor: 'background.paper',
- p: 3,
- borderRadius: 2,
- minWidth: 500,
- }}>
- <Typography variant="h6" gutterBottom color="warning.main">
- {t("Manual Lot Confirmation")}
- </Typography>
-
- <Box sx={{ mb: 2 }}>
- <Typography variant="body2" gutterBottom>
- <strong>{t("Expected Lot Number")}:</strong>
- </Typography>
- <TextField
- fullWidth
- size="small"
- value={expectedLotInput}
- onChange={(e) => {
- setExpectedLotInput(e.target.value);
- setError('');
- }}
- placeholder={expectedLot?.lotNo || t("Enter expected lot number")}
- sx={{ mb: 2 }}
- error={!!error && !expectedLotInput.trim()}
- />
- </Box>
-
- <Box sx={{ mb: 2 }}>
- <Typography variant="body2" gutterBottom>
- <strong>{t("Scanned Lot Number")}:</strong>
- </Typography>
- <TextField
- fullWidth
- size="small"
- value={scannedLotInput}
- onChange={(e) => {
- setScannedLotInput(e.target.value);
- setError('');
- }}
- placeholder={scannedLot?.lotNo || t("Enter scanned lot number")}
- sx={{ mb: 2 }}
- error={!!error && !scannedLotInput.trim()}
- />
- </Box>
-
- {error && (
- <Box sx={{ mb: 2, p: 1, backgroundColor: '#ffebee', borderRadius: 1 }}>
- <Typography variant="body2" color="error">
- {error}
- </Typography>
- </Box>
- )}
-
- <Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
- <Button onClick={onClose} variant="outlined" disabled={isLoading}>
- {t("Cancel")}
- </Button>
- <Button
- onClick={handleConfirm}
- variant="contained"
- color="warning"
- disabled={isLoading || !expectedLotInput.trim() || !scannedLotInput.trim()}
- >
- {isLoading ? t("Processing...") : t("Confirm")}
- </Button>
- </Box>
- </Box>
- </Modal>
- );
- };
-
- /** 過期批號(未換有效批前):與 noLot 類似——單筆/批量預設提交量為 0,除非 Issue 改數 */
- 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;
- const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
- if (solSt === "completed" || solSt === "partially_completed") return false;
- if (lot.lotAvailability === "status_unavailable") return true;
- return String(lot.lotStatus || "").toLowerCase() === "unavailable";
- }
-
- /** 提貨台「列印標籤」彈窗頂部:依目前表格列判斷可提貨/已用畢/已過期等 */
- function isWorkbenchSourceLotExpired(lot: any): boolean {
- if (!lot) return false;
- if (isLotAvailabilityExpired(lot)) return true;
- if (String(lot.lotAvailability || "").toLowerCase() === "expired") return true;
- if (lot.expiryDate) {
- const d = dayjs(lot.expiryDate).startOf("day");
- if (d.isValid() && d.isBefore(dayjs().startOf("day"))) return true;
- }
- return false;
- }
-
- function getWorkbenchSourceLotStatusSummary(lot: any): {
- severity: "success" | "warning" | "error";
- text: string;
- } {
- if (!lot) {
- return { severity: "warning", text: "無法判斷此批號狀態" };
- }
- if (isWorkbenchSourceLotExpired(lot)) {
- return { severity: "error", text: "此批號狀態:已過期" };
- }
- const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
- if (solSt === "rejected") {
- return { severity: "warning", text: "此出庫行:已拒絕,請改掃其他批號" };
- }
- if (solSt === "completed" || solSt === "partially_completed") {
- return { severity: "warning", text: "此出庫行:已完成,無需再提貨" };
- }
- /**
- * 無批次列:後端仍標 insufficient_stock,語意是「尚無可出庫批號」而非「已用畢」。
- * 與表格紅字「請檢查周圍是否有 QR 碼…」一致。
- */
- const isNoLotRow =
- lot.noLot === true ||
- !lot.lotNo ||
- String(lot.lotNo || "").trim() === "";
- if (isNoLotRow) {
- return {
- severity: "warning",
- text: "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR",
- };
- }
- const av = String(lot.lotAvailability || "").toLowerCase();
- if (av === "insufficient_stock") {
- return { severity: "warning", text: "此批號狀態:已用畢(無剩餘庫存)" };
- }
- const avail = Number(lot.availableQty);
- if (lot.lotNo && Number.isFinite(avail) && avail <= 0) {
- return { severity: "warning", text: "此批號狀態:已用畢(可用量為 0)" };
- }
- if (isInventoryLotLineUnavailable(lot)) {
- return {
- severity: "warning",
- text: "此批號狀態:庫存不可用(未上架或行狀態不可用)",
- };
- }
- return { severity: "success", text: "此批號狀態:可提貨" };
- }
-
- type PickOrderT = (key: string, options?: Record<string, unknown>) => string;
-
- function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string {
- const msg = raw.trim();
- if (!msg) return msg;
-
- const expiredMatch = msg.match(/^Lot is expired \(expiry=([^)]+)\)\.?$/i);
- if (expiredMatch) {
- return t("Lot is expired (expiry={{expiry}})", {
- expiry: expiredMatch[1],
- });
- }
-
- return t(msg);
- }
-
- /**
- * 顯示後端拒絕原因:優先 workbench scan API 的 message(暫存於 scanRejectBySolId),
- * 其次階層 API 若帶 stockOutLineRejectMessage,最後依 rejected + lotAvailability 推斷(與後端語意對齊)。
- */
- function buildLotRejectDisplayMessage(
- lot: any,
- scanRejectBySolId: Record<number, string>,
- t: PickOrderT,
- ): string | undefined {
- const solId = Number(lot.stockOutLineId) || 0;
- const fromScan = solId > 0 ? scanRejectBySolId[solId]?.trim() : "";
- if (fromScan) return translateWorkbenchRejectMessage(fromScan, t);
-
- const fromApi =
- typeof lot.stockOutLineRejectMessage === "string"
- ? lot.stockOutLineRejectMessage.trim()
- : "";
- if (fromApi) return translateWorkbenchRejectMessage(fromApi, t);
-
- 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.");
- }
-
- /** Issue「改數」未寫入 SOL,刷新/換頁後需靠 session 還原,否則 Qty will submit 會回到 req */
- const FG_ISSUE_PICKED_KEY = (doPickOrderId: number) =>
- `fpsms-fg-issuePickedQty:${doPickOrderId}`;
-
- function loadIssuePickedMap(doPickOrderId: number): Record<number, number> {
- if (typeof window === "undefined" || !doPickOrderId) return {};
- try {
- const raw = sessionStorage.getItem(FG_ISSUE_PICKED_KEY(doPickOrderId));
- if (!raw) return {};
- const parsed = JSON.parse(raw) as Record<string, number>;
- const out: Record<number, number> = {};
- Object.entries(parsed).forEach(([k, v]) => {
- const n = Number(v);
- if (!Number.isNaN(n)) out[Number(k)] = n;
- });
- return out;
- } catch {
- return {};
- }
- }
-
- function saveIssuePickedMap(doPickOrderId: number, map: Record<number, number>) {
- if (typeof window === "undefined" || !doPickOrderId) return;
- try {
- sessionStorage.setItem(FG_ISSUE_PICKED_KEY(doPickOrderId), JSON.stringify(map));
- } catch {
- // quota / private mode
- }
- }
-
- const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({
- filterArgs,
- onSwitchToRecordTab,
- onRefreshReleasedOrderCount,
- onWorkbenchHierarchyEmpty,
- }) => {
- const workbenchMode = true;
- const { t } = useTranslation("pickOrder");
- const router = useRouter();
- const { data: session } = useSession() as { data: SessionWithTokens | null };
- const [doPickOrderDetail, setDoPickOrderDetail] = useState<DoPickOrderDetail | null>(null);
- const [selectedPickOrderId, setSelectedPickOrderId] = useState<number | null>(null);
- const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
- const currentUserId = session?.id ? parseInt(session.id) : undefined;
- const [allLotsCompleted, setAllLotsCompleted] = useState(false);
- const [combinedLotData, setCombinedLotData] = useState<any[]>([]);
- const [combinedDataLoading, setCombinedDataLoading] = useState(false);
- const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]);
- // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required)
- const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({});
- const applyLocalStockOutLineUpdate = useCallback((
- stockOutLineId: number,
- status: string,
- actualPickQty?: number
- ) => {
- setCombinedLotData(prev => prev.map((lot) => {
- if (Number(lot.stockOutLineId) !== Number(stockOutLineId)) return lot;
- return {
- ...lot,
- stockOutLineStatus: status,
- ...(typeof actualPickQty === "number"
- ? { actualPickQty, stockOutLineQty: actualPickQty }
- : {}),
- };
- }));
- }, []);
- // 防止重复点击(Submit / Just Completed / Issue)
- const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({});
-
- const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext();
-
- const [qrScanInput, setQrScanInput] = useState<string>('');
- const [qrScanError, setQrScanError] = useState<boolean>(false);
- const [qrScanErrorMsg, setQrScanErrorMsg] = useState<string>('');
- const [qrScanSuccess, setQrScanSuccess] = useState<boolean>(false);
- /** Workbench scanPick 等非 SUCCESS 時後端 message,按 stockOutLineId 顯示在批號欄 */
- const [scanRejectMessageBySolId, setScanRejectMessageBySolId] = useState<Record<number, string>>({});
- 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 [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false);
- /** 使用者覆寫「可提交數量」:鍵不存在=未編輯;鍵存在且為 0=明確要送 0 */
- const [pickQtyData, setPickQtyData] = useState<Record<string, number>>({});
- /** Workbench row: false = qty TextField read-only; Edit toggles true to type qty (replaces opening issue form). */
- const [workbenchSubmitQtyFieldEnabledByLotKey, setWorkbenchSubmitQtyFieldEnabledByLotKey] = useState<
- Record<string, boolean>
- >({});
-
- 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 (!Number.isNaN(Number(fromPick))) {
- return Number(fromPick);
- }
- }
- 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}`;
- }, []);
-
- /** Use table row from combinedLotData so pickQtyData key pickOrderLineId-lotId matches the row user edited (expectedLot from QR index may differ 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));
- // Explicit 0 short submit must send qty=0; implicit 0 must omit qty (backend implicit fill path).
- 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 [searchQuery, setSearchQuery] = useState<Record<string, any>>({});
-
- const [paginationController, setPaginationController] = useState({
- pageNum: 0,
- pageSize: -1,
- });
-
- const [usernameList, setUsernameList] = useState<NameList[]>([]);
-
- const initializationRef = useRef(false);
- const autoAssignRef = useRef(false);
- /** 曾成功載入過 workbench 階層資料;避免「列表仍有單但階層暫空」時對外層重複觸發造成迴圈 */
- const workbenchHierarchicalReadyRef = useRef(false);
-
- const formProps = useForm();
- const errors = formProps.formState.errors;
-
- // Add GoodPickExecutionForm states
- const [pickExecutionFormOpen, setPickExecutionFormOpen] = useState(false);
- const [selectedLotForExecutionForm, setSelectedLotForExecutionForm] = useState<any | null>(null);
- const [fgPickOrders, setFgPickOrders] = useState<FGPickOrderResponse[]>([]);
-
- const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false);
-
- const lotFloorPrefixFilter = useMemo(() => {
- const storeId = String(fgPickOrders?.[0]?.storeId ?? "")
- .trim()
- .toUpperCase()
- .replace(/\s/g, "");
- const floorKey = storeId.replace(/\//g, "");
- return floorKey ? `${floorKey}-` : "";
- }, [fgPickOrders]);
-
- const defaultLabelPrinterName = useMemo(() => {
- const storeId = String(fgPickOrders?.[0]?.storeId ?? "")
- .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;
- }, [fgPickOrders]);
-
- const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] =
- useState(false);
- const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] =
- useState<{ itemId: number; stockInLineId: number } | null>(null);
- const [workbenchLotLabelReminderText, setWorkbenchLotLabelReminderText] =
- useState<string | null>(null);
- const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] =
- useState<any | null>(null);
-
- useEffect(() => {
- if (!qrScanSuccess || !workbenchLotLabelModalOpen) return;
- setWorkbenchLotLabelModalOpen(false);
- setWorkbenchLotLabelInitialPayload(null);
- setWorkbenchLotLabelReminderText(null);
- setWorkbenchLotLabelContextLot(null);
- }, [qrScanSuccess, workbenchLotLabelModalOpen]);
-
- // Add these missing state variables after line 352
- const [isManualScanning, setIsManualScanning] = useState<boolean>(false);
- // Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling
- const [processedQrCombinations, setProcessedQrCombinations] = useState<Map<number, Set<number>>>(new Map());
- const [processedQrCodes, setProcessedQrCodes] = useState<Set<string>>(new Set());
- const [lastProcessedQr, setLastProcessedQr] = useState<string>('');
- const [isRefreshingData, setIsRefreshingData] = useState<boolean>(false);
- const [isSubmittingAll, setIsSubmittingAll] = useState<boolean>(false);
-
- // Cache for fetchStockInLineInfo API calls to avoid redundant requests
- const stockInLineInfoCache = useRef<Map<number, { lotNo: string | null; timestamp: number }>>(new Map());
- const CACHE_TTL = 60000; // 60 seconds cache TTL
- const abortControllerRef = useRef<AbortController | null>(null);
- const qrProcessingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
-
- // Use refs for processed QR tracking to avoid useEffect dependency issues and delays
- const processedQrCodesRef = useRef<Set<string>>(new Set());
- const lastProcessedQrRef = useRef<string>('');
-
- // Store callbacks in refs to avoid useEffect dependency issues
- const processOutsideQrCodeRef = useRef<
- ((latestQr: string, qrScanCountAtInvoke?: number) => Promise<void>) | null
- >(null);
- const resetScanRef = useRef<(() => void) | null>(null);
-
-
-
- // Handle QR code button click
- const handleQrCodeClick = (pickOrderId: number) => {
- console.log(`QR Code clicked for pick order ID: ${pickOrderId}`);
- // TODO: Implement QR code functionality
- };
- const progress = useMemo(() => {
- if (combinedLotData.length === 0) {
- return { completed: 0, total: 0 };
- }
-
- // 與 allItemsReady 一致:noLot / 過期 / unavailable 的 pending 也算「已面對該行」可收尾
- const nonPendingCount = combinedLotData.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: combinedLotData.length,
- };
- }, [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);
-
- // Return cached value if still valid
- if (cached && (now - cached.timestamp) < CACHE_TTL) {
- console.log(`✅ [CACHE HIT] Using cached stockInLineInfo for ${stockInLineId}`);
- return { lotNo: cached.lotNo };
- }
-
- // Cancel previous request if exists
- if (abortControllerRef.current) {
- abortControllerRef.current.abort();
- }
-
- // Create new abort controller for this request
- const abortController = new AbortController();
- abortControllerRef.current = abortController;
-
- try {
- console.log(` [CACHE MISS] Fetching stockInLineInfo for ${stockInLineId}`);
- const stockInLineInfo = await fetchStockInLineInfo(stockInLineId);
-
- // Store in cache
- stockInLineInfoCache.current.set(stockInLineId, {
- lotNo: stockInLineInfo.lotNo || null,
- timestamp: now
- });
-
- // Limit cache size to prevent memory leaks
- if (stockInLineInfoCache.current.size > 100) {
- const firstKey = stockInLineInfoCache.current.keys().next().value;
- if (firstKey !== undefined) {
- stockInLineInfoCache.current.delete(firstKey);
- }
- }
-
- return { lotNo: stockInLineInfo.lotNo || null };
- } catch (error: any) {
- if (error.name === 'AbortError') {
- console.log(` [CACHE] Request aborted for ${stockInLineId}`);
- throw error;
- }
- console.error(`❌ [CACHE] Error fetching stockInLineInfo for ${stockInLineId}:`, error);
- throw error;
- }
- }, []);
-
- const checkAllLotsCompleted = useCallback((lotData: any[]) => {
- if (lotData.length === 0) {
- setAllLotsCompleted(false);
- return false;
- }
-
- // Filter out rejected lots
- const nonRejectedLots = lotData.filter(lot =>
- lot.lotAvailability !== 'rejected' &&
- lot.stockOutLineStatus !== 'rejected'
- );
-
- if (nonRejectedLots.length === 0) {
- setAllLotsCompleted(false);
- return false;
- }
-
- // Check if all non-rejected lots are completed
- const allCompleted = nonRejectedLots.every(lot =>
- lot.stockOutLineStatus === 'completed'
- );
-
- setAllLotsCompleted(allCompleted);
- return allCompleted;
- }, []);
- // 在 fetchAllCombinedLotData 函数中(约 446-684 行)
-
- const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdOverride?: number) => {
- setCombinedDataLoading(true);
- try {
- const userIdToUse = userId || currentUserId;
-
- console.log(" fetchAllCombinedLotData called with userId:", userIdToUse);
-
- if (!userIdToUse) {
- console.warn("⚠️ No userId available, skipping API call");
- setCombinedLotData([]);
- setOriginalCombinedData([]);
- setAllLotsCompleted(false);
- setIssuePickedQtyBySolId({});
- return;
- }
-
- // 获取新结构的层级数据
- const hierarchicalData = await fetchAllPickOrderLotsHierarchicalWorkbench(userIdToUse);
- console.log(" Hierarchical data (new structure):", hierarchicalData);
-
- // 检查数据结构
- if (!hierarchicalData?.fgInfo || !hierarchicalData.pickOrders?.length) {
- console.warn("⚠️ No FG info or pick orders found");
- setCombinedLotData([]);
- setOriginalCombinedData([]);
- setAllLotsCompleted(false);
- setIssuePickedQtyBySolId({});
- setFgPickOrders([]);
- if (workbenchHierarchicalReadyRef.current) {
- workbenchHierarchicalReadyRef.current = false;
- onWorkbenchHierarchyEmpty?.();
- }
- return;
- }
-
- // 使用合并后的 pick order 对象(现在只有一个对象,包含所有数据)
- const mergedPickOrder = hierarchicalData.pickOrders[0];
-
- // 设置 FG info 到 fgPickOrders(用于显示 FG 信息卡片)
- // 修改第 478-509 行的 fgOrder 构建逻辑:
-
- const fgOrder: FGPickOrderResponse = {
- doPickOrderId: hierarchicalData.fgInfo.doPickOrderId,
- ticketNo: hierarchicalData.fgInfo.ticketNo,
- storeId: hierarchicalData.fgInfo.storeId,
- shopCode: hierarchicalData.fgInfo.shopCode,
- shopName: hierarchicalData.fgInfo.shopName,
- truckLanceCode: hierarchicalData.fgInfo.truckLanceCode,
- DepartureTime: hierarchicalData.fgInfo.departureTime,
- shopAddress: "",
- pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
-
- // 兼容字段(注意 consoCodes 是数组)
- pickOrderId: mergedPickOrder.pickOrderIds?.[0] || 0,
- pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes)
- ? mergedPickOrder.consoCodes[0] || ""
- : "",
- pickOrderTargetDate: mergedPickOrder.targetDate || "",
- pickOrderStatus: mergedPickOrder.status || "",
- deliveryOrderId: mergedPickOrder.doOrderIds?.[0] || 0,
- deliveryNo: mergedPickOrder.deliveryOrderCodes?.[0] || "",
- deliveryDate: "",
- shopId: 0,
- shopPoNo: "",
- numberOfCartons: mergedPickOrder.pickOrderLines?.length || 0,
- qrCodeData: hierarchicalData.fgInfo.doPickOrderId,
-
- // 多个 pick orders 信息:全部保留为数组
- numberOfPickOrders: mergedPickOrder.pickOrderIds?.length || 0,
- pickOrderIds: mergedPickOrder.pickOrderIds || [],
- pickOrderCodes: Array.isArray(mergedPickOrder.pickOrderCodes)
- ? mergedPickOrder.pickOrderCodes
- : [],
- deliveryOrderIds: mergedPickOrder.doOrderIds || [],
- deliveryNos: Array.isArray(mergedPickOrder.deliveryOrderCodes)
- ? mergedPickOrder.deliveryOrderCodes
- : [],
- lineCountsPerPickOrder: Array.isArray(mergedPickOrder.lineCountsPerPickOrder)
- ? mergedPickOrder.lineCountsPerPickOrder
- : [],
- };
-
- setFgPickOrders([fgOrder]);
- workbenchHierarchicalReadyRef.current = true;
- console.log(" DEBUG fgOrder.lineCountsPerPickOrder:", fgOrder.lineCountsPerPickOrder);
- console.log(" DEBUG fgOrder.pickOrderCodes:", fgOrder.pickOrderCodes);
- console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos);
-
- // 直接使用合并后的 pickOrderLines
- console.log("🎯 Displaying merged pick order lines");
-
- // 将层级数据转换为平铺格式(用于表格显示)
- const flatLotData: any[] = [];
-
- // 2/F 與後端 store_id 一致時需按 itemOrder;避免 API 未走 2F 分支時畫面仍亂序
- const doFloorKey = String(hierarchicalData.fgInfo.storeId ?? '')
- .trim()
- .toUpperCase()
- .replace(/\//g, '')
- .replace(/\s/g, '');
- const pickOrderLinesForDisplay =
- doFloorKey === '2F'
- ? [...(mergedPickOrder.pickOrderLines || [])].sort((a: any, b: any) => {
- const ao = a.itemOrder != null ? Number(a.itemOrder) : 999999;
- const bo = b.itemOrder != null ? Number(b.itemOrder) : 999999;
- if (ao !== bo) return ao - bo;
- return (Number(a.id) || 0) - (Number(b.id) || 0);
- })
- : mergedPickOrder.pickOrderLines || [];
-
- pickOrderLinesForDisplay.forEach((line: any) => {
- // 用来记录这一行已经通过 lots 出现过的 lotId
- const lotIdSet = new Set<number>();
- /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數) */
- let lotsAllocatedSumForLine = 0;
-
- // ✅ lots:按 lotId 去重并合并 requiredQty
- if (line.lots && line.lots.length > 0) {
- const lotMap = new Map<number, any>();
-
- line.lots.forEach((lot: any) => {
- const lotId = lot.id;
- 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.id != null) {
- lotIdSet.add(lot.id);
- }
-
- flatLotData.push({
- pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes)
- ? mergedPickOrder.consoCodes[0] || ""
- : "",
- pickOrderTargetDate: mergedPickOrder.targetDate,
- pickOrderStatus: mergedPickOrder.status,
- pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0,
- pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
- pickOrderLineId: line.id,
- pickOrderLineRequiredQty: line.requiredQty,
- pickOrderLineStatus: line.status,
-
- itemId: line.item.id,
- itemCode: line.item.code,
- itemName: line.item.name,
- uomDesc: line.item.uomDesc,
- uomShortDesc: line.item.uomShortDesc,
-
- lotId: lot.id,
- lotNo: lot.lotNo,
- expiryDate: lot.expiryDate,
- location: lot.location,
- stockUnit: lot.stockUnit,
- availableQty: lot.availableQty,
- requiredQty: lot.requiredQty,
- actualPickQty: lot.actualPickQty,
- inQty: lot.inQty,
- outQty: lot.outQty,
- holdQty: lot.holdQty,
- lotStatus: lot.lotStatus,
- lotAvailability: lot.lotAvailability,
- processingStatus: lot.processingStatus,
- suggestedPickLotId: lot.suggestedPickLotId,
- stockOutLineId: lot.stockOutLineId,
- stockOutLineStatus: lot.stockOutLineStatus,
- stockOutLineQty: lot.stockOutLineQty,
- stockOutLineRejectMessage: lot.stockOutLineRejectMessage ?? null,
- stockInLineId: lot.stockInLineId,
- routerId: lot.router?.id,
- routerIndex: lot.router?.index,
- routerRoute: lot.router?.route,
- routerArea: lot.router?.area,
- noLot: false,
- });
- });
- }
-
- // ✅ stockouts:只保留“真正无批次 / 未在 lots 出现过”的行
- 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;
- }
-
- // 只渲染:
- // - noLot === true 的 Null stock 行
- // - 或者 lotId 在 lots 中不存在的特殊情况
- flatLotData.push({
- pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes)
- ? mergedPickOrder.consoCodes[0] || ""
- : "",
- pickOrderTargetDate: mergedPickOrder.targetDate,
- pickOrderStatus: mergedPickOrder.status,
- pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0,
- pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
- pickOrderLineId: line.id,
- pickOrderLineRequiredQty: line.requiredQty,
- pickOrderLineStatus: line.status,
-
- itemId: line.item.id,
- itemCode: line.item.code,
- itemName: line.item.name,
- uomDesc: line.item.uomDesc,
- uomShortDesc: line.item.uomShortDesc,
-
- lotId: stockout.lotId || null,
- lotNo: stockout.lotNo || null,
- expiryDate: null,
- location: stockout.location || null,
- stockUnit: line.item.uomDesc,
- availableQty: stockout.availableQty || 0,
- // 無批次列對應 suggested_pick_lot 的缺口量(如 11),勿用整行 POL 需求(100)以免顯示成 89 / 100
- requiredQty: stockout.noLot
- ? Math.max(
- 0,
- (Number(line.requiredQty) || 0) - lotsAllocatedSumForLine
- )
- : Number(line.requiredQty) || 0,
- actualPickQty: stockout.qty || 0,
- inQty: 0,
- outQty: 0,
- holdQty: 0,
- lotStatus: stockout.noLot ? "unavailable" : "available",
- lotAvailability: stockout.noLot ? "insufficient_stock" : "available",
- processingStatus: stockout.status || "pending",
- suggestedPickLotId: null,
- stockOutLineId: stockout.id || null,
- stockOutLineStatus: stockout.status || null,
- stockOutLineQty: stockout.qty || 0,
- stockOutLineRejectMessage: stockout.rejectMessage ?? stockout.rejectReason ?? null,
-
- routerId: null,
- routerIndex: stockout.noLot ? 999999 : null,
- routerRoute: null,
- routerArea: null,
- noLot: !!stockout.noLot,
- });
- });
- }
- });
-
- console.log(" Transformed flat lot data:", flatLotData);
- console.log(" Total items (including null stock):", flatLotData.length);
-
- setCombinedLotData(flatLotData);
- setOriginalCombinedData(flatLotData);
- const doPid = hierarchicalData.fgInfo?.doPickOrderId;
- if (doPid) {
- setIssuePickedQtyBySolId(loadIssuePickedMap(doPid));
- }
- checkAllLotsCompleted(flatLotData);
- } catch (error) {
- console.error(" Error fetching combined lot data:", error);
- setCombinedLotData([]);
- setOriginalCombinedData([]);
- setAllLotsCompleted(false);
- setIssuePickedQtyBySolId({});
- } finally {
- setCombinedDataLoading(false);
- }
- }, [currentUserId, checkAllLotsCompleted, onWorkbenchHierarchyEmpty]); // 移除 selectedPickOrderId 依赖
-
- /** After workbench scan-pick (incl. split → new stock_out_line), reload hierarchical rows. */
- const refreshWorkbenchAfterScanPick = useCallback(async () => {
- setIsRefreshingData(true);
- try {
- await fetchAllCombinedLotData();
- } finally {
- setIsRefreshingData(false);
- }
- }, [fetchAllCombinedLotData]);
-
- 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: fgPickOrders?.[0]?.storeId ?? null,
- 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));
- await refreshWorkbenchAfterScanPick();
- setWorkbenchLotLabelModalOpen(false);
- setWorkbenchLotLabelContextLot(null);
- setWorkbenchLotLabelInitialPayload(null);
- setWorkbenchLotLabelReminderText(null);
- },
- [
- workbenchLotLabelContextLot,
- fgPickOrders,
- currentUserId,
- workbenchScanPickQtyFromLot,
- refreshWorkbenchAfterScanPick,
- rememberWorkbenchScanReject,
- clearWorkbenchScanReject,
- t,
- ],
- );
-
- const workbenchLotLabelStatusBanner = useMemo(() => {
- if (!workbenchLotLabelModalOpen || !workbenchLotLabelContextLot) {
- return {
- text: undefined as string | undefined,
- severity: undefined as "success" | "warning" | "error" | undefined,
- };
- }
- const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot);
- return { text: s.text, severity: s.severity };
- }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]);
-
- 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],
- );
-
- /**
- * 與「掃描結果」欄綠勾一致:有批號且 SOL 非 pending、非 rejected 時視為已掃/已提貨,停用掃碼提貨。
- */
- 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]);
-
- // Manual lot substitution is part of the legacy "checked then batch submit" flow.
- // Workbench now uses scan-pick (immediate posting) + qty=0 zero-complete; disable this path to avoid writing `checked`.
- const handleManualLotConfirmation = useCallback(async () => {
- alert(t("Workbench does not support manual lot substitution in this flow. Please use scan-pick / Just Completed."));
- }, [t]);
- useEffect(() => {
- if (combinedLotData.length > 0) {
- checkAllLotsCompleted(combinedLotData);
- }
- }, [combinedLotData, checkAllLotsCompleted]);
-
- // Add function to expose completion status to parent
- const getCompletionStatus = useCallback(() => {
- return allLotsCompleted;
- }, [allLotsCompleted]);
-
- // Expose completion status to parent component
- useEffect(() => {
- // Dispatch custom event with completion status
- const event = new CustomEvent('pickOrderCompletionStatus', {
- detail: {
- allLotsCompleted,
- tabIndex: 1 // 明确指定这是来自标签页 1 的事件
- }
- });
- window.dispatchEvent(event);
- }, [allLotsCompleted]);
-
- const handleQrCodeSubmit = useCallback(async (lotNo: string) => {
- console.log(` Processing QR Code for lot: ${lotNo}`);
-
- // 检查 lotNo 是否为 null 或 undefined(包括字符串 "null")
- if (!lotNo || lotNo === 'null' || lotNo.trim() === '') {
- console.error(" Invalid lotNo: null, undefined, or empty");
- return;
- }
-
- // Use current data without refreshing to avoid infinite loop
- const currentLotData = combinedLotData;
- console.log(` Available lots:`, currentLotData.map(lot => lot.lotNo));
-
- // 修复:在比较前确保 lotNo 不为 null
- const lotNoLower = lotNo.toLowerCase();
- const matchingLots = currentLotData.filter(lot => {
- if (!lot.lotNo) return false; // 跳过 null lotNo
- 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;
- }
-
- // Legacy QR flow marked SOL as `checked` (normal version). Workbench uses scan-pick (immediate posting).
- setQrScanError(true);
- setQrScanSuccess(false);
- setQrScanErrorMsg(
- t("Workbench uses scan-pick. Please use Just Completed / submit via scan-pick instead of checked status."),
- );
- }, [combinedLotData]);
- const handleFastQrScan = useCallback(async (lotNo: string) => {
- const startTime = performance.now();
- console.log(` [FAST SCAN START] Lot: ${lotNo}`);
- console.log(` Start time: ${new Date().toISOString()}`);
-
- // 从 combinedLotData 中找到对应的 lot
- const findStartTime = performance.now();
- const matchingLot = combinedLotData.find(lot =>
- lot.lotNo && lot.lotNo === lotNo
- );
- const findTime = performance.now() - findStartTime;
- console.log(` Find lot time: ${findTime.toFixed(2)}ms`);
-
- if (!matchingLot || !matchingLot.stockOutLineId) {
- const totalTime = performance.now() - startTime;
- console.warn(`⚠️ Fast scan: Lot ${lotNo} not found or no stockOutLineId`);
- console.log(` Total time: ${totalTime.toFixed(2)}ms`);
- return;
- }
-
- try {
- const apiStartTime = performance.now();
- const res = await workbenchScanPick({
- stockOutLineId: matchingLot.stockOutLineId,
- lotNo,
- ...(typeof matchingLot.stockInLineId === "number" &&
- Number.isFinite(matchingLot.stockInLineId) &&
- matchingLot.stockInLineId > 0
- ? { stockInLineId: matchingLot.stockInLineId }
- : {}),
- ...workbenchScanPickQtyFromLot(matchingLot),
- storeId: fgPickOrders?.[0]?.storeId ?? null,
- userId: currentUserId ?? 1,
- });
- const apiTime = performance.now() - apiStartTime;
- console.log(` API call time: ${apiTime.toFixed(2)}ms`);
-
- const ok = res.code === "SUCCESS";
- if (ok) {
- clearWorkbenchScanReject(Number(matchingLot.stockOutLineId));
- const entity = res.entity as any;
- const nextStatus = String(entity?.status ?? "completed").toLowerCase();
- const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
- const patchRow = (prev: any[]) =>
- prev.map((row) => {
- if (
- row.stockOutLineId === matchingLot.stockOutLineId &&
- row.pickOrderLineId === matchingLot.pickOrderLineId
- ) {
- return {
- ...row,
- stockOutLineStatus: nextStatus,
- stockOutLineQty: nextQty ?? row.stockOutLineQty,
- actualPickQty: nextQty ?? row.actualPickQty,
- };
- }
- return row;
- });
- setCombinedLotData(patchRow);
- setOriginalCombinedData(patchRow);
-
- const totalTime = performance.now() - startTime;
- console.log(`✅ [FAST SCAN END] Lot: ${lotNo}`);
- console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`);
- console.log(` End time: ${new Date().toISOString()}`);
- } else {
- const totalTime = performance.now() - startTime;
- console.warn(`⚠️ Fast scan failed for ${lotNo}:`, res.code);
- console.log(` Total time: ${totalTime.toFixed(2)}ms`);
- if (workbenchMode && matchingLot.stockOutLineId != null) {
- rememberWorkbenchScanReject(
- Number(matchingLot.stockOutLineId),
- (res as { message?: string })?.message,
- );
- }
- }
- } catch (error) {
- const totalTime = performance.now() - startTime;
- console.error(` Fast scan error for ${lotNo}:`, error);
- console.log(` Total time: ${totalTime.toFixed(2)}ms`);
- }
- }, [combinedLotData, updateStockOutLineStatusByQRCodeAndLotNo, workbenchMode, currentUserId, clearWorkbenchScanReject, rememberWorkbenchScanReject, refreshWorkbenchAfterScanPick, workbenchScanPickQtyFromLot]);
- // Enhanced lotDataIndexes with cached active lots for better performance
- const lotDataIndexes = useMemo(() => {
- const indexStartTime = performance.now();
- console.log(` [PERF] lotDataIndexes calculation START, data length: ${combinedLotData.length}`);
-
- const byItemId = new Map<number, any[]>();
- const byItemCode = new Map<string, any[]>();
- const byLotId = new Map<number, any>();
- const byLotNo = new Map<string, any[]>();
- const byStockInLineId = new Map<number, any[]>();
- // Cache active lots separately to avoid filtering on every scan
- const activeLotsByItemId = new Map<number, any[]>();
- const rejectedStatuses = new Set(['rejected']);
-
- // ✅ Use for loop instead of forEach for better performance on tablets
- 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);
- }
- }
-
- const indexTime = performance.now() - indexStartTime;
- if (indexTime > 10) {
- console.log(` [PERF] lotDataIndexes calculation END: ${indexTime.toFixed(2)}ms (${(indexTime / 1000).toFixed(3)}s)`);
- }
-
- return { byItemId, byItemCode, byLotId, byLotNo, byStockInLineId, activeLotsByItemId };
- }, [combinedLotData.length, combinedLotData]);
-
- // Store resetScan in ref for immediate access (update on every render)
- resetScanRef.current = resetScan;
-
- 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';
- const isUnavailable = isInventoryLotLineUnavailable(scannedLot);
-
- if (isRejected) {
- console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected`);
- 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;
- }
-
- if (isUnavailable) {
- console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is unavailable; opening lot-label modal`);
- startTransition(() => {
- setQrScanError(false);
- setQrScanSuccess(false);
- });
- openWorkbenchLotLabelModalForLot(
- scannedLot,
- t("This lot is not available, please scan another lot."),
- );
- return;
- }
-
- const isExpired =
- String(scannedLot.lotAvailability || '').toLowerCase() === 'expired';
- if (isExpired) {
- console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired; opening lot-label modal`);
- startTransition(() => {
- setQrScanError(false);
- setQrScanSuccess(false);
- });
- openWorkbenchLotLabelModalForLot(
- scannedLot,
- `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`,
- );
- 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(
- t("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 ||
- t("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: fgPickOrders?.[0]?.storeId ?? null,
- userId: currentUserId ?? 1,
- });
- const ok = res.code === "SUCCESS";
- if (!ok) {
- const failMsg =
- (res as { message?: string })?.message ||
- t("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));
- const entity = res.entity as any;
- const nextStatus = String(entity?.status ?? "completed").toLowerCase();
- const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
-
- startTransition(() => {
- setQrScanError(false);
- setQrScanSuccess(true);
- setCombinedLotData((prev) =>
- prev.map((lot) => {
- if (
- lot.stockOutLineId === expectedLot.stockOutLineId &&
- lot.pickOrderLineId === expectedLot.pickOrderLineId
- ) {
- return {
- ...lot,
- lotNo: scannedLotNo,
- stockOutLineStatus: nextStatus,
- stockOutLineQty: nextQty ?? lot.stockOutLineQty,
- };
- }
- return lot;
- }),
- );
- setOriginalCombinedData((prev) =>
- prev.map((lot) => {
- if (
- lot.stockOutLineId === expectedLot.stockOutLineId &&
- lot.pickOrderLineId === expectedLot.pickOrderLineId
- ) {
- return {
- ...lot,
- lotNo: scannedLotNo,
- stockOutLineStatus: nextStatus,
- stockOutLineQty: nextQty ?? lot.stockOutLineQty,
- };
- }
- return lot;
- }),
- );
- });
-
- 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(
- t("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 ||
- t("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: fgPickOrders?.[0]?.storeId ?? null,
- userId: currentUserId ?? 1,
- });
- const ok = res.code === "SUCCESS";
- if (!ok) {
- const failMsg =
- (res as { message?: string })?.message ||
- t("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));
- const entity = res.entity as any;
- const nextStatus = String(entity?.status ?? "completed").toLowerCase();
- const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
-
- startTransition(() => {
- setQrScanError(false);
- setQrScanSuccess(true);
- setCombinedLotData((prev) =>
- prev.map((lot) => {
- if (
- lot.stockOutLineId === expectedLot.stockOutLineId &&
- lot.pickOrderLineId === expectedLot.pickOrderLineId
- ) {
- return {
- ...lot,
- lotNo: scannedLotNo,
- stockOutLineStatus: nextStatus,
- stockOutLineQty: nextQty ?? lot.stockOutLineQty,
- };
- }
- return lot;
- }),
- );
- setOriginalCombinedData((prev) =>
- prev.map((lot) => {
- if (
- lot.stockOutLineId === expectedLot.stockOutLineId &&
- lot.pickOrderLineId === expectedLot.pickOrderLineId
- ) {
- return {
- ...lot,
- lotNo: scannedLotNo,
- stockOutLineStatus: nextStatus,
- stockOutLineQty: nextQty ?? lot.stockOutLineQty,
- };
- }
- return lot;
- }),
- );
- });
-
- 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: fgPickOrders?.[0]?.storeId ?? null,
- 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));
- const entity = res.entity as any;
- const nextStatus = String(entity?.status ?? "completed").toLowerCase();
- const nextQty =
- entity?.qty != null ? Number(entity.qty) : undefined;
-
- // ✅ Batch state updates using startTransition
- const stateUpdateStartTime = performance.now();
- startTransition(() => {
- setQrScanError(false);
- setQrScanSuccess(true);
-
- setCombinedLotData(prev => prev.map(lot => {
- if (lot.stockOutLineId === exactMatch.stockOutLineId &&
- lot.pickOrderLineId === exactMatch.pickOrderLineId) {
- return {
- ...lot,
- stockOutLineStatus: nextStatus,
- stockOutLineQty: nextQty ?? lot.stockOutLineQty,
- };
- }
- return lot;
- }));
-
- setOriginalCombinedData(prev => prev.map(lot => {
- if (lot.stockOutLineId === exactMatch.stockOutLineId &&
- lot.pickOrderLineId === exactMatch.pickOrderLineId) {
- return {
- ...lot,
- stockOutLineStatus: nextStatus,
- stockOutLineQty: nextQty ?? lot.stockOutLineQty,
- };
- }
- return lot;
- }));
- });
- 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 ||
- t("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 = t("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 ||
- t("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: fgPickOrders?.[0]?.storeId ?? null,
- userId: currentUserId ?? 1,
- });
-
- const ok = res.code === "SUCCESS";
-
- if (!ok) {
- const failMsg =
- (res as { message?: string })?.message ||
- t("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));
- const entity = res.entity as any;
- const nextStatus = String(entity?.status ?? "completed").toLowerCase();
- const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
-
- startTransition(() => {
- setQrScanError(false);
- setQrScanSuccess(true);
-
- setCombinedLotData((prev) =>
- prev.map((lot) => {
- if (
- lot.stockOutLineId === expectedLot.stockOutLineId &&
- lot.pickOrderLineId === expectedLot.pickOrderLineId
- ) {
- return {
- ...lot,
- lotNo: scannedLotNo,
- stockOutLineStatus: nextStatus,
- stockOutLineQty: nextQty ?? lot.stockOutLineQty,
- };
- }
- return lot;
- }),
- );
-
- setOriginalCombinedData((prev) =>
- prev.map((lot) => {
- if (
- lot.stockOutLineId === expectedLot.stockOutLineId &&
- lot.pickOrderLineId === expectedLot.pickOrderLineId
- ) {
- return {
- ...lot,
- lotNo: scannedLotNo,
- stockOutLineStatus: nextStatus,
- stockOutLineQty: nextQty ?? lot.stockOutLineQty,
- };
- }
- return lot;
- }),
- );
- });
-
- 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,
- openWorkbenchLotLabelModalForLot,
- shouldOpenWorkbenchLotLabelModalForFailure,
- t,
- ]);
- // Store processOutsideQrCode in ref for immediate access (update on every render)
- processOutsideQrCodeRef.current = processOutsideQrCode;
-
- useEffect(() => {
- // Skip if scanner is not active or no data available
- if (!isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData) {
- return;
- }
-
- const qrValuesChangeStartTime = performance.now();
- console.log(` [QR VALUES EFFECT] Triggered at: ${new Date().toISOString()}`);
- console.log(` [QR VALUES EFFECT] qrValues.length: ${qrValues.length}`);
- console.log(` [QR VALUES EFFECT] qrValues:`, qrValues);
-
- const latestQr = qrValues[qrValues.length - 1];
- console.log(` [QR VALUES EFFECT] Latest QR: ${latestQr}`);
- console.log(` [QR VALUES EFFECT] Latest QR detected at: ${new Date().toISOString()}`);
-
- // ✅ FIXED: Handle test shortcut {2fitestx,y} or {2fittestx,y} where x=itemId, y=stockInLineId
- // Support both formats: {2fitest (2 t's) and {2fittest (3 t's)
- if ((latestQr.startsWith("{2fitest") || latestQr.startsWith("{2fittest")) && latestQr.endsWith("}")) {
- // Extract content: remove "{2fitest" or "{2fittest" and "}"
- let content = '';
- if (latestQr.startsWith("{2fittest")) {
- content = latestQr.substring(9, latestQr.length - 1); // Remove "{2fittest" and "}"
- } else if (latestQr.startsWith("{2fitest")) {
- content = latestQr.substring(8, latestQr.length - 1); // Remove "{2fitest" and "}"
- }
-
- 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)) {
- console.log(
- `%c TEST QR: Detected ${latestQr.substring(0, 9)}... - Simulating QR input (itemId=${itemId}, stockInLineId=${stockInLineId})`,
- "color: purple; font-weight: bold"
- );
-
- // ✅ Simulate QR code JSON format
- const simulatedQr = JSON.stringify({
- itemId: itemId,
- stockInLineId: stockInLineId
- });
-
- console.log(` [TEST QR] Simulated QR content: ${simulatedQr}`);
- console.log(` [TEST QR] Start time: ${new Date().toISOString()}`);
- const testStartTime = performance.now();
-
- // ✅ Mark as processed FIRST to avoid duplicate processing
- 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);
- }
- }
- setLastProcessedQr(latestQr);
- setProcessedQrCodes(new Set(processedQrCodesRef.current));
-
- // ✅ Process immediately (bypass QR scanner delay)
- if (processOutsideQrCodeRef.current) {
- processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => {
- const testTime = performance.now() - testStartTime;
- console.log(` [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`);
- console.log(` [TEST QR] End time: ${new Date().toISOString()}`);
- }).catch((error) => {
- const testTime = performance.now() - testStartTime;
- console.error(`❌ [TEST QR] Error after ${testTime.toFixed(2)}ms:`, error);
- });
- }
-
- // Reset scan
- if (resetScanRef.current) {
- resetScanRef.current();
- }
-
- const qrValuesChangeTime = performance.now() - qrValuesChangeStartTime;
- console.log(` [QR VALUES EFFECT] Test QR handling time: ${qrValuesChangeTime.toFixed(2)}ms`);
- return; // ✅ IMPORTANT: Return early to prevent normal processing
- } else {
- console.warn(` [TEST QR] Invalid itemId or stockInLineId: itemId=${parts[0]}, stockInLineId=${parts[1]}`);
- }
- } else {
- console.warn(` [TEST QR] Invalid format. Expected {2fitestx,y} or {2fittestx,y}, got: ${latestQr}`);
- }
- }
-
- // Skip processing if manual confirmation modal is open
- if (manualLotConfirmationOpen) {
- // Check if this is a different QR code than what triggered the modal
- const modalTriggerQr = lastProcessedQrRef.current;
- if (latestQr === modalTriggerQr) {
- console.log(` [QR PROCESS] Skipping - manual modal open for same QR`);
- return;
- }
- // If it's a different QR, allow processing
- console.log(` [QR PROCESS] Different QR detected while manual modal open, allowing processing`);
- }
-
- const qrDetectionStartTime = performance.now();
- console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`);
- console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`);
- console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`);
-
- // Skip if already processed (use refs to avoid dependency issues and delays)
- const checkProcessedStartTime = performance.now();
- if (processedQrCodesRef.current.has(latestQr) || lastProcessedQrRef.current === latestQr) {
- const checkTime = performance.now() - checkProcessedStartTime;
- console.log(` [QR PROCESS] Already processed check time: ${checkTime.toFixed(2)}ms`);
- return;
- }
- const checkTime = performance.now() - checkProcessedStartTime;
- console.log(` [QR PROCESS] Not processed check time: ${checkTime.toFixed(2)}ms`);
-
- // Handle special shortcut
- if (latestQr === "{2fic}") {
- console.log(" Detected {2fic} shortcut - opening manual lot confirmation form");
- setManualLotConfirmationOpen(true);
- if (resetScanRef.current) {
- resetScanRef.current();
- }
- 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);
- }
- }
- setLastProcessedQr(latestQr);
- setProcessedQrCodes(prev => {
- const newSet = new Set(prev);
- newSet.add(latestQr);
- if (newSet.size > 100) {
- const firstValue = newSet.values().next().value;
- if (firstValue !== undefined) {
- newSet.delete(firstValue);
- }
- }
- return newSet;
- });
- return;
- }
-
- // Process new QR code immediately (background mode - no modal)
- // Check against refs to avoid state update delays
- if (latestQr && latestQr !== lastProcessedQrRef.current) {
- const processingStartTime = performance.now();
- console.log(` [QR PROCESS] Starting processing at: ${new Date().toISOString()}`);
- console.log(` [QR PROCESS] Time since detection: ${(processingStartTime - qrDetectionStartTime).toFixed(2)}ms`);
-
- // ✅ Process immediately for better responsiveness
- // Clear any pending debounced processing
- if (qrProcessingTimeoutRef.current) {
- clearTimeout(qrProcessingTimeoutRef.current);
- qrProcessingTimeoutRef.current = null;
- }
-
- // Log immediately (console.log is synchronous)
- console.log(` [QR PROCESS] Processing new QR code with enhanced validation: ${latestQr}`);
-
- // Update refs immediately (no state update delay) - do this FIRST
- const refUpdateStartTime = performance.now();
- 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);
- }
- }
- const refUpdateTime = performance.now() - refUpdateStartTime;
- console.log(` [QR PROCESS] Ref update time: ${refUpdateTime.toFixed(2)}ms`);
-
- // Process immediately in background - no modal/form needed, no delays
- // Use ref to avoid dependency issues
- const processCallStartTime = performance.now();
- if (processOutsideQrCodeRef.current) {
- processOutsideQrCodeRef.current(latestQr, qrValues.length).then(() => {
- const processCallTime = performance.now() - processCallStartTime;
- const totalProcessingTime = performance.now() - processingStartTime;
- console.log(` [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`);
- console.log(` [QR PROCESS] Total processing time: ${totalProcessingTime.toFixed(2)}ms (${(totalProcessingTime / 1000).toFixed(3)}s)`);
- }).catch((error) => {
- const processCallTime = performance.now() - processCallStartTime;
- const totalProcessingTime = performance.now() - processingStartTime;
- console.error(`❌ [QR PROCESS] processOutsideQrCode error after ${processCallTime.toFixed(2)}ms:`, error);
- console.error(`❌ [QR PROCESS] Total processing time before error: ${totalProcessingTime.toFixed(2)}ms`);
- });
- }
-
- // Update state for UI (but don't block on it)
- const stateUpdateStartTime = performance.now();
- setLastProcessedQr(latestQr);
- setProcessedQrCodes(new Set(processedQrCodesRef.current));
- const stateUpdateTime = performance.now() - stateUpdateStartTime;
- console.log(` [QR PROCESS] State update time: ${stateUpdateTime.toFixed(2)}ms`);
-
- const detectionTime = performance.now() - qrDetectionStartTime;
- const totalEffectTime = performance.now() - qrValuesChangeStartTime;
- console.log(` [QR DETECTION] Total detection time: ${detectionTime.toFixed(2)}ms`);
- console.log(` [QR VALUES EFFECT] Total effect time: ${totalEffectTime.toFixed(2)}ms`);
- }
-
- return () => {
- if (qrProcessingTimeoutRef.current) {
- clearTimeout(qrProcessingTimeoutRef.current);
- qrProcessingTimeoutRef.current = null;
- }
- };
- }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, manualLotConfirmationOpen]);
- const renderCountRef = useRef(0);
- const renderStartTimeRef = useRef<number | null>(null);
-
- // Track render performance
- useEffect(() => {
- renderCountRef.current++;
- const now = performance.now();
-
- if (renderStartTimeRef.current !== null) {
- const renderTime = now - renderStartTimeRef.current;
- if (renderTime > 100) { // Only log slow renders (>100ms)
- console.log(` [PERF] Render #${renderCountRef.current} took ${renderTime.toFixed(2)}ms, combinedLotData length: ${combinedLotData.length}`);
- }
- renderStartTimeRef.current = null;
- }
- }, [combinedLotData.length]);
- // Auto-start scanner only once on mount
- const scannerInitializedRef = useRef(false);
-
- useEffect(() => {
- if (session && currentUserId && !initializationRef.current) {
- console.log(" Session loaded, initializing pick order...");
- initializationRef.current = true;
-
- // Only fetch existing data, no auto-assignment
- fetchAllCombinedLotData();
- }
- }, [session, currentUserId, fetchAllCombinedLotData]);
-
- // Separate effect for auto-starting scanner (only once, prevents multiple resets)
- useEffect(() => {
- if (session && currentUserId && !scannerInitializedRef.current) {
- scannerInitializedRef.current = true;
- // ✅ Auto-start scanner on mount for tablet use (background mode - no modal)
- console.log("✅ Auto-starting QR scanner in background mode");
- setIsManualScanning(true);
- startScan();
- }
- }, [session, currentUserId, startScan]);
-
- // Add event listener for manual assignment
- useEffect(() => {
- const handlePickOrderAssigned = () => {
- console.log("🔄 Pick order assigned event received, refreshing data...");
- fetchAllCombinedLotData();
- };
-
- window.addEventListener('pickOrderAssigned', handlePickOrderAssigned);
-
- return () => {
- window.removeEventListener('pickOrderAssigned', handlePickOrderAssigned);
- };
- }, [fetchAllCombinedLotData]);
-
-
-
- const handleManualInputSubmit = useCallback(() => {
- if (qrScanInput.trim() !== '') {
- handleQrCodeSubmit(qrScanInput.trim());
- }
- }, [qrScanInput, handleQrCodeSubmit]);
-
- // Handle QR code submission from modal (internal scanning)
- const handleQrCodeSubmitFromModal = useCallback(async () => {
- // Legacy path: marked SOL as `checked` (normal version). Disabled for workbench.
- setQrScanError(true);
- setQrScanSuccess(false);
- setQrScanErrorMsg(t("Workbench uses scan-pick; this QR modal flow is not supported."));
- }, [t]);
-
-
- const handlePickQtyChange = useCallback((lotKey: string, value: number | string) => {
- if (value === '' || value === null || value === undefined) {
- setPickQtyData((prev) => {
- if (!Object.prototype.hasOwnProperty.call(prev, lotKey)) return prev;
- const next = { ...prev };
- delete next[lotKey];
- return next;
- });
- return;
- }
-
- const numericValue = typeof value === "string" ? parseFloat(value) : value;
-
- if (Number.isNaN(numericValue) || numericValue < 0) {
- setPickQtyData((prev) => {
- if (!Object.prototype.hasOwnProperty.call(prev, lotKey)) return prev;
- const next = { ...prev };
- delete next[lotKey];
- return next;
- });
- return;
- }
-
- setPickQtyData((prev) => ({
- ...prev,
- [lotKey]: numericValue,
- }));
- }, []);
-
- const [autoAssignStatus, setAutoAssignStatus] = useState<'idle' | 'checking' | 'assigned' | 'no_orders'>('idle');
- const [autoAssignMessage, setAutoAssignMessage] = useState<string>('');
- const [completionStatus, setCompletionStatus] = useState<PickOrderCompletionResponse | null>(null);
-
- 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]);
-
- // Handle reject lot
- // 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);
- }, []);
-
- const handlePickExecutionFormSubmit = useCallback(async (data: any) => {
- try {
- console.log("Pick execution form submitted:", data);
- const issueData = {
- ...data,
- type: "Do", // Delivery Order Record 类型
- pickerName: session?.user?.name || '',
- };
-
- const result = await recordPickExecutionIssue(issueData);
- console.log("Pick execution issue recorded:", result);
-
- if (result && result.code === "SUCCESS") {
- console.log(" Pick execution issue recorded successfully");
- // 关键:issue form 只记录问题,不会更新 SOL.qty
- // 但 batch submit 需要知道“实际拣到多少”,否则会按 requiredQty 补拣到满
- const solId = Number(issueData.stockOutLineId || issueData.stockOutLineId === 0 ? issueData.stockOutLineId : data?.stockOutLineId);
- if (solId > 0) {
- const picked = Number(issueData.actualPickQty || 0);
- setIssuePickedQtyBySolId((prev) => {
- const next = { ...prev, [solId]: picked };
- const doId = fgPickOrders[0]?.doPickOrderId;
- if (doId) saveIssuePickedMap(doId, next);
- return next;
- });
- setCombinedLotData(prev => prev.map(lot => {
- if (Number(lot.stockOutLineId) === solId) {
- return { ...lot, actualPickQty: picked, stockOutLineQty: picked };
- }
- return lot;
- }));
- }
- } else {
- console.error(" Failed to record pick execution issue:", result);
- }
-
- setPickExecutionFormOpen(false);
- setSelectedLotForExecutionForm(null);
- setQrScanError(false);
- setQrScanSuccess(false);
- setQrScanInput('');
- // ✅ Keep scanner active after form submission - don't stop scanning
- // Only clear processed QR codes for the specific lot, not all
- // setIsManualScanning(false); // Removed - keep scanner active
- // stopScan(); // Removed - keep scanner active
- // resetScan(); // Removed - keep scanner active
- // Don't clear all processed codes - only clear for this specific lot if needed
- await fetchAllCombinedLotData();
- } catch (error) {
- console.error("Error submitting pick execution form:", error);
- }
- }, [fetchAllCombinedLotData, session, fgPickOrders]);
-
- // 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);
- }, []);
-
- // Search criteria
- const searchCriteria: Criterion<any>[] = [
- {
- label: t("Pick Order Code"),
- paramName: "pickOrderCode",
- type: "text",
- },
- {
- label: t("Item Code"),
- paramName: "itemCode",
- type: "text",
- },
- {
- label: t("Item Name"),
- paramName: "itemName",
- type: "text",
- },
- {
- label: t("Lot No"),
- paramName: "lotNo",
- type: "text",
- },
- ];
-
- const handleSearch = useCallback((query: Record<string, any>) => {
- setSearchQuery({ ...query });
- console.log("Search query:", query);
-
- if (!originalCombinedData) return;
-
- const filtered = originalCombinedData.filter((lot: any) => {
- const pickOrderCodeMatch = !query.pickOrderCode ||
- lot.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase());
-
- const itemCodeMatch = !query.itemCode ||
- lot.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase());
-
- const itemNameMatch = !query.itemName ||
- lot.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase());
-
- const lotNoMatch = !query.lotNo ||
- lot.lotNo?.toLowerCase().includes((query.lotNo || "").toLowerCase());
-
- return pickOrderCodeMatch && itemCodeMatch && itemNameMatch && lotNoMatch;
- });
-
- setCombinedLotData(filtered);
- console.log("Filtered lots count:", filtered.length);
- }, [originalCombinedData]);
-
- const handleReset = useCallback(() => {
- setSearchQuery({});
- if (originalCombinedData) {
- setCombinedLotData(originalCombinedData);
- }
- }, [originalCombinedData]);
-
- const handlePageChange = useCallback((event: unknown, newPage: number) => {
- setPaginationController(prev => ({
- ...prev,
- pageNum: newPage,
- }));
- }, []);
-
- const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
- const newPageSize = parseInt(event.target.value, 10);
- setPaginationController({
- pageNum: 0,
- pageSize: newPageSize === -1 ? -1 : newPageSize,
- });
- }, []);
-
- // ✅ Workbench list: group same item (within same route), sort completed lots first,
- // and suppress repeated item fields on subsequent rows.
- const paginatedData = useMemo(() => {
- type RowMeta = {
- lot: any;
- isGroupFirst: boolean;
- groupDisplayIndex: number;
- };
-
- const isCompletedStatus = (lot: any) => {
- const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
- return (
- st === "completed" ||
- st === "partially_completed" ||
- st === "partially_complete"
- );
- };
-
- const isCheckedStatus = (lot: any) => {
- const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
- return st === "checked";
- };
-
- const statusRank = (lot: any) => {
- // Desired order within same item:
- // completed -> checked -> pending -> rejected -> others
- const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
- if (isCompletedStatus(lot)) return 0;
- if (isCheckedStatus(lot)) return 1;
- if (st === "pending") return 2;
- if (st === "rejected") return 3;
- return 9;
- };
-
- // Keep stable group ordering by first appearance.
- const groups = new Map<
- string,
- { firstIndex: number; items: { lot: any; originalIndex: number }[] }
- >();
- combinedLotData.forEach((lot: any, originalIndex: number) => {
- const routeKey = String(lot?.routerRoute ?? "").trim();
- const itemKey =
- lot?.itemId != null
- ? `itemId:${String(lot.itemId)}`
- : `itemCode:${String(lot?.itemCode ?? "").trim()}`;
- // Group only within same route to avoid collapsing different routes visually.
- const key = `${routeKey}__${itemKey}`;
- const g = groups.get(key);
- if (!g) {
- groups.set(key, { firstIndex: originalIndex, items: [{ lot, originalIndex }] });
- } else {
- g.items.push({ lot, originalIndex });
- }
- });
-
- const groupEntries = Array.from(groups.values()).sort(
- (a, b) => a.firstIndex - b.firstIndex,
- );
-
- const flattened: RowMeta[] = [];
- for (let gi = 0; gi < groupEntries.length; gi += 1) {
- const g = groupEntries[gi];
- // Re-number groups contiguously (avoid gaps after grouping)
- const groupDisplayIndex = gi + 1;
- const sortedWithin = [...g.items].sort((a, b) => {
- const ra = statusRank(a.lot);
- const rb = statusRank(b.lot);
- if (ra !== rb) return ra - rb;
- return a.originalIndex - b.originalIndex; // stable fallback
- });
- sortedWithin.forEach((it, idx) => {
- flattened.push({
- lot: it.lot,
- isGroupFirst: idx === 0,
- groupDisplayIndex,
- });
- });
- }
-
- if (paginationController.pageSize === -1) return flattened;
- const startIndex =
- paginationController.pageNum * paginationController.pageSize;
- const endIndex = startIndex + paginationController.pageSize;
- return flattened.slice(startIndex, endIndex);
- }, [combinedLotData, paginationController.pageNum, paginationController.pageSize]);
- const allItemsReady = useMemo(() => {
- if (combinedLotData.length === 0) return false;
-
- return combinedLotData.every((lot: any) => {
- const status = lot.stockOutLineStatus?.toLowerCase();
- const isRejected =
- status === 'rejected' || lot.lotAvailability === 'rejected';
- const isCompleted =
- status === 'completed' || status === 'partially_completed' || status === 'partially_complete';
- const isChecked = status === 'checked';
- const isPending = status === 'pending';
-
- // ✅ FIXED: 无库存(noLot)行:pending 状态也应该被视为 ready(可以提交)
- // ✅ 過期批號(未換批):與 noLot 相同,視為可收尾
- if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) {
- return isChecked || isCompleted || isRejected || isPending;
- }
-
- // 正常 lot:必须已扫描/提交或者被拒收
- return isChecked || isCompleted || isRejected;
- });
- }, [combinedLotData]);
- 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;
- }
-
- 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;
-
- // Workbench「Just Completed」:不掃 QR,直接用列上的 lotNo + stockInLineId 走 scan-pick 完成庫存扣帳
- 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 canPostScanPick =
- // unavailable lot: Just Completed must always submit qty=0, even without lotNo
- isUnavailableForJustComplete || (
- canonicalLotForSol.lotNo && String(canonicalLotForSol.lotNo).trim() !== "" && (
- // explicit short submit: user typed 0 (must send qty=0 to backend)
- (hasExplicitSubmitOverride &&
- Number.isFinite(explicitSubmitOverride) &&
- explicitSubmitOverride === 0) ||
- // normal pick: positive qty
- (wbJustQty != null && wbJustQty > 0)
- )
- );
-
- if (canPostScanPick) {
- const qtyToSend = isUnavailableForJustComplete
- ? 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: fgPickOrders?.[0]?.storeId ?? null,
- 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));
- const entity = res.entity as any;
- const nextStatus = String(entity?.status ?? "completed").toLowerCase();
- const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
- 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 a valid lot number and 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}`);
-
- // ✅ Workbench: route qty=0 through scan-pick as well (backend supports zero-complete).
- // Allow empty lotNo for noLot/expired/unavailable rows.
- 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: fgPickOrders?.[0]?.storeId ?? null,
- 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;
- }
-
- // Legacy non-workbench path used `checked` as an intermediate state.
- // Workbench mode is always true in this page; keep this branch unreachable.
- throw new Error("Unsupported legacy checked flow on workbench page");
- }
-
- // DO Workbench: inventory posting + SOL/POL rules live in /doPickOrder/workbench/scan-pick
- 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: fgPickOrders?.[0]?.storeId ?? null,
- 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 entity = res.entity as any;
- const nextStatus = String(entity?.status ?? "completed").toLowerCase();
- const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
- 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;
- }
-
- // FIXED: Calculate cumulative quantity correctly
- const currentActualPickQty = lot.actualPickQty || 0;
- const cumulativeQty = currentActualPickQty + effectiveSubmitQty;
-
- // FIXED: Determine status based on cumulative quantity vs required quantity
- let newStatus = 'partially_completed';
-
- if (cumulativeQty >= lot.requiredQty) {
- newStatus = 'completed';
- } else if (cumulativeQty > 0) {
- newStatus = 'partially_completed';
- } else {
- // Legacy non-workbench path used `checked` as an intermediate state.
- // Workbench posts immediately via scan-pick.
- 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,
- // 后端 updateStatus 的 qty 是“增量 delta”,不能传 cumulativeQty(否则会重复累加导致 out/hold 大幅偏移)
- qty: effectiveSubmitQty
- });
- applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), newStatus, cumulativeQty);
- }
- // 注意:库存过账(hold->out)与 ledger 由后端 updateStatus 内部统一处理;
- // 前端不再额外调用 updateInventoryLotLineQuantities(operation='pick'),避免 double posting。
-
- // Workbench completion is handled in backend scan-pick flow.
-
- void fetchAllCombinedLotData();
- 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 }));
- }
- }, [
- fetchAllCombinedLotData,
- checkAndAutoAssignNext,
- actionBusyBySolId,
- applyLocalStockOutLineUpdate,
- workbenchMode,
- currentUserId,
- rememberWorkbenchScanReject,
- clearWorkbenchScanReject,
- refreshWorkbenchAfterScanPick,
- combinedLotData,
- workbenchScanPickQtyFromLot,
- t,
- ]);
-
- 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 handleStartScan = useCallback(() => {
- const startTime = performance.now();
- console.log(` [START SCAN] Called at: ${new Date().toISOString()}`);
- console.log(` [START SCAN] Starting manual QR scan...`);
-
- setIsManualScanning(true);
- const setManualScanningTime = performance.now() - startTime;
- console.log(` [START SCAN] setManualScanning time: ${setManualScanningTime.toFixed(2)}ms`);
-
- setProcessedQrCodes(new Set());
- setLastProcessedQr('');
- setQrScanError(false);
- setQrScanSuccess(false);
-
- const beforeStartScanTime = performance.now();
- startScan();
- const startScanTime = performance.now() - beforeStartScanTime;
- console.log(` [START SCAN] startScan() call time: ${startScanTime.toFixed(2)}ms`);
-
- const totalTime = performance.now() - startTime;
- console.log(` [START SCAN] Total start scan time: ${totalTime.toFixed(2)}ms`);
- console.log(` [START SCAN] Start scan completed at: ${new Date().toISOString()}`);
- }, [startScan]);
- const handlePickOrderSwitch = useCallback(async (pickOrderId: number) => {
- if (pickOrderSwitching) return;
-
- setPickOrderSwitching(true);
- try {
- console.log(" Switching to pick order:", pickOrderId);
- setSelectedPickOrderId(pickOrderId);
-
- // 强制刷新数据,确保显示正确的 pick order 数据
- await fetchAllCombinedLotData(currentUserId, pickOrderId);
- } catch (error) {
- console.error("Error switching pick order:", error);
- } finally {
- setPickOrderSwitching(false);
- }
- }, [pickOrderSwitching, currentUserId, fetchAllCombinedLotData]);
-
- const handleStopScan = useCallback(() => {
- console.log("⏸️ Pausing QR scanner...");
- setIsManualScanning(false);
- setQrScanError(false);
- setQrScanSuccess(false);
- stopScan();
- resetScan();
- }, [stopScan, resetScan]);
- // ... existing code around line 1469 ...
- const handlelotnull = useCallback(async (lot: any) => {
- // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId
- const stockOutLineId = lot.stockOutLineId;
-
- if (!stockOutLineId) {
- console.error(" No stockOutLineId found for lot:", lot);
- return;
- }
- const solId = Number(stockOutLineId);
- if (solId > 0 && actionBusyBySolId[solId]) {
- console.warn("Action already in progress for stockOutLineId:", solId);
- return;
- }
-
- try {
- if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true }));
- // Step 1: Update stock out line status
- await updateStockOutLineStatus({
- id: stockOutLineId,
- status: 'completed',
- qty: 0
- });
-
- // Step 2: Create pick execution issue for no-lot case
- // Get pick order ID from fgPickOrders or use 0 if not available
- const pickOrderId = lot.pickOrderId || fgPickOrders[0]?.pickOrderId || 0;
- const pickOrderCode = lot.pickOrderCode || fgPickOrders[0]?.pickOrderCode || lot.pickOrderConsoCode || '';
-
- const issueData: PickExecutionIssueData = {
- type: "Do", // Delivery Order type
- pickOrderId: pickOrderId,
- pickOrderCode: pickOrderCode,
- pickOrderCreateDate: dayjs().format('YYYY-MM-DD'), // Use dayjs format
- pickExecutionDate: dayjs().format('YYYY-MM-DD'),
- pickOrderLineId: lot.pickOrderLineId,
- itemId: lot.itemId,
- itemCode: lot.itemCode || '',
- itemDescription: lot.itemName || '',
- lotId: null, // No lot available
- lotNo: null, // No lot number
- storeLocation: lot.location || '',
- requiredQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0,
- actualPickQty: 0, // No items picked (no lot available)
- missQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0, // All quantity is missing
- badItemQty: 0,
- issueRemark: `No lot available for this item. Handled via handlelotnull.`,
- pickerName: session?.user?.name || '',
-
- };
-
- const result = await recordPickExecutionIssue(issueData);
- console.log(" Pick execution issue created for no-lot item:", result);
-
- if (result && result.code === "SUCCESS") {
- console.log(" No-lot item handled and issue recorded successfully");
- } else {
- console.error(" Failed to record pick execution issue:", result);
- }
-
- // Step 3: Refresh data
- await fetchAllCombinedLotData();
- } catch (error) {
- console.error(" Error in handlelotnull:", error);
- } finally {
- if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false }));
- }
- }, [fetchAllCombinedLotData, session, currentUserId, fgPickOrders, actionBusyBySolId]);
- const handleBatchScan = useCallback(async () => {
- const startTime = performance.now();
- console.log(` [BATCH SCAN START]`);
- console.log(` Start time: ${new Date().toISOString()}`);
-
- // 获取所有活跃批次(未扫描的)
- const activeLots = combinedLotData.filter(lot => {
- return (
- lot.lotAvailability !== 'rejected' &&
- lot.stockOutLineStatus !== 'rejected' &&
- lot.stockOutLineStatus !== 'completed' &&
- lot.stockOutLineStatus !== 'checked' && // ✅ 只处理未扫描的
- lot.processingStatus !== 'completed' &&
- lot.noLot !== true &&
- lot.lotNo // ✅ 必须有 lotNo
- );
- });
-
- if (activeLots.length === 0) {
- console.log("No active lots to scan");
- return;
- }
-
- console.log(`📦 Batch scanning ${activeLots.length} active lots using batch API...`);
-
- try {
- // ✅ 转换为批量扫描 API 所需的格式
- const lines: BatchScanLineRequest[] = activeLots.map((lot) => ({
- pickOrderLineId: Number(lot.pickOrderLineId),
- inventoryLotLineId: lot.lotId ? Number(lot.lotId) : null,
- pickOrderConsoCode: String(lot.pickOrderConsoCode || ''),
- lotNo: lot.lotNo || null,
- itemId: Number(lot.itemId),
- itemCode: String(lot.itemCode || ''),
- stockOutLineId: lot.stockOutLineId ? Number(lot.stockOutLineId) : null, // ✅ 新增
- }));
-
- const request: BatchScanRequest = {
- userId: currentUserId || 0,
- lines: lines
- };
-
- console.log(`📤 Sending batch scan request with ${lines.length} lines`);
- console.log(`📋 Request data:`, JSON.stringify(request, null, 2));
-
- const scanStartTime = performance.now();
-
- // ✅ 使用新的批量扫描 API(一次性处理所有请求)
- const result = await batchScan(request);
-
- const scanTime = performance.now() - scanStartTime;
- console.log(` Batch scan API call completed in ${scanTime.toFixed(2)}ms (${(scanTime / 1000).toFixed(3)}s)`);
- console.log(`📥 Batch scan result:`, result);
-
- // ✅ 刷新数据以获取最新的状态
- const refreshStartTime = performance.now();
- await fetchAllCombinedLotData();
- 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 SCAN 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);
- setQrScanError(false);
- } else {
- console.error("❌ Batch scan failed:", result);
- setQrScanError(true);
- setQrScanSuccess(false);
- }
-
- } catch (error) {
- console.error("❌ Error in batch scan:", error);
- setQrScanError(true);
- setQrScanSuccess(false);
- }
- }, [combinedLotData, fetchAllCombinedLotData, currentUserId]);
- 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;
- }
- // Workbench batch submit is now dedicated to closing noLot / expired / unavailable rows (qty=0 via workbench scan-pick batch).
- 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 path allows empty lotNo (workbench zero-complete)
- qty: 0,
- storeId: fgPickOrders?.[0]?.storeId ?? null,
- 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);
-
- // Refresh data once after batch submission
- const refreshStartTime = performance.now();
- await fetchAllCombinedLotData();
- 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 (onSwitchToRecordTab) {
- onSwitchToRecordTab();
- }
- if (onRefreshReleasedOrderCount) {
- onRefreshReleasedOrderCount();
- }
- }, 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, fetchAllCombinedLotData, checkAndAutoAssignNext, currentUserId, onSwitchToRecordTab, onRefreshReleasedOrderCount, fgPickOrders]);
-
- // Calculate scanned items count
- // Calculate scanned items count (should match handleSubmitAllScanned filter logic)
- const scannedItemsCount = useMemo(() => {
- const filtered = combinedLotData.filter(lot => {
- const status = lot.stockOutLineStatus;
- const statusLower = String(status || "").toLowerCase();
- if (statusLower === "completed" || statusLower === "complete") {
- return false;
- }
- // Keep consistent with handleSubmitAllScanned: batch submit is only for noLot/expired/unavailable.
- 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]);
- /*
- // ADD THIS: Auto-stop scan when no data available
- useEffect(() => {
- if (isManualScanning && combinedLotData.length === 0) {
- console.log("⏹️ No data available, auto-stopping QR scan...");
- handleStopScan();
- }
- }, [combinedLotData.length, isManualScanning, handleStopScan]);
- */
-
- // Cleanup effect
- useEffect(() => {
- return () => {
- // Cleanup when component unmounts (e.g., when switching tabs)
- if (isManualScanning) {
- console.log("🧹 Pick execution component unmounting, stopping QR scanner...");
- stopScan();
- resetScan();
- }
- };
- }, [isManualScanning, stopScan, resetScan]);
-
- const getStatusMessage = useCallback((lot: any) => {
- switch (lot.stockOutLineStatus?.toLowerCase()) {
- case 'pending':
- return t("Please finish QR code scan and 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 (
- <TestQrCodeProvider
- lotData={combinedLotData}
- onScanLot={handleQrCodeSubmit}
- onBatchScan={handleBatchScan}
- filterActive={(lot) => (
- lot.lotAvailability !== 'rejected' &&
- lot.stockOutLineStatus !== 'rejected' &&
- lot.stockOutLineStatus !== 'completed'
- )}
- >
- <FormProvider {...formProps}>
- <Stack spacing={2}>
- <Box
- sx={{
- // Keep visible while scrolling, but don't cover global top header
- position: 'sticky',
- top: 0,
- zIndex: 5,
- backgroundColor: 'background.paper',
- pt: 1,
- pb: 1,
- px: 2,
- borderBottom: '1px solid',
- borderColor: 'divider',
- boxShadow: 'none',
- }}
- >
- <LinearProgressWithLabel
- completed={progress.completed}
- total={progress.total}
- label={t("Progress")}
- />
- <ScanStatusAlert
- error={qrScanError}
- success={qrScanSuccess}
- errorMessage={t("QR code does not match any item in current orders.")}
- successMessage={t("QR code verified.")}
- />
- </Box>
- {/* DO Header */}
-
-
-
-
- {/* 保留:Combined Lot Table - 包含所有 QR 扫描功能 */}
- <Box>
- <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, mt: 2 }}>
- <Typography variant="h6" gutterBottom sx={{ mb: 0 }}>
- {t("All Pick Order Lots")}
- </Typography>
-
- <Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
- {/* Scanner status indicator (always visible) */}
- {/*
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
- <QrCodeIcon
- sx={{
- color: isManualScanning ? '#4caf50' : '#9e9e9e',
- animation: isManualScanning ? 'pulse 2s infinite' : 'none',
- '@keyframes pulse': {
- '0%, 100%': { opacity: 1 },
- '50%': { opacity: 0.5 }
- }
- }}
- />
- <Typography variant="body2" sx={{ color: isManualScanning ? '#4caf50' : '#9e9e9e' }}>
- {isManualScanning ? t("Scanner Active") : t("Scanner Inactive")}
- </Typography>
- </Box>
- */}
-
- {/* Pause/Resume button instead of Start/Stop */}
- {isManualScanning ? (
- <Button
- variant="outlined"
- startIcon={<QrCodeIcon />}
- onClick={handleStopScan}
- color="secondary"
- sx={{ minWidth: '120px' }}
- >
- {t("Stop QR Scan")}
- </Button>
- ) : (
- <Button
- variant="contained"
- startIcon={<QrCodeIcon />}
- onClick={handleStartScan}
- color="primary"
- sx={{ minWidth: '120px' }}
- >
- {t("Start QR Scan")}
- </Button>
- )}
-
- {/* 保留:Submit All Scanned Button */}
- <Button
- variant="contained"
- color="success"
- onClick={handleSubmitAllScanned}
- disabled={
- scannedItemsCount === 0
- || isSubmittingAll}
- sx={{ minWidth: '160px' }}
- >
- {isSubmittingAll ? (
- <>
- <CircularProgress size={16} sx={{ mr: 1, color: 'white' }} />
- {t("Submitting...")}
- </>
- ) : (
- `${t("Submit All Scanned")} (${scannedItemsCount})`
- )}
- </Button>
- </Box>
- </Box>
-
-
- {fgPickOrders.length > 0 && (
- <Paper sx={{ p: 2, mb: 2 }}>
- <Stack spacing={2}>
- {/* 基本信息 */}
- <Stack direction="row" spacing={4} useFlexGap flexWrap="wrap">
- <Typography variant="subtitle1">
- <strong>{t("Shop Name")}:</strong> {fgPickOrders[0].shopName || '-'}
- </Typography>
- <Typography variant="subtitle1">
- <strong>{t("Store ID")}:</strong> {fgPickOrders[0].storeId || '-'}
- </Typography>
- <Typography variant="subtitle1">
- <strong>{t("Ticket No.")}:</strong> {fgPickOrders[0].ticketNo || '-'}
- </Typography>
- <Typography variant="subtitle1">
- <strong>{t("Departure Time")}:</strong> {fgPickOrders[0].DepartureTime || '-'}
- </Typography>
- </Stack>
-
- {/* 改进:三个字段显示在一起,使用表格式布局 */}
- {/* 改进:三个字段合并显示 */}
- {/* 改进:表格式显示每个 pick order */}
- <Box sx={{
- p: 2,
- backgroundColor: '#f5f5f5',
- borderRadius: 1
- }}>
- <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 'bold' }}>
- {t("Pick Orders Details")}:
- </Typography>
-
- {(() => {
- const pickOrderCodes = fgPickOrders[0].pickOrderCodes as string[] | string | undefined;
- const deliveryNos = fgPickOrders[0].deliveryNos as string[] | string | undefined;
- const lineCounts = fgPickOrders[0].lineCountsPerPickOrder;
-
- const pickOrderCodesArray = Array.isArray(pickOrderCodes)
- ? pickOrderCodes
- : (typeof pickOrderCodes === 'string' ? pickOrderCodes.split(', ') : []);
-
- const deliveryNosArray = Array.isArray(deliveryNos)
- ? deliveryNos
- : (typeof deliveryNos === 'string' ? deliveryNos.split(', ') : []);
-
- const lineCountsArray = Array.isArray(lineCounts) ? lineCounts : [];
-
- const maxLength = Math.max(
- pickOrderCodesArray.length,
- deliveryNosArray.length,
- lineCountsArray.length
- );
-
- if (maxLength === 0) {
- return <Typography variant="body2" color="text.secondary">-</Typography>;
- }
-
- // 使用与外部基本信息相同的样式
- return Array.from({ length: maxLength }, (_, idx) => (
- <Stack
- key={idx}
- direction="row"
- spacing={4}
- useFlexGap
- flexWrap="wrap"
- sx={{ mb: idx < maxLength - 1 ? 1 : 0 }} // 除了最后一行,都添加底部间距
- >
- <Typography variant="subtitle1">
- <strong>{t("Delivery Order")}:</strong> {deliveryNosArray[idx] || '-'}
- </Typography>
- <Typography variant="subtitle1">
- <strong>{t("Pick Order")}:</strong> {pickOrderCodesArray[idx] || '-'}
- </Typography>
- <Typography variant="subtitle1">
- <strong>{t("Finsihed good items")}:</strong> {lineCountsArray[idx] || '-'}<strong>{t("kinds")}</strong>
- </Typography>
- </Stack>
- ));
- })()}
- </Box>
- </Stack>
- </Paper>
- )}
-
-
- <TableContainer component={Paper}>
- <Table>
- <TableHead>
- <TableRow>
- <TableCell>{t("Index")}</TableCell>
-
- <TableCell>{t("Item Code")}</TableCell>
- <TableCell>{t("Item Name")}</TableCell>
- <TableCell>{t("Route")}</TableCell>
- <TableCell>{t("Suggest Lot No.")}</TableCell>
- <TableCell align="right">{t("Lot Required Pick Qty")}</TableCell>
- <TableCell align="center">{t("Scan Result")}</TableCell>
- {/*<TableCell align="center">{t("Qty will submit")}</TableCell>*/}
- <TableCell align="center">{t("Submit Required Pick Qty")}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {paginatedData.length === 0 ? (
- <TableRow>
- <TableCell colSpan={11} align="center">
- <Typography variant="body2" color="text.secondary">
- {t("No data available")}
- </Typography>
- </TableCell>
- </TableRow>
- ) : (
- // 在第 1797-1938 行之间,将整个 map 函数修改为:
- paginatedData.map((row, index) => {
- const lot = row.lot;
- const solIdForKey = Number(lot.stockOutLineId) || 0;
- const lotKeyForSubmitQty =
- Number.isFinite(solIdForKey) && solIdForKey > 0 ? `sol:${solIdForKey}` : `${lot.pickOrderLineId}-${lot.lotId}`;
- const lockedSubmitQtyDisplay = isInventoryLotLineUnavailable(lot) ? 0 : resolveSingleSubmitQty(lot);
- const 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;
-
- // 检查是否是 issue lot
- const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo;
- const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t);
- const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
- const isSolRejected =
- solSt === "rejected" || String(lot.lotAvailability || "").toLowerCase() === "rejected";
-
- return (
- <TableRow
- key={`${lot.pickOrderLineId}-${lot.lotId || 'null'}`}
- sx={{
- //backgroundColor: isIssueLot ? '#fff3e0' : 'inherit',
- // opacity: isIssueLot ? 0.6 : 1,
- '& .MuiTableCell-root': {
- //color: isIssueLot ? 'warning.main' : 'inherit'
- }
- }}
- >
- <TableCell>
- <Typography variant="body2" fontWeight="bold">
- {row.isGroupFirst ? row.groupDisplayIndex : ""}
- </Typography>
- </TableCell>
-
- <TableCell>{row.isGroupFirst ? lot.itemCode : ""}</TableCell>
- <TableCell>
- {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""}
- </TableCell>
- <TableCell>
- <Typography variant="body2">
- {lot.routerRoute || '-'}
- </Typography>
- </TableCell>
- <TableCell>
- <Stack direction="row" spacing={1} alignItems="flex-start">
- <Box sx={{ flex: 1, minWidth: 0 }}>
- <Typography
- sx={{
- color:
- rejectDisplay || isSolRejected
- ? 'error.main'
- : isInventoryLotLineUnavailable(lot)
- ? 'error.main'
- : lot.lotAvailability === 'expired'
- ? 'warning.main'
- : 'inherit',
- }}
- >
- {lot.lotNo ? (
- rejectDisplay ? (
- <>
- {lot.lotNo}
- <Box
- component="span"
- sx={{ display: 'block', mt: 0.25, typography: 'body2', fontWeight: 400 }}
- >
- {rejectDisplay}
- </Box>
- </>
- ) :
-
- lot.lotAvailability === 'expired' ? (
- <>
- {lot.lotNo}{' '}
- {t('is expired. Please check around have available QR code or not.')}
- </>
- ) : isInventoryLotLineUnavailable(lot) ? (
- <>
- {lot.lotNo}{' '}
- {t('is unavable. Please check around have available QR code or not.')}
- </>
- ) : (
- lot.lotNo
- )
- ) : (
- <Box component="span" sx={{ fontSize: "0.85rem", lineHeight: 1.4 }}>
- {rejectDisplay ||
- t(
- "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.",
- )}
- </Box>
- )}
- </Typography>
- </Box>
- {Number(lot.stockOutLineId) > 0 && Number(lot.itemId) > 0 ? (
- <Button
- variant="outlined"
- size="small"
- onClick={() => openWorkbenchLotLabelModalForLot(lot)}
- sx={{
- flexShrink: 0,
- fontSize: "0.7rem",
- py: 0.25,
- minWidth: "auto",
- px: 1,
- whiteSpace: "nowrap",
- }}
- >
- {t("lot QR code")}
- </Button>
- ) : null}
- </Stack>
- </TableCell>
- <TableCell align="right">
- {(() => {
- const requiredQty = lot.requiredQty || 0;
- return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')';
- })()}
- </TableCell>
-
- <TableCell align="center">
- {(() => {
- const status = lot.stockOutLineStatus?.toLowerCase();
- const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected';
- const isNoLot = !lot.lotNo;
-
- // rejected lot:显示红色勾选(已扫描但被拒绝)
- if (isRejected && !isNoLot) {
- return (
- <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
- <Checkbox
- checked={true}
- disabled={true}
- readOnly={true}
- size="large"
- sx={{
- color: 'error.main',
- '&.Mui-checked': { color: 'error.main' },
- transform: 'scale(1.3)',
- }}
- />
- </Box>
- );
- }
-
- // 過期批號:與 noLot 同類——視為已掃到/可處理(含 pending),顯示警示色勾選
- if (isLotAvailabilityExpired(lot) && status !== "rejected") {
- return (
- <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
- <Checkbox
- checked={true}
- disabled={true}
- readOnly={true}
- size="large"
- sx={{
- color: "warning.main",
- "&.Mui-checked": { color: "warning.main" },
- transform: "scale(1.3)",
- }}
- />
- </Box>
- );
- }
-
- // 正常 lot:已扫描(checked/partially_completed/completed)
- if (!isNoLot && status !== 'pending' && status !== 'rejected') {
- return (
- <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
- <Checkbox
- checked={true}
- disabled={true}
- readOnly={true}
- size="large"
- sx={{
- color: 'success.main',
- '&.Mui-checked': { color: 'success.main' },
- transform: 'scale(1.3)',
- }}
- />
- </Box>
- );
- }
-
- // noLot 且已完成/部分完成:显示红色勾选
- if (isNoLot && (status === 'partially_completed' || status === 'completed')) {
- return (
- <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
- <Checkbox
- checked={true}
- disabled={true}
- readOnly={true}
- size="large"
- sx={{
- color: 'error.main',
- '&.Mui-checked': { color: 'error.main' },
- transform: 'scale(1.3)',
- }}
- />
- </Box>
- );
- }
-
- return null;
- })()}
- </TableCell>
- {/*
- <TableCell align="center">
- {workbenchSubmitQtyDisplay}
- </TableCell>
- */}
- <TableCell align="center">
- <Box sx={{ display: 'flex', justifyContent: 'center' }}>
- {(() => {
- const status = lot.stockOutLineStatus?.toLowerCase();
- const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected';
- const isNoLot = !lot.lotNo;
- const isUnavailableRow = isInventoryLotLineUnavailable(lot);
-
- // ✅ rejected lot:显示提示文本(换行显示)
- if (isRejected && !isNoLot) {
- const rejectHint = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t);
- return (
- <Typography
- variant="body2"
- color="error.main"
- sx={{
- textAlign: 'center',
- whiteSpace: 'normal',
- wordBreak: 'break-word',
- maxWidth: '200px',
- lineHeight: 1.5
- }}
- >
- {rejectHint || t("This lot is rejected, please scan another lot.")}
- </Typography>
- );
- }
-
- // noLot 且非 unavailable:保留舊行為(Issue)
- if (isNoLot && !isUnavailableRow) {
- return (
- <Button
- variant="outlined"
- size="small"
- onClick={() => handlelotnull(lot)}
- /*
- disabled={
- status === 'completed' ||
- (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
- }
- */
- disabled={true}
- sx={{
- fontSize: '0.7rem',
- py: 0.5,
- minHeight: '28px',
- minWidth: '60px',
- borderColor: 'warning.main',
- color: 'warning.main'
- }}
- >
- {t("Issue")}
- </Button>
- );
- }
-
- // 正常 lot:Submit + 可編輯數量(Edit 解鎖輸入,不再開 issue form)
- {
- 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 (
- <Stack direction="row" spacing={1} alignItems="center">
- {/*
- <Button
- variant="contained"
- onClick={() => {
- const submitQty = displayedSubmitQty;
- handlePickQtyChange(lotKey, submitQty);
- handleSubmitPickQtyWithQty(lot, submitQty, 'singleSubmit');
- }}
- disabled={
- lot.lotAvailability === 'expired' ||
- isInventoryLotLineUnavailable(lot) ||
- lot.lotAvailability === 'rejected' ||
- lot.stockOutLineStatus === 'completed' ||
- lot.stockOutLineStatus === 'pending' ||
- (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
- }
- sx={{ fontSize: '0.75rem', py: 0.5, minHeight: '28px', minWidth: '70px' }}
- >
- {t("Submit")}
- </Button>
- */}
-
- <TextField
- type="number"
- size="small"
- disabled={!qtyFieldEnabled}
- value={textFieldValue}
- onKeyDown={(e) => {
- if (!qtyFieldEnabled) return;
- if (e.key !== "{") return;
- e.preventDefault();
- setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({
- ...prev,
- [lotKey]: false,
- }));
- (e.currentTarget as HTMLInputElement).blur();
- }}
- onChange={(e) => {
- if (!qtyFieldEnabled) return;
- const n = Number(e.target.value);
- if (Number.isFinite(n) && n < 0) return;
- handlePickQtyChange(lotKey, e.target.value);
- }}
- inputProps={{ min: 0, step: 1 }}
- sx={{
- width: 96,
- '& .MuiInputBase-input': { fontSize: '0.75rem', py: 0.5, textAlign: 'center' },
- }}
- />
-
- <Button
- variant="outlined"
- size="small"
- onClick={() => {
- setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({
- ...prev,
- [lotKey]: !(prev[lotKey] === true),
- }));
- }}
- disabled={
- lot.stockOutLineStatus === 'completed' ||
- (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
- }
- sx={{
- fontSize: '0.7rem',
- py: 0.5,
- minHeight: '28px',
- minWidth: '60px',
- borderColor: 'warning.main',
- color: 'warning.main',
- }}
- title={qtyFieldEnabled ? t('Lock quantity') : t('Edit quantity')}
- >
- {t("Edit")}
- </Button>
- <Button
- variant="outlined"
- size="small"
- onClick={() => handleSkip(lot)}
- disabled={
- lot.stockOutLineStatus === 'completed' ||
- lot.stockOutLineStatus === 'checked' ||
- lot.stockOutLineStatus === 'partially_completed' ||
-
- // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交)
- (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) ||
- (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
- }
-
-
- sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '60px' }}
- >
- {t("Just Completed")}
- </Button>
- </Stack>
- );
- }
- })()}
- </Box>
- </TableCell>
- </TableRow>
- );
- })
- )}
- </TableBody>
- </Table>
- </TableContainer>
-
- <TablePagination
- component="div"
- count={combinedLotData.length}
- page={paginationController.pageNum}
- rowsPerPage={paginationController.pageSize}
- onPageChange={handlePageChange}
- onRowsPerPageChange={handlePageSizeChange}
- rowsPerPageOptions={[10, 25, 50,-1]}
- labelRowsPerPage={t("Rows per page")}
- labelDisplayedRows={({ from, to, count }) =>
- `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
- }
- />
- </Box>
- </Stack>
-
- {/* QR Code Scanner works in background - no modal needed */}
- <ManualLotConfirmationModal
- open={manualLotConfirmationOpen}
- onClose={() => {
- setManualLotConfirmationOpen(false);
- }}
- onConfirm={handleManualLotConfirmation}
- expectedLot={null}
- scannedLot={null}
- isLoading={false}
- />
-
- {/* 保留:Good Pick Execution Form Modal */}
- {pickExecutionFormOpen && selectedLotForExecutionForm && (
- <GoodPickExecutionForm
- open={pickExecutionFormOpen}
- onClose={() => {
- setPickExecutionFormOpen(false);
- setSelectedLotForExecutionForm(null);
- }}
- onSubmit={handlePickExecutionFormSubmit}
- selectedLot={selectedLotForExecutionForm}
- selectedPickOrderLine={{
- id: selectedLotForExecutionForm.pickOrderLineId,
- itemId: selectedLotForExecutionForm.itemId,
- itemCode: selectedLotForExecutionForm.itemCode,
- itemName: selectedLotForExecutionForm.itemName,
- pickOrderCode: selectedLotForExecutionForm.pickOrderCode,
- availableQty: selectedLotForExecutionForm.availableQty || 0,
- requiredQty: selectedLotForExecutionForm.requiredQty || 0,
- // uomCode: selectedLotForExecutionForm.uomCode || '',
- uomDesc: selectedLotForExecutionForm.uomDesc || '',
- pickedQty: selectedLotForExecutionForm.actualPickQty || 0,
- uomShortDesc: selectedLotForExecutionForm.uomShortDesc || '',
- suggestedList: [],
- noLotLines: [],
- }}
- pickOrderId={selectedLotForExecutionForm.pickOrderId}
- pickOrderCreateDate={new Date()}
- />
- )}
-
- <WorkbenchLotLabelPrintModal
- open={workbenchLotLabelModalOpen}
- onClose={() => {
- setWorkbenchLotLabelModalOpen(false);
- setWorkbenchLotLabelReminderText(null);
- setWorkbenchLotLabelContextLot(null);
- setWorkbenchLotLabelInitialPayload(null);
- }}
- initialPayload={workbenchLotLabelInitialPayload}
- initialItemId={
- workbenchLotLabelContextLot != null
- ? Number(workbenchLotLabelContextLot.itemId)
- : null
- }
- defaultPrinterName={defaultLabelPrinterName}
- hideScanSection={
- workbenchLotLabelInitialPayload != null ||
- workbenchLotLabelContextLot != null
- }
- reminderText={workbenchLotLabelReminderText ?? undefined}
- statusTitleText={workbenchLotLabelStatusBanner.text}
- statusTitleSeverity={workbenchLotLabelStatusBanner.severity}
- warehouseCodePrefixFilter={lotFloorPrefixFilter}
- triggerLotAvailableQty={
- workbenchLotLabelContextLot != null
- ? Number(workbenchLotLabelContextLot.availableQty)
- : null
- }
- triggerLotUom={
- workbenchLotLabelContextLot != null
- ? String(
- workbenchLotLabelContextLot.uomShortDesc ??
- workbenchLotLabelContextLot.stockUnit ??
- "",
- ).trim() || null
- : null
- }
- disableScanPick={workbenchLotLabelScanPickDisabled}
- onWorkbenchScanPick={handleWorkbenchLotLabelScanPick}
- submitQty={workbenchLotLabelSubmitQty}
- onSubmitQtyChange={handleWorkbenchLotLabelSubmitQtyChange}
- />
- </FormProvider>
-
-
-
-
- </TestQrCodeProvider>
- );
- };
-
- export default WorkbenchGoodPickExecutionDetail;
|