FPSMS-frontend
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

4112 líneas
157 KiB

  1. "use client";
  2. import {
  3. Box,
  4. Button,
  5. Stack,
  6. TextField,
  7. Typography,
  8. Alert,
  9. CircularProgress,
  10. Table,
  11. TableBody,
  12. TableCell,
  13. TableContainer,
  14. TableHead,
  15. TableRow,
  16. Paper,
  17. Checkbox,
  18. TablePagination,
  19. Modal,
  20. Chip,
  21. } from "@mui/material";
  22. import dayjs from 'dayjs';
  23. import TestQrCodeProvider from "@/components/QrCodeScannerProvider/TestQrCodeProvider";
  24. import { fetchLotDetail } from "@/app/api/inventory/actions";
  25. import React, { useCallback, useEffect, useState, useRef, useMemo, startTransition } from "react";
  26. import { useTranslation } from "react-i18next";
  27. import { useRouter } from "next/navigation";
  28. import {
  29. updateStockOutLineStatus,
  30. createStockOutLine,
  31. updateStockOutLine,
  32. recordPickExecutionIssue,
  33. fetchFGPickOrders, // Add this import
  34. FGPickOrderResponse,
  35. stockReponse,
  36. PickExecutionIssueData,
  37. checkPickOrderCompletion,
  38. fetchAllPickOrderLotsHierarchicalWorkbench,
  39. PickOrderCompletionResponse,
  40. updateSuggestedLotLineId,
  41. updateStockOutLineStatusByQRCodeAndLotNo,
  42. confirmLotSubstitution,
  43. fetchDoPickOrderDetail, // 必须添加
  44. DoPickOrderDetail, // 必须添加
  45. batchScan,
  46. BatchScanRequest,
  47. BatchScanLineRequest,
  48. } from "@/app/api/pickOrder/actions";
  49. import { workbenchBatchScanPick, workbenchScanPick } from "@/app/api/doworkbench/actions";
  50. import { workbenchScanPickResponseNeedsFullRefresh } from "@/app/api/doworkbench/workbenchScanPickUtils";
  51. import FGPickOrderInfoCard from "@/components/FinishedGoodSearch/FGPickOrderInfoCard";
  52. //import { fetchItem } from "@/app/api/settings/item";
  53. import { updateInventoryLotLineStatus, analyzeQrCode } from "@/app/api/inventory/actions";
  54. import { fetchNameList, NameList } from "@/app/api/user/actions";
  55. import {
  56. FormProvider,
  57. useForm,
  58. } from "react-hook-form";
  59. import SearchBox, { Criterion } from "@/components/SearchBox";
  60. import { CreateStockOutLine } from "@/app/api/pickOrder/actions";
  61. import { updateInventoryLotLineQuantities } from "@/app/api/inventory/actions";
  62. import QrCodeIcon from '@mui/icons-material/QrCode';
  63. import { useQrCodeScannerContext } from "@/components/QrCodeScannerProvider/QrCodeScannerProvider";
  64. import { useSession } from "next-auth/react";
  65. import { SessionWithTokens } from "@/config/authConfig";
  66. import { fetchStockInLineInfo } from "@/app/api/po/actions";
  67. import GoodPickExecutionForm from "@/components/FinishedGoodSearch/GoodPickExecutionForm";
  68. import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal";
  69. import FGPickOrderCard from "@/components/FinishedGoodSearch/FGPickOrderCard";
  70. import LinearProgressWithLabel from "@/components/common/LinearProgressWithLabel";
  71. import ScanStatusAlert from "@/components/common/ScanStatusAlert";
  72. interface Props {
  73. filterArgs: Record<string, any>;
  74. onSwitchToRecordTab?: () => void;
  75. onRefreshReleasedOrderCount?: () => void;
  76. /** 階層揀貨資料已無(例如訂單已完成/釋放)時通知外層,以便重新檢查是否仍為「已指派」狀態 */
  77. onWorkbenchHierarchyEmpty?: () => void;
  78. }
  79. /** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */
  80. function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null {
  81. if (!activeSuggestedLots?.length) return null;
  82. const withLotNo = activeSuggestedLots.filter(
  83. (l) => l.lotNo != null && String(l.lotNo).trim() !== ""
  84. );
  85. if (withLotNo.length === 1) return withLotNo[0];
  86. if (withLotNo.length > 1) {
  87. const pending = withLotNo.find(
  88. (l) => (l.stockOutLineStatus || "").toLowerCase() === "pending"
  89. );
  90. return pending || withLotNo[0];
  91. }
  92. return activeSuggestedLots[0];
  93. }
  94. const ManualLotConfirmationModal: React.FC<{
  95. open: boolean;
  96. onClose: () => void;
  97. onConfirm: (expectedLotNo: string, scannedLotNo: string) => void;
  98. expectedLot: {
  99. lotNo: string;
  100. itemCode: string;
  101. itemName: string;
  102. } | null;
  103. scannedLot: {
  104. lotNo: string;
  105. itemCode: string;
  106. itemName: string;
  107. } | null;
  108. isLoading?: boolean;
  109. }> = ({ open, onClose, onConfirm, expectedLot, scannedLot, isLoading = false }) => {
  110. const { t } = useTranslation("pickOrder");
  111. const [expectedLotInput, setExpectedLotInput] = useState<string>('');
  112. const [scannedLotInput, setScannedLotInput] = useState<string>('');
  113. const [error, setError] = useState<string>('');
  114. // 当模态框打开时,预填充输入框
  115. useEffect(() => {
  116. if (open) {
  117. setExpectedLotInput(expectedLot?.lotNo || '');
  118. setScannedLotInput(scannedLot?.lotNo || '');
  119. setError('');
  120. }
  121. }, [open, expectedLot, scannedLot]);
  122. const handleConfirm = () => {
  123. if (!expectedLotInput.trim() || !scannedLotInput.trim()) {
  124. setError(t("Please enter both expected and scanned lot numbers."));
  125. return;
  126. }
  127. if (expectedLotInput.trim() === scannedLotInput.trim()) {
  128. setError(t("Expected and scanned lot numbers cannot be the same."));
  129. return;
  130. }
  131. onConfirm(expectedLotInput.trim(), scannedLotInput.trim());
  132. };
  133. return (
  134. <Modal open={open} onClose={onClose}>
  135. <Box sx={{
  136. position: 'absolute',
  137. top: '50%',
  138. left: '50%',
  139. transform: 'translate(-50%, -50%)',
  140. bgcolor: 'background.paper',
  141. p: 3,
  142. borderRadius: 2,
  143. minWidth: 500,
  144. }}>
  145. <Typography variant="h6" gutterBottom color="warning.main">
  146. {t("Manual Lot Confirmation")}
  147. </Typography>
  148. <Box sx={{ mb: 2 }}>
  149. <Typography variant="body2" gutterBottom>
  150. <strong>{t("Expected Lot Number")}:</strong>
  151. </Typography>
  152. <TextField
  153. fullWidth
  154. size="small"
  155. value={expectedLotInput}
  156. onChange={(e) => {
  157. setExpectedLotInput(e.target.value);
  158. setError('');
  159. }}
  160. placeholder={expectedLot?.lotNo || t("Enter expected lot number")}
  161. sx={{ mb: 2 }}
  162. error={!!error && !expectedLotInput.trim()}
  163. />
  164. </Box>
  165. <Box sx={{ mb: 2 }}>
  166. <Typography variant="body2" gutterBottom>
  167. <strong>{t("Scanned Lot Number")}:</strong>
  168. </Typography>
  169. <TextField
  170. fullWidth
  171. size="small"
  172. value={scannedLotInput}
  173. onChange={(e) => {
  174. setScannedLotInput(e.target.value);
  175. setError('');
  176. }}
  177. placeholder={scannedLot?.lotNo || t("Enter scanned lot number")}
  178. sx={{ mb: 2 }}
  179. error={!!error && !scannedLotInput.trim()}
  180. />
  181. </Box>
  182. {error && (
  183. <Box sx={{ mb: 2, p: 1, backgroundColor: '#ffebee', borderRadius: 1 }}>
  184. <Typography variant="body2" color="error">
  185. {error}
  186. </Typography>
  187. </Box>
  188. )}
  189. <Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
  190. <Button onClick={onClose} variant="outlined" disabled={isLoading}>
  191. {t("Cancel")}
  192. </Button>
  193. <Button
  194. onClick={handleConfirm}
  195. variant="contained"
  196. color="warning"
  197. disabled={isLoading || !expectedLotInput.trim() || !scannedLotInput.trim()}
  198. >
  199. {isLoading ? t("Processing...") : t("Confirm")}
  200. </Button>
  201. </Box>
  202. </Box>
  203. </Modal>
  204. );
  205. };
  206. /** 過期批號(未換有效批前):與 noLot 類似——單筆/批量預設提交量為 0,除非 Issue 改數 */
  207. function isLotAvailabilityExpired(lot: any): boolean {
  208. return String(lot?.lotAvailability || "").toLowerCase() === "expired";
  209. }
  210. /** inventory_lot_line.status = unavailable(API 可能用 lotAvailability 或 lotStatus) */
  211. function isInventoryLotLineUnavailable(lot: any): boolean {
  212. if (!lot) return false;
  213. const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
  214. if (solSt === "completed" || solSt === "partially_completed") return false;
  215. if (lot.lotAvailability === "status_unavailable") return true;
  216. return String(lot.lotStatus || "").toLowerCase() === "unavailable";
  217. }
  218. /** 提貨台「列印標籤」彈窗頂部:依目前表格列判斷可提貨/已用畢/已過期等 */
  219. function isWorkbenchSourceLotExpired(lot: any): boolean {
  220. if (!lot) return false;
  221. if (isLotAvailabilityExpired(lot)) return true;
  222. if (String(lot.lotAvailability || "").toLowerCase() === "expired") return true;
  223. if (lot.expiryDate) {
  224. const d = dayjs(lot.expiryDate).startOf("day");
  225. if (d.isValid() && d.isBefore(dayjs().startOf("day"))) return true;
  226. }
  227. return false;
  228. }
  229. function getWorkbenchSourceLotStatusSummary(lot: any): {
  230. severity: "success" | "warning" | "error";
  231. text: string;
  232. } {
  233. if (!lot) {
  234. return { severity: "warning", text: "無法判斷此批號狀態" };
  235. }
  236. if (isWorkbenchSourceLotExpired(lot)) {
  237. return { severity: "error", text: "此批號狀態:已過期" };
  238. }
  239. const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
  240. if (solSt === "rejected") {
  241. return { severity: "warning", text: "此出庫行:已拒絕,請改掃其他批號" };
  242. }
  243. if (solSt === "completed" || solSt === "partially_completed") {
  244. return { severity: "warning", text: "此出庫行:已完成,無需再提貨" };
  245. }
  246. /**
  247. * 無批次列:後端仍標 insufficient_stock,語意是「尚無可出庫批號」而非「已用畢」。
  248. * 與表格紅字「請檢查周圍是否有 QR 碼…」一致。
  249. */
  250. const isNoLotRow =
  251. lot.noLot === true ||
  252. !lot.lotNo ||
  253. String(lot.lotNo || "").trim() === "";
  254. if (isNoLotRow) {
  255. return {
  256. severity: "warning",
  257. text: "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR",
  258. };
  259. }
  260. const av = String(lot.lotAvailability || "").toLowerCase();
  261. if (av === "insufficient_stock") {
  262. return { severity: "warning", text: "此批號狀態:已用畢(無剩餘庫存)" };
  263. }
  264. const avail = Number(lot.availableQty);
  265. if (lot.lotNo && Number.isFinite(avail) && avail <= 0) {
  266. return { severity: "warning", text: "此批號狀態:已用畢(可用量為 0)" };
  267. }
  268. if (isInventoryLotLineUnavailable(lot)) {
  269. return {
  270. severity: "warning",
  271. text: "此批號狀態:庫存不可用(未上架或行狀態不可用)",
  272. };
  273. }
  274. return { severity: "success", text: "此批號狀態:可提貨" };
  275. }
  276. type PickOrderT = (key: string, options?: Record<string, unknown>) => string;
  277. function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string {
  278. const msg = raw.trim();
  279. if (!msg) return msg;
  280. const expiredMatch = msg.match(/^Lot is expired \(expiry=([^)]+)\)\.?$/i);
  281. if (expiredMatch) {
  282. return t("Lot is expired (expiry={{expiry}})", {
  283. expiry: expiredMatch[1],
  284. });
  285. }
  286. return t(msg);
  287. }
  288. /**
  289. * 顯示後端拒絕原因:優先 workbench scan API 的 message(暫存於 scanRejectBySolId),
  290. * 其次階層 API 若帶 stockOutLineRejectMessage,最後依 rejected + lotAvailability 推斷(與後端語意對齊)。
  291. */
  292. function buildLotRejectDisplayMessage(
  293. lot: any,
  294. scanRejectBySolId: Record<number, string>,
  295. t: PickOrderT,
  296. ): string | undefined {
  297. const solId = Number(lot.stockOutLineId) || 0;
  298. const fromScan = solId > 0 ? scanRejectBySolId[solId]?.trim() : "";
  299. if (fromScan) return translateWorkbenchRejectMessage(fromScan, t);
  300. const fromApi =
  301. typeof lot.stockOutLineRejectMessage === "string"
  302. ? lot.stockOutLineRejectMessage.trim()
  303. : "";
  304. if (fromApi) return translateWorkbenchRejectMessage(fromApi, t);
  305. const st = String(lot.stockOutLineStatus || "").toLowerCase();
  306. const av = String(lot.lotAvailability || "").toLowerCase();
  307. const isRejected = st === "rejected" || av === "rejected";
  308. if (!isRejected) return undefined;
  309. if (isLotAvailabilityExpired(lot) || av === "expired") {
  310. return t("Rejected: lot expired or no longer valid.");
  311. }
  312. if (av === "insufficient_stock") {
  313. return t("Rejected: no remaining quantity / empty lot.");
  314. }
  315. if (isInventoryLotLineUnavailable(lot) || av === "status_unavailable") {
  316. return t("Rejected: lot unavailable or not yet putaway.");
  317. }
  318. return t("Pick was rejected. Please scan another lot or check stock.");
  319. }
  320. /** Issue「改數」未寫入 SOL,刷新/換頁後需靠 session 還原,否則 Qty will submit 會回到 req */
  321. const FG_ISSUE_PICKED_KEY = (doPickOrderId: number) =>
  322. `fpsms-fg-issuePickedQty:${doPickOrderId}`;
  323. function loadIssuePickedMap(doPickOrderId: number): Record<number, number> {
  324. if (typeof window === "undefined" || !doPickOrderId) return {};
  325. try {
  326. const raw = sessionStorage.getItem(FG_ISSUE_PICKED_KEY(doPickOrderId));
  327. if (!raw) return {};
  328. const parsed = JSON.parse(raw) as Record<string, number>;
  329. const out: Record<number, number> = {};
  330. Object.entries(parsed).forEach(([k, v]) => {
  331. const n = Number(v);
  332. if (!Number.isNaN(n)) out[Number(k)] = n;
  333. });
  334. return out;
  335. } catch {
  336. return {};
  337. }
  338. }
  339. function saveIssuePickedMap(doPickOrderId: number, map: Record<number, number>) {
  340. if (typeof window === "undefined" || !doPickOrderId) return;
  341. try {
  342. sessionStorage.setItem(FG_ISSUE_PICKED_KEY(doPickOrderId), JSON.stringify(map));
  343. } catch {
  344. // quota / private mode
  345. }
  346. }
  347. const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({
  348. filterArgs,
  349. onSwitchToRecordTab,
  350. onRefreshReleasedOrderCount,
  351. onWorkbenchHierarchyEmpty,
  352. }) => {
  353. const workbenchMode = true;
  354. const { t } = useTranslation("pickOrder");
  355. const router = useRouter();
  356. const { data: session } = useSession() as { data: SessionWithTokens | null };
  357. const [doPickOrderDetail, setDoPickOrderDetail] = useState<DoPickOrderDetail | null>(null);
  358. const [selectedPickOrderId, setSelectedPickOrderId] = useState<number | null>(null);
  359. const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
  360. const currentUserId = session?.id ? parseInt(session.id) : undefined;
  361. const [allLotsCompleted, setAllLotsCompleted] = useState(false);
  362. const [combinedLotData, setCombinedLotData] = useState<any[]>([]);
  363. const [combinedDataLoading, setCombinedDataLoading] = useState(false);
  364. const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]);
  365. // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required)
  366. const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({});
  367. const applyLocalStockOutLineUpdate = useCallback((
  368. stockOutLineId: number,
  369. status: string,
  370. actualPickQty?: number
  371. ) => {
  372. setCombinedLotData(prev => prev.map((lot) => {
  373. if (Number(lot.stockOutLineId) !== Number(stockOutLineId)) return lot;
  374. return {
  375. ...lot,
  376. stockOutLineStatus: status,
  377. ...(typeof actualPickQty === "number"
  378. ? { actualPickQty, stockOutLineQty: actualPickQty }
  379. : {}),
  380. };
  381. }));
  382. }, []);
  383. // 防止重复点击(Submit / Just Completed / Issue)
  384. const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({});
  385. const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext();
  386. const [qrScanInput, setQrScanInput] = useState<string>('');
  387. const [qrScanError, setQrScanError] = useState<boolean>(false);
  388. const [qrScanErrorMsg, setQrScanErrorMsg] = useState<string>('');
  389. const [qrScanSuccess, setQrScanSuccess] = useState<boolean>(false);
  390. /** Workbench scanPick 等非 SUCCESS 時後端 message,按 stockOutLineId 顯示在批號欄 */
  391. const [scanRejectMessageBySolId, setScanRejectMessageBySolId] = useState<Record<number, string>>({});
  392. const rememberWorkbenchScanReject = useCallback((stockOutLineId: number, message: string | undefined | null) => {
  393. const id = Number(stockOutLineId);
  394. const m = String(message ?? "").trim();
  395. if (!id || !m) return;
  396. setScanRejectMessageBySolId((prev) => ({ ...prev, [id]: m }));
  397. }, []);
  398. const clearWorkbenchScanReject = useCallback((stockOutLineId: number) => {
  399. const id = Number(stockOutLineId);
  400. if (!id) return;
  401. setScanRejectMessageBySolId((prev) => {
  402. if (!(id in prev)) return prev;
  403. const next = { ...prev };
  404. delete next[id];
  405. return next;
  406. });
  407. }, []);
  408. const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false);
  409. /** 使用者覆寫「可提交數量」:鍵不存在=未編輯;鍵存在且為 0=明確要送 0 */
  410. const [pickQtyData, setPickQtyData] = useState<Record<string, number>>({});
  411. /** Workbench row: false = qty TextField read-only; Edit toggles true to type qty (replaces opening issue form). */
  412. const [workbenchSubmitQtyFieldEnabledByLotKey, setWorkbenchSubmitQtyFieldEnabledByLotKey] = useState<
  413. Record<string, boolean>
  414. >({});
  415. const resolveSingleSubmitQty = useCallback(
  416. (lot: any) => {
  417. const required = Number(lot.requiredQty || lot.pickOrderLineRequiredQty || 0);
  418. const solId = Number(lot.stockOutLineId) || 0;
  419. const lotId = lot.lotId;
  420. const lotKey =
  421. Number.isFinite(solId) && solId > 0 ? `sol:${solId}` : `${lot.pickOrderLineId}-${lotId}`;
  422. const issuePicked = solId > 0 ? issuePickedQtyBySolId[solId] : undefined;
  423. if (issuePicked !== undefined && !Number.isNaN(Number(issuePicked))) {
  424. return Number(issuePicked);
  425. }
  426. const st = String(lot.stockOutLineStatus || "").toLowerCase();
  427. if (
  428. st === "completed" ||
  429. st === "partially_completed" ||
  430. st === "partially_complete"
  431. ) {
  432. return Number(lot.stockOutLineQty ?? lot.actualPickQty ?? 0);
  433. }
  434. if (Object.prototype.hasOwnProperty.call(pickQtyData, lotKey)) {
  435. const fromPick = pickQtyData[lotKey];
  436. if (!Number.isNaN(Number(fromPick))) {
  437. return Number(fromPick);
  438. }
  439. }
  440. return required;
  441. },
  442. [issuePickedQtyBySolId, pickQtyData],
  443. );
  444. const getWorkbenchQtyLotKey = useCallback((lot: any) => {
  445. const solId = Number(lot?.stockOutLineId) || 0;
  446. if (Number.isFinite(solId) && solId > 0) return `sol:${solId}`;
  447. return `${lot?.pickOrderLineId}-${lot?.lotId}`;
  448. }, []);
  449. /** Use table row from combinedLotData so pickQtyData key pickOrderLineId-lotId matches the row user edited (expectedLot from QR index may differ lotId). */
  450. const workbenchScanPickQtyFromLot = useCallback(
  451. (lot: any) => {
  452. const solId = Number(lot?.stockOutLineId);
  453. const sourceLot =
  454. Number.isFinite(solId) && solId > 0
  455. ? combinedLotData.find((r) => Number(r.stockOutLineId) === solId) ?? lot
  456. : lot;
  457. const lotKey = getWorkbenchQtyLotKey(sourceLot);
  458. const hasExplicitPickOverride = Object.prototype.hasOwnProperty.call(
  459. pickQtyData,
  460. lotKey,
  461. );
  462. const n = Number(resolveSingleSubmitQty(sourceLot));
  463. // Explicit 0 short submit must send qty=0; implicit 0 must omit qty (backend implicit fill path).
  464. if (hasExplicitPickOverride && Number.isFinite(n) && n === 0) return { qty: 0 } as const;
  465. if (!Number.isFinite(n) || n <= 0) return {};
  466. return { qty: n } as const;
  467. },
  468. [resolveSingleSubmitQty, combinedLotData, pickQtyData, getWorkbenchQtyLotKey],
  469. );
  470. const [searchQuery, setSearchQuery] = useState<Record<string, any>>({});
  471. const [paginationController, setPaginationController] = useState({
  472. pageNum: 0,
  473. pageSize: -1,
  474. });
  475. const [usernameList, setUsernameList] = useState<NameList[]>([]);
  476. const initializationRef = useRef(false);
  477. const autoAssignRef = useRef(false);
  478. /** 曾成功載入過 workbench 階層資料;避免「列表仍有單但階層暫空」時對外層重複觸發造成迴圈 */
  479. const workbenchHierarchicalReadyRef = useRef(false);
  480. const formProps = useForm();
  481. const errors = formProps.formState.errors;
  482. // Add GoodPickExecutionForm states
  483. const [pickExecutionFormOpen, setPickExecutionFormOpen] = useState(false);
  484. const [selectedLotForExecutionForm, setSelectedLotForExecutionForm] = useState<any | null>(null);
  485. const [fgPickOrders, setFgPickOrders] = useState<FGPickOrderResponse[]>([]);
  486. const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false);
  487. const lotFloorPrefixFilter = useMemo(() => {
  488. const storeId = String(fgPickOrders?.[0]?.storeId ?? "")
  489. .trim()
  490. .toUpperCase()
  491. .replace(/\s/g, "");
  492. const floorKey = storeId.replace(/\//g, "");
  493. return floorKey ? `${floorKey}-` : "";
  494. }, [fgPickOrders]);
  495. const defaultLabelPrinterName = useMemo(() => {
  496. const storeId = String(fgPickOrders?.[0]?.storeId ?? "")
  497. .trim()
  498. .toUpperCase()
  499. .replace(/\s/g, "");
  500. const floorKey = storeId.replace(/\//g, "");
  501. if (floorKey === "2F") return "Label機 2F A+B";
  502. if (floorKey === "4F") return "Label機 4F 乾貨 C, D";
  503. return undefined;
  504. }, [fgPickOrders]);
  505. const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] =
  506. useState(false);
  507. const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] =
  508. useState<{ itemId: number; stockInLineId: number } | null>(null);
  509. const [workbenchLotLabelReminderText, setWorkbenchLotLabelReminderText] =
  510. useState<string | null>(null);
  511. const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] =
  512. useState<any | null>(null);
  513. useEffect(() => {
  514. if (!qrScanSuccess || !workbenchLotLabelModalOpen) return;
  515. setWorkbenchLotLabelModalOpen(false);
  516. setWorkbenchLotLabelInitialPayload(null);
  517. setWorkbenchLotLabelReminderText(null);
  518. setWorkbenchLotLabelContextLot(null);
  519. }, [qrScanSuccess, workbenchLotLabelModalOpen]);
  520. // Add these missing state variables after line 352
  521. const [isManualScanning, setIsManualScanning] = useState<boolean>(false);
  522. // Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling
  523. const [processedQrCombinations, setProcessedQrCombinations] = useState<Map<number, Set<number>>>(new Map());
  524. const [processedQrCodes, setProcessedQrCodes] = useState<Set<string>>(new Set());
  525. const [lastProcessedQr, setLastProcessedQr] = useState<string>('');
  526. const [isRefreshingData, setIsRefreshingData] = useState<boolean>(false);
  527. const [isSubmittingAll, setIsSubmittingAll] = useState<boolean>(false);
  528. // Cache for fetchStockInLineInfo API calls to avoid redundant requests
  529. const stockInLineInfoCache = useRef<Map<number, { lotNo: string | null; timestamp: number }>>(new Map());
  530. const CACHE_TTL = 60000; // 60 seconds cache TTL
  531. const abortControllerRef = useRef<AbortController | null>(null);
  532. const qrProcessingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  533. // Use refs for processed QR tracking to avoid useEffect dependency issues and delays
  534. const processedQrCodesRef = useRef<Set<string>>(new Set());
  535. const lastProcessedQrRef = useRef<string>('');
  536. // Store callbacks in refs to avoid useEffect dependency issues
  537. const processOutsideQrCodeRef = useRef<
  538. ((latestQr: string, qrScanCountAtInvoke?: number) => Promise<void>) | null
  539. >(null);
  540. const resetScanRef = useRef<(() => void) | null>(null);
  541. // Handle QR code button click
  542. const handleQrCodeClick = (pickOrderId: number) => {
  543. console.log(`QR Code clicked for pick order ID: ${pickOrderId}`);
  544. // TODO: Implement QR code functionality
  545. };
  546. const progress = useMemo(() => {
  547. if (combinedLotData.length === 0) {
  548. return { completed: 0, total: 0 };
  549. }
  550. // 與 allItemsReady 一致:noLot / 過期 / unavailable 的 pending 也算「已面對該行」可收尾
  551. const nonPendingCount = combinedLotData.filter((lot) => {
  552. const status = lot.stockOutLineStatus?.toLowerCase();
  553. if (status !== "pending") return true;
  554. if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) return true;
  555. return false;
  556. }).length;
  557. return {
  558. completed: nonPendingCount,
  559. total: combinedLotData.length,
  560. };
  561. }, [combinedLotData]);
  562. // Cached version of fetchStockInLineInfo to avoid redundant API calls
  563. const fetchStockInLineInfoCached = useCallback(async (stockInLineId: number): Promise<{ lotNo: string | null }> => {
  564. const now = Date.now();
  565. const cached = stockInLineInfoCache.current.get(stockInLineId);
  566. // Return cached value if still valid
  567. if (cached && (now - cached.timestamp) < CACHE_TTL) {
  568. console.log(`✅ [CACHE HIT] Using cached stockInLineInfo for ${stockInLineId}`);
  569. return { lotNo: cached.lotNo };
  570. }
  571. // Cancel previous request if exists
  572. if (abortControllerRef.current) {
  573. abortControllerRef.current.abort();
  574. }
  575. // Create new abort controller for this request
  576. const abortController = new AbortController();
  577. abortControllerRef.current = abortController;
  578. try {
  579. console.log(` [CACHE MISS] Fetching stockInLineInfo for ${stockInLineId}`);
  580. const stockInLineInfo = await fetchStockInLineInfo(stockInLineId);
  581. // Store in cache
  582. stockInLineInfoCache.current.set(stockInLineId, {
  583. lotNo: stockInLineInfo.lotNo || null,
  584. timestamp: now
  585. });
  586. // Limit cache size to prevent memory leaks
  587. if (stockInLineInfoCache.current.size > 100) {
  588. const firstKey = stockInLineInfoCache.current.keys().next().value;
  589. if (firstKey !== undefined) {
  590. stockInLineInfoCache.current.delete(firstKey);
  591. }
  592. }
  593. return { lotNo: stockInLineInfo.lotNo || null };
  594. } catch (error: any) {
  595. if (error.name === 'AbortError') {
  596. console.log(` [CACHE] Request aborted for ${stockInLineId}`);
  597. throw error;
  598. }
  599. console.error(`❌ [CACHE] Error fetching stockInLineInfo for ${stockInLineId}:`, error);
  600. throw error;
  601. }
  602. }, []);
  603. const checkAllLotsCompleted = useCallback((lotData: any[]) => {
  604. if (lotData.length === 0) {
  605. setAllLotsCompleted(false);
  606. return false;
  607. }
  608. // Filter out rejected lots
  609. const nonRejectedLots = lotData.filter(lot =>
  610. lot.lotAvailability !== 'rejected' &&
  611. lot.stockOutLineStatus !== 'rejected'
  612. );
  613. if (nonRejectedLots.length === 0) {
  614. setAllLotsCompleted(false);
  615. return false;
  616. }
  617. // Check if all non-rejected lots are completed
  618. const allCompleted = nonRejectedLots.every(lot =>
  619. lot.stockOutLineStatus === 'completed'
  620. );
  621. setAllLotsCompleted(allCompleted);
  622. return allCompleted;
  623. }, []);
  624. // 在 fetchAllCombinedLotData 函数中(约 446-684 行)
  625. const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdOverride?: number) => {
  626. setCombinedDataLoading(true);
  627. try {
  628. const userIdToUse = userId || currentUserId;
  629. console.log(" fetchAllCombinedLotData called with userId:", userIdToUse);
  630. if (!userIdToUse) {
  631. console.warn("⚠️ No userId available, skipping API call");
  632. setCombinedLotData([]);
  633. setOriginalCombinedData([]);
  634. setAllLotsCompleted(false);
  635. setIssuePickedQtyBySolId({});
  636. return;
  637. }
  638. // 获取新结构的层级数据
  639. const hierarchicalData = await fetchAllPickOrderLotsHierarchicalWorkbench(userIdToUse);
  640. console.log(" Hierarchical data (new structure):", hierarchicalData);
  641. // 检查数据结构
  642. if (!hierarchicalData?.fgInfo || !hierarchicalData.pickOrders?.length) {
  643. console.warn("⚠️ No FG info or pick orders found");
  644. setCombinedLotData([]);
  645. setOriginalCombinedData([]);
  646. setAllLotsCompleted(false);
  647. setIssuePickedQtyBySolId({});
  648. setFgPickOrders([]);
  649. if (workbenchHierarchicalReadyRef.current) {
  650. workbenchHierarchicalReadyRef.current = false;
  651. onWorkbenchHierarchyEmpty?.();
  652. }
  653. return;
  654. }
  655. // 使用合并后的 pick order 对象(现在只有一个对象,包含所有数据)
  656. const mergedPickOrder = hierarchicalData.pickOrders[0];
  657. // 设置 FG info 到 fgPickOrders(用于显示 FG 信息卡片)
  658. // 修改第 478-509 行的 fgOrder 构建逻辑:
  659. const fgOrder: FGPickOrderResponse = {
  660. doPickOrderId: hierarchicalData.fgInfo.doPickOrderId,
  661. ticketNo: hierarchicalData.fgInfo.ticketNo,
  662. storeId: hierarchicalData.fgInfo.storeId,
  663. shopCode: hierarchicalData.fgInfo.shopCode,
  664. shopName: hierarchicalData.fgInfo.shopName,
  665. truckLanceCode: hierarchicalData.fgInfo.truckLanceCode,
  666. DepartureTime: hierarchicalData.fgInfo.departureTime,
  667. shopAddress: "",
  668. pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
  669. // 兼容字段(注意 consoCodes 是数组)
  670. pickOrderId: mergedPickOrder.pickOrderIds?.[0] || 0,
  671. pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes)
  672. ? mergedPickOrder.consoCodes[0] || ""
  673. : "",
  674. pickOrderTargetDate: mergedPickOrder.targetDate || "",
  675. pickOrderStatus: mergedPickOrder.status || "",
  676. deliveryOrderId: mergedPickOrder.doOrderIds?.[0] || 0,
  677. deliveryNo: mergedPickOrder.deliveryOrderCodes?.[0] || "",
  678. deliveryDate: "",
  679. shopId: 0,
  680. shopPoNo: "",
  681. numberOfCartons: mergedPickOrder.pickOrderLines?.length || 0,
  682. qrCodeData: hierarchicalData.fgInfo.doPickOrderId,
  683. // 多个 pick orders 信息:全部保留为数组
  684. numberOfPickOrders: mergedPickOrder.pickOrderIds?.length || 0,
  685. pickOrderIds: mergedPickOrder.pickOrderIds || [],
  686. pickOrderCodes: Array.isArray(mergedPickOrder.pickOrderCodes)
  687. ? mergedPickOrder.pickOrderCodes
  688. : [],
  689. deliveryOrderIds: mergedPickOrder.doOrderIds || [],
  690. deliveryNos: Array.isArray(mergedPickOrder.deliveryOrderCodes)
  691. ? mergedPickOrder.deliveryOrderCodes
  692. : [],
  693. lineCountsPerPickOrder: Array.isArray(mergedPickOrder.lineCountsPerPickOrder)
  694. ? mergedPickOrder.lineCountsPerPickOrder
  695. : [],
  696. };
  697. setFgPickOrders([fgOrder]);
  698. workbenchHierarchicalReadyRef.current = true;
  699. console.log(" DEBUG fgOrder.lineCountsPerPickOrder:", fgOrder.lineCountsPerPickOrder);
  700. console.log(" DEBUG fgOrder.pickOrderCodes:", fgOrder.pickOrderCodes);
  701. console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos);
  702. // 直接使用合并后的 pickOrderLines
  703. console.log("🎯 Displaying merged pick order lines");
  704. // 将层级数据转换为平铺格式(用于表格显示)
  705. const flatLotData: any[] = [];
  706. // 2/F 與後端 store_id 一致時需按 itemOrder;避免 API 未走 2F 分支時畫面仍亂序
  707. const doFloorKey = String(hierarchicalData.fgInfo.storeId ?? '')
  708. .trim()
  709. .toUpperCase()
  710. .replace(/\//g, '')
  711. .replace(/\s/g, '');
  712. const pickOrderLinesForDisplay =
  713. doFloorKey === '2F'
  714. ? [...(mergedPickOrder.pickOrderLines || [])].sort((a: any, b: any) => {
  715. const ao = a.itemOrder != null ? Number(a.itemOrder) : 999999;
  716. const bo = b.itemOrder != null ? Number(b.itemOrder) : 999999;
  717. if (ao !== bo) return ao - bo;
  718. return (Number(a.id) || 0) - (Number(b.id) || 0);
  719. })
  720. : mergedPickOrder.pickOrderLines || [];
  721. pickOrderLinesForDisplay.forEach((line: any) => {
  722. // 用来记录这一行已经通过 lots 出现过的 lotId
  723. const lotIdSet = new Set<number>();
  724. /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數) */
  725. let lotsAllocatedSumForLine = 0;
  726. // ✅ lots:按 lotId 去重并合并 requiredQty
  727. if (line.lots && line.lots.length > 0) {
  728. const lotMap = new Map<number, any>();
  729. line.lots.forEach((lot: any) => {
  730. const lotId = lot.id;
  731. if (lotMap.has(lotId)) {
  732. const existingLot = lotMap.get(lotId);
  733. existingLot.requiredQty =
  734. (existingLot.requiredQty || 0) + (lot.requiredQty || 0);
  735. } else {
  736. lotMap.set(lotId, { ...lot });
  737. }
  738. });
  739. lotMap.forEach((lot: any) => {
  740. lotsAllocatedSumForLine += Number(lot.requiredQty) || 0;
  741. if (lot.id != null) {
  742. lotIdSet.add(lot.id);
  743. }
  744. flatLotData.push({
  745. pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes)
  746. ? mergedPickOrder.consoCodes[0] || ""
  747. : "",
  748. pickOrderTargetDate: mergedPickOrder.targetDate,
  749. pickOrderStatus: mergedPickOrder.status,
  750. pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0,
  751. pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
  752. pickOrderLineId: line.id,
  753. pickOrderLineRequiredQty: line.requiredQty,
  754. pickOrderLineStatus: line.status,
  755. itemId: line.item.id,
  756. itemCode: line.item.code,
  757. itemName: line.item.name,
  758. uomDesc: line.item.uomDesc,
  759. uomShortDesc: line.item.uomShortDesc,
  760. lotId: lot.id,
  761. lotNo: lot.lotNo,
  762. expiryDate: lot.expiryDate,
  763. location: lot.location,
  764. stockUnit: lot.stockUnit,
  765. availableQty: lot.availableQty,
  766. requiredQty: lot.requiredQty,
  767. actualPickQty: lot.actualPickQty,
  768. inQty: lot.inQty,
  769. outQty: lot.outQty,
  770. holdQty: lot.holdQty,
  771. lotStatus: lot.lotStatus,
  772. lotAvailability: lot.lotAvailability,
  773. processingStatus: lot.processingStatus,
  774. suggestedPickLotId: lot.suggestedPickLotId,
  775. stockOutLineId: lot.stockOutLineId,
  776. stockOutLineStatus: lot.stockOutLineStatus,
  777. stockOutLineQty: lot.stockOutLineQty,
  778. stockOutLineRejectMessage: lot.stockOutLineRejectMessage ?? null,
  779. stockInLineId: lot.stockInLineId,
  780. routerId: lot.router?.id,
  781. routerIndex: lot.router?.index,
  782. routerRoute: lot.router?.route,
  783. routerArea: lot.router?.area,
  784. noLot: false,
  785. });
  786. });
  787. }
  788. // ✅ stockouts:只保留“真正无批次 / 未在 lots 出现过”的行
  789. if (line.stockouts && line.stockouts.length > 0) {
  790. line.stockouts.forEach((stockout: any) => {
  791. const hasLot = stockout.lotId != null;
  792. const lotAlreadyInLots =
  793. hasLot && lotIdSet.has(stockout.lotId as number);
  794. // 有批次 & 已经通过 lots 渲染过 → 跳过,避免一条变两行
  795. if (!stockout.noLot && lotAlreadyInLots) {
  796. return;
  797. }
  798. // 只渲染:
  799. // - noLot === true 的 Null stock 行
  800. // - 或者 lotId 在 lots 中不存在的特殊情况
  801. flatLotData.push({
  802. pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes)
  803. ? mergedPickOrder.consoCodes[0] || ""
  804. : "",
  805. pickOrderTargetDate: mergedPickOrder.targetDate,
  806. pickOrderStatus: mergedPickOrder.status,
  807. pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0,
  808. pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
  809. pickOrderLineId: line.id,
  810. pickOrderLineRequiredQty: line.requiredQty,
  811. pickOrderLineStatus: line.status,
  812. itemId: line.item.id,
  813. itemCode: line.item.code,
  814. itemName: line.item.name,
  815. uomDesc: line.item.uomDesc,
  816. uomShortDesc: line.item.uomShortDesc,
  817. lotId: stockout.lotId || null,
  818. lotNo: stockout.lotNo || null,
  819. expiryDate: null,
  820. location: stockout.location || null,
  821. stockUnit: line.item.uomDesc,
  822. availableQty: stockout.availableQty || 0,
  823. // 無批次列對應 suggested_pick_lot 的缺口量(如 11),勿用整行 POL 需求(100)以免顯示成 89 / 100
  824. requiredQty: stockout.noLot
  825. ? Math.max(
  826. 0,
  827. (Number(line.requiredQty) || 0) - lotsAllocatedSumForLine
  828. )
  829. : Number(line.requiredQty) || 0,
  830. actualPickQty: stockout.qty || 0,
  831. inQty: 0,
  832. outQty: 0,
  833. holdQty: 0,
  834. lotStatus: stockout.noLot ? "unavailable" : "available",
  835. lotAvailability: stockout.noLot ? "insufficient_stock" : "available",
  836. processingStatus: stockout.status || "pending",
  837. suggestedPickLotId: null,
  838. stockOutLineId: stockout.id || null,
  839. stockOutLineStatus: stockout.status || null,
  840. stockOutLineQty: stockout.qty || 0,
  841. stockOutLineRejectMessage: stockout.rejectMessage ?? stockout.rejectReason ?? null,
  842. routerId: null,
  843. routerIndex: stockout.noLot ? 999999 : null,
  844. routerRoute: null,
  845. routerArea: null,
  846. noLot: !!stockout.noLot,
  847. });
  848. });
  849. }
  850. });
  851. console.log(" Transformed flat lot data:", flatLotData);
  852. console.log(" Total items (including null stock):", flatLotData.length);
  853. setCombinedLotData(flatLotData);
  854. setOriginalCombinedData(flatLotData);
  855. const doPid = hierarchicalData.fgInfo?.doPickOrderId;
  856. if (doPid) {
  857. setIssuePickedQtyBySolId(loadIssuePickedMap(doPid));
  858. }
  859. checkAllLotsCompleted(flatLotData);
  860. } catch (error) {
  861. console.error(" Error fetching combined lot data:", error);
  862. setCombinedLotData([]);
  863. setOriginalCombinedData([]);
  864. setAllLotsCompleted(false);
  865. setIssuePickedQtyBySolId({});
  866. } finally {
  867. setCombinedDataLoading(false);
  868. }
  869. }, [currentUserId, checkAllLotsCompleted, onWorkbenchHierarchyEmpty]); // 移除 selectedPickOrderId 依赖
  870. /** After workbench scan-pick (incl. split → new stock_out_line), reload hierarchical rows. */
  871. const refreshWorkbenchAfterScanPick = useCallback(async () => {
  872. setIsRefreshingData(true);
  873. try {
  874. await fetchAllCombinedLotData();
  875. } finally {
  876. setIsRefreshingData(false);
  877. }
  878. }, [fetchAllCombinedLotData]);
  879. const openWorkbenchLotLabelModalForLot = useCallback(
  880. (lot: any, reminderText?: string | null) => {
  881. const itemId = Number(lot?.itemId);
  882. const stockInLineId = Number(lot?.stockInLineId);
  883. const solId = Number(lot?.stockOutLineId);
  884. if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) {
  885. return;
  886. }
  887. setWorkbenchLotLabelContextLot(lot);
  888. if (Number.isFinite(stockInLineId) && stockInLineId > 0) {
  889. setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId });
  890. } else {
  891. setWorkbenchLotLabelInitialPayload(null);
  892. }
  893. setWorkbenchLotLabelReminderText(reminderText ?? null);
  894. // Clear latched success so the lot-label modal effect cannot instantly re-close on open.
  895. setQrScanSuccess(false);
  896. setWorkbenchLotLabelModalOpen(true);
  897. },
  898. [],
  899. );
  900. const shouldOpenWorkbenchLotLabelModalForFailure = useCallback(
  901. (code?: string | null, msg?: string | null): boolean => {
  902. const normalizedCode = String(code || "").toUpperCase();
  903. if (
  904. normalizedCode.includes("UNAVAILABLE") ||
  905. normalizedCode.includes("EXPIRED")
  906. ) {
  907. return true;
  908. }
  909. const normalizedMsg = String(msg || "").toLowerCase();
  910. if (!normalizedMsg) return false;
  911. return (
  912. normalizedMsg.includes("unavailable") ||
  913. normalizedMsg.includes("not available") ||
  914. normalizedMsg.includes("expired") ||
  915. normalizedMsg.includes("不可用") ||
  916. normalizedMsg.includes("無法使用") ||
  917. normalizedMsg.includes("过期")
  918. );
  919. },
  920. [],
  921. );
  922. const handleWorkbenchLotLabelScanPick = useCallback(
  923. async ({
  924. inventoryLotLineId,
  925. lotNo,
  926. qty,
  927. }: {
  928. inventoryLotLineId: number;
  929. lotNo: string;
  930. qty?: number;
  931. }) => {
  932. const lot = workbenchLotLabelContextLot;
  933. if (!lot?.stockOutLineId) {
  934. throw new Error(t("Missing stock out line for this row."));
  935. }
  936. const qtyPayload = Number.isFinite(Number(qty))
  937. ? { qty: Number(qty) }
  938. : workbenchScanPickQtyFromLot(lot);
  939. const res = await workbenchScanPick({
  940. stockOutLineId: Number(lot.stockOutLineId),
  941. lotNo: String(lotNo || "").trim(),
  942. inventoryLotLineId,
  943. storeId: fgPickOrders?.[0]?.storeId ?? null,
  944. userId: currentUserId ?? 1,
  945. ...qtyPayload,
  946. });
  947. if (res.code !== "SUCCESS") {
  948. const errMsg =
  949. (res as { message?: string })?.message ||
  950. t("Workbench scan-pick failed.");
  951. rememberWorkbenchScanReject(Number(lot.stockOutLineId), errMsg);
  952. throw new Error(errMsg);
  953. }
  954. clearWorkbenchScanReject(Number(lot.stockOutLineId));
  955. await refreshWorkbenchAfterScanPick();
  956. setWorkbenchLotLabelModalOpen(false);
  957. setWorkbenchLotLabelContextLot(null);
  958. setWorkbenchLotLabelInitialPayload(null);
  959. setWorkbenchLotLabelReminderText(null);
  960. },
  961. [
  962. workbenchLotLabelContextLot,
  963. fgPickOrders,
  964. currentUserId,
  965. workbenchScanPickQtyFromLot,
  966. refreshWorkbenchAfterScanPick,
  967. rememberWorkbenchScanReject,
  968. clearWorkbenchScanReject,
  969. t,
  970. ],
  971. );
  972. const workbenchLotLabelStatusBanner = useMemo(() => {
  973. if (!workbenchLotLabelModalOpen || !workbenchLotLabelContextLot) {
  974. return {
  975. text: undefined as string | undefined,
  976. severity: undefined as "success" | "warning" | "error" | undefined,
  977. };
  978. }
  979. const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot);
  980. return { text: s.text, severity: s.severity };
  981. }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]);
  982. const workbenchLotLabelSubmitQty = useMemo(() => {
  983. if (!workbenchLotLabelContextLot) return 0;
  984. return Number(resolveSingleSubmitQty(workbenchLotLabelContextLot)) || 0;
  985. }, [workbenchLotLabelContextLot, resolveSingleSubmitQty]);
  986. const handleWorkbenchLotLabelSubmitQtyChange = useCallback(
  987. (qty: number) => {
  988. if (!workbenchLotLabelContextLot) return;
  989. const lotKey = getWorkbenchQtyLotKey(workbenchLotLabelContextLot);
  990. setPickQtyData((prev) => ({ ...prev, [lotKey]: qty }));
  991. },
  992. [workbenchLotLabelContextLot, getWorkbenchQtyLotKey],
  993. );
  994. /**
  995. * 與「掃描結果」欄綠勾一致:有批號且 SOL 非 pending、非 rejected 時視為已掃/已提貨,停用掃碼提貨。
  996. */
  997. const workbenchLotLabelScanPickDisabled = useMemo(() => {
  998. if (!workbenchLotLabelModalOpen || !workbenchLotLabelContextLot) return true;
  999. const lot = workbenchLotLabelContextLot;
  1000. const status = String(lot.stockOutLineStatus || "").toLowerCase();
  1001. const isNoLot = !lot.lotNo || String(lot.lotNo).trim() === "";
  1002. if (isNoLot) return false;
  1003. if (status === "pending" || status === "rejected") return false;
  1004. return true;
  1005. }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]);
  1006. // Manual lot substitution is part of the legacy "checked then batch submit" flow.
  1007. // Workbench now uses scan-pick (immediate posting) + qty=0 zero-complete; disable this path to avoid writing `checked`.
  1008. const handleManualLotConfirmation = useCallback(async () => {
  1009. alert(t("Workbench does not support manual lot substitution in this flow. Please use scan-pick / Just Completed."));
  1010. }, [t]);
  1011. useEffect(() => {
  1012. if (combinedLotData.length > 0) {
  1013. checkAllLotsCompleted(combinedLotData);
  1014. }
  1015. }, [combinedLotData, checkAllLotsCompleted]);
  1016. // Add function to expose completion status to parent
  1017. const getCompletionStatus = useCallback(() => {
  1018. return allLotsCompleted;
  1019. }, [allLotsCompleted]);
  1020. // Expose completion status to parent component
  1021. useEffect(() => {
  1022. // Dispatch custom event with completion status
  1023. const event = new CustomEvent('pickOrderCompletionStatus', {
  1024. detail: {
  1025. allLotsCompleted,
  1026. tabIndex: 1 // 明确指定这是来自标签页 1 的事件
  1027. }
  1028. });
  1029. window.dispatchEvent(event);
  1030. }, [allLotsCompleted]);
  1031. const handleQrCodeSubmit = useCallback(async (lotNo: string) => {
  1032. console.log(` Processing QR Code for lot: ${lotNo}`);
  1033. // 检查 lotNo 是否为 null 或 undefined(包括字符串 "null")
  1034. if (!lotNo || lotNo === 'null' || lotNo.trim() === '') {
  1035. console.error(" Invalid lotNo: null, undefined, or empty");
  1036. return;
  1037. }
  1038. // Use current data without refreshing to avoid infinite loop
  1039. const currentLotData = combinedLotData;
  1040. console.log(` Available lots:`, currentLotData.map(lot => lot.lotNo));
  1041. // 修复:在比较前确保 lotNo 不为 null
  1042. const lotNoLower = lotNo.toLowerCase();
  1043. const matchingLots = currentLotData.filter(lot => {
  1044. if (!lot.lotNo) return false; // 跳过 null lotNo
  1045. return lot.lotNo === lotNo || lot.lotNo.toLowerCase() === lotNoLower;
  1046. });
  1047. if (matchingLots.length === 0) {
  1048. console.error(` Lot not found: ${lotNo}`);
  1049. setQrScanError(true);
  1050. setQrScanSuccess(false);
  1051. const availableLotNos = currentLotData.map(lot => lot.lotNo).join(', ');
  1052. console.log(` QR Code "${lotNo}" does not match any expected lots. Available lots: ${availableLotNos}`);
  1053. return;
  1054. }
  1055. const hasExpiredLot = matchingLots.some(
  1056. (lot: any) => String(lot.lotAvailability || '').toLowerCase() === 'expired'
  1057. );
  1058. if (hasExpiredLot) {
  1059. console.warn(`⚠️ [QR PROCESS] Scanned lot ${lotNo} is expired`);
  1060. setQrScanError(true);
  1061. setQrScanSuccess(false);
  1062. return;
  1063. }
  1064. // Legacy QR flow marked SOL as `checked` (normal version). Workbench uses scan-pick (immediate posting).
  1065. setQrScanError(true);
  1066. setQrScanSuccess(false);
  1067. setQrScanErrorMsg(
  1068. t("Workbench uses scan-pick. Please use Just Completed / submit via scan-pick instead of checked status."),
  1069. );
  1070. }, [combinedLotData]);
  1071. const handleFastQrScan = useCallback(async (lotNo: string) => {
  1072. const startTime = performance.now();
  1073. console.log(` [FAST SCAN START] Lot: ${lotNo}`);
  1074. console.log(` Start time: ${new Date().toISOString()}`);
  1075. // 从 combinedLotData 中找到对应的 lot
  1076. const findStartTime = performance.now();
  1077. const matchingLot = combinedLotData.find(lot =>
  1078. lot.lotNo && lot.lotNo === lotNo
  1079. );
  1080. const findTime = performance.now() - findStartTime;
  1081. console.log(` Find lot time: ${findTime.toFixed(2)}ms`);
  1082. if (!matchingLot || !matchingLot.stockOutLineId) {
  1083. const totalTime = performance.now() - startTime;
  1084. console.warn(`⚠️ Fast scan: Lot ${lotNo} not found or no stockOutLineId`);
  1085. console.log(` Total time: ${totalTime.toFixed(2)}ms`);
  1086. return;
  1087. }
  1088. try {
  1089. const apiStartTime = performance.now();
  1090. const res = await workbenchScanPick({
  1091. stockOutLineId: matchingLot.stockOutLineId,
  1092. lotNo,
  1093. ...(typeof matchingLot.stockInLineId === "number" &&
  1094. Number.isFinite(matchingLot.stockInLineId) &&
  1095. matchingLot.stockInLineId > 0
  1096. ? { stockInLineId: matchingLot.stockInLineId }
  1097. : {}),
  1098. ...workbenchScanPickQtyFromLot(matchingLot),
  1099. storeId: fgPickOrders?.[0]?.storeId ?? null,
  1100. userId: currentUserId ?? 1,
  1101. });
  1102. const apiTime = performance.now() - apiStartTime;
  1103. console.log(` API call time: ${apiTime.toFixed(2)}ms`);
  1104. const ok = res.code === "SUCCESS";
  1105. if (ok) {
  1106. clearWorkbenchScanReject(Number(matchingLot.stockOutLineId));
  1107. const entity = res.entity as any;
  1108. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  1109. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  1110. const patchRow = (prev: any[]) =>
  1111. prev.map((row) => {
  1112. if (
  1113. row.stockOutLineId === matchingLot.stockOutLineId &&
  1114. row.pickOrderLineId === matchingLot.pickOrderLineId
  1115. ) {
  1116. return {
  1117. ...row,
  1118. stockOutLineStatus: nextStatus,
  1119. stockOutLineQty: nextQty ?? row.stockOutLineQty,
  1120. actualPickQty: nextQty ?? row.actualPickQty,
  1121. };
  1122. }
  1123. return row;
  1124. });
  1125. setCombinedLotData(patchRow);
  1126. setOriginalCombinedData(patchRow);
  1127. const totalTime = performance.now() - startTime;
  1128. console.log(`✅ [FAST SCAN END] Lot: ${lotNo}`);
  1129. console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`);
  1130. console.log(` End time: ${new Date().toISOString()}`);
  1131. } else {
  1132. const totalTime = performance.now() - startTime;
  1133. console.warn(`⚠️ Fast scan failed for ${lotNo}:`, res.code);
  1134. console.log(` Total time: ${totalTime.toFixed(2)}ms`);
  1135. if (workbenchMode && matchingLot.stockOutLineId != null) {
  1136. rememberWorkbenchScanReject(
  1137. Number(matchingLot.stockOutLineId),
  1138. (res as { message?: string })?.message,
  1139. );
  1140. }
  1141. }
  1142. } catch (error) {
  1143. const totalTime = performance.now() - startTime;
  1144. console.error(` Fast scan error for ${lotNo}:`, error);
  1145. console.log(` Total time: ${totalTime.toFixed(2)}ms`);
  1146. }
  1147. }, [combinedLotData, updateStockOutLineStatusByQRCodeAndLotNo, workbenchMode, currentUserId, clearWorkbenchScanReject, rememberWorkbenchScanReject, refreshWorkbenchAfterScanPick, workbenchScanPickQtyFromLot]);
  1148. // Enhanced lotDataIndexes with cached active lots for better performance
  1149. const lotDataIndexes = useMemo(() => {
  1150. const indexStartTime = performance.now();
  1151. console.log(` [PERF] lotDataIndexes calculation START, data length: ${combinedLotData.length}`);
  1152. const byItemId = new Map<number, any[]>();
  1153. const byItemCode = new Map<string, any[]>();
  1154. const byLotId = new Map<number, any>();
  1155. const byLotNo = new Map<string, any[]>();
  1156. const byStockInLineId = new Map<number, any[]>();
  1157. // Cache active lots separately to avoid filtering on every scan
  1158. const activeLotsByItemId = new Map<number, any[]>();
  1159. const rejectedStatuses = new Set(['rejected']);
  1160. // ✅ Use for loop instead of forEach for better performance on tablets
  1161. for (let i = 0; i < combinedLotData.length; i++) {
  1162. const lot = combinedLotData[i];
  1163. const solStatus = String(lot.stockOutLineStatus || "").toLowerCase();
  1164. const lotAvailability = String(lot.lotAvailability || "").toLowerCase();
  1165. const processingStatus = String(lot.processingStatus || "").toLowerCase();
  1166. const isUnavailable = isInventoryLotLineUnavailable(lot);
  1167. const isExpired = isLotAvailabilityExpired(lot);
  1168. const isRejected =
  1169. rejectedStatuses.has(lotAvailability) ||
  1170. rejectedStatuses.has(solStatus) ||
  1171. rejectedStatuses.has(processingStatus);
  1172. const isEnded = solStatus === "checked" || solStatus === "completed";
  1173. const isPartially = solStatus === "partially_completed" || solStatus === "partially_complete";
  1174. const isPending = solStatus === "pending" || solStatus === "";
  1175. const isActive = !isRejected && !isUnavailable && !isExpired && !isEnded && (isPending || isPartially);
  1176. if (lot.itemId) {
  1177. if (!byItemId.has(lot.itemId)) {
  1178. byItemId.set(lot.itemId, []);
  1179. activeLotsByItemId.set(lot.itemId, []);
  1180. }
  1181. byItemId.get(lot.itemId)!.push(lot);
  1182. if (isActive) {
  1183. activeLotsByItemId.get(lot.itemId)!.push(lot);
  1184. }
  1185. }
  1186. if (lot.itemCode) {
  1187. if (!byItemCode.has(lot.itemCode)) {
  1188. byItemCode.set(lot.itemCode, []);
  1189. }
  1190. byItemCode.get(lot.itemCode)!.push(lot);
  1191. }
  1192. if (lot.lotId) {
  1193. byLotId.set(lot.lotId, lot);
  1194. }
  1195. if (lot.lotNo) {
  1196. if (!byLotNo.has(lot.lotNo)) {
  1197. byLotNo.set(lot.lotNo, []);
  1198. }
  1199. byLotNo.get(lot.lotNo)!.push(lot);
  1200. }
  1201. if (lot.stockInLineId) {
  1202. if (!byStockInLineId.has(lot.stockInLineId)) {
  1203. byStockInLineId.set(lot.stockInLineId, []);
  1204. }
  1205. byStockInLineId.get(lot.stockInLineId)!.push(lot);
  1206. }
  1207. }
  1208. const indexTime = performance.now() - indexStartTime;
  1209. if (indexTime > 10) {
  1210. console.log(` [PERF] lotDataIndexes calculation END: ${indexTime.toFixed(2)}ms (${(indexTime / 1000).toFixed(3)}s)`);
  1211. }
  1212. return { byItemId, byItemCode, byLotId, byLotNo, byStockInLineId, activeLotsByItemId };
  1213. }, [combinedLotData.length, combinedLotData]);
  1214. // Store resetScan in ref for immediate access (update on every render)
  1215. resetScanRef.current = resetScan;
  1216. const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => {
  1217. const totalStartTime = performance.now();
  1218. console.log(` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`);
  1219. console.log(` Start time: ${new Date().toISOString()}`);
  1220. // ✅ Measure index access time
  1221. const indexAccessStart = performance.now();
  1222. const indexes = lotDataIndexes; // Access the memoized indexes
  1223. const indexAccessTime = performance.now() - indexAccessStart;
  1224. console.log(` [PERF] Index access time: ${indexAccessTime.toFixed(2)}ms`);
  1225. // 1) Parse JSON safely (parse once, reuse)
  1226. const parseStartTime = performance.now();
  1227. let qrData: any = null;
  1228. let parseTime = 0;
  1229. try {
  1230. qrData = JSON.parse(latestQr);
  1231. parseTime = performance.now() - parseStartTime;
  1232. console.log(` [PERF] JSON parse time: ${parseTime.toFixed(2)}ms`);
  1233. } catch {
  1234. console.log("QR content is not JSON; skipping lotNo direct submit to avoid false matches.");
  1235. startTransition(() => {
  1236. setQrScanError(true);
  1237. setQrScanSuccess(false);
  1238. });
  1239. return;
  1240. }
  1241. try {
  1242. const validationStartTime = performance.now();
  1243. if (!(qrData?.stockInLineId && qrData?.itemId)) {
  1244. console.log("QR JSON missing required fields (itemId, stockInLineId).");
  1245. startTransition(() => {
  1246. setQrScanError(true);
  1247. setQrScanSuccess(false);
  1248. });
  1249. return;
  1250. }
  1251. const validationTime = performance.now() - validationStartTime;
  1252. console.log(` [PERF] Validation time: ${validationTime.toFixed(2)}ms`);
  1253. const scannedItemId = qrData.itemId;
  1254. const scannedStockInLineId = qrData.stockInLineId;
  1255. // ✅ Check if this combination was already processed
  1256. const duplicateCheckStartTime = performance.now();
  1257. const itemProcessedSet = processedQrCombinations.get(scannedItemId);
  1258. if (itemProcessedSet?.has(scannedStockInLineId)) {
  1259. const duplicateCheckTime = performance.now() - duplicateCheckStartTime;
  1260. console.log(` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed(2)}ms)`);
  1261. return;
  1262. }
  1263. const duplicateCheckTime = performance.now() - duplicateCheckStartTime;
  1264. console.log(` [PERF] Duplicate check time: ${duplicateCheckTime.toFixed(2)}ms`);
  1265. // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed)
  1266. const lookupStartTime = performance.now();
  1267. const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || [];
  1268. // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected
  1269. const allLotsForItem = indexes.byItemId.get(scannedItemId) || [];
  1270. const lookupTime = performance.now() - lookupStartTime;
  1271. console.log(` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`);
  1272. // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots
  1273. // This allows users to scan other lots even when all suggested lots are rejected
  1274. const scannedLot = allLotsForItem.find(
  1275. (lot: any) => lot.stockInLineId === scannedStockInLineId
  1276. );
  1277. if (scannedLot) {
  1278. const isRejected =
  1279. scannedLot.stockOutLineStatus?.toLowerCase() === 'rejected' ||
  1280. scannedLot.lotAvailability === 'rejected';
  1281. const isUnavailable = isInventoryLotLineUnavailable(scannedLot);
  1282. if (isRejected) {
  1283. console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected`);
  1284. startTransition(() => {
  1285. setQrScanError(true);
  1286. setQrScanSuccess(false);
  1287. setQrScanErrorMsg(
  1288. `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`
  1289. );
  1290. });
  1291. // Mark as processed to prevent re-processing
  1292. setProcessedQrCombinations(prev => {
  1293. const newMap = new Map(prev);
  1294. if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
  1295. newMap.get(scannedItemId)!.add(scannedStockInLineId);
  1296. return newMap;
  1297. });
  1298. return;
  1299. }
  1300. if (isUnavailable) {
  1301. console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is unavailable; opening lot-label modal`);
  1302. startTransition(() => {
  1303. setQrScanError(false);
  1304. setQrScanSuccess(false);
  1305. });
  1306. openWorkbenchLotLabelModalForLot(
  1307. scannedLot,
  1308. t("This lot is not available, please scan another lot."),
  1309. );
  1310. return;
  1311. }
  1312. const isExpired =
  1313. String(scannedLot.lotAvailability || '').toLowerCase() === 'expired';
  1314. if (isExpired) {
  1315. console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired; opening lot-label modal`);
  1316. startTransition(() => {
  1317. setQrScanError(false);
  1318. setQrScanSuccess(false);
  1319. });
  1320. openWorkbenchLotLabelModalForLot(
  1321. scannedLot,
  1322. `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`,
  1323. );
  1324. return;
  1325. }
  1326. }
  1327. // ✅ If no active suggested lots, auto-switch to scanned lot (no modal)
  1328. if (activeSuggestedLots.length === 0) {
  1329. // Check if there are any lots for this item (even if all are rejected)
  1330. if (allLotsForItem.length === 0) {
  1331. console.error("No lots found for this item");
  1332. startTransition(() => {
  1333. setQrScanError(true);
  1334. setQrScanSuccess(false);
  1335. setQrScanErrorMsg("当前订单中没有此物品的批次信息");
  1336. });
  1337. return;
  1338. }
  1339. console.log(`⚠️ [QR PROCESS] No active suggested lots, auto-switching to scanned lot.`);
  1340. // Find a rejected lot as expected lot (the one that was rejected)
  1341. const rejectedLot = allLotsForItem.find((lot: any) =>
  1342. lot.stockOutLineStatus?.toLowerCase() === 'rejected' ||
  1343. lot.lotAvailability === 'rejected' ||
  1344. isInventoryLotLineUnavailable(lot)
  1345. );
  1346. const expectedLot =
  1347. rejectedLot ||
  1348. pickExpectedLotForSubstitution(
  1349. allLotsForItem.filter(
  1350. (l: any) => l.lotNo != null && String(l.lotNo).trim() !== ""
  1351. )
  1352. ) ||
  1353. allLotsForItem[0];
  1354. let scannedLotNo: string | null = scannedLot?.lotNo || null;
  1355. if (!scannedLotNo) {
  1356. try {
  1357. const info = await fetchStockInLineInfoCached(scannedStockInLineId);
  1358. scannedLotNo = info?.lotNo || null;
  1359. } catch (e) {
  1360. console.warn("Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e);
  1361. }
  1362. }
  1363. if (!scannedLotNo) {
  1364. startTransition(() => {
  1365. setQrScanError(true);
  1366. setQrScanSuccess(false);
  1367. setQrScanErrorMsg(
  1368. t("Cannot resolve lot number from QR. Please rescan or use manual confirmation.")
  1369. );
  1370. });
  1371. return;
  1372. }
  1373. if (!workbenchMode) {
  1374. const substitutionResult = await confirmLotSubstitution({
  1375. pickOrderLineId: expectedLot.pickOrderLineId,
  1376. stockOutLineId: expectedLot.stockOutLineId,
  1377. originalSuggestedPickLotId: expectedLot.suggestedPickLotId,
  1378. newInventoryLotNo: "",
  1379. newStockInLineId: scannedStockInLineId,
  1380. });
  1381. const substitutionCode = (substitutionResult as any)?.code;
  1382. const switchedToUnavailable =
  1383. substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE";
  1384. if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) {
  1385. const errMsg =
  1386. substitutionResult?.message ||
  1387. t("Lot switch failed; pick line was not updated.");
  1388. startTransition(() => {
  1389. setQrScanError(true);
  1390. setQrScanSuccess(false);
  1391. setQrScanErrorMsg(errMsg);
  1392. });
  1393. if (expectedLot.stockOutLineId != null) {
  1394. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), errMsg);
  1395. }
  1396. return;
  1397. }
  1398. }
  1399. const res = await workbenchScanPick({
  1400. stockOutLineId: expectedLot.stockOutLineId,
  1401. lotNo: scannedLotNo,
  1402. ...(typeof scannedStockInLineId === "number" &&
  1403. Number.isFinite(scannedStockInLineId) &&
  1404. scannedStockInLineId > 0
  1405. ? { stockInLineId: scannedStockInLineId }
  1406. : {}),
  1407. ...workbenchScanPickQtyFromLot(expectedLot),
  1408. storeId: fgPickOrders?.[0]?.storeId ?? null,
  1409. userId: currentUserId ?? 1,
  1410. });
  1411. const ok = res.code === "SUCCESS";
  1412. if (!ok) {
  1413. const failMsg =
  1414. (res as { message?: string })?.message ||
  1415. t("Workbench scan-pick failed.");
  1416. if (
  1417. shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
  1418. expectedLot
  1419. ) {
  1420. openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
  1421. return;
  1422. }
  1423. if (workbenchMode && expectedLot.stockOutLineId != null) {
  1424. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), failMsg);
  1425. }
  1426. startTransition(() => {
  1427. setQrScanError(true);
  1428. setQrScanSuccess(false);
  1429. setQrScanErrorMsg(failMsg);
  1430. });
  1431. return;
  1432. }
  1433. clearWorkbenchScanReject(Number(expectedLot.stockOutLineId));
  1434. const entity = res.entity as any;
  1435. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  1436. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  1437. startTransition(() => {
  1438. setQrScanError(false);
  1439. setQrScanSuccess(true);
  1440. setCombinedLotData((prev) =>
  1441. prev.map((lot) => {
  1442. if (
  1443. lot.stockOutLineId === expectedLot.stockOutLineId &&
  1444. lot.pickOrderLineId === expectedLot.pickOrderLineId
  1445. ) {
  1446. return {
  1447. ...lot,
  1448. lotNo: scannedLotNo,
  1449. stockOutLineStatus: nextStatus,
  1450. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  1451. };
  1452. }
  1453. return lot;
  1454. }),
  1455. );
  1456. setOriginalCombinedData((prev) =>
  1457. prev.map((lot) => {
  1458. if (
  1459. lot.stockOutLineId === expectedLot.stockOutLineId &&
  1460. lot.pickOrderLineId === expectedLot.pickOrderLineId
  1461. ) {
  1462. return {
  1463. ...lot,
  1464. lotNo: scannedLotNo,
  1465. stockOutLineStatus: nextStatus,
  1466. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  1467. };
  1468. }
  1469. return lot;
  1470. }),
  1471. );
  1472. });
  1473. setProcessedQrCombinations((prev) => {
  1474. const newMap = new Map(prev);
  1475. if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
  1476. newMap.get(scannedItemId)!.add(scannedStockInLineId);
  1477. return newMap;
  1478. });
  1479. if (workbenchMode) {
  1480. await refreshWorkbenchAfterScanPick();
  1481. }
  1482. return;
  1483. }
  1484. // ✅ OPTIMIZATION: Direct Map lookup for stockInLineId match (O(1))
  1485. const matchStartTime = performance.now();
  1486. let exactMatch: any = null;
  1487. const stockInLineLots = indexes.byStockInLineId.get(scannedStockInLineId) || [];
  1488. // Find exact match from stockInLineId index, then verify it's in active lots
  1489. for (let i = 0; i < stockInLineLots.length; i++) {
  1490. const lot = stockInLineLots[i];
  1491. if (lot.itemId === scannedItemId && activeSuggestedLots.includes(lot)) {
  1492. exactMatch = lot;
  1493. break;
  1494. }
  1495. }
  1496. const matchTime = performance.now() - matchStartTime;
  1497. console.log(` [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${exactMatch ? 'yes' : 'no'}`);
  1498. // ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots
  1499. // This handles the case where Lot A is rejected and user scans Lot B
  1500. // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined)
  1501. if (!exactMatch) {
  1502. const expectedLot =
  1503. pickExpectedLotForSubstitution(activeSuggestedLots) || allLotsForItem[0];
  1504. if (expectedLot) {
  1505. const shouldAutoSwitch =
  1506. !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId);
  1507. if (shouldAutoSwitch) {
  1508. console.log(
  1509. `⚠️ [QR PROCESS] Auto-switching (scanned lot ${scannedLot?.lotNo || 'not in data'} is not in active suggested lots)`,
  1510. );
  1511. let scannedLotNo: string | null = scannedLot?.lotNo || null;
  1512. if (!scannedLotNo) {
  1513. try {
  1514. const info = await fetchStockInLineInfoCached(scannedStockInLineId);
  1515. scannedLotNo = info?.lotNo || null;
  1516. } catch (e) {
  1517. console.warn("Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e);
  1518. }
  1519. }
  1520. if (!scannedLotNo) {
  1521. startTransition(() => {
  1522. setQrScanError(true);
  1523. setQrScanSuccess(false);
  1524. setQrScanErrorMsg(
  1525. t("Cannot resolve lot number from QR. Please rescan or use manual confirmation.")
  1526. );
  1527. });
  1528. return;
  1529. }
  1530. if (!workbenchMode) {
  1531. const substitutionResult = await confirmLotSubstitution({
  1532. pickOrderLineId: expectedLot.pickOrderLineId,
  1533. stockOutLineId: expectedLot.stockOutLineId,
  1534. originalSuggestedPickLotId: expectedLot.suggestedPickLotId,
  1535. newInventoryLotNo: "",
  1536. newStockInLineId: scannedStockInLineId,
  1537. });
  1538. const substitutionCode = (substitutionResult as any)?.code;
  1539. const switchedToUnavailable =
  1540. substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE";
  1541. if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) {
  1542. const errMsg =
  1543. substitutionResult?.message ||
  1544. t("Lot switch failed; pick line was not updated.");
  1545. startTransition(() => {
  1546. setQrScanError(true);
  1547. setQrScanSuccess(false);
  1548. setQrScanErrorMsg(errMsg);
  1549. });
  1550. if (expectedLot.stockOutLineId != null) {
  1551. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), errMsg);
  1552. }
  1553. return;
  1554. }
  1555. }
  1556. const res = await workbenchScanPick({
  1557. stockOutLineId: expectedLot.stockOutLineId,
  1558. lotNo: scannedLotNo,
  1559. ...(typeof scannedStockInLineId === "number" &&
  1560. Number.isFinite(scannedStockInLineId) &&
  1561. scannedStockInLineId > 0
  1562. ? { stockInLineId: scannedStockInLineId }
  1563. : {}),
  1564. ...workbenchScanPickQtyFromLot(expectedLot),
  1565. storeId: fgPickOrders?.[0]?.storeId ?? null,
  1566. userId: currentUserId ?? 1,
  1567. });
  1568. const ok = res.code === "SUCCESS";
  1569. if (!ok) {
  1570. const failMsg =
  1571. (res as { message?: string })?.message ||
  1572. t("Workbench scan-pick failed.");
  1573. if (
  1574. shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
  1575. expectedLot
  1576. ) {
  1577. openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
  1578. return;
  1579. }
  1580. if (workbenchMode && expectedLot.stockOutLineId != null) {
  1581. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), failMsg);
  1582. }
  1583. startTransition(() => {
  1584. setQrScanError(true);
  1585. setQrScanSuccess(false);
  1586. setQrScanErrorMsg(failMsg);
  1587. });
  1588. return;
  1589. }
  1590. clearWorkbenchScanReject(Number(expectedLot.stockOutLineId));
  1591. const entity = res.entity as any;
  1592. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  1593. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  1594. startTransition(() => {
  1595. setQrScanError(false);
  1596. setQrScanSuccess(true);
  1597. setCombinedLotData((prev) =>
  1598. prev.map((lot) => {
  1599. if (
  1600. lot.stockOutLineId === expectedLot.stockOutLineId &&
  1601. lot.pickOrderLineId === expectedLot.pickOrderLineId
  1602. ) {
  1603. return {
  1604. ...lot,
  1605. lotNo: scannedLotNo,
  1606. stockOutLineStatus: nextStatus,
  1607. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  1608. };
  1609. }
  1610. return lot;
  1611. }),
  1612. );
  1613. setOriginalCombinedData((prev) =>
  1614. prev.map((lot) => {
  1615. if (
  1616. lot.stockOutLineId === expectedLot.stockOutLineId &&
  1617. lot.pickOrderLineId === expectedLot.pickOrderLineId
  1618. ) {
  1619. return {
  1620. ...lot,
  1621. lotNo: scannedLotNo,
  1622. stockOutLineStatus: nextStatus,
  1623. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  1624. };
  1625. }
  1626. return lot;
  1627. }),
  1628. );
  1629. });
  1630. setProcessedQrCombinations((prev) => {
  1631. const newMap = new Map(prev);
  1632. if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
  1633. newMap.get(scannedItemId)!.add(scannedStockInLineId);
  1634. return newMap;
  1635. });
  1636. if (workbenchMode) {
  1637. await refreshWorkbenchAfterScanPick();
  1638. }
  1639. return;
  1640. }
  1641. }
  1642. }
  1643. if (exactMatch) {
  1644. // ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认
  1645. console.log(`✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`);
  1646. if (!exactMatch.stockOutLineId) {
  1647. console.warn("No stockOutLineId on exactMatch, cannot update status by QR.");
  1648. startTransition(() => {
  1649. setQrScanError(true);
  1650. setQrScanSuccess(false);
  1651. });
  1652. return;
  1653. }
  1654. try {
  1655. const apiStartTime = performance.now();
  1656. console.log(
  1657. workbenchMode
  1658. ? ` [API CALL START] workbenchScanPick`
  1659. : ` [API CALL START] Calling updateStockOutLineStatusByQRCodeAndLotNo`
  1660. );
  1661. console.log(` [API CALL] API start time: ${new Date().toISOString()}`);
  1662. const res = await workbenchScanPick({
  1663. stockOutLineId: exactMatch.stockOutLineId,
  1664. lotNo: exactMatch.lotNo,
  1665. ...(typeof scannedStockInLineId === "number" &&
  1666. Number.isFinite(scannedStockInLineId) &&
  1667. scannedStockInLineId > 0
  1668. ? { stockInLineId: scannedStockInLineId }
  1669. : typeof exactMatch.stockInLineId === "number" &&
  1670. Number.isFinite(exactMatch.stockInLineId) &&
  1671. exactMatch.stockInLineId > 0
  1672. ? { stockInLineId: exactMatch.stockInLineId }
  1673. : {}),
  1674. ...workbenchScanPickQtyFromLot(exactMatch),
  1675. storeId: fgPickOrders?.[0]?.storeId ?? null,
  1676. userId: currentUserId ?? 1,
  1677. });
  1678. const apiTime = performance.now() - apiStartTime;
  1679. console.log(` [API CALL END] Total API time: ${apiTime.toFixed(2)}ms (${(apiTime / 1000).toFixed(3)}s)`);
  1680. console.log(` [API CALL] API end time: ${new Date().toISOString()}`);
  1681. const ok = res.code === "SUCCESS";
  1682. if (ok) {
  1683. clearWorkbenchScanReject(Number(exactMatch.stockOutLineId));
  1684. const entity = res.entity as any;
  1685. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  1686. const nextQty =
  1687. entity?.qty != null ? Number(entity.qty) : undefined;
  1688. // ✅ Batch state updates using startTransition
  1689. const stateUpdateStartTime = performance.now();
  1690. startTransition(() => {
  1691. setQrScanError(false);
  1692. setQrScanSuccess(true);
  1693. setCombinedLotData(prev => prev.map(lot => {
  1694. if (lot.stockOutLineId === exactMatch.stockOutLineId &&
  1695. lot.pickOrderLineId === exactMatch.pickOrderLineId) {
  1696. return {
  1697. ...lot,
  1698. stockOutLineStatus: nextStatus,
  1699. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  1700. };
  1701. }
  1702. return lot;
  1703. }));
  1704. setOriginalCombinedData(prev => prev.map(lot => {
  1705. if (lot.stockOutLineId === exactMatch.stockOutLineId &&
  1706. lot.pickOrderLineId === exactMatch.pickOrderLineId) {
  1707. return {
  1708. ...lot,
  1709. stockOutLineStatus: nextStatus,
  1710. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  1711. };
  1712. }
  1713. return lot;
  1714. }));
  1715. });
  1716. const stateUpdateTime = performance.now() - stateUpdateStartTime;
  1717. console.log(` [PERF] State update time: ${stateUpdateTime.toFixed(2)}ms`);
  1718. // Mark this combination as processed
  1719. const markProcessedStartTime = performance.now();
  1720. setProcessedQrCombinations(prev => {
  1721. const newMap = new Map(prev);
  1722. if (!newMap.has(scannedItemId)) {
  1723. newMap.set(scannedItemId, new Set());
  1724. }
  1725. newMap.get(scannedItemId)!.add(scannedStockInLineId);
  1726. return newMap;
  1727. });
  1728. const markProcessedTime = performance.now() - markProcessedStartTime;
  1729. console.log(` [PERF] Mark processed time: ${markProcessedTime.toFixed(2)}ms`);
  1730. if (workbenchMode) {
  1731. await refreshWorkbenchAfterScanPick();
  1732. }
  1733. const totalTime = performance.now() - totalStartTime;
  1734. console.log(`✅ [PROCESS OUTSIDE QR END] Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`);
  1735. console.log(` End time: ${new Date().toISOString()}`);
  1736. 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`);
  1737. console.log(
  1738. workbenchMode
  1739. ? "✅ Workbench scan-pick: list refreshed from server"
  1740. : "✅ Status updated locally, no full data refresh needed",
  1741. );
  1742. } else {
  1743. console.warn("Unexpected response code from backend:", res.code);
  1744. const failMsg =
  1745. (res as { message?: string })?.message ||
  1746. t("Workbench scan-pick failed.");
  1747. if (
  1748. shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
  1749. exactMatch
  1750. ) {
  1751. openWorkbenchLotLabelModalForLot(exactMatch, failMsg);
  1752. return;
  1753. }
  1754. if (workbenchMode && exactMatch.stockOutLineId != null) {
  1755. rememberWorkbenchScanReject(Number(exactMatch.stockOutLineId), failMsg);
  1756. }
  1757. startTransition(() => {
  1758. setQrScanError(true);
  1759. setQrScanSuccess(false);
  1760. setQrScanErrorMsg(failMsg);
  1761. });
  1762. }
  1763. } catch (e) {
  1764. const totalTime = performance.now() - totalStartTime;
  1765. console.error(`❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`);
  1766. console.error("Error calling updateStockOutLineStatusByQRCodeAndLotNo:", e);
  1767. startTransition(() => {
  1768. setQrScanError(true);
  1769. setQrScanSuccess(false);
  1770. });
  1771. }
  1772. return; // ✅ 直接返回,不需要确认表单
  1773. }
  1774. // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配
  1775. // Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描
  1776. const mismatchCheckStartTime = performance.now();
  1777. const itemProcessedSet2 = processedQrCombinations.get(scannedItemId);
  1778. if (itemProcessedSet2?.has(scannedStockInLineId)) {
  1779. const mismatchCheckTime = performance.now() - mismatchCheckStartTime;
  1780. console.log(
  1781. ` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed(2)}ms)`,
  1782. );
  1783. return;
  1784. }
  1785. const mismatchCheckTime = performance.now() - mismatchCheckStartTime;
  1786. console.log(` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`);
  1787. const expectedLotStartTime = performance.now();
  1788. const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots);
  1789. if (!expectedLot) {
  1790. console.error("Could not determine expected lot for auto-switch");
  1791. startTransition(() => {
  1792. setQrScanError(true);
  1793. setQrScanSuccess(false);
  1794. });
  1795. return;
  1796. }
  1797. const expectedLotTime = performance.now() - expectedLotStartTime;
  1798. console.log(` [PERF] Get expected lot time: ${expectedLotTime.toFixed(2)}ms`);
  1799. console.log(
  1800. `⚠️ Lot mismatch (auto): Expected stockInLineId=${expectedLot.stockInLineId}, Scanned stockInLineId=${scannedStockInLineId}`,
  1801. );
  1802. // 1) 先把掃到的 stockInLineId 轉成 lotNo(workbenchScanPick 需要 lotNo)
  1803. let scannedLotNo: string | null = null;
  1804. try {
  1805. const info = await fetchStockInLineInfoCached(scannedStockInLineId);
  1806. scannedLotNo = info?.lotNo || null;
  1807. } catch (e) {
  1808. console.warn("Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e);
  1809. }
  1810. if (!scannedLotNo) {
  1811. const msg = t("Cannot resolve lot number from QR. Please rescan or use manual confirmation.");
  1812. setQrScanError(true);
  1813. setQrScanSuccess(false);
  1814. setQrScanErrorMsg(msg);
  1815. return;
  1816. }
  1817. // 2) 非 workbench:先 confirmLotSubstitution;workbench 僅依 scan-pick 規則與錯誤訊息
  1818. if (!workbenchMode) {
  1819. const substitutionResult = await confirmLotSubstitution({
  1820. pickOrderLineId: expectedLot.pickOrderLineId,
  1821. stockOutLineId: expectedLot.stockOutLineId,
  1822. originalSuggestedPickLotId: expectedLot.suggestedPickLotId,
  1823. newInventoryLotNo: "",
  1824. newStockInLineId: scannedStockInLineId,
  1825. });
  1826. const substitutionCode = (substitutionResult as any)?.code;
  1827. const switchedToUnavailable =
  1828. substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE";
  1829. if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) {
  1830. const errMsg =
  1831. substitutionResult?.message ||
  1832. t("Lot switch failed; pick line was not updated.");
  1833. setQrScanError(true);
  1834. setQrScanSuccess(false);
  1835. setQrScanErrorMsg(errMsg);
  1836. if (expectedLot.stockOutLineId != null) {
  1837. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), errMsg);
  1838. }
  1839. return;
  1840. }
  1841. }
  1842. // 3) 提交掃描(workbench:直接 workbenchScanPick)
  1843. try {
  1844. const res = await workbenchScanPick({
  1845. stockOutLineId: expectedLot.stockOutLineId,
  1846. lotNo: scannedLotNo,
  1847. ...(typeof scannedStockInLineId === "number" &&
  1848. Number.isFinite(scannedStockInLineId) &&
  1849. scannedStockInLineId > 0
  1850. ? { stockInLineId: scannedStockInLineId }
  1851. : {}),
  1852. ...workbenchScanPickQtyFromLot(expectedLot),
  1853. storeId: fgPickOrders?.[0]?.storeId ?? null,
  1854. userId: currentUserId ?? 1,
  1855. });
  1856. const ok = res.code === "SUCCESS";
  1857. if (!ok) {
  1858. const failMsg =
  1859. (res as { message?: string })?.message ||
  1860. t("Workbench scan-pick failed.");
  1861. if (
  1862. shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
  1863. expectedLot
  1864. ) {
  1865. openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
  1866. return;
  1867. }
  1868. if (workbenchMode && expectedLot.stockOutLineId != null) {
  1869. rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), failMsg);
  1870. }
  1871. setQrScanError(true);
  1872. setQrScanSuccess(false);
  1873. setQrScanErrorMsg(failMsg);
  1874. return;
  1875. }
  1876. clearWorkbenchScanReject(Number(expectedLot.stockOutLineId));
  1877. const entity = res.entity as any;
  1878. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  1879. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  1880. startTransition(() => {
  1881. setQrScanError(false);
  1882. setQrScanSuccess(true);
  1883. setCombinedLotData((prev) =>
  1884. prev.map((lot) => {
  1885. if (
  1886. lot.stockOutLineId === expectedLot.stockOutLineId &&
  1887. lot.pickOrderLineId === expectedLot.pickOrderLineId
  1888. ) {
  1889. return {
  1890. ...lot,
  1891. lotNo: scannedLotNo,
  1892. stockOutLineStatus: nextStatus,
  1893. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  1894. };
  1895. }
  1896. return lot;
  1897. }),
  1898. );
  1899. setOriginalCombinedData((prev) =>
  1900. prev.map((lot) => {
  1901. if (
  1902. lot.stockOutLineId === expectedLot.stockOutLineId &&
  1903. lot.pickOrderLineId === expectedLot.pickOrderLineId
  1904. ) {
  1905. return {
  1906. ...lot,
  1907. lotNo: scannedLotNo,
  1908. stockOutLineStatus: nextStatus,
  1909. stockOutLineQty: nextQty ?? lot.stockOutLineQty,
  1910. };
  1911. }
  1912. return lot;
  1913. }),
  1914. );
  1915. });
  1916. setProcessedQrCombinations((prev) => {
  1917. const newMap = new Map(prev);
  1918. if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
  1919. newMap.get(scannedItemId)!.add(scannedStockInLineId);
  1920. return newMap;
  1921. });
  1922. if (workbenchMode) {
  1923. await refreshWorkbenchAfterScanPick();
  1924. }
  1925. } catch (e) {
  1926. console.error("Auto-switch scanPick failed:", e);
  1927. setQrScanError(true);
  1928. setQrScanSuccess(false);
  1929. return;
  1930. }
  1931. const totalTime = performance.now() - totalStartTime;
  1932. console.log(
  1933. `✅ [PROCESS OUTSIDE QR AUTO-SWITCH] Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`,
  1934. );
  1935. console.log(` End time: ${new Date().toISOString()}`);
  1936. console.log(
  1937. `📊 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`,
  1938. );
  1939. } catch (error) {
  1940. const totalTime = performance.now() - totalStartTime;
  1941. console.error(`❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`);
  1942. console.error("Error during QR code processing:", error);
  1943. startTransition(() => {
  1944. setQrScanError(true);
  1945. setQrScanSuccess(false);
  1946. });
  1947. return;
  1948. }
  1949. }, [
  1950. lotDataIndexes,
  1951. processedQrCombinations,
  1952. combinedLotData,
  1953. fetchStockInLineInfoCached,
  1954. workbenchMode,
  1955. currentUserId,
  1956. clearWorkbenchScanReject,
  1957. rememberWorkbenchScanReject,
  1958. refreshWorkbenchAfterScanPick,
  1959. workbenchScanPickQtyFromLot,
  1960. openWorkbenchLotLabelModalForLot,
  1961. shouldOpenWorkbenchLotLabelModalForFailure,
  1962. t,
  1963. ]);
  1964. // Store processOutsideQrCode in ref for immediate access (update on every render)
  1965. processOutsideQrCodeRef.current = processOutsideQrCode;
  1966. useEffect(() => {
  1967. // Skip if scanner is not active or no data available
  1968. if (!isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData) {
  1969. return;
  1970. }
  1971. const qrValuesChangeStartTime = performance.now();
  1972. console.log(` [QR VALUES EFFECT] Triggered at: ${new Date().toISOString()}`);
  1973. console.log(` [QR VALUES EFFECT] qrValues.length: ${qrValues.length}`);
  1974. console.log(` [QR VALUES EFFECT] qrValues:`, qrValues);
  1975. const latestQr = qrValues[qrValues.length - 1];
  1976. console.log(` [QR VALUES EFFECT] Latest QR: ${latestQr}`);
  1977. console.log(` [QR VALUES EFFECT] Latest QR detected at: ${new Date().toISOString()}`);
  1978. // ✅ FIXED: Handle test shortcut {2fitestx,y} or {2fittestx,y} where x=itemId, y=stockInLineId
  1979. // Support both formats: {2fitest (2 t's) and {2fittest (3 t's)
  1980. if ((latestQr.startsWith("{2fitest") || latestQr.startsWith("{2fittest")) && latestQr.endsWith("}")) {
  1981. // Extract content: remove "{2fitest" or "{2fittest" and "}"
  1982. let content = '';
  1983. if (latestQr.startsWith("{2fittest")) {
  1984. content = latestQr.substring(9, latestQr.length - 1); // Remove "{2fittest" and "}"
  1985. } else if (latestQr.startsWith("{2fitest")) {
  1986. content = latestQr.substring(8, latestQr.length - 1); // Remove "{2fitest" and "}"
  1987. }
  1988. const parts = content.split(',');
  1989. if (parts.length === 2) {
  1990. const itemId = parseInt(parts[0].trim(), 10);
  1991. const stockInLineId = parseInt(parts[1].trim(), 10);
  1992. if (!isNaN(itemId) && !isNaN(stockInLineId)) {
  1993. console.log(
  1994. `%c TEST QR: Detected ${latestQr.substring(0, 9)}... - Simulating QR input (itemId=${itemId}, stockInLineId=${stockInLineId})`,
  1995. "color: purple; font-weight: bold"
  1996. );
  1997. // ✅ Simulate QR code JSON format
  1998. const simulatedQr = JSON.stringify({
  1999. itemId: itemId,
  2000. stockInLineId: stockInLineId
  2001. });
  2002. console.log(` [TEST QR] Simulated QR content: ${simulatedQr}`);
  2003. console.log(` [TEST QR] Start time: ${new Date().toISOString()}`);
  2004. const testStartTime = performance.now();
  2005. // ✅ Mark as processed FIRST to avoid duplicate processing
  2006. lastProcessedQrRef.current = latestQr;
  2007. processedQrCodesRef.current.add(latestQr);
  2008. if (processedQrCodesRef.current.size > 100) {
  2009. const firstValue = processedQrCodesRef.current.values().next().value;
  2010. if (firstValue !== undefined) {
  2011. processedQrCodesRef.current.delete(firstValue);
  2012. }
  2013. }
  2014. setLastProcessedQr(latestQr);
  2015. setProcessedQrCodes(new Set(processedQrCodesRef.current));
  2016. // ✅ Process immediately (bypass QR scanner delay)
  2017. if (processOutsideQrCodeRef.current) {
  2018. processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => {
  2019. const testTime = performance.now() - testStartTime;
  2020. console.log(` [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`);
  2021. console.log(` [TEST QR] End time: ${new Date().toISOString()}`);
  2022. }).catch((error) => {
  2023. const testTime = performance.now() - testStartTime;
  2024. console.error(`❌ [TEST QR] Error after ${testTime.toFixed(2)}ms:`, error);
  2025. });
  2026. }
  2027. // Reset scan
  2028. if (resetScanRef.current) {
  2029. resetScanRef.current();
  2030. }
  2031. const qrValuesChangeTime = performance.now() - qrValuesChangeStartTime;
  2032. console.log(` [QR VALUES EFFECT] Test QR handling time: ${qrValuesChangeTime.toFixed(2)}ms`);
  2033. return; // ✅ IMPORTANT: Return early to prevent normal processing
  2034. } else {
  2035. console.warn(` [TEST QR] Invalid itemId or stockInLineId: itemId=${parts[0]}, stockInLineId=${parts[1]}`);
  2036. }
  2037. } else {
  2038. console.warn(` [TEST QR] Invalid format. Expected {2fitestx,y} or {2fittestx,y}, got: ${latestQr}`);
  2039. }
  2040. }
  2041. // Skip processing if manual confirmation modal is open
  2042. if (manualLotConfirmationOpen) {
  2043. // Check if this is a different QR code than what triggered the modal
  2044. const modalTriggerQr = lastProcessedQrRef.current;
  2045. if (latestQr === modalTriggerQr) {
  2046. console.log(` [QR PROCESS] Skipping - manual modal open for same QR`);
  2047. return;
  2048. }
  2049. // If it's a different QR, allow processing
  2050. console.log(` [QR PROCESS] Different QR detected while manual modal open, allowing processing`);
  2051. }
  2052. const qrDetectionStartTime = performance.now();
  2053. console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`);
  2054. console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`);
  2055. console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`);
  2056. // Skip if already processed (use refs to avoid dependency issues and delays)
  2057. const checkProcessedStartTime = performance.now();
  2058. if (processedQrCodesRef.current.has(latestQr) || lastProcessedQrRef.current === latestQr) {
  2059. const checkTime = performance.now() - checkProcessedStartTime;
  2060. console.log(` [QR PROCESS] Already processed check time: ${checkTime.toFixed(2)}ms`);
  2061. return;
  2062. }
  2063. const checkTime = performance.now() - checkProcessedStartTime;
  2064. console.log(` [QR PROCESS] Not processed check time: ${checkTime.toFixed(2)}ms`);
  2065. // Handle special shortcut
  2066. if (latestQr === "{2fic}") {
  2067. console.log(" Detected {2fic} shortcut - opening manual lot confirmation form");
  2068. setManualLotConfirmationOpen(true);
  2069. if (resetScanRef.current) {
  2070. resetScanRef.current();
  2071. }
  2072. lastProcessedQrRef.current = latestQr;
  2073. processedQrCodesRef.current.add(latestQr);
  2074. if (processedQrCodesRef.current.size > 100) {
  2075. const firstValue = processedQrCodesRef.current.values().next().value;
  2076. if (firstValue !== undefined) {
  2077. processedQrCodesRef.current.delete(firstValue);
  2078. }
  2079. }
  2080. setLastProcessedQr(latestQr);
  2081. setProcessedQrCodes(prev => {
  2082. const newSet = new Set(prev);
  2083. newSet.add(latestQr);
  2084. if (newSet.size > 100) {
  2085. const firstValue = newSet.values().next().value;
  2086. if (firstValue !== undefined) {
  2087. newSet.delete(firstValue);
  2088. }
  2089. }
  2090. return newSet;
  2091. });
  2092. return;
  2093. }
  2094. // Process new QR code immediately (background mode - no modal)
  2095. // Check against refs to avoid state update delays
  2096. if (latestQr && latestQr !== lastProcessedQrRef.current) {
  2097. const processingStartTime = performance.now();
  2098. console.log(` [QR PROCESS] Starting processing at: ${new Date().toISOString()}`);
  2099. console.log(` [QR PROCESS] Time since detection: ${(processingStartTime - qrDetectionStartTime).toFixed(2)}ms`);
  2100. // ✅ Process immediately for better responsiveness
  2101. // Clear any pending debounced processing
  2102. if (qrProcessingTimeoutRef.current) {
  2103. clearTimeout(qrProcessingTimeoutRef.current);
  2104. qrProcessingTimeoutRef.current = null;
  2105. }
  2106. // Log immediately (console.log is synchronous)
  2107. console.log(` [QR PROCESS] Processing new QR code with enhanced validation: ${latestQr}`);
  2108. // Update refs immediately (no state update delay) - do this FIRST
  2109. const refUpdateStartTime = performance.now();
  2110. lastProcessedQrRef.current = latestQr;
  2111. processedQrCodesRef.current.add(latestQr);
  2112. if (processedQrCodesRef.current.size > 100) {
  2113. const firstValue = processedQrCodesRef.current.values().next().value;
  2114. if (firstValue !== undefined) {
  2115. processedQrCodesRef.current.delete(firstValue);
  2116. }
  2117. }
  2118. const refUpdateTime = performance.now() - refUpdateStartTime;
  2119. console.log(` [QR PROCESS] Ref update time: ${refUpdateTime.toFixed(2)}ms`);
  2120. // Process immediately in background - no modal/form needed, no delays
  2121. // Use ref to avoid dependency issues
  2122. const processCallStartTime = performance.now();
  2123. if (processOutsideQrCodeRef.current) {
  2124. processOutsideQrCodeRef.current(latestQr, qrValues.length).then(() => {
  2125. const processCallTime = performance.now() - processCallStartTime;
  2126. const totalProcessingTime = performance.now() - processingStartTime;
  2127. console.log(` [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`);
  2128. console.log(` [QR PROCESS] Total processing time: ${totalProcessingTime.toFixed(2)}ms (${(totalProcessingTime / 1000).toFixed(3)}s)`);
  2129. }).catch((error) => {
  2130. const processCallTime = performance.now() - processCallStartTime;
  2131. const totalProcessingTime = performance.now() - processingStartTime;
  2132. console.error(`❌ [QR PROCESS] processOutsideQrCode error after ${processCallTime.toFixed(2)}ms:`, error);
  2133. console.error(`❌ [QR PROCESS] Total processing time before error: ${totalProcessingTime.toFixed(2)}ms`);
  2134. });
  2135. }
  2136. // Update state for UI (but don't block on it)
  2137. const stateUpdateStartTime = performance.now();
  2138. setLastProcessedQr(latestQr);
  2139. setProcessedQrCodes(new Set(processedQrCodesRef.current));
  2140. const stateUpdateTime = performance.now() - stateUpdateStartTime;
  2141. console.log(` [QR PROCESS] State update time: ${stateUpdateTime.toFixed(2)}ms`);
  2142. const detectionTime = performance.now() - qrDetectionStartTime;
  2143. const totalEffectTime = performance.now() - qrValuesChangeStartTime;
  2144. console.log(` [QR DETECTION] Total detection time: ${detectionTime.toFixed(2)}ms`);
  2145. console.log(` [QR VALUES EFFECT] Total effect time: ${totalEffectTime.toFixed(2)}ms`);
  2146. }
  2147. return () => {
  2148. if (qrProcessingTimeoutRef.current) {
  2149. clearTimeout(qrProcessingTimeoutRef.current);
  2150. qrProcessingTimeoutRef.current = null;
  2151. }
  2152. };
  2153. }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, manualLotConfirmationOpen]);
  2154. const renderCountRef = useRef(0);
  2155. const renderStartTimeRef = useRef<number | null>(null);
  2156. // Track render performance
  2157. useEffect(() => {
  2158. renderCountRef.current++;
  2159. const now = performance.now();
  2160. if (renderStartTimeRef.current !== null) {
  2161. const renderTime = now - renderStartTimeRef.current;
  2162. if (renderTime > 100) { // Only log slow renders (>100ms)
  2163. console.log(` [PERF] Render #${renderCountRef.current} took ${renderTime.toFixed(2)}ms, combinedLotData length: ${combinedLotData.length}`);
  2164. }
  2165. renderStartTimeRef.current = null;
  2166. }
  2167. }, [combinedLotData.length]);
  2168. // Auto-start scanner only once on mount
  2169. const scannerInitializedRef = useRef(false);
  2170. useEffect(() => {
  2171. if (session && currentUserId && !initializationRef.current) {
  2172. console.log(" Session loaded, initializing pick order...");
  2173. initializationRef.current = true;
  2174. // Only fetch existing data, no auto-assignment
  2175. fetchAllCombinedLotData();
  2176. }
  2177. }, [session, currentUserId, fetchAllCombinedLotData]);
  2178. // Separate effect for auto-starting scanner (only once, prevents multiple resets)
  2179. useEffect(() => {
  2180. if (session && currentUserId && !scannerInitializedRef.current) {
  2181. scannerInitializedRef.current = true;
  2182. // ✅ Auto-start scanner on mount for tablet use (background mode - no modal)
  2183. console.log("✅ Auto-starting QR scanner in background mode");
  2184. setIsManualScanning(true);
  2185. startScan();
  2186. }
  2187. }, [session, currentUserId, startScan]);
  2188. // Add event listener for manual assignment
  2189. useEffect(() => {
  2190. const handlePickOrderAssigned = () => {
  2191. console.log("🔄 Pick order assigned event received, refreshing data...");
  2192. fetchAllCombinedLotData();
  2193. };
  2194. window.addEventListener('pickOrderAssigned', handlePickOrderAssigned);
  2195. return () => {
  2196. window.removeEventListener('pickOrderAssigned', handlePickOrderAssigned);
  2197. };
  2198. }, [fetchAllCombinedLotData]);
  2199. const handleManualInputSubmit = useCallback(() => {
  2200. if (qrScanInput.trim() !== '') {
  2201. handleQrCodeSubmit(qrScanInput.trim());
  2202. }
  2203. }, [qrScanInput, handleQrCodeSubmit]);
  2204. // Handle QR code submission from modal (internal scanning)
  2205. const handleQrCodeSubmitFromModal = useCallback(async () => {
  2206. // Legacy path: marked SOL as `checked` (normal version). Disabled for workbench.
  2207. setQrScanError(true);
  2208. setQrScanSuccess(false);
  2209. setQrScanErrorMsg(t("Workbench uses scan-pick; this QR modal flow is not supported."));
  2210. }, [t]);
  2211. const handlePickQtyChange = useCallback((lotKey: string, value: number | string) => {
  2212. if (value === '' || value === null || value === undefined) {
  2213. setPickQtyData((prev) => {
  2214. if (!Object.prototype.hasOwnProperty.call(prev, lotKey)) return prev;
  2215. const next = { ...prev };
  2216. delete next[lotKey];
  2217. return next;
  2218. });
  2219. return;
  2220. }
  2221. const numericValue = typeof value === "string" ? parseFloat(value) : value;
  2222. if (Number.isNaN(numericValue) || numericValue < 0) {
  2223. setPickQtyData((prev) => {
  2224. if (!Object.prototype.hasOwnProperty.call(prev, lotKey)) return prev;
  2225. const next = { ...prev };
  2226. delete next[lotKey];
  2227. return next;
  2228. });
  2229. return;
  2230. }
  2231. setPickQtyData((prev) => ({
  2232. ...prev,
  2233. [lotKey]: numericValue,
  2234. }));
  2235. }, []);
  2236. const [autoAssignStatus, setAutoAssignStatus] = useState<'idle' | 'checking' | 'assigned' | 'no_orders'>('idle');
  2237. const [autoAssignMessage, setAutoAssignMessage] = useState<string>('');
  2238. const [completionStatus, setCompletionStatus] = useState<PickOrderCompletionResponse | null>(null);
  2239. const checkAndAutoAssignNext = useCallback(async () => {
  2240. if (!currentUserId) return;
  2241. try {
  2242. const completionResponse = await checkPickOrderCompletion(currentUserId);
  2243. if (completionResponse.code === "SUCCESS" && completionResponse.entity?.hasCompletedOrders) {
  2244. console.log("Found completed pick orders, auto-assigning next...");
  2245. // 移除前端的自动分配逻辑,因为后端已经处理了
  2246. // await handleAutoAssignAndRelease(); // 删除这个函数
  2247. }
  2248. } catch (error) {
  2249. console.error("Error checking pick order completion:", error);
  2250. }
  2251. }, [currentUserId]);
  2252. // Handle reject lot
  2253. // Handle pick execution form
  2254. const handlePickExecutionForm = useCallback((lot: any) => {
  2255. console.log("=== Pick Execution Form ===");
  2256. console.log("Lot data:", lot);
  2257. if (!lot) {
  2258. console.warn("No lot data provided for pick execution form");
  2259. return;
  2260. }
  2261. console.log("Opening pick execution form for lot:", lot.lotNo);
  2262. setSelectedLotForExecutionForm(lot);
  2263. setPickExecutionFormOpen(true);
  2264. console.log("Pick execution form opened for lot ID:", lot.lotId);
  2265. }, []);
  2266. const handlePickExecutionFormSubmit = useCallback(async (data: any) => {
  2267. try {
  2268. console.log("Pick execution form submitted:", data);
  2269. const issueData = {
  2270. ...data,
  2271. type: "Do", // Delivery Order Record 类型
  2272. pickerName: session?.user?.name || '',
  2273. };
  2274. const result = await recordPickExecutionIssue(issueData);
  2275. console.log("Pick execution issue recorded:", result);
  2276. if (result && result.code === "SUCCESS") {
  2277. console.log(" Pick execution issue recorded successfully");
  2278. // 关键:issue form 只记录问题,不会更新 SOL.qty
  2279. // 但 batch submit 需要知道“实际拣到多少”,否则会按 requiredQty 补拣到满
  2280. const solId = Number(issueData.stockOutLineId || issueData.stockOutLineId === 0 ? issueData.stockOutLineId : data?.stockOutLineId);
  2281. if (solId > 0) {
  2282. const picked = Number(issueData.actualPickQty || 0);
  2283. setIssuePickedQtyBySolId((prev) => {
  2284. const next = { ...prev, [solId]: picked };
  2285. const doId = fgPickOrders[0]?.doPickOrderId;
  2286. if (doId) saveIssuePickedMap(doId, next);
  2287. return next;
  2288. });
  2289. setCombinedLotData(prev => prev.map(lot => {
  2290. if (Number(lot.stockOutLineId) === solId) {
  2291. return { ...lot, actualPickQty: picked, stockOutLineQty: picked };
  2292. }
  2293. return lot;
  2294. }));
  2295. }
  2296. } else {
  2297. console.error(" Failed to record pick execution issue:", result);
  2298. }
  2299. setPickExecutionFormOpen(false);
  2300. setSelectedLotForExecutionForm(null);
  2301. setQrScanError(false);
  2302. setQrScanSuccess(false);
  2303. setQrScanInput('');
  2304. // ✅ Keep scanner active after form submission - don't stop scanning
  2305. // Only clear processed QR codes for the specific lot, not all
  2306. // setIsManualScanning(false); // Removed - keep scanner active
  2307. // stopScan(); // Removed - keep scanner active
  2308. // resetScan(); // Removed - keep scanner active
  2309. // Don't clear all processed codes - only clear for this specific lot if needed
  2310. await fetchAllCombinedLotData();
  2311. } catch (error) {
  2312. console.error("Error submitting pick execution form:", error);
  2313. }
  2314. }, [fetchAllCombinedLotData, session, fgPickOrders]);
  2315. // Calculate remaining required quantity
  2316. const calculateRemainingRequiredQty = useCallback((lot: any) => {
  2317. const requiredQty = lot.requiredQty || 0;
  2318. const stockOutLineQty = lot.stockOutLineQty || 0;
  2319. return Math.max(0, requiredQty - stockOutLineQty);
  2320. }, []);
  2321. // Search criteria
  2322. const searchCriteria: Criterion<any>[] = [
  2323. {
  2324. label: t("Pick Order Code"),
  2325. paramName: "pickOrderCode",
  2326. type: "text",
  2327. },
  2328. {
  2329. label: t("Item Code"),
  2330. paramName: "itemCode",
  2331. type: "text",
  2332. },
  2333. {
  2334. label: t("Item Name"),
  2335. paramName: "itemName",
  2336. type: "text",
  2337. },
  2338. {
  2339. label: t("Lot No"),
  2340. paramName: "lotNo",
  2341. type: "text",
  2342. },
  2343. ];
  2344. const handleSearch = useCallback((query: Record<string, any>) => {
  2345. setSearchQuery({ ...query });
  2346. console.log("Search query:", query);
  2347. if (!originalCombinedData) return;
  2348. const filtered = originalCombinedData.filter((lot: any) => {
  2349. const pickOrderCodeMatch = !query.pickOrderCode ||
  2350. lot.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase());
  2351. const itemCodeMatch = !query.itemCode ||
  2352. lot.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase());
  2353. const itemNameMatch = !query.itemName ||
  2354. lot.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase());
  2355. const lotNoMatch = !query.lotNo ||
  2356. lot.lotNo?.toLowerCase().includes((query.lotNo || "").toLowerCase());
  2357. return pickOrderCodeMatch && itemCodeMatch && itemNameMatch && lotNoMatch;
  2358. });
  2359. setCombinedLotData(filtered);
  2360. console.log("Filtered lots count:", filtered.length);
  2361. }, [originalCombinedData]);
  2362. const handleReset = useCallback(() => {
  2363. setSearchQuery({});
  2364. if (originalCombinedData) {
  2365. setCombinedLotData(originalCombinedData);
  2366. }
  2367. }, [originalCombinedData]);
  2368. const handlePageChange = useCallback((event: unknown, newPage: number) => {
  2369. setPaginationController(prev => ({
  2370. ...prev,
  2371. pageNum: newPage,
  2372. }));
  2373. }, []);
  2374. const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
  2375. const newPageSize = parseInt(event.target.value, 10);
  2376. setPaginationController({
  2377. pageNum: 0,
  2378. pageSize: newPageSize === -1 ? -1 : newPageSize,
  2379. });
  2380. }, []);
  2381. // ✅ Workbench list: group same item (within same route), sort completed lots first,
  2382. // and suppress repeated item fields on subsequent rows.
  2383. const paginatedData = useMemo(() => {
  2384. type RowMeta = {
  2385. lot: any;
  2386. isGroupFirst: boolean;
  2387. groupDisplayIndex: number;
  2388. };
  2389. const isCompletedStatus = (lot: any) => {
  2390. const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
  2391. return (
  2392. st === "completed" ||
  2393. st === "partially_completed" ||
  2394. st === "partially_complete"
  2395. );
  2396. };
  2397. const isCheckedStatus = (lot: any) => {
  2398. const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
  2399. return st === "checked";
  2400. };
  2401. const statusRank = (lot: any) => {
  2402. // Desired order within same item:
  2403. // completed -> checked -> pending -> rejected -> others
  2404. const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
  2405. if (isCompletedStatus(lot)) return 0;
  2406. if (isCheckedStatus(lot)) return 1;
  2407. if (st === "pending") return 2;
  2408. if (st === "rejected") return 3;
  2409. return 9;
  2410. };
  2411. // Keep stable group ordering by first appearance.
  2412. const groups = new Map<
  2413. string,
  2414. { firstIndex: number; items: { lot: any; originalIndex: number }[] }
  2415. >();
  2416. combinedLotData.forEach((lot: any, originalIndex: number) => {
  2417. const routeKey = String(lot?.routerRoute ?? "").trim();
  2418. const itemKey =
  2419. lot?.itemId != null
  2420. ? `itemId:${String(lot.itemId)}`
  2421. : `itemCode:${String(lot?.itemCode ?? "").trim()}`;
  2422. // Group only within same route to avoid collapsing different routes visually.
  2423. const key = `${routeKey}__${itemKey}`;
  2424. const g = groups.get(key);
  2425. if (!g) {
  2426. groups.set(key, { firstIndex: originalIndex, items: [{ lot, originalIndex }] });
  2427. } else {
  2428. g.items.push({ lot, originalIndex });
  2429. }
  2430. });
  2431. const groupEntries = Array.from(groups.values()).sort(
  2432. (a, b) => a.firstIndex - b.firstIndex,
  2433. );
  2434. const flattened: RowMeta[] = [];
  2435. for (let gi = 0; gi < groupEntries.length; gi += 1) {
  2436. const g = groupEntries[gi];
  2437. // Re-number groups contiguously (avoid gaps after grouping)
  2438. const groupDisplayIndex = gi + 1;
  2439. const sortedWithin = [...g.items].sort((a, b) => {
  2440. const ra = statusRank(a.lot);
  2441. const rb = statusRank(b.lot);
  2442. if (ra !== rb) return ra - rb;
  2443. return a.originalIndex - b.originalIndex; // stable fallback
  2444. });
  2445. sortedWithin.forEach((it, idx) => {
  2446. flattened.push({
  2447. lot: it.lot,
  2448. isGroupFirst: idx === 0,
  2449. groupDisplayIndex,
  2450. });
  2451. });
  2452. }
  2453. if (paginationController.pageSize === -1) return flattened;
  2454. const startIndex =
  2455. paginationController.pageNum * paginationController.pageSize;
  2456. const endIndex = startIndex + paginationController.pageSize;
  2457. return flattened.slice(startIndex, endIndex);
  2458. }, [combinedLotData, paginationController.pageNum, paginationController.pageSize]);
  2459. const allItemsReady = useMemo(() => {
  2460. if (combinedLotData.length === 0) return false;
  2461. return combinedLotData.every((lot: any) => {
  2462. const status = lot.stockOutLineStatus?.toLowerCase();
  2463. const isRejected =
  2464. status === 'rejected' || lot.lotAvailability === 'rejected';
  2465. const isCompleted =
  2466. status === 'completed' || status === 'partially_completed' || status === 'partially_complete';
  2467. const isChecked = status === 'checked';
  2468. const isPending = status === 'pending';
  2469. // ✅ FIXED: 无库存(noLot)行:pending 状态也应该被视为 ready(可以提交)
  2470. // ✅ 過期批號(未換批):與 noLot 相同,視為可收尾
  2471. if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) {
  2472. return isChecked || isCompleted || isRejected || isPending;
  2473. }
  2474. // 正常 lot:必须已扫描/提交或者被拒收
  2475. return isChecked || isCompleted || isRejected;
  2476. });
  2477. }, [combinedLotData]);
  2478. const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: number, source: 'justComplete' | 'singleSubmit') => {
  2479. if (!lot.stockOutLineId) {
  2480. console.error("No stock out line found for this lot");
  2481. return;
  2482. }
  2483. const solId = Number(lot.stockOutLineId);
  2484. if (solId > 0 && actionBusyBySolId[solId]) {
  2485. console.warn("Action already in progress for stockOutLineId:", solId);
  2486. return;
  2487. }
  2488. try {
  2489. if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true }));
  2490. const targetUnavailable = isInventoryLotLineUnavailable(lot);
  2491. const effectiveSubmitQty = targetUnavailable && submitQty > 0 ? 0 : submitQty;
  2492. const canonicalLotForSol =
  2493. solId > 0
  2494. ? combinedLotData.find((r) => Number(r.stockOutLineId) === solId) ?? lot
  2495. : lot;
  2496. // Workbench「Just Completed」:不掃 QR,直接用列上的 lotNo + stockInLineId 走 scan-pick 完成庫存扣帳
  2497. if (workbenchMode && source === "justComplete") {
  2498. const solIdForOverride = Number(canonicalLotForSol.stockOutLineId) || 0;
  2499. const lotIdForOverride = canonicalLotForSol.lotId;
  2500. const lotKeyForOverride =
  2501. Number.isFinite(solIdForOverride) && solIdForOverride > 0
  2502. ? `sol:${solIdForOverride}`
  2503. : `${canonicalLotForSol.pickOrderLineId}-${lotIdForOverride}`;
  2504. const hasExplicitSubmitOverride = Object.prototype.hasOwnProperty.call(
  2505. pickQtyData,
  2506. lotKeyForOverride,
  2507. );
  2508. const explicitSubmitOverride = hasExplicitSubmitOverride
  2509. ? Number(pickQtyData[lotKeyForOverride])
  2510. : NaN;
  2511. const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol);
  2512. const wbJustQty = qtyPayload.qty;
  2513. const isUnavailableForJustComplete = isInventoryLotLineUnavailable(canonicalLotForSol);
  2514. const canPostScanPick =
  2515. // unavailable lot: Just Completed must always submit qty=0, even without lotNo
  2516. isUnavailableForJustComplete || (
  2517. canonicalLotForSol.lotNo && String(canonicalLotForSol.lotNo).trim() !== "" && (
  2518. // explicit short submit: user typed 0 (must send qty=0 to backend)
  2519. (hasExplicitSubmitOverride &&
  2520. Number.isFinite(explicitSubmitOverride) &&
  2521. explicitSubmitOverride === 0) ||
  2522. // normal pick: positive qty
  2523. (wbJustQty != null && wbJustQty > 0)
  2524. )
  2525. );
  2526. if (canPostScanPick) {
  2527. const qtyToSend = isUnavailableForJustComplete
  2528. ? 0
  2529. : hasExplicitSubmitOverride && explicitSubmitOverride === 0
  2530. ? 0
  2531. : Number(wbJustQty);
  2532. const res = await workbenchScanPick({
  2533. stockOutLineId: Number(canonicalLotForSol.stockOutLineId),
  2534. lotNo: String(canonicalLotForSol.lotNo).trim(),
  2535. ...(typeof canonicalLotForSol.stockInLineId === "number" &&
  2536. Number.isFinite(canonicalLotForSol.stockInLineId) &&
  2537. canonicalLotForSol.stockInLineId > 0
  2538. ? { stockInLineId: canonicalLotForSol.stockInLineId }
  2539. : {}),
  2540. qty: qtyToSend,
  2541. storeId: fgPickOrders?.[0]?.storeId ?? null,
  2542. userId: currentUserId ?? 1,
  2543. });
  2544. const scanOk = res.code === "SUCCESS";
  2545. if (!scanOk) {
  2546. rememberWorkbenchScanReject(
  2547. Number(canonicalLotForSol.stockOutLineId),
  2548. (res as { message?: string })?.message,
  2549. );
  2550. throw new Error(
  2551. (res as { message?: string })?.message || "Workbench scan-pick failed",
  2552. );
  2553. }
  2554. clearWorkbenchScanReject(Number(canonicalLotForSol.stockOutLineId));
  2555. const entity = res.entity as any;
  2556. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  2557. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  2558. setPickQtyData((prev) => {
  2559. if (!Object.prototype.hasOwnProperty.call(prev, lotKeyForOverride)) return prev;
  2560. const next = { ...prev };
  2561. delete next[lotKeyForOverride];
  2562. return next;
  2563. });
  2564. await refreshWorkbenchAfterScanPick();
  2565. setTimeout(() => {
  2566. checkAndAutoAssignNext();
  2567. }, 1000);
  2568. console.log("Just Completed (workbench): workbenchScanPick posted without QR.");
  2569. return;
  2570. }
  2571. const justCompleteErr = t(
  2572. "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.",
  2573. );
  2574. if (solId > 0) {
  2575. rememberWorkbenchScanReject(solId, justCompleteErr);
  2576. }
  2577. setQrScanErrorMsg(justCompleteErr);
  2578. throw new Error(justCompleteErr);
  2579. }
  2580. if (effectiveSubmitQty === 0 && source === 'singleSubmit') {
  2581. console.log(`=== SUBMITTING ALL ZEROS CASE ===`);
  2582. console.log(`Lot: ${lot.lotNo}`);
  2583. console.log(`Stock Out Line ID: ${lot.stockOutLineId}`);
  2584. // ✅ Workbench: route qty=0 through scan-pick as well (backend supports zero-complete).
  2585. // Allow empty lotNo for noLot/expired/unavailable rows.
  2586. if (workbenchMode) {
  2587. const res = await workbenchScanPick({
  2588. stockOutLineId: Number(lot.stockOutLineId),
  2589. lotNo: String(lot.lotNo ?? "").trim(),
  2590. ...(typeof lot.stockInLineId === "number" &&
  2591. Number.isFinite(lot.stockInLineId) &&
  2592. lot.stockInLineId > 0
  2593. ? { stockInLineId: lot.stockInLineId }
  2594. : {}),
  2595. qty: 0,
  2596. storeId: fgPickOrders?.[0]?.storeId ?? null,
  2597. userId: currentUserId ?? 1,
  2598. });
  2599. const scanOk = res.code === "SUCCESS";
  2600. if (!scanOk) {
  2601. rememberWorkbenchScanReject(
  2602. Number(lot.stockOutLineId),
  2603. (res as { message?: string })?.message,
  2604. );
  2605. throw new Error(
  2606. (res as { message?: string })?.message || "Workbench scan-pick failed (qty=0)",
  2607. );
  2608. }
  2609. clearWorkbenchScanReject(Number(lot.stockOutLineId));
  2610. await refreshWorkbenchAfterScanPick();
  2611. setTimeout(() => {
  2612. checkAndAutoAssignNext();
  2613. }, 1000);
  2614. return;
  2615. }
  2616. // Legacy non-workbench path used `checked` as an intermediate state.
  2617. // Workbench mode is always true in this page; keep this branch unreachable.
  2618. throw new Error("Unsupported legacy checked flow on workbench page");
  2619. }
  2620. // DO Workbench: inventory posting + SOL/POL rules live in /doPickOrder/workbench/scan-pick
  2621. if (
  2622. workbenchMode &&
  2623. effectiveSubmitQty > 0 &&
  2624. lot.lotNo &&
  2625. String(lot.lotNo).trim() !== "" &&
  2626. !isLotAvailabilityExpired(lot) &&
  2627. !isInventoryLotLineUnavailable(lot)
  2628. ) {
  2629. const res = await workbenchScanPick({
  2630. stockOutLineId: Number(lot.stockOutLineId),
  2631. lotNo: String(lot.lotNo).trim(),
  2632. ...(typeof lot.stockInLineId === "number" &&
  2633. Number.isFinite(lot.stockInLineId) &&
  2634. lot.stockInLineId > 0
  2635. ? { stockInLineId: lot.stockInLineId }
  2636. : {}),
  2637. qty: Number(effectiveSubmitQty),
  2638. storeId: fgPickOrders?.[0]?.storeId ?? null,
  2639. userId: currentUserId ?? 1,
  2640. });
  2641. const scanOk = res.code === "SUCCESS";
  2642. if (!scanOk) {
  2643. rememberWorkbenchScanReject(
  2644. Number(lot.stockOutLineId),
  2645. (res as { message?: string })?.message,
  2646. );
  2647. throw new Error(
  2648. (res as { message?: string })?.message || "Workbench scan-pick failed",
  2649. );
  2650. }
  2651. clearWorkbenchScanReject(Number(lot.stockOutLineId));
  2652. const entity = res.entity as any;
  2653. const nextStatus = String(entity?.status ?? "completed").toLowerCase();
  2654. const nextQty = entity?.qty != null ? Number(entity.qty) : undefined;
  2655. const successLotKey = getWorkbenchQtyLotKey(lot);
  2656. setPickQtyData((prev) => {
  2657. if (!Object.prototype.hasOwnProperty.call(prev, successLotKey)) return prev;
  2658. const next = { ...prev };
  2659. delete next[successLotKey];
  2660. return next;
  2661. });
  2662. await refreshWorkbenchAfterScanPick();
  2663. setTimeout(() => {
  2664. checkAndAutoAssignNext();
  2665. }, 1000);
  2666. return;
  2667. }
  2668. // FIXED: Calculate cumulative quantity correctly
  2669. const currentActualPickQty = lot.actualPickQty || 0;
  2670. const cumulativeQty = currentActualPickQty + effectiveSubmitQty;
  2671. // FIXED: Determine status based on cumulative quantity vs required quantity
  2672. let newStatus = 'partially_completed';
  2673. if (cumulativeQty >= lot.requiredQty) {
  2674. newStatus = 'completed';
  2675. } else if (cumulativeQty > 0) {
  2676. newStatus = 'partially_completed';
  2677. } else {
  2678. // Legacy non-workbench path used `checked` as an intermediate state.
  2679. // Workbench posts immediately via scan-pick.
  2680. newStatus = 'pending';
  2681. }
  2682. console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`);
  2683. console.log(`Lot: ${lot.lotNo}`);
  2684. console.log(`Required Qty: ${lot.requiredQty}`);
  2685. console.log(`Current Actual Pick Qty: ${currentActualPickQty}`);
  2686. console.log(`New Submitted Qty: ${effectiveSubmitQty}`);
  2687. console.log(`Cumulative Qty: ${cumulativeQty}`);
  2688. console.log(`New Status: ${newStatus}`);
  2689. console.log(`=====================================`);
  2690. if (!workbenchMode) {
  2691. await updateStockOutLineStatus({
  2692. id: lot.stockOutLineId,
  2693. status: newStatus,
  2694. // 后端 updateStatus 的 qty 是“增量 delta”,不能传 cumulativeQty(否则会重复累加导致 out/hold 大幅偏移)
  2695. qty: effectiveSubmitQty
  2696. });
  2697. applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), newStatus, cumulativeQty);
  2698. }
  2699. // 注意:库存过账(hold->out)与 ledger 由后端 updateStatus 内部统一处理;
  2700. // 前端不再额外调用 updateInventoryLotLineQuantities(operation='pick'),避免 double posting。
  2701. // Workbench completion is handled in backend scan-pick flow.
  2702. void fetchAllCombinedLotData();
  2703. console.log("Pick quantity submitted successfully!");
  2704. setTimeout(() => {
  2705. checkAndAutoAssignNext();
  2706. }, 1000);
  2707. } catch (error) {
  2708. console.error("Error submitting pick quantity:", error);
  2709. setQrScanError(true);
  2710. setQrScanSuccess(false);
  2711. } finally {
  2712. if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false }));
  2713. }
  2714. }, [
  2715. fetchAllCombinedLotData,
  2716. checkAndAutoAssignNext,
  2717. actionBusyBySolId,
  2718. applyLocalStockOutLineUpdate,
  2719. workbenchMode,
  2720. currentUserId,
  2721. rememberWorkbenchScanReject,
  2722. clearWorkbenchScanReject,
  2723. refreshWorkbenchAfterScanPick,
  2724. combinedLotData,
  2725. workbenchScanPickQtyFromLot,
  2726. t,
  2727. ]);
  2728. const handleSkip = useCallback(async (lot: any) => {
  2729. try {
  2730. console.log("Just Complete clicked (workbench: scan-pick without QR when possible):", lot.lotNo);
  2731. await handleSubmitPickQtyWithQty(lot, 0, 'justComplete');
  2732. } catch (err) {
  2733. console.error("Error in Skip:", err);
  2734. }
  2735. }, [handleSubmitPickQtyWithQty]);
  2736. const hasPendingBatchSubmit = useMemo(() => {
  2737. return combinedLotData.some((lot) => {
  2738. const status = String(lot.stockOutLineStatus || "").toLowerCase();
  2739. return status === "pending" || status === "partially_completed" || status === "partially_complete";
  2740. });
  2741. }, [combinedLotData]);
  2742. useEffect(() => {
  2743. if (!hasPendingBatchSubmit) return;
  2744. const handler = (event: BeforeUnloadEvent) => {
  2745. event.preventDefault();
  2746. event.returnValue = "";
  2747. };
  2748. window.addEventListener("beforeunload", handler);
  2749. return () => window.removeEventListener("beforeunload", handler);
  2750. }, [hasPendingBatchSubmit]);
  2751. const handleStartScan = useCallback(() => {
  2752. const startTime = performance.now();
  2753. console.log(` [START SCAN] Called at: ${new Date().toISOString()}`);
  2754. console.log(` [START SCAN] Starting manual QR scan...`);
  2755. setIsManualScanning(true);
  2756. const setManualScanningTime = performance.now() - startTime;
  2757. console.log(` [START SCAN] setManualScanning time: ${setManualScanningTime.toFixed(2)}ms`);
  2758. setProcessedQrCodes(new Set());
  2759. setLastProcessedQr('');
  2760. setQrScanError(false);
  2761. setQrScanSuccess(false);
  2762. const beforeStartScanTime = performance.now();
  2763. startScan();
  2764. const startScanTime = performance.now() - beforeStartScanTime;
  2765. console.log(` [START SCAN] startScan() call time: ${startScanTime.toFixed(2)}ms`);
  2766. const totalTime = performance.now() - startTime;
  2767. console.log(` [START SCAN] Total start scan time: ${totalTime.toFixed(2)}ms`);
  2768. console.log(` [START SCAN] Start scan completed at: ${new Date().toISOString()}`);
  2769. }, [startScan]);
  2770. const handlePickOrderSwitch = useCallback(async (pickOrderId: number) => {
  2771. if (pickOrderSwitching) return;
  2772. setPickOrderSwitching(true);
  2773. try {
  2774. console.log(" Switching to pick order:", pickOrderId);
  2775. setSelectedPickOrderId(pickOrderId);
  2776. // 强制刷新数据,确保显示正确的 pick order 数据
  2777. await fetchAllCombinedLotData(currentUserId, pickOrderId);
  2778. } catch (error) {
  2779. console.error("Error switching pick order:", error);
  2780. } finally {
  2781. setPickOrderSwitching(false);
  2782. }
  2783. }, [pickOrderSwitching, currentUserId, fetchAllCombinedLotData]);
  2784. const handleStopScan = useCallback(() => {
  2785. console.log("⏸️ Pausing QR scanner...");
  2786. setIsManualScanning(false);
  2787. setQrScanError(false);
  2788. setQrScanSuccess(false);
  2789. stopScan();
  2790. resetScan();
  2791. }, [stopScan, resetScan]);
  2792. // ... existing code around line 1469 ...
  2793. const handlelotnull = useCallback(async (lot: any) => {
  2794. // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId
  2795. const stockOutLineId = lot.stockOutLineId;
  2796. if (!stockOutLineId) {
  2797. console.error(" No stockOutLineId found for lot:", lot);
  2798. return;
  2799. }
  2800. const solId = Number(stockOutLineId);
  2801. if (solId > 0 && actionBusyBySolId[solId]) {
  2802. console.warn("Action already in progress for stockOutLineId:", solId);
  2803. return;
  2804. }
  2805. try {
  2806. if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true }));
  2807. // Step 1: Update stock out line status
  2808. await updateStockOutLineStatus({
  2809. id: stockOutLineId,
  2810. status: 'completed',
  2811. qty: 0
  2812. });
  2813. // Step 2: Create pick execution issue for no-lot case
  2814. // Get pick order ID from fgPickOrders or use 0 if not available
  2815. const pickOrderId = lot.pickOrderId || fgPickOrders[0]?.pickOrderId || 0;
  2816. const pickOrderCode = lot.pickOrderCode || fgPickOrders[0]?.pickOrderCode || lot.pickOrderConsoCode || '';
  2817. const issueData: PickExecutionIssueData = {
  2818. type: "Do", // Delivery Order type
  2819. pickOrderId: pickOrderId,
  2820. pickOrderCode: pickOrderCode,
  2821. pickOrderCreateDate: dayjs().format('YYYY-MM-DD'), // Use dayjs format
  2822. pickExecutionDate: dayjs().format('YYYY-MM-DD'),
  2823. pickOrderLineId: lot.pickOrderLineId,
  2824. itemId: lot.itemId,
  2825. itemCode: lot.itemCode || '',
  2826. itemDescription: lot.itemName || '',
  2827. lotId: null, // No lot available
  2828. lotNo: null, // No lot number
  2829. storeLocation: lot.location || '',
  2830. requiredQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0,
  2831. actualPickQty: 0, // No items picked (no lot available)
  2832. missQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0, // All quantity is missing
  2833. badItemQty: 0,
  2834. issueRemark: `No lot available for this item. Handled via handlelotnull.`,
  2835. pickerName: session?.user?.name || '',
  2836. };
  2837. const result = await recordPickExecutionIssue(issueData);
  2838. console.log(" Pick execution issue created for no-lot item:", result);
  2839. if (result && result.code === "SUCCESS") {
  2840. console.log(" No-lot item handled and issue recorded successfully");
  2841. } else {
  2842. console.error(" Failed to record pick execution issue:", result);
  2843. }
  2844. // Step 3: Refresh data
  2845. await fetchAllCombinedLotData();
  2846. } catch (error) {
  2847. console.error(" Error in handlelotnull:", error);
  2848. } finally {
  2849. if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false }));
  2850. }
  2851. }, [fetchAllCombinedLotData, session, currentUserId, fgPickOrders, actionBusyBySolId]);
  2852. const handleBatchScan = useCallback(async () => {
  2853. const startTime = performance.now();
  2854. console.log(` [BATCH SCAN START]`);
  2855. console.log(` Start time: ${new Date().toISOString()}`);
  2856. // 获取所有活跃批次(未扫描的)
  2857. const activeLots = combinedLotData.filter(lot => {
  2858. return (
  2859. lot.lotAvailability !== 'rejected' &&
  2860. lot.stockOutLineStatus !== 'rejected' &&
  2861. lot.stockOutLineStatus !== 'completed' &&
  2862. lot.stockOutLineStatus !== 'checked' && // ✅ 只处理未扫描的
  2863. lot.processingStatus !== 'completed' &&
  2864. lot.noLot !== true &&
  2865. lot.lotNo // ✅ 必须有 lotNo
  2866. );
  2867. });
  2868. if (activeLots.length === 0) {
  2869. console.log("No active lots to scan");
  2870. return;
  2871. }
  2872. console.log(`📦 Batch scanning ${activeLots.length} active lots using batch API...`);
  2873. try {
  2874. // ✅ 转换为批量扫描 API 所需的格式
  2875. const lines: BatchScanLineRequest[] = activeLots.map((lot) => ({
  2876. pickOrderLineId: Number(lot.pickOrderLineId),
  2877. inventoryLotLineId: lot.lotId ? Number(lot.lotId) : null,
  2878. pickOrderConsoCode: String(lot.pickOrderConsoCode || ''),
  2879. lotNo: lot.lotNo || null,
  2880. itemId: Number(lot.itemId),
  2881. itemCode: String(lot.itemCode || ''),
  2882. stockOutLineId: lot.stockOutLineId ? Number(lot.stockOutLineId) : null, // ✅ 新增
  2883. }));
  2884. const request: BatchScanRequest = {
  2885. userId: currentUserId || 0,
  2886. lines: lines
  2887. };
  2888. console.log(`📤 Sending batch scan request with ${lines.length} lines`);
  2889. console.log(`📋 Request data:`, JSON.stringify(request, null, 2));
  2890. const scanStartTime = performance.now();
  2891. // ✅ 使用新的批量扫描 API(一次性处理所有请求)
  2892. const result = await batchScan(request);
  2893. const scanTime = performance.now() - scanStartTime;
  2894. console.log(` Batch scan API call completed in ${scanTime.toFixed(2)}ms (${(scanTime / 1000).toFixed(3)}s)`);
  2895. console.log(`📥 Batch scan result:`, result);
  2896. // ✅ 刷新数据以获取最新的状态
  2897. const refreshStartTime = performance.now();
  2898. await fetchAllCombinedLotData();
  2899. const refreshTime = performance.now() - refreshStartTime;
  2900. console.log(` Data refresh time: ${refreshTime.toFixed(2)}ms (${(refreshTime / 1000).toFixed(3)}s)`);
  2901. const totalTime = performance.now() - startTime;
  2902. console.log(` [BATCH SCAN END]`);
  2903. console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`);
  2904. console.log(` End time: ${new Date().toISOString()}`);
  2905. if (result && result.code === "SUCCESS") {
  2906. setQrScanSuccess(true);
  2907. setQrScanError(false);
  2908. } else {
  2909. console.error("❌ Batch scan failed:", result);
  2910. setQrScanError(true);
  2911. setQrScanSuccess(false);
  2912. }
  2913. } catch (error) {
  2914. console.error("❌ Error in batch scan:", error);
  2915. setQrScanError(true);
  2916. setQrScanSuccess(false);
  2917. }
  2918. }, [combinedLotData, fetchAllCombinedLotData, currentUserId]);
  2919. const handleSubmitAllScanned = useCallback(async () => {
  2920. const startTime = performance.now();
  2921. console.log(` [BATCH SUBMIT START]`);
  2922. console.log(` Start time: ${new Date().toISOString()}`);
  2923. const scannedLots = combinedLotData.filter(lot => {
  2924. const status = lot.stockOutLineStatus;
  2925. const statusLower = String(status || "").toLowerCase();
  2926. if (statusLower === "completed" || statusLower === "complete") {
  2927. return false;
  2928. }
  2929. // Workbench batch submit is now dedicated to closing noLot / expired / unavailable rows (qty=0 via workbench scan-pick batch).
  2930. if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) {
  2931. return true;
  2932. }
  2933. return false;
  2934. });
  2935. if (scannedLots.length === 0) {
  2936. console.log("No scanned items to submit");
  2937. return;
  2938. }
  2939. setIsSubmittingAll(true);
  2940. console.log(`📦 Submitting ${scannedLots.length} items using workbench batch scan-pick (qty=0)...`);
  2941. try {
  2942. const submitStartTime = performance.now();
  2943. const result = await workbenchBatchScanPick({
  2944. lines: scannedLots.map((lot) => ({
  2945. stockOutLineId: Number(lot.stockOutLineId) || 0,
  2946. lotNo: "", // qty=0 path allows empty lotNo (workbench zero-complete)
  2947. qty: 0,
  2948. storeId: fgPickOrders?.[0]?.storeId ?? null,
  2949. userId: currentUserId ?? 1,
  2950. })),
  2951. });
  2952. const submitTime = performance.now() - submitStartTime;
  2953. console.log(` Batch submit API call completed in ${submitTime.toFixed(2)}ms (${(submitTime / 1000).toFixed(3)}s)`);
  2954. console.log(`📥 Batch submit result:`, result);
  2955. // Refresh data once after batch submission
  2956. const refreshStartTime = performance.now();
  2957. await fetchAllCombinedLotData();
  2958. const refreshTime = performance.now() - refreshStartTime;
  2959. console.log(` Data refresh time: ${refreshTime.toFixed(2)}ms (${(refreshTime / 1000).toFixed(3)}s)`);
  2960. const totalTime = performance.now() - startTime;
  2961. console.log(` [BATCH SUBMIT END]`);
  2962. console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`);
  2963. console.log(` End time: ${new Date().toISOString()}`);
  2964. if (result && result.code === "SUCCESS") {
  2965. setQrScanSuccess(true);
  2966. setTimeout(() => {
  2967. setQrScanSuccess(false);
  2968. checkAndAutoAssignNext();
  2969. if (onSwitchToRecordTab) {
  2970. onSwitchToRecordTab();
  2971. }
  2972. if (onRefreshReleasedOrderCount) {
  2973. onRefreshReleasedOrderCount();
  2974. }
  2975. }, 2000);
  2976. } else {
  2977. console.error("Batch submit failed:", result);
  2978. setQrScanError(true);
  2979. }
  2980. } catch (error) {
  2981. console.error("Error submitting all scanned items:", error);
  2982. setQrScanError(true);
  2983. } finally {
  2984. setIsSubmittingAll(false);
  2985. }
  2986. }, [combinedLotData, fetchAllCombinedLotData, checkAndAutoAssignNext, currentUserId, onSwitchToRecordTab, onRefreshReleasedOrderCount, fgPickOrders]);
  2987. // Calculate scanned items count
  2988. // Calculate scanned items count (should match handleSubmitAllScanned filter logic)
  2989. const scannedItemsCount = useMemo(() => {
  2990. const filtered = combinedLotData.filter(lot => {
  2991. const status = lot.stockOutLineStatus;
  2992. const statusLower = String(status || "").toLowerCase();
  2993. if (statusLower === "completed" || statusLower === "complete") {
  2994. return false;
  2995. }
  2996. // Keep consistent with handleSubmitAllScanned: batch submit is only for noLot/expired/unavailable.
  2997. if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) {
  2998. return true;
  2999. }
  3000. return false;
  3001. });
  3002. // 添加调试日志
  3003. const noLotCount = filtered.filter(l => l.noLot === true).length;
  3004. const normalCount = filtered.filter(l => l.noLot !== true).length;
  3005. console.log(`📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`);
  3006. console.log(`📊 All items breakdown:`, {
  3007. total: combinedLotData.length,
  3008. noLot: combinedLotData.filter(l => l.noLot === true).length,
  3009. normal: combinedLotData.filter(l => l.noLot !== true).length
  3010. });
  3011. return filtered.length;
  3012. }, [combinedLotData]);
  3013. /*
  3014. // ADD THIS: Auto-stop scan when no data available
  3015. useEffect(() => {
  3016. if (isManualScanning && combinedLotData.length === 0) {
  3017. console.log("⏹️ No data available, auto-stopping QR scan...");
  3018. handleStopScan();
  3019. }
  3020. }, [combinedLotData.length, isManualScanning, handleStopScan]);
  3021. */
  3022. // Cleanup effect
  3023. useEffect(() => {
  3024. return () => {
  3025. // Cleanup when component unmounts (e.g., when switching tabs)
  3026. if (isManualScanning) {
  3027. console.log("🧹 Pick execution component unmounting, stopping QR scanner...");
  3028. stopScan();
  3029. resetScan();
  3030. }
  3031. };
  3032. }, [isManualScanning, stopScan, resetScan]);
  3033. const getStatusMessage = useCallback((lot: any) => {
  3034. switch (lot.stockOutLineStatus?.toLowerCase()) {
  3035. case 'pending':
  3036. return t("Please finish QR code scan and pick order.");
  3037. case 'partially_completed':
  3038. return t("Partial quantity submitted. Please submit more or complete the order.");
  3039. case 'completed':
  3040. return t("Pick order completed successfully!");
  3041. case 'rejected':
  3042. return t("Lot has been rejected and marked as unavailable.");
  3043. case 'unavailable':
  3044. return t("This order is insufficient, please pick another lot.");
  3045. default:
  3046. return t("Please finish QR code scan and pick order.");
  3047. }
  3048. }, [t]);
  3049. return (
  3050. <TestQrCodeProvider
  3051. lotData={combinedLotData}
  3052. onScanLot={handleQrCodeSubmit}
  3053. onBatchScan={handleBatchScan}
  3054. filterActive={(lot) => (
  3055. lot.lotAvailability !== 'rejected' &&
  3056. lot.stockOutLineStatus !== 'rejected' &&
  3057. lot.stockOutLineStatus !== 'completed'
  3058. )}
  3059. >
  3060. <FormProvider {...formProps}>
  3061. <Stack spacing={2}>
  3062. <Box
  3063. sx={{
  3064. // Keep visible while scrolling, but don't cover global top header
  3065. position: 'sticky',
  3066. top: 0,
  3067. zIndex: 5,
  3068. backgroundColor: 'background.paper',
  3069. pt: 1,
  3070. pb: 1,
  3071. px: 2,
  3072. borderBottom: '1px solid',
  3073. borderColor: 'divider',
  3074. boxShadow: 'none',
  3075. }}
  3076. >
  3077. <LinearProgressWithLabel
  3078. completed={progress.completed}
  3079. total={progress.total}
  3080. label={t("Progress")}
  3081. />
  3082. <ScanStatusAlert
  3083. error={qrScanError}
  3084. success={qrScanSuccess}
  3085. errorMessage={t("QR code does not match any item in current orders.")}
  3086. successMessage={t("QR code verified.")}
  3087. />
  3088. </Box>
  3089. {/* DO Header */}
  3090. {/* 保留:Combined Lot Table - 包含所有 QR 扫描功能 */}
  3091. <Box>
  3092. <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, mt: 2 }}>
  3093. <Typography variant="h6" gutterBottom sx={{ mb: 0 }}>
  3094. {t("All Pick Order Lots")}
  3095. </Typography>
  3096. <Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
  3097. {/* Scanner status indicator (always visible) */}
  3098. {/*
  3099. <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
  3100. <QrCodeIcon
  3101. sx={{
  3102. color: isManualScanning ? '#4caf50' : '#9e9e9e',
  3103. animation: isManualScanning ? 'pulse 2s infinite' : 'none',
  3104. '@keyframes pulse': {
  3105. '0%, 100%': { opacity: 1 },
  3106. '50%': { opacity: 0.5 }
  3107. }
  3108. }}
  3109. />
  3110. <Typography variant="body2" sx={{ color: isManualScanning ? '#4caf50' : '#9e9e9e' }}>
  3111. {isManualScanning ? t("Scanner Active") : t("Scanner Inactive")}
  3112. </Typography>
  3113. </Box>
  3114. */}
  3115. {/* Pause/Resume button instead of Start/Stop */}
  3116. {isManualScanning ? (
  3117. <Button
  3118. variant="outlined"
  3119. startIcon={<QrCodeIcon />}
  3120. onClick={handleStopScan}
  3121. color="secondary"
  3122. sx={{ minWidth: '120px' }}
  3123. >
  3124. {t("Stop QR Scan")}
  3125. </Button>
  3126. ) : (
  3127. <Button
  3128. variant="contained"
  3129. startIcon={<QrCodeIcon />}
  3130. onClick={handleStartScan}
  3131. color="primary"
  3132. sx={{ minWidth: '120px' }}
  3133. >
  3134. {t("Start QR Scan")}
  3135. </Button>
  3136. )}
  3137. {/* 保留:Submit All Scanned Button */}
  3138. <Button
  3139. variant="contained"
  3140. color="success"
  3141. onClick={handleSubmitAllScanned}
  3142. disabled={
  3143. scannedItemsCount === 0
  3144. || isSubmittingAll}
  3145. sx={{ minWidth: '160px' }}
  3146. >
  3147. {isSubmittingAll ? (
  3148. <>
  3149. <CircularProgress size={16} sx={{ mr: 1, color: 'white' }} />
  3150. {t("Submitting...")}
  3151. </>
  3152. ) : (
  3153. `${t("Submit All Scanned")} (${scannedItemsCount})`
  3154. )}
  3155. </Button>
  3156. </Box>
  3157. </Box>
  3158. {fgPickOrders.length > 0 && (
  3159. <Paper sx={{ p: 2, mb: 2 }}>
  3160. <Stack spacing={2}>
  3161. {/* 基本信息 */}
  3162. <Stack direction="row" spacing={4} useFlexGap flexWrap="wrap">
  3163. <Typography variant="subtitle1">
  3164. <strong>{t("Shop Name")}:</strong> {fgPickOrders[0].shopName || '-'}
  3165. </Typography>
  3166. <Typography variant="subtitle1">
  3167. <strong>{t("Store ID")}:</strong> {fgPickOrders[0].storeId || '-'}
  3168. </Typography>
  3169. <Typography variant="subtitle1">
  3170. <strong>{t("Ticket No.")}:</strong> {fgPickOrders[0].ticketNo || '-'}
  3171. </Typography>
  3172. <Typography variant="subtitle1">
  3173. <strong>{t("Departure Time")}:</strong> {fgPickOrders[0].DepartureTime || '-'}
  3174. </Typography>
  3175. </Stack>
  3176. {/* 改进:三个字段显示在一起,使用表格式布局 */}
  3177. {/* 改进:三个字段合并显示 */}
  3178. {/* 改进:表格式显示每个 pick order */}
  3179. <Box sx={{
  3180. p: 2,
  3181. backgroundColor: '#f5f5f5',
  3182. borderRadius: 1
  3183. }}>
  3184. <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 'bold' }}>
  3185. {t("Pick Orders Details")}:
  3186. </Typography>
  3187. {(() => {
  3188. const pickOrderCodes = fgPickOrders[0].pickOrderCodes as string[] | string | undefined;
  3189. const deliveryNos = fgPickOrders[0].deliveryNos as string[] | string | undefined;
  3190. const lineCounts = fgPickOrders[0].lineCountsPerPickOrder;
  3191. const pickOrderCodesArray = Array.isArray(pickOrderCodes)
  3192. ? pickOrderCodes
  3193. : (typeof pickOrderCodes === 'string' ? pickOrderCodes.split(', ') : []);
  3194. const deliveryNosArray = Array.isArray(deliveryNos)
  3195. ? deliveryNos
  3196. : (typeof deliveryNos === 'string' ? deliveryNos.split(', ') : []);
  3197. const lineCountsArray = Array.isArray(lineCounts) ? lineCounts : [];
  3198. const maxLength = Math.max(
  3199. pickOrderCodesArray.length,
  3200. deliveryNosArray.length,
  3201. lineCountsArray.length
  3202. );
  3203. if (maxLength === 0) {
  3204. return <Typography variant="body2" color="text.secondary">-</Typography>;
  3205. }
  3206. // 使用与外部基本信息相同的样式
  3207. return Array.from({ length: maxLength }, (_, idx) => (
  3208. <Stack
  3209. key={idx}
  3210. direction="row"
  3211. spacing={4}
  3212. useFlexGap
  3213. flexWrap="wrap"
  3214. sx={{ mb: idx < maxLength - 1 ? 1 : 0 }} // 除了最后一行,都添加底部间距
  3215. >
  3216. <Typography variant="subtitle1">
  3217. <strong>{t("Delivery Order")}:</strong> {deliveryNosArray[idx] || '-'}
  3218. </Typography>
  3219. <Typography variant="subtitle1">
  3220. <strong>{t("Pick Order")}:</strong> {pickOrderCodesArray[idx] || '-'}
  3221. </Typography>
  3222. <Typography variant="subtitle1">
  3223. <strong>{t("Finsihed good items")}:</strong> {lineCountsArray[idx] || '-'}<strong>{t("kinds")}</strong>
  3224. </Typography>
  3225. </Stack>
  3226. ));
  3227. })()}
  3228. </Box>
  3229. </Stack>
  3230. </Paper>
  3231. )}
  3232. <TableContainer component={Paper}>
  3233. <Table>
  3234. <TableHead>
  3235. <TableRow>
  3236. <TableCell>{t("Index")}</TableCell>
  3237. <TableCell>{t("Item Code")}</TableCell>
  3238. <TableCell>{t("Item Name")}</TableCell>
  3239. <TableCell>{t("Route")}</TableCell>
  3240. <TableCell>{t("Suggest Lot No.")}</TableCell>
  3241. <TableCell align="right">{t("Lot Required Pick Qty")}</TableCell>
  3242. <TableCell align="center">{t("Scan Result")}</TableCell>
  3243. {/*<TableCell align="center">{t("Qty will submit")}</TableCell>*/}
  3244. <TableCell align="center">{t("Submit Required Pick Qty")}</TableCell>
  3245. </TableRow>
  3246. </TableHead>
  3247. <TableBody>
  3248. {paginatedData.length === 0 ? (
  3249. <TableRow>
  3250. <TableCell colSpan={11} align="center">
  3251. <Typography variant="body2" color="text.secondary">
  3252. {t("No data available")}
  3253. </Typography>
  3254. </TableCell>
  3255. </TableRow>
  3256. ) : (
  3257. // 在第 1797-1938 行之间,将整个 map 函数修改为:
  3258. paginatedData.map((row, index) => {
  3259. const lot = row.lot;
  3260. const solIdForKey = Number(lot.stockOutLineId) || 0;
  3261. const lotKeyForSubmitQty =
  3262. Number.isFinite(solIdForKey) && solIdForKey > 0 ? `sol:${solIdForKey}` : `${lot.pickOrderLineId}-${lot.lotId}`;
  3263. const lockedSubmitQtyDisplay = isInventoryLotLineUnavailable(lot) ? 0 : resolveSingleSubmitQty(lot);
  3264. const hasPickOverride = Object.prototype.hasOwnProperty.call(pickQtyData, lotKeyForSubmitQty);
  3265. const fromPickRow = hasPickOverride ? pickQtyData[lotKeyForSubmitQty] : undefined;
  3266. const workbenchSubmitQtyDisplay =
  3267. hasPickOverride && fromPickRow !== undefined && fromPickRow !== null && !Number.isNaN(Number(fromPickRow))
  3268. ? Number(fromPickRow)
  3269. : lockedSubmitQtyDisplay;
  3270. // 检查是否是 issue lot
  3271. const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo;
  3272. const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t);
  3273. const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
  3274. const isSolRejected =
  3275. solSt === "rejected" || String(lot.lotAvailability || "").toLowerCase() === "rejected";
  3276. return (
  3277. <TableRow
  3278. key={`${lot.pickOrderLineId}-${lot.lotId || 'null'}`}
  3279. sx={{
  3280. //backgroundColor: isIssueLot ? '#fff3e0' : 'inherit',
  3281. // opacity: isIssueLot ? 0.6 : 1,
  3282. '& .MuiTableCell-root': {
  3283. //color: isIssueLot ? 'warning.main' : 'inherit'
  3284. }
  3285. }}
  3286. >
  3287. <TableCell>
  3288. <Typography variant="body2" fontWeight="bold">
  3289. {row.isGroupFirst ? row.groupDisplayIndex : ""}
  3290. </Typography>
  3291. </TableCell>
  3292. <TableCell>{row.isGroupFirst ? lot.itemCode : ""}</TableCell>
  3293. <TableCell>
  3294. {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""}
  3295. </TableCell>
  3296. <TableCell>
  3297. <Typography variant="body2">
  3298. {lot.routerRoute || '-'}
  3299. </Typography>
  3300. </TableCell>
  3301. <TableCell>
  3302. <Stack direction="row" spacing={1} alignItems="flex-start">
  3303. <Box sx={{ flex: 1, minWidth: 0 }}>
  3304. <Typography
  3305. sx={{
  3306. color:
  3307. rejectDisplay || isSolRejected
  3308. ? 'error.main'
  3309. : isInventoryLotLineUnavailable(lot)
  3310. ? 'error.main'
  3311. : lot.lotAvailability === 'expired'
  3312. ? 'warning.main'
  3313. : 'inherit',
  3314. }}
  3315. >
  3316. {lot.lotNo ? (
  3317. rejectDisplay ? (
  3318. <>
  3319. {lot.lotNo}
  3320. <Box
  3321. component="span"
  3322. sx={{ display: 'block', mt: 0.25, typography: 'body2', fontWeight: 400 }}
  3323. >
  3324. {rejectDisplay}
  3325. </Box>
  3326. </>
  3327. ) :
  3328. lot.lotAvailability === 'expired' ? (
  3329. <>
  3330. {lot.lotNo}{' '}
  3331. {t('is expired. Please check around have available QR code or not.')}
  3332. </>
  3333. ) : isInventoryLotLineUnavailable(lot) ? (
  3334. <>
  3335. {lot.lotNo}{' '}
  3336. {t('is unavable. Please check around have available QR code or not.')}
  3337. </>
  3338. ) : (
  3339. lot.lotNo
  3340. )
  3341. ) : (
  3342. <Box component="span" sx={{ fontSize: "0.85rem", lineHeight: 1.4 }}>
  3343. {rejectDisplay ||
  3344. t(
  3345. "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.",
  3346. )}
  3347. </Box>
  3348. )}
  3349. </Typography>
  3350. </Box>
  3351. {Number(lot.stockOutLineId) > 0 && Number(lot.itemId) > 0 ? (
  3352. <Button
  3353. variant="outlined"
  3354. size="small"
  3355. onClick={() => openWorkbenchLotLabelModalForLot(lot)}
  3356. sx={{
  3357. flexShrink: 0,
  3358. fontSize: "0.7rem",
  3359. py: 0.25,
  3360. minWidth: "auto",
  3361. px: 1,
  3362. whiteSpace: "nowrap",
  3363. }}
  3364. >
  3365. {t("lot QR code")}
  3366. </Button>
  3367. ) : null}
  3368. </Stack>
  3369. </TableCell>
  3370. <TableCell align="right">
  3371. {(() => {
  3372. const requiredQty = lot.requiredQty || 0;
  3373. return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')';
  3374. })()}
  3375. </TableCell>
  3376. <TableCell align="center">
  3377. {(() => {
  3378. const status = lot.stockOutLineStatus?.toLowerCase();
  3379. const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected';
  3380. const isNoLot = !lot.lotNo;
  3381. // rejected lot:显示红色勾选(已扫描但被拒绝)
  3382. if (isRejected && !isNoLot) {
  3383. return (
  3384. <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
  3385. <Checkbox
  3386. checked={true}
  3387. disabled={true}
  3388. readOnly={true}
  3389. size="large"
  3390. sx={{
  3391. color: 'error.main',
  3392. '&.Mui-checked': { color: 'error.main' },
  3393. transform: 'scale(1.3)',
  3394. }}
  3395. />
  3396. </Box>
  3397. );
  3398. }
  3399. // 過期批號:與 noLot 同類——視為已掃到/可處理(含 pending),顯示警示色勾選
  3400. if (isLotAvailabilityExpired(lot) && status !== "rejected") {
  3401. return (
  3402. <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center" }}>
  3403. <Checkbox
  3404. checked={true}
  3405. disabled={true}
  3406. readOnly={true}
  3407. size="large"
  3408. sx={{
  3409. color: "warning.main",
  3410. "&.Mui-checked": { color: "warning.main" },
  3411. transform: "scale(1.3)",
  3412. }}
  3413. />
  3414. </Box>
  3415. );
  3416. }
  3417. // 正常 lot:已扫描(checked/partially_completed/completed)
  3418. if (!isNoLot && status !== 'pending' && status !== 'rejected') {
  3419. return (
  3420. <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
  3421. <Checkbox
  3422. checked={true}
  3423. disabled={true}
  3424. readOnly={true}
  3425. size="large"
  3426. sx={{
  3427. color: 'success.main',
  3428. '&.Mui-checked': { color: 'success.main' },
  3429. transform: 'scale(1.3)',
  3430. }}
  3431. />
  3432. </Box>
  3433. );
  3434. }
  3435. // noLot 且已完成/部分完成:显示红色勾选
  3436. if (isNoLot && (status === 'partially_completed' || status === 'completed')) {
  3437. return (
  3438. <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
  3439. <Checkbox
  3440. checked={true}
  3441. disabled={true}
  3442. readOnly={true}
  3443. size="large"
  3444. sx={{
  3445. color: 'error.main',
  3446. '&.Mui-checked': { color: 'error.main' },
  3447. transform: 'scale(1.3)',
  3448. }}
  3449. />
  3450. </Box>
  3451. );
  3452. }
  3453. return null;
  3454. })()}
  3455. </TableCell>
  3456. {/*
  3457. <TableCell align="center">
  3458. {workbenchSubmitQtyDisplay}
  3459. </TableCell>
  3460. */}
  3461. <TableCell align="center">
  3462. <Box sx={{ display: 'flex', justifyContent: 'center' }}>
  3463. {(() => {
  3464. const status = lot.stockOutLineStatus?.toLowerCase();
  3465. const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected';
  3466. const isNoLot = !lot.lotNo;
  3467. const isUnavailableRow = isInventoryLotLineUnavailable(lot);
  3468. // ✅ rejected lot:显示提示文本(换行显示)
  3469. if (isRejected && !isNoLot) {
  3470. const rejectHint = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t);
  3471. return (
  3472. <Typography
  3473. variant="body2"
  3474. color="error.main"
  3475. sx={{
  3476. textAlign: 'center',
  3477. whiteSpace: 'normal',
  3478. wordBreak: 'break-word',
  3479. maxWidth: '200px',
  3480. lineHeight: 1.5
  3481. }}
  3482. >
  3483. {rejectHint || t("This lot is rejected, please scan another lot.")}
  3484. </Typography>
  3485. );
  3486. }
  3487. // noLot 且非 unavailable:保留舊行為(Issue)
  3488. if (isNoLot && !isUnavailableRow) {
  3489. return (
  3490. <Button
  3491. variant="outlined"
  3492. size="small"
  3493. onClick={() => handlelotnull(lot)}
  3494. /*
  3495. disabled={
  3496. status === 'completed' ||
  3497. (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
  3498. }
  3499. */
  3500. disabled={true}
  3501. sx={{
  3502. fontSize: '0.7rem',
  3503. py: 0.5,
  3504. minHeight: '28px',
  3505. minWidth: '60px',
  3506. borderColor: 'warning.main',
  3507. color: 'warning.main'
  3508. }}
  3509. >
  3510. {t("Issue")}
  3511. </Button>
  3512. );
  3513. }
  3514. // 正常 lot:Submit + 可編輯數量(Edit 解鎖輸入,不再開 issue form)
  3515. {
  3516. const lotKey = lotKeyForSubmitQty;
  3517. const qtyFieldEnabled = workbenchSubmitQtyFieldEnabledByLotKey[lotKey] === true;
  3518. const displayedSubmitQty = workbenchSubmitQtyDisplay;
  3519. const hasPickOverrideRow = Object.prototype.hasOwnProperty.call(pickQtyData, lotKey);
  3520. const textFieldValue = qtyFieldEnabled
  3521. ? hasPickOverrideRow
  3522. ? String(pickQtyData[lotKey])
  3523. : String(displayedSubmitQty)
  3524. : String(displayedSubmitQty);
  3525. return (
  3526. <Stack direction="row" spacing={1} alignItems="center">
  3527. {/*
  3528. <Button
  3529. variant="contained"
  3530. onClick={() => {
  3531. const submitQty = displayedSubmitQty;
  3532. handlePickQtyChange(lotKey, submitQty);
  3533. handleSubmitPickQtyWithQty(lot, submitQty, 'singleSubmit');
  3534. }}
  3535. disabled={
  3536. lot.lotAvailability === 'expired' ||
  3537. isInventoryLotLineUnavailable(lot) ||
  3538. lot.lotAvailability === 'rejected' ||
  3539. lot.stockOutLineStatus === 'completed' ||
  3540. lot.stockOutLineStatus === 'pending' ||
  3541. (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
  3542. }
  3543. sx={{ fontSize: '0.75rem', py: 0.5, minHeight: '28px', minWidth: '70px' }}
  3544. >
  3545. {t("Submit")}
  3546. </Button>
  3547. */}
  3548. <TextField
  3549. type="number"
  3550. size="small"
  3551. disabled={!qtyFieldEnabled}
  3552. value={textFieldValue}
  3553. onKeyDown={(e) => {
  3554. if (!qtyFieldEnabled) return;
  3555. if (e.key !== "{") return;
  3556. e.preventDefault();
  3557. setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({
  3558. ...prev,
  3559. [lotKey]: false,
  3560. }));
  3561. (e.currentTarget as HTMLInputElement).blur();
  3562. }}
  3563. onChange={(e) => {
  3564. if (!qtyFieldEnabled) return;
  3565. const n = Number(e.target.value);
  3566. if (Number.isFinite(n) && n < 0) return;
  3567. handlePickQtyChange(lotKey, e.target.value);
  3568. }}
  3569. inputProps={{ min: 0, step: 1 }}
  3570. sx={{
  3571. width: 96,
  3572. '& .MuiInputBase-input': { fontSize: '0.75rem', py: 0.5, textAlign: 'center' },
  3573. }}
  3574. />
  3575. <Button
  3576. variant="outlined"
  3577. size="small"
  3578. onClick={() => {
  3579. setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({
  3580. ...prev,
  3581. [lotKey]: !(prev[lotKey] === true),
  3582. }));
  3583. }}
  3584. disabled={
  3585. lot.stockOutLineStatus === 'completed' ||
  3586. (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
  3587. }
  3588. sx={{
  3589. fontSize: '0.7rem',
  3590. py: 0.5,
  3591. minHeight: '28px',
  3592. minWidth: '60px',
  3593. borderColor: 'warning.main',
  3594. color: 'warning.main',
  3595. }}
  3596. title={qtyFieldEnabled ? t('Lock quantity') : t('Edit quantity')}
  3597. >
  3598. {t("Edit")}
  3599. </Button>
  3600. <Button
  3601. variant="outlined"
  3602. size="small"
  3603. onClick={() => handleSkip(lot)}
  3604. disabled={
  3605. lot.stockOutLineStatus === 'completed' ||
  3606. lot.stockOutLineStatus === 'checked' ||
  3607. lot.stockOutLineStatus === 'partially_completed' ||
  3608. // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交)
  3609. (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) ||
  3610. (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
  3611. }
  3612. sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '60px' }}
  3613. >
  3614. {t("Just Completed")}
  3615. </Button>
  3616. </Stack>
  3617. );
  3618. }
  3619. })()}
  3620. </Box>
  3621. </TableCell>
  3622. </TableRow>
  3623. );
  3624. })
  3625. )}
  3626. </TableBody>
  3627. </Table>
  3628. </TableContainer>
  3629. <TablePagination
  3630. component="div"
  3631. count={combinedLotData.length}
  3632. page={paginationController.pageNum}
  3633. rowsPerPage={paginationController.pageSize}
  3634. onPageChange={handlePageChange}
  3635. onRowsPerPageChange={handlePageSizeChange}
  3636. rowsPerPageOptions={[10, 25, 50,-1]}
  3637. labelRowsPerPage={t("Rows per page")}
  3638. labelDisplayedRows={({ from, to, count }) =>
  3639. `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
  3640. }
  3641. />
  3642. </Box>
  3643. </Stack>
  3644. {/* QR Code Scanner works in background - no modal needed */}
  3645. <ManualLotConfirmationModal
  3646. open={manualLotConfirmationOpen}
  3647. onClose={() => {
  3648. setManualLotConfirmationOpen(false);
  3649. }}
  3650. onConfirm={handleManualLotConfirmation}
  3651. expectedLot={null}
  3652. scannedLot={null}
  3653. isLoading={false}
  3654. />
  3655. {/* 保留:Good Pick Execution Form Modal */}
  3656. {pickExecutionFormOpen && selectedLotForExecutionForm && (
  3657. <GoodPickExecutionForm
  3658. open={pickExecutionFormOpen}
  3659. onClose={() => {
  3660. setPickExecutionFormOpen(false);
  3661. setSelectedLotForExecutionForm(null);
  3662. }}
  3663. onSubmit={handlePickExecutionFormSubmit}
  3664. selectedLot={selectedLotForExecutionForm}
  3665. selectedPickOrderLine={{
  3666. id: selectedLotForExecutionForm.pickOrderLineId,
  3667. itemId: selectedLotForExecutionForm.itemId,
  3668. itemCode: selectedLotForExecutionForm.itemCode,
  3669. itemName: selectedLotForExecutionForm.itemName,
  3670. pickOrderCode: selectedLotForExecutionForm.pickOrderCode,
  3671. availableQty: selectedLotForExecutionForm.availableQty || 0,
  3672. requiredQty: selectedLotForExecutionForm.requiredQty || 0,
  3673. // uomCode: selectedLotForExecutionForm.uomCode || '',
  3674. uomDesc: selectedLotForExecutionForm.uomDesc || '',
  3675. pickedQty: selectedLotForExecutionForm.actualPickQty || 0,
  3676. uomShortDesc: selectedLotForExecutionForm.uomShortDesc || '',
  3677. suggestedList: [],
  3678. noLotLines: [],
  3679. }}
  3680. pickOrderId={selectedLotForExecutionForm.pickOrderId}
  3681. pickOrderCreateDate={new Date()}
  3682. />
  3683. )}
  3684. <WorkbenchLotLabelPrintModal
  3685. open={workbenchLotLabelModalOpen}
  3686. onClose={() => {
  3687. setWorkbenchLotLabelModalOpen(false);
  3688. setWorkbenchLotLabelReminderText(null);
  3689. setWorkbenchLotLabelContextLot(null);
  3690. setWorkbenchLotLabelInitialPayload(null);
  3691. }}
  3692. initialPayload={workbenchLotLabelInitialPayload}
  3693. initialItemId={
  3694. workbenchLotLabelContextLot != null
  3695. ? Number(workbenchLotLabelContextLot.itemId)
  3696. : null
  3697. }
  3698. defaultPrinterName={defaultLabelPrinterName}
  3699. hideScanSection={
  3700. workbenchLotLabelInitialPayload != null ||
  3701. workbenchLotLabelContextLot != null
  3702. }
  3703. reminderText={workbenchLotLabelReminderText ?? undefined}
  3704. statusTitleText={workbenchLotLabelStatusBanner.text}
  3705. statusTitleSeverity={workbenchLotLabelStatusBanner.severity}
  3706. warehouseCodePrefixFilter={lotFloorPrefixFilter}
  3707. triggerLotAvailableQty={
  3708. workbenchLotLabelContextLot != null
  3709. ? Number(workbenchLotLabelContextLot.availableQty)
  3710. : null
  3711. }
  3712. triggerLotUom={
  3713. workbenchLotLabelContextLot != null
  3714. ? String(
  3715. workbenchLotLabelContextLot.uomShortDesc ??
  3716. workbenchLotLabelContextLot.stockUnit ??
  3717. "",
  3718. ).trim() || null
  3719. : null
  3720. }
  3721. disableScanPick={workbenchLotLabelScanPickDisabled}
  3722. onWorkbenchScanPick={handleWorkbenchLotLabelScanPick}
  3723. submitQty={workbenchLotLabelSubmitQty}
  3724. onSubmitQtyChange={handleWorkbenchLotLabelSubmitQtyChange}
  3725. />
  3726. </FormProvider>
  3727. </TestQrCodeProvider>
  3728. );
  3729. };
  3730. export default WorkbenchGoodPickExecutionDetail;