diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts index b9353cd..6eb140e 100644 --- a/src/app/api/doworkbench/actions.ts +++ b/src/app/api/doworkbench/actions.ts @@ -417,6 +417,8 @@ export async function fetchWorkbenchCompletedLotDetails( export type WorkbenchScanPayload = { itemId: number; stockInLineId: number; + /** POL UomConversion id — when set, sameItemLots only include matching lot UOM */ + uomId?: number | null; }; export async function fetchWorkbenchPrinters() { return serverFetchJson(`${BASE_API_URL}/printers`, { @@ -433,9 +435,16 @@ export async function analyzeWorkbenchQrCode(payload: WorkbenchScanPayload) { }); } -export async function fetchWorkbenchAvailableLotsByItem(itemId: number) { +export async function fetchWorkbenchAvailableLotsByItem( + itemId: number, + uomId?: number | null, +) { + const qs = + uomId != null && Number.isFinite(Number(uomId)) && Number(uomId) > 0 + ? `?uomId=${Number(uomId)}` + : ""; return serverFetchJson( - `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}`, + `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}${qs}`, { method: "GET", cache: "no-store", diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts index 417c1b3..5da0eeb 100644 --- a/src/app/api/inventory/actions.ts +++ b/src/app/api/inventory/actions.ts @@ -23,6 +23,7 @@ export interface LotLineInfo { lotNo: string; remainingQty: number; uom: string; + uomId: number; } export interface SearchInventoryLotLine extends Pageable { @@ -170,7 +171,10 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) { export const fetchInventories = cache(fetchInventoriesImpl); -/** Inventory search page: latest inventory row per item (no baseUnit/uomId filter). */ +/** + * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 + * Inventory search page: latest inventory row per item (no baseUnit/uomId filter). + */ export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); /** Bypass React cache() after mutations so lists show fresh qty. */ diff --git a/src/app/api/settings/item/actions.ts b/src/app/api/settings/item/actions.ts index 3f4b782..f6b9180 100644 --- a/src/app/api/settings/item/actions.ts +++ b/src/app/api/settings/item/actions.ts @@ -95,6 +95,30 @@ export interface ItemWithDetails { uomDesc: string; currentStockBalance: number; } + +export interface PickUomOption { + uomId: number; + uomCode: string; + uomDesc: string; +} + +export interface AvailablePickUomsResponse { + itemId: number; + stockUom: PickUomOption | null; + options: PickUomOption[]; +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 */ +export const fetchAvailablePickUoms = cache(async (itemId: number) => { + return serverFetchJson( + `${BASE_API_URL}/items/${itemId}/available-pick-uoms`, + { + method: "GET", + next: { tags: ["items"] }, + }, + ); +}); + export const fetchItemsWithDetails = cache(async (searchParams?: Record) => { if (searchParams) { const queryString = new URLSearchParams(searchParams).toString(); diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 146bc59..84af6b6 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -551,6 +551,7 @@ function saveIssuePickedMap(doPickOrderId: number, map: Record) } } +/** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.1 | 2026-07-22 */ const WorkbenchGoodPickExecutionDetail: React.FC = ({ filterArgs, onSwitchToRecordTab, @@ -1076,6 +1077,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO itemId: line.item.id, itemCode: line.item.code, itemName: line.item.name, + uomId: line.item.uomId ?? null, uomDesc: line.item.uomDesc, uomShortDesc: line.item.uomShortDesc, @@ -1147,6 +1149,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO itemId: line.item.id, itemCode: line.item.code, itemName: line.item.name, + uomId: line.item.uomId ?? null, uomDesc: line.item.uomDesc, uomShortDesc: line.item.uomShortDesc, @@ -1688,14 +1691,91 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO }); return; } - + // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) const lookupStartTime = performance.now(); - const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; + let activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected - const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; + let allLotsForItem = indexes.byItemId.get(scannedItemId) || []; const lookupTime = performance.now() - lookupStartTime; console.log(` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`); + + // Case A: reject only if scanned lot UOM matches none of this item's POL UOMs + // (same item may have multiple lines with different UOMs on one pick order) + let scannedUomId = 0; + const allowedUomIds = new Set( + allLotsForItem + .map((l: any) => Number(l?.uomId)) + .filter((id: number) => Number.isFinite(id) && id > 0), + ); + const hasLineUoms = allowedUomIds.size > 0; + try { + const lotDetail = await fetchLotDetail(scannedStockInLineId); + scannedUomId = Number(lotDetail?.uomId) || 0; + } catch (e) { + if (hasLineUoms) { + console.warn( + "[QR PROCESS] lot-detail UOM check failed; rejecting scan", + e, + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + t( + "This lot UOM does not match the pick line. Please scan another lot.", + ), + ); + }); + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.delete(latestQr); + return; + } + } + if ( + hasLineUoms && + (!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId)) + ) { + console.warn( + ` [QR PROCESS] UOM mismatch: allowed=[${Array.from(allowedUomIds).join(",")}], scannedLot=${scannedUomId}, stockInLineId=${scannedStockInLineId}`, + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + t( + "This lot UOM does not match the pick line. Please scan another lot.", + ), + ); + }); + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.delete(latestQr); + return; + } + + // Strict UOM filter — never fall back to other UOM lines + if (scannedUomId > 0 && hasLineUoms) { + allLotsForItem = allLotsForItem.filter( + (l: any) => Number(l?.uomId) === scannedUomId, + ); + activeSuggestedLots = activeSuggestedLots.filter( + (l: any) => Number(l?.uomId) === scannedUomId, + ); + if (allLotsForItem.length === 0) { + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + t( + "This lot UOM does not match the pick line. Please scan another lot.", + ), + ); + }); + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.delete(latestQr); + return; + } + } // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots // This allows users to scan other lots even when all suggested lots are rejected @@ -2646,7 +2726,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO const qrDetectionStartTime = performance.now(); console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); - console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); + //console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`); const qrPayload = parseWorkbenchScanQrPayload(latestQr); @@ -3457,7 +3537,7 @@ const handleStartScan = useCallback(() => { resetScan(); lastConsumedQrValuesLengthRef.current = 0; }, [stopScan, resetScan]); - // ... existing code around line 1469 ... + const handlelotnull = useCallback(async (lot: any) => { // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId const stockOutLineId = lot.stockOutLineId; @@ -4106,7 +4186,7 @@ paginatedData.map((row, index) => { ? Number(fromPickRow) : lockedSubmitQtyDisplay; - // 检查是否是 issue lot + const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo; const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t); const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); @@ -4131,14 +4211,20 @@ paginatedData.map((row, index) => { }} > - + {row.isGroupFirst ? row.groupDisplayIndex : ""} - {row.isGroupFirst ? lot.itemCode : ""} + + {row.isGroupFirst ? lot.itemCode : ""} + + + + {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""} + @@ -4146,7 +4232,7 @@ paginatedData.map((row, index) => { - + {(() => { const hasLotNo = Boolean(lot.lotNo); @@ -4165,7 +4251,7 @@ paginatedData.map((row, index) => { : 'inherit'; return ( { sx={{ flexShrink: 0, fontSize: "0.75rem", - py: 0.25, + //py: 0.25, minWidth: "auto", px: 1, whiteSpace: "nowrap", @@ -4556,6 +4642,12 @@ paginatedData.map((row, index) => { ).trim() || null : null } + expectedUomId={ + workbenchLotLabelContextLot != null && + Number(workbenchLotLabelContextLot.uomId) > 0 + ? Number(workbenchLotLabelContextLot.uomId) + : null + } disableScanPick={workbenchLotLabelScanPickDisabled} onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} submitQty={workbenchLotLabelSubmitQty} diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx index e6abd6d..2863a5b 100644 --- a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx +++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx @@ -63,6 +63,8 @@ type QrCodeAnalysisResponse = { inventoryLotLineId: number; warehouseCode?: string | null; warehouseName?: string | null; + uom?: string | null; + uomId?: number | null; } | null; sameItemLots: Array<{ lotNo: string; @@ -70,6 +72,7 @@ type QrCodeAnalysisResponse = { stockInLineId?: number | null; availableQty: number; uom: string; + uomId?: number | null; warehouseCode?: string | null; warehouseName?: string | null; }>; @@ -95,6 +98,8 @@ export interface WorkbenchLotLabelPrintModalProps { /** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */ triggerLotAvailableQty?: number | null; triggerLotUom?: string | null; + /** POL UomConversion id — workbench lot list only returns matching UOM */ + expectedUomId?: number | null; /** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */ disableScanPick?: boolean; /** @@ -141,6 +146,7 @@ function isLabelPrinter(p: Printer): boolean { return s.includes("label") && !s.includes("a4"); } +/** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */ const WorkbenchLotLabelPrintModal: React.FC = ({ open, onClose, @@ -155,6 +161,7 @@ const WorkbenchLotLabelPrintModal: React.FC = hideTriggeredLot = false, triggerLotAvailableQty = null, triggerLotUom = null, + expectedUomId = null, disableScanPick = false, onWorkbenchScanPick, submitQty = null, @@ -257,13 +264,22 @@ const WorkbenchLotLabelPrintModal: React.FC = if (id != null) setSelectedPrinterId(id); }, [open, printers, selectedPrinterId, pickDefaultPrinterId]); + const resolveExpectedUomId = useCallback((): number | null => { + const n = Number(expectedUomId); + return Number.isFinite(n) && n > 0 ? n : null; + }, [expectedUomId]); + const analyzePayload = useCallback( async (payload: ScanPayload) => { setLastPayload(payload); setScanError(null); setAnalysisLoading(true); try { - const data = (await analyzeWorkbenchQrCode(payload)) as QrCodeAnalysisResponse; + const uomId = resolveExpectedUomId(); + const data = (await analyzeWorkbenchQrCode({ + ...payload, + ...(uomId != null ? { uomId } : {}), + })) as QrCodeAnalysisResponse; setAnalysis(data); setSnackbar({ open: true, @@ -277,7 +293,7 @@ const WorkbenchLotLabelPrintModal: React.FC = setAnalysisLoading(false); } }, - [], + [resolveExpectedUomId], ); const analyzeByItem = useCallback( @@ -290,7 +306,11 @@ const WorkbenchLotLabelPrintModal: React.FC = setScanError(null); setAnalysisLoading(true); try { - const data = (await fetchWorkbenchAvailableLotsByItem(itemId)) as { + const uomId = resolveExpectedUomId(); + const data = (await fetchWorkbenchAvailableLotsByItem( + itemId, + uomId, + )) as { itemId: number; itemCode: string; itemName: string; @@ -315,7 +335,7 @@ const WorkbenchLotLabelPrintModal: React.FC = setAnalysisLoading(false); } }, - [], + [resolveExpectedUomId], ); const handleAnalyze = useCallback(async () => { @@ -381,30 +401,51 @@ const WorkbenchLotLabelPrintModal: React.FC = Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0; const fromApi = Number(scannedRow?.availableQty ?? 0); const scanned = analysis.scanned; - const scannedLot = scannedLotLineId - ? { - lotNo: scanned?.lotNo ?? "", - inventoryLotLineId: scannedLotLineId, - stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, - availableQty: Math.max(fromApi, fromTable) as number, - uom: (scannedRow?.uom ?? triggerLotUom ?? "") as string, - warehouseCode: - scanned?.warehouseCode ?? scannedRow?.warehouseCode, - warehouseName: - scanned?.warehouseName ?? scannedRow?.warehouseName, - _scanned: true as const, - } - : null; + const expectUom = Number(expectedUomId); + const hasExpectUom = Number.isFinite(expectUom) && expectUom > 0; + const scannedUomId = Number(scanned?.uomId ?? scannedRow?.uomId ?? 0); + const scannedUomOk = + !hasExpectUom || + !Number.isFinite(scannedUomId) || + scannedUomId <= 0 || + scannedUomId === expectUom; + const scannedLot = + scannedLotLineId && scannedUomOk + ? { + lotNo: scanned?.lotNo ?? "", + inventoryLotLineId: scannedLotLineId, + stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, + availableQty: Math.max(fromApi, fromTable) as number, + uom: (scanned?.uom ?? scannedRow?.uom ?? triggerLotUom ?? "") as string, + uomId: scannedUomId > 0 ? scannedUomId : null, + warehouseCode: + scanned?.warehouseCode ?? scannedRow?.warehouseCode, + warehouseName: + scanned?.warehouseName ?? scannedRow?.warehouseName, + _scanned: true as const, + } + : null; const merged = [ ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []), ...list .filter((x) => x.inventoryLotLineId !== scannedLotLineId) + .filter((x) => { + if (!hasExpectUom) return true; + const id = Number(x.uomId); + return !Number.isFinite(id) || id <= 0 || id === expectUom; + }) .map((x) => ({ ...x, _scanned: false as const })), ]; return merged; - }, [analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom]); + }, [ + analysis, + hideTriggeredLot, + triggerLotAvailableQty, + triggerLotUom, + expectedUomId, + ]); const filteredLots = useMemo(() => { const prefix = String(warehouseCodePrefixFilter ?? "").trim(); diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index 8909955..b1c2dd5 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -56,6 +56,7 @@ type SearchQuery = Partial< >; type SearchParamNames = keyof SearchQuery; +/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { const { t } = useTranslation(['inventory', 'common', 'item']); diff --git a/src/components/PickOrderSearch/AssignAndRelease.tsx b/src/components/PickOrderSearch/AssignAndRelease.tsx index b34b49a..7abe80e 100644 --- a/src/components/PickOrderSearch/AssignAndRelease.tsx +++ b/src/components/PickOrderSearch/AssignAndRelease.tsx @@ -50,7 +50,9 @@ interface ItemRow { id: string; pickOrderId: number; pickOrderCode: string; + pickOrderLineId?: number; itemId: number; + uomId?: number; itemCode: string; itemName: string; requiredQty: number; @@ -85,6 +87,7 @@ const style = { width: { xs: "100%", sm: "100%", md: "100%" }, }; +/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ const AssignAndRelease: React.FC = ({ filterArgs }) => { const { t } = useTranslation("pickOrder"); const { setIsUploading } = useUploadContext(); @@ -167,11 +170,13 @@ const AssignAndRelease: React.FC = ({ filterArgs }) => { // const filteredRecords = res.records.filter( // (item: any) => (item.status || "").toLowerCase() === "pending" //); - const itemRows: ItemRow[] = res.records.map((item: any) => ({ - id: item.id, + const itemRows: ItemRow[] = res.records.map((item: any, index: number) => ({ + id: item.id ?? `${item.pickOrderId}-${item.pickOrderLineId ?? item.itemId}-${index}`, pickOrderId: item.pickOrderId, pickOrderCode: item.pickOrderCode, + pickOrderLineId: item.pickOrderLineId, itemId: item.itemId, + uomId: item.uomId, itemCode: item.itemCode, itemName: item.itemName, requiredQty: item.requiredQty, @@ -493,7 +498,7 @@ const AssignAndRelease: React.FC = ({ filterArgs }) => { ) : ( groupedItems.map((group) => ( group.items.map((item, index) => ( - + {/* Checkbox - 只在第一个项目显示,按 pick order 选择 */} {index === 0 ? ( diff --git a/src/components/PickOrderSearch/CreatedItemsTable.tsx b/src/components/PickOrderSearch/CreatedItemsTable.tsx index 05d7149..3b2e77b 100644 --- a/src/components/PickOrderSearch/CreatedItemsTable.tsx +++ b/src/components/PickOrderSearch/CreatedItemsTable.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react'; import { - Box, Typography, Table, TableBody, @@ -43,15 +42,16 @@ interface Group { interface CreatedItemsTableProps { items: CreatedItem[]; groups: Group[]; - onItemSelect: (itemId: number, checked: boolean) => void; - onQtyChange: (itemId: number, qty: number) => void; - onGroupChange: (itemId: number, groupId: string) => void; + onItemSelect: (itemId: number, checked: boolean, uomId?: number) => void; + onQtyChange: (itemId: number, qty: number, uomId?: number) => void; + onGroupChange: (itemId: number, groupId: string, uomId?: number) => void; pageNum: number; pageSize: number; onPageChange: (event: unknown, newPage: number) => void; onPageSizeChange: (event: React.ChangeEvent) => void; } +/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ const CreatedItemsTable: React.FC = ({ items, groups, @@ -65,15 +65,14 @@ const CreatedItemsTable: React.FC = ({ }) => { const { t } = useTranslation("pickOrder"); - // Calculate pagination const startIndex = (pageNum - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedItems = items.slice(startIndex, endIndex); - const handleQtyChange = useCallback((itemId: number, value: string) => { + const handleQtyChange = useCallback((itemId: number, uomId: number, value: string) => { const numValue = Number(value); if (!isNaN(numValue) && numValue >= 1) { - onQtyChange(itemId, numValue); + onQtyChange(itemId, numValue, uomId); } }, [onQtyChange]); @@ -117,11 +116,11 @@ const CreatedItemsTable: React.FC = ({ ) : ( paginatedItems.map((item) => ( - + onItemSelect(item.itemId, e.target.checked)} + onChange={(e) => onItemSelect(item.itemId, e.target.checked, item.uomId)} /> @@ -134,7 +133,7 @@ const CreatedItemsTable: React.FC = ({ onGroupChange(item.id, e.target.value)} + onChange={(e) => onGroupChange(rowKey, e.target.value)} displayEmpty - disabled={isItemInCreated(item.id)} - > + disabled={inCreated} + > - {t("No Group")} + {t("No Group")} {groups.map((group) => ( - + {group.name} - + ))} - + - - - {/* Current Stock */} + = ({ {item.currentStockBalance?.toLocaleString()||0} - - {/* Stock Unit */} - {item.uomDesc || "-"} + {item.uomDesc || item.uom || "-"} - - {/* Order Quantity */} - - { - const value = e.target.value; - // Only allow numbers - if (value === "" || /^\d+$/.test(value)) { - const numValue = value === "" ? null : Number(value); - onQtyChange(item.id, numValue); - } - }} - onBlur={() => { - // Trigger auto-add check when user finishes input (clicks elsewhere) - onQtyBlur(item.id); // ← Change this to call onQtyBlur instead! - }} - inputProps={{ + handleQtyChange(rowKey, e.target.value)} + onBlur={() => onQtyBlur(rowKey)} + disabled={inCreated} + inputProps={{ + min: 1, + max: item.currentStockBalance || 0, + step: 1, style: { textAlign: 'center' } - }} - sx={{ + }} + sx={{ width: '80px', '& .MuiInputBase-input': { - textAlign: 'center', - cursor: 'text' + textAlign: 'center', + cursor: 'text' } - }} - disabled={isItemInCreated(item.id)} + }} /> - {/* Target Date */} {item.targetDate ? dayjs(item.targetDate).format(OUTPUT_DATE_FORMAT) : "-"} - )) + ); + }) )} @@ -242,4 +227,4 @@ const SearchResultsTable: React.FC = ({ ); }; -export default SearchResultsTable; \ No newline at end of file +export default SearchResultsTable; diff --git a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx index c0d3d6c..30d95d6 100644 --- a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx +++ b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx @@ -35,6 +35,7 @@ import { } from "@/app/api/pickOrder/actions"; import { workbenchScanPick } from "@/app/api/doworkbench/actions"; import { fetchStockInLineInfo } from "@/app/api/po/actions"; +import { fetchLotDetail } from "@/app/api/inventory/actions"; import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; @@ -66,6 +67,7 @@ type LineRow = { requiredQty: number; pickedQty: number; stockUnit: string; + uomId?: number; status: string; lotsRaw: unknown[]; }; @@ -89,6 +91,7 @@ type LotRow = { itemCode: string; itemName: string; uomDesc: string; + uomId?: number; requiredQty: number; pickOrderLineRequiredQty?: number; availableQty: number; @@ -273,6 +276,7 @@ function flattenLotsFromPickOrder(po: PickOrderTopRow): LotRow[] { itemCode: line.itemCode, itemName: line.itemName, uomDesc: toStr(lot.stockUnit) || line.stockUnit, + uomId: line.uomId, requiredQty: toNum(lot.requiredQty, line.requiredQty), pickOrderLineRequiredQty: line.requiredQty, availableQty: toNum(lot.availableQty), @@ -333,6 +337,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { requiredQty: toNum(line?.requiredQty), pickedQty: sumLinePickedQtyFromLots(lots), stockUnit: toStr(item?.uomDesc ?? item?.uomCode), + uomId: toNum(item?.uomId) > 0 ? toNum(item?.uomId) : undefined, status: toStr(line?.status), lotsRaw: lots, }; @@ -351,6 +356,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { }); } +/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.1 | 2026-07-22 */ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { const { t } = useTranslation("pickOrder"); const { data: session } = useSession() as { data: SessionWithTokens | null }; @@ -770,6 +776,16 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { processedQrCodesRef.current.add(latestQr); }, []); + /** Soft-fail / switch-PO: allow the same QR to be processed again. */ + const releaseProcessedQr = useCallback((qr: string) => { + const key = String(qr || "").trim(); + if (!key) return; + processedQrCodesRef.current.delete(key); + if (lastProcessedQrRef.current === key) { + lastProcessedQrRef.current = ""; + } + }, []); + const openUnpickableScanLotLabelModal = useCallback( ( pickRow: LotRow, @@ -1177,16 +1193,102 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { return; } - const expectedPool = activeSuggestedLots.length > 0 ? activeSuggestedLots : allLotsForItem; - const expectedRow = pickExpectedRowForSubstitution(expectedPool) || allLotsForItem[0]; + // Case A: reject only if scanned lot UOM matches none of this item's POL UOMs + // (same item may have multiple lines with different UOMs on one pick order) + let scannedUomId = 0; + const allowedUomIds = new Set( + allLotsForItem + .map((l) => Number(l?.uomId)) + .filter((id) => Number.isFinite(id) && id > 0), + ); + const hasLineUoms = allowedUomIds.size > 0; + try { + const lotDetail = await fetchLotDetail(scannedStockInLineId); + scannedUomId = Number(lotDetail?.uomId) || 0; + } catch (e) { + if (hasLineUoms) { + console.warn( + "[QR PROCESS] lot-detail UOM check failed; rejecting scan", + e, + ); + const msg = t( + "This lot UOM does not match the pick line. Please scan another lot.", + ); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + releaseProcessedQr(latest); + return; + } + } + if ( + hasLineUoms && + (!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId)) + ) { + const msg = t( + "This lot UOM does not match the pick line. Please scan another lot.", + ); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + releaseProcessedQr(latest); + return; + } + + // Strict UOM filter — never fall back to other UOM lines + const filterByScannedUom = (rows: LotRow[]) => { + if (!(scannedUomId > 0) || !hasLineUoms) return rows; + return rows.filter((r) => Number(r.uomId) === scannedUomId); + }; + const activeForUom = filterByScannedUom(activeSuggestedLots); + const allForUom = filterByScannedUom(allLotsForItem); + if (hasLineUoms && scannedUomId > 0 && allForUom.length === 0) { + const msg = t( + "This lot UOM does not match the pick line. Please scan another lot.", + ); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + releaseProcessedQr(latest); + return; + } + const expectedPool = activeForUom.length > 0 ? activeForUom : allForUom; + let expectedRow = pickExpectedRowForSubstitution(expectedPool) || allForUom[0]; + if (!expectedRow) { + setError(t("Scanned item is not found in current line")); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(t("Scanned item is not found in current line")); + }); + releaseProcessedQr(latest); + return; + } const scannedRows = lotRowIndexes.byStockInLineId.get(scannedStockInLineId) || []; - const scannedRowInItem = + let scannedRowInItem = scannedRows.find( (r) => Number(r.itemId) === scannedItemId && - r.stockOutLineId > 0, + r.stockOutLineId > 0 && + (!(scannedUomId > 0) || !hasLineUoms || Number(r.uomId) === scannedUomId), ) || + (!(scannedUomId > 0) || !hasLineUoms + ? scannedRows.find( + (r) => + Number(r.itemId) === scannedItemId && + r.stockOutLineId > 0, + ) + : undefined) || null; if (scannedRowInItem && isRejectedStatus(scannedRowInItem.status)) { @@ -1231,14 +1333,46 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { return; } - if (scannedRowInItem && (isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status))) { - setError(t("Scanned lot is already completed or checked")); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(t("Scanned lot is already completed or checked")); + // Same lot already bound to a completed/checked SOL: only reuse for another + // pending line with the SAME uomId (never cross UOM A ↔ B). + if ( + scannedRowInItem && + (isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status)) + ) { + const completedPolId = Number(scannedRowInItem.pickOrderLineId); + const completedSolId = Number(scannedRowInItem.stockOutLineId); + const pendingPoolBase = activeForUom.length > 0 ? activeForUom : allForUom; + const pendingSameUom = pendingPoolBase.filter((r) => { + if (!(r.stockOutLineId > 0)) return false; + if (isCompletedStatus(r.status) || isCheckedStatus(r.status) || isRejectedStatus(r.status)) { + return false; + } + if (Number(r.stockOutLineId) === completedSolId) return false; + if (completedPolId > 0 && Number(r.pickOrderLineId) === completedPolId) return false; + // Require explicit same UOM — do not reuse across different UOM lines + if (scannedUomId > 0 && hasLineUoms) { + return Number(r.uomId) === scannedUomId; + } + // Without line UOM metadata, do not auto-reuse (avoids A/B cross-pick) + return false; }); - return; + const nextExpected = + pickExpectedRowForSubstitution(pendingSameUom) || pendingSameUom[0] || null; + if (nextExpected) { + expectedRow = nextExpected; + // Force substitution path using scanned SIL, not the completed SOL row. + scannedRowInItem = null; + } else { + const msg = t("This lot has already been picked. Please scan another lot."); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + releaseProcessedQr(latest); + return; + } } let scannedState: ConfirmLotState | null = null; @@ -1292,6 +1426,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { pickExpectedRowForSubstitution, lotRowIndexes, openUnpickableScanLotLabelModal, + releaseProcessedQr, resetScan, submitRow, t, @@ -1306,10 +1441,9 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { }, [isScanning, startScan, userId]); useEffect(() => { - if (!selectedPickOrderId) { - lastProcessedQrRef.current = ""; - processedQrCodesRef.current.clear(); - } + // Clear on any PO selection change (including PO1 → PO2), so the same lot QR can be scanned again. + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.clear(); }, [selectedPickOrderId]); useEffect(() => { @@ -1779,6 +1913,12 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { statusTitleSeverity={workbenchLotLabelStatusBanner.severity} triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null} triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null} + expectedUomId={ + workbenchLotLabelContextLot != null && + Number(workbenchLotLabelContextLot.uomId) > 0 + ? Number(workbenchLotLabelContextLot.uomId) + : null + } submitQty={ workbenchLotLabelContextLot?.stockOutLineId ? Number(resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot)) diff --git a/src/components/PickOrderSearch/newcreatitem.tsx b/src/components/PickOrderSearch/newcreatitem.tsx index 4cd647e..2f68c7f 100644 --- a/src/components/PickOrderSearch/newcreatitem.tsx +++ b/src/components/PickOrderSearch/newcreatitem.tsx @@ -70,6 +70,8 @@ interface CreatedItem { // Add interface for search items with quantity interface SearchItemWithQty extends ItemCombo { + /** Unique per item+uom search row */ + rowKey: string; qty: number | null; // Changed from number to number | null jobOrderCode?: string; jobOrderId?: number; @@ -77,6 +79,10 @@ interface SearchItemWithQty extends ItemCombo { targetDate?: string | null; // Allow null values groupId?: number | null; // Allow null values } + +const toSearchRowKey = (itemId: number, uomId?: number | null) => + `${itemId}_${Number(uomId) > 0 ? Number(uomId) : 0}`; + interface JobOrderDetailPickLine { id: number; code: string; @@ -95,8 +101,9 @@ interface Group { } // Move the counter outside the component to persist across re-renders let checkboxChangeCallCount = 0; -let processingItems = new Set(); +let processingItems = new Set(); +/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCreated }) => { const { t } = useTranslation("pickOrder"); const [items, setItems] = useState([]); @@ -174,6 +181,7 @@ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCr uom: item.uom, uomId: 0, uomDesc: item.uomDesc || "", // Add missing uomDesc + rowKey: toSearchRowKey(item.id, 0), jobOrderCode: jobOrderDetail.code, jobOrderId: jobOrderDetail.id, currentStockBalance: 0, // Add default value @@ -263,49 +271,50 @@ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCr ); }, []); - // Modified handler for search item selection + // Modified handler for search item selection (legacy filteredItems path) const handleSearchItemSelect = useCallback((itemId: number, isSelected: boolean) => { if (isSelected) { const item = filteredItems.find(i => i.id === itemId); if (!item) return; - - const existingItem = createdItems.find(created => created.itemId === item.id); - if (existingItem) { + const uomId = Number(item.uomId) || 0; + if (createdItems.some((c) => c.itemId === item.id && Number(c.uomId) === uomId)) { alert(t("Item already exists in created items")); return; } - - // Fix the newCreatedItem creation - add missing uomDesc const newCreatedItem: CreatedItem = { itemId: item.id, itemName: item.label, itemCode: item.label, qty: item.qty || 1, uom: item.uom || "", - uomId: item.uomId || 0, - uomDesc: item.uomDesc || "", // Add missing uomDesc + uomId, + uomDesc: item.uomDesc || "", isSelected: true, currentStockBalance: item.currentStockBalance, - targetDate: item.targetDate || targetDate, // Use item's targetDate or fallback to form's targetDate - groupId: item.groupId || undefined, // Handle null values + targetDate: item.targetDate || targetDate, + groupId: item.groupId || undefined, }; setCreatedItems(prev => [...prev, newCreatedItem]); } }, [filteredItems, createdItems, t, targetDate]); // Handler for created item selection - const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean) => { + const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean, uomId?: number) => { setCreatedItems(prev => prev.map(item => - item.itemId === itemId ? { ...item, isSelected } : item + item.itemId === itemId && + (uomId == null || Number(item.uomId) === Number(uomId)) + ? { ...item, isSelected } + : item ) ); }, []); - const handleQtyChange = useCallback((itemId: number, newQty: number) => { + const handleQtyChange = useCallback((itemId: number, newQty: number, uomId?: number) => { setCreatedItems(prev => prev.map(item => - item.itemId === itemId + item.itemId === itemId && + (uomId == null || Number(item.uomId) === Number(uomId)) ? { ...item, qty: Math.max(1, Math.min(newQty, Math.max(0, item.currentStockBalance ?? 0))), @@ -315,18 +324,23 @@ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCr ); }, []); - // Check if item is already in created items - const isItemInCreated = useCallback((itemId: number) => { - return createdItems.some(item => item.itemId === itemId); + // Check if item (+ optional uom) is already in created items + const isItemInCreated = useCallback((itemId: number, uomId?: number) => { + return createdItems.some( + (item) => + item.itemId === itemId && + (uomId == null || Number(item.uomId) === Number(uomId)), + ); }, [createdItems]); // 1) Created Items 行内改组:只改这一行的 groupId,并把该行 targetDate 同步为该组日期 - const handleCreatedItemGroupChange = useCallback((itemId: number, newGroupId: string) => { + const handleCreatedItemGroupChange = useCallback((itemId: number, newGroupId: string, uomId?: number) => { const gid = newGroupId ? Number(newGroupId) : undefined; const group = groups.find(g => g.id === gid); setCreatedItems(prev => prev.map(it => - it.itemId === itemId + it.itemId === itemId && + (uomId == null || Number(it.uomId) === Number(uomId)) ? { ...it, groupId: gid, @@ -410,27 +424,23 @@ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCr } }, [t]); - const checkAndAutoAddItem = useCallback((itemId: number) => { - const item = secondSearchResults.find(i => i.id === itemId); + const checkAndAutoAddItem = useCallback((rowKey: string) => { + const item = secondSearchResults.find((i) => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); if (!item) return; - // Check if item has ALL 3 conditions: - // 1. Item is selected (checkbox checked) - const isSelected = selectedSecondSearchItemIds.includes(itemId); - // 2. Group is assigned + const key = item.rowKey || toSearchRowKey(item.id, item.uomId); + const isSelected = selectedSecondSearchItemIds.includes(key); const hasGroup = item.groupId !== undefined && item.groupId !== null; - // 3. Quantity is entered const hasQty = item.qty !== null && item.qty !== undefined && item.qty > 0; - if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id)) { - // Auto-add to created items + if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id, item.uomId)) { const newCreatedItem: CreatedItem = { itemId: item.id, itemName: item.label, itemCode: item.label, qty: item.qty || 1, uom: item.uom || "", - uomId: item.uomId || 0, + uomId: Number(item.uomId) || 0, uomDesc: item.uomDesc || "", isSelected: true, currentStockBalance: item.currentStockBalance, @@ -438,33 +448,29 @@ const NewCreateItem: React.FC = ({ filterArgs, searchQuery, onPickOrderCr groupId: item.groupId || undefined, }; setCreatedItems(prev => [...prev, newCreatedItem]); - - // Remove from search results since it's now in created items - setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId)); - setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); + setSecondSearchResults(prev => prev.filter((searchItem) => (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== key)); + setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== key)); } }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); - // Add this function after checkAndAutoAddItem - // Add this function after checkAndAutoAddItem -const handleQtyBlur = useCallback((itemId: number) => { - // Only auto-add if item is already selected (scenario 1: select first, then enter quantity) + +const handleQtyBlur = useCallback((rowKey: string) => { setTimeout(() => { - const currentItem = secondSearchResults.find(i => i.id === itemId); + const currentItem = secondSearchResults.find((i) => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); if (!currentItem) return; - const isSelected = selectedSecondSearchItemIds.includes(itemId); + const key = currentItem.rowKey || toSearchRowKey(currentItem.id, currentItem.uomId); + const isSelected = selectedSecondSearchItemIds.includes(key); const hasGroup = currentItem.groupId !== undefined && currentItem.groupId !== null; const hasQty = currentItem.qty !== null && currentItem.qty !== undefined && currentItem.qty > 0; - // Only auto-add if item is already selected (scenario 1: select first, then enter quantity) - if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id)) { + if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id, currentItem.uomId)) { const newCreatedItem: CreatedItem = { itemId: currentItem.id, itemName: currentItem.label, itemCode: currentItem.label, qty: currentItem.qty || 1, uom: currentItem.uom || "", - uomId: currentItem.uomId || 0, + uomId: Number(currentItem.uomId) || 0, uomDesc: currentItem.uomDesc || "", isSelected: true, currentStockBalance: currentItem.currentStockBalance, @@ -472,18 +478,18 @@ const handleQtyBlur = useCallback((itemId: number) => { groupId: currentItem.groupId || undefined, }; setCreatedItems(prev => [...prev, newCreatedItem]); - - setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId)); - setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); + setSecondSearchResults(prev => prev.filter((searchItem) => (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== key)); + setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== key)); } }, 0); }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); - const handleSearchItemGroupChange = useCallback((itemId: number, groupId: string) => { + + const handleSearchItemGroupChange = useCallback((rowKey: string, groupId: string) => { const gid = groupId ? Number(groupId) : undefined; const group = groups.find(g => g.id === gid); setSecondSearchResults(prev => prev.map(item => - item.id === itemId + (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey ? { ...item, groupId: gid, @@ -492,9 +498,8 @@ const handleQtyBlur = useCallback((itemId: number) => { : item )); - // Check auto-add after group assignment setTimeout(() => { - checkAndAutoAddItem(itemId); + checkAndAutoAddItem(rowKey); }, 0); }, [groups, checkAndAutoAddItem]); // 5) 选中新增的待选项:依然按“当前 Group”赋 groupId + targetDate(新加入的应随 Group) @@ -550,6 +555,7 @@ const handleQtyBlur = useCallback((itemId: number) => { uomId: item.uomId, uom: item.uom, uomDesc: item.uomDesc, + rowKey: toSearchRowKey(item.id, item.uomId), currentStockBalance: item.currentStockBalance, qty: null, targetDate: targetDate, @@ -1017,7 +1023,7 @@ const handleQtyBlur = useCallback((itemId: number) => { name: "id", label: "", type: "checkbox", - disabled: (item) => isItemInCreated(item.id), // Disable if already in created items + disabled: (item) => isItemInCreated(item.id, item.uomId), // Disable if already in created items }, { @@ -1141,7 +1147,7 @@ const handleQtyBlur = useCallback((itemId: number) => { }, [formProps]); // 添加数量变更处理函数 - const handleSecondSearchQtyChange = useCallback((itemId: number, newQty: number | null) => { + const handleSecondSearchQtyChange = useCallback((rowKey: string, newQty: number | null) => { const getClampedQty = (qty: number | null, stock?: number) => { if (qty === null) return null; const maxQty = Math.max(0, stock ?? 0); @@ -1150,7 +1156,9 @@ const handleQtyBlur = useCallback((itemId: number) => { setSecondSearchResults(prev => prev.map(item => - item.id === itemId ? { ...item, qty: getClampedQty(newQty, item.currentStockBalance) } : item + (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey + ? { ...item, qty: getClampedQty(newQty, item.currentStockBalance) } + : item ) ); @@ -1163,24 +1171,24 @@ const handleQtyBlur = useCallback((itemId: number) => { const newIds = ids(selectedSecondSearchItemIds); setSelectedSecondSearchItemIds(newIds); - // 处理全选逻辑 - 选择所有搜索结果,不仅仅是当前页面 if (newIds.length === secondSearchResults.length) { - // 全选:将所有搜索结果添加到创建项目 secondSearchResults.forEach(item => { - if (!isItemInCreated(item.id)) { + if (!isItemInCreated(item.id, item.uomId)) { handleSearchItemSelect(item.id, true); } }); } else { - // 部分选择:只处理当前页面的选择 secondSearchResults.forEach(item => { - const isSelected = newIds.includes(item.id); - const isCurrentlyInCreated = isItemInCreated(item.id); + const key = item.rowKey || toSearchRowKey(item.id, item.uomId); + const isSelected = newIds.includes(key); + const isCurrentlyInCreated = isItemInCreated(item.id, item.uomId); if (isSelected && !isCurrentlyInCreated) { handleSearchItemSelect(item.id, true); } else if (!isSelected && isCurrentlyInCreated) { - setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== item.id)); + setCreatedItems(prev => prev.filter(createdItem => + !(createdItem.itemId === item.id && Number(createdItem.uomId) === Number(item.uomId)) + )); } }); } @@ -1192,13 +1200,19 @@ const handleQtyBlur = useCallback((itemId: number) => { const newlyDeselected = previousIds.filter(id => !ids.includes(id)); newlySelected.forEach(id => { - if (!isItemInCreated(id as number)) { - handleSearchItemSelect(id as number, true); + const row = secondSearchResults.find(item => (item.rowKey || toSearchRowKey(item.id, item.uomId)) === String(id)); + if (row && !isItemInCreated(row.id, row.uomId)) { + handleSearchItemSelect(row.id, true); } }); newlyDeselected.forEach(id => { - setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== id)); + const row = secondSearchResults.find(item => (item.rowKey || toSearchRowKey(item.id, item.uomId)) === String(id)); + if (row) { + setCreatedItems(prev => prev.filter(createdItem => + !(createdItem.itemId === row.id && Number(createdItem.uomId) === Number(row.uomId)) + )); + } }); } }, [selectedSecondSearchItemIds, secondSearchResults, isItemInCreated, handleSearchItemSelect]); @@ -1209,7 +1223,7 @@ const handleQtyBlur = useCallback((itemId: number) => { name: "id", label: "", type: "checkbox", - disabled: (item) => isItemInCreated(item.id), + disabled: (item) => isItemInCreated(item.id, item.uomId), }, { name: "label", @@ -1260,7 +1274,7 @@ const handleQtyBlur = useCallback((itemId: number) => { renderCell: (item) => ( {/* Add right alignment for the value */} - {item.uom || "-"} + {item.uomDesc || item.uom || "-"} ), @@ -1280,7 +1294,7 @@ const handleQtyBlur = useCallback((itemId: number) => { // Only allow numbers if (value === "" || /^\d+$/.test(value)) { const numValue = value === "" ? null : Number(value); - handleSecondSearchQtyChange(item.id, numValue); + handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), numValue); } }} inputProps={{ @@ -1299,7 +1313,7 @@ const handleQtyBlur = useCallback((itemId: number) => { const value = e.target.value; const numValue = value === "" ? null : Number(value); if (numValue !== null && numValue < 1) { - handleSecondSearchQtyChange(item.id, 1); // Enforce min value + handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), 1); // Enforce min value } }} /> @@ -1368,6 +1382,7 @@ const handleQtyBlur = useCallback((itemId: number) => { uomId: item.uomId, uom: item.uom, uomDesc: item.uomDesc, + rowKey: toSearchRowKey(item.id, item.uomId), currentStockBalance: item.currentStockBalance, qty: null, targetDate: undefined, @@ -1493,7 +1508,7 @@ const handleQtyBlur = useCallback((itemId: number) => { }, []); const getValidationMessage = useCallback(() => { const selectedItems = secondSearchResults.filter(item => - selectedSecondSearchItemIds.includes(item.id) + selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) ); const itemsWithoutGroup = selectedItems.filter(item => @@ -1517,42 +1532,42 @@ const getValidationMessage = useCallback(() => { // Fix the handleAddSelectedToCreatedItems function to properly clear selections const handleAddSelectedToCreatedItems = useCallback(() => { const selectedItems = secondSearchResults.filter(item => - selectedSecondSearchItemIds.includes(item.id) + selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) ); - - // Add selected items to created items with their own group info - selectedItems.forEach(item => { - if (!isItemInCreated(item.id)) { - const newCreatedItem: CreatedItem = { - itemId: item.id, - itemName: item.label, - itemCode: item.label, - qty: item.qty || 1, - uom: item.uom || "", - uomId: item.uomId || 0, - uomDesc: item.uomDesc || "", - isSelected: true, - currentStockBalance: item.currentStockBalance, - targetDate: item.targetDate || targetDate, - groupId: item.groupId || undefined, - }; - setCreatedItems(prev => [...prev, newCreatedItem]); - } - }); - - // Clear the selection + + const toAdd: CreatedItem[] = []; + const addedKeys: string[] = []; + for (const item of selectedItems) { + const key = item.rowKey || toSearchRowKey(item.id, item.uomId); + if (isItemInCreated(item.id, item.uomId)) continue; + toAdd.push({ + itemId: item.id, + itemName: item.label, + itemCode: item.label, + qty: item.qty || 1, + uom: item.uom || "", + uomId: Number(item.uomId) || 0, + uomDesc: item.uomDesc || "", + isSelected: true, + currentStockBalance: item.currentStockBalance, + targetDate: item.targetDate || targetDate, + groupId: item.groupId || undefined, + }); + addedKeys.push(key); + } + if (toAdd.length > 0) { + setCreatedItems((prev) => [...prev, ...toAdd]); + } setSelectedSecondSearchItemIds([]); - - // Remove the selected/added items from search results entirely - setSecondSearchResults(prev => prev.filter(item => - !selectedSecondSearchItemIds.includes(item.id) - )); + setSecondSearchResults((prev) => + prev.filter((item) => !addedKeys.includes(item.rowKey || toSearchRowKey(item.id, item.uomId))), + ); }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); // Add a validation function to check if selected items are valid const areSelectedItemsValid = useCallback(() => { const selectedItems = secondSearchResults.filter(item => - selectedSecondSearchItemIds.includes(item.id) + selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) ); return selectedItems.every(item => @@ -1566,17 +1581,17 @@ const getValidationMessage = useCallback(() => { // Move these handlers to the component level (outside of CustomSearchResultsTable) // Handle individual checkbox change - ONLY select, don't add to created items - const handleIndividualCheckboxChange = useCallback((itemId: number, checked: boolean) => { + const handleIndividualCheckboxChange = useCallback((rowKey: string, checked: boolean) => { checkboxChangeCallCount++; if (checked) { // Add to selected IDs - setSelectedSecondSearchItemIds(prev => [...prev, itemId]); + setSelectedSecondSearchItemIds(prev => [...prev, rowKey]); // Set the item's group and targetDate to current group when selected setSecondSearchResults(prev => { const updatedResults = prev.map(item => - item.id === itemId + (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey ? { ...item, groupId: selectedGroup?.id || undefined, @@ -1588,29 +1603,27 @@ const getValidationMessage = useCallback(() => { // Check if should auto-add after state update setTimeout(() => { // Check if we're already processing this item - if (processingItems.has(itemId)) { - //alert(`Item ${itemId} is already being processed, skipping duplicate auto-add`); + if (processingItems.has(rowKey)) { return; } - const updatedItem = updatedResults.find(i => i.id === itemId); + const updatedItem = updatedResults.find(i => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); if (updatedItem) { const isSelected = true; // We just selected it const hasGroup = updatedItem.groupId !== undefined && updatedItem.groupId !== null; const hasQty = updatedItem.qty !== null && updatedItem.qty !== undefined && updatedItem.qty > 0; // Only auto-add if item has quantity (scenario 2: enter quantity first, then select) - if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)) { - // Mark this item as being processed - processingItems.add(itemId); - + if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id, updatedItem.uomId)) { + processingItems.add(rowKey); + const newCreatedItem: CreatedItem = { itemId: updatedItem.id, itemName: updatedItem.label, itemCode: updatedItem.label, qty: updatedItem.qty || 1, uom: updatedItem.uom || "", - uomId: updatedItem.uomId || 0, + uomId: Number(updatedItem.uomId) || 0, uomDesc: updatedItem.uomDesc || "", isSelected: true, currentStockBalance: updatedItem.currentStockBalance, @@ -1618,29 +1631,16 @@ const getValidationMessage = useCallback(() => { groupId: updatedItem.groupId || undefined, }; setCreatedItems(prev => [...prev, newCreatedItem]); - - setSecondSearchResults(current => current.filter(searchItem => searchItem.id !== itemId)); - setSelectedSecondSearchItemIds(current => current.filter(id => id !== itemId)); - - // Remove from processing set after a short delay + + setSecondSearchResults(current => current.filter(searchItem => + (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== rowKey + )); + setSelectedSecondSearchItemIds(current => current.filter(id => id !== rowKey)); + setTimeout(() => { - processingItems.delete(itemId); + processingItems.delete(rowKey); }, 100); } - - // Show final debug info in one alert - /* - alert(`FINAL DEBUG INFO for item ${itemId}: -Function called ${checkboxChangeCallCount} times -Is Selected: ${isSelected} -Has Group: ${hasGroup} -Has Quantity: ${hasQty} -Quantity: ${updatedItem.qty} -Group ID: ${updatedItem.groupId} -Is Item In Created: ${isItemInCreated(updatedItem.id)} -Auto-add triggered: ${isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)} -Processing items: ${Array.from(processingItems).join(', ')}`); - */ } }, 0); @@ -1648,11 +1648,11 @@ Processing items: ${Array.from(processingItems).join(', ')}`); }); } else { // Remove from selected IDs - setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); + setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== rowKey)); // Clear the item's group and targetDate when deselected setSecondSearchResults(prev => prev.map(item => - item.id === itemId + (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey ? { ...item, groupId: undefined, @@ -1668,17 +1668,19 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S if (checked) { // Select all items on current page that are not already in created items const newSelectedIds = paginatedResults - .filter(item => !isItemInCreated(item.id)) - .map(item => item.id); + .filter(item => !isItemInCreated(item.id, item.uomId)) + .map(item => item.rowKey || toSearchRowKey(item.id, item.uomId)); setSelectedSecondSearchItemIds(prev => { - const existingIds = prev.filter(id => !paginatedResults.some(item => item.id === id)); + const existingIds = prev.filter(id => !paginatedResults.some(item => + (item.rowKey || toSearchRowKey(item.id, item.uomId)) === id + )); return [...existingIds, ...newSelectedIds]; }); // Set group and targetDate for all selected items on current page setSecondSearchResults(prev => prev.map(item => - newSelectedIds.includes(item.id) + newSelectedIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) ? { ...item, groupId: selectedGroup?.id || undefined, @@ -1688,12 +1690,12 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S )); } else { // Deselect all items on current page - const pageItemIds = paginatedResults.map(item => item.id); - setSelectedSecondSearchItemIds(prev => prev.filter(id => !pageItemIds.includes(id as number))); + const pageItemIds = paginatedResults.map(item => item.rowKey || toSearchRowKey(item.id, item.uomId)); + setSelectedSecondSearchItemIds(prev => prev.filter(id => !pageItemIds.includes(String(id)))); // Clear group and targetDate for all deselected items on current page setSecondSearchResults(prev => prev.map(item => - pageItemIds.includes(item.id) + pageItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) ? { ...item, groupId: undefined, diff --git a/src/i18n/en/pickOrder.json b/src/i18n/en/pickOrder.json index 5c8cb77..db082be 100644 --- a/src/i18n/en/pickOrder.json +++ b/src/i18n/en/pickOrder.json @@ -497,6 +497,10 @@ "Tomorrow": "Tomorrow", "packaging": "packaging", "This lot is not available, please scan another lot.": "This lot is not available, please scan another lot.", + "This lot UOM does not match the pick line. Please scan another lot.": "This lot UOM does not match the pick line. Please scan another lot.", + "Scanned lot UOM does not match pick line UOM": "This lot UOM does not match the pick line. Please scan another lot.", + "This lot has already been picked. Please scan another lot.": "This lot has already been picked. Please scan another lot.", + "Scanned lot is already completed or checked": "This lot has already been picked. Please scan another lot.", "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.", "Lot is expired (expiry={{expiry}})": "Lot is expired (expiry={{expiry}})", "Day After Tomorrow": "Day After Tomorrow", diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index 9bfb9b4..0a95cb6 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -507,6 +507,10 @@ "packaging": "提料中", "No Stock Available": "沒有庫存可用", "This lot is not available, please scan another lot.": "此批號不可用,請掃描其他批號。", + "This lot UOM does not match the pick line. Please scan another lot.": "此批號單位與提貨行不符,請掃描其他批號。", + "Scanned lot UOM does not match pick line UOM": "此批號單位與提貨行不符,請掃描其他批號。", + "This lot has already been picked. Please scan another lot.": "此批號已提貨,請掃描其他批號。", + "Scanned lot is already completed or checked": "此物料已提貨,請掃描其他批號。", "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有其他可用 QR 碼。", "Lot is expired (expiry={{expiry}})": "掃描批號已過期(到期日={{expiry}})", "Day After Tomorrow": "後日",