瀏覽代碼

建議批號 UOM 檢核/不符則擋、標籤列印只顯示同 UOM

production
CANCERYS\kw093 2 小時之前
父節點
當前提交
ff6789711c
共有 13 個檔案被更改,包括 581 行新增271 行删除
  1. +11
    -2
      src/app/api/doworkbench/actions.ts
  2. +5
    -1
      src/app/api/inventory/actions.ts
  3. +24
    -0
      src/app/api/settings/item/actions.ts
  4. +103
    -11
      src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
  5. +60
    -19
      src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx
  6. +1
    -0
      src/components/InventorySearch/InventorySearch.tsx
  7. +8
    -3
      src/components/PickOrderSearch/AssignAndRelease.tsx
  8. +12
    -13
      src/components/PickOrderSearch/CreatedItemsTable.tsx
  9. +55
    -70
      src/components/PickOrderSearch/SearchResultsTable.tsx
  10. +155
    -15
      src/components/PickOrderSearch/WorkbenchPickExecution.tsx
  11. +139
    -137
      src/components/PickOrderSearch/newcreatitem.tsx
  12. +4
    -0
      src/i18n/en/pickOrder.json
  13. +4
    -0
      src/i18n/zh/pickOrder.json

+ 11
- 2
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<any[]>(`${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<any>(
`${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",


+ 5
- 1
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. */


+ 24
- 0
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<AvailablePickUomsResponse>(
`${BASE_API_URL}/items/${itemId}/available-pick-uoms`,
{
method: "GET",
next: { tags: ["items"] },
},
);
});

export const fetchItemsWithDetails = cache(async (searchParams?: Record<string, any>) => {
if (searchParams) {
const queryString = new URLSearchParams(searchParams).toString();


+ 103
- 11
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx 查看文件

@@ -551,6 +551,7 @@ function saveIssuePickedMap(doPickOrderId: number, map: Record<number, number>)
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.1 | 2026-07-22 */
const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({
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) => {
}}
>
<TableCell>
<Typography variant="body2" fontWeight="bold">
<Typography variant="body1" fontWeight="bold">
{row.isGroupFirst ? row.groupDisplayIndex : ""}
</Typography>
</TableCell>

<TableCell>{row.isGroupFirst ? lot.itemCode : ""}</TableCell>
<TableCell>
<Typography variant="body1">
{row.isGroupFirst ? lot.itemCode : ""}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body1">
{row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
@@ -4146,7 +4232,7 @@ paginatedData.map((row, index) => {
</Typography>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Stack direction="row" spacing={1} alignItems="center">
<Box sx={{ flex: 1, minWidth: 0 }}>
{(() => {
const hasLotNo = Boolean(lot.lotNo);
@@ -4165,7 +4251,7 @@ paginatedData.map((row, index) => {
: 'inherit';
return (
<Typography
variant={isIssueText ? "body2" : "body1"}
variant={isIssueText ? "body2" : "body2"}
sx={{
color: textColor,
whiteSpace: 'pre-wrap',
@@ -4213,7 +4299,7 @@ paginatedData.map((row, index) => {
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}


+ 60
- 19
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<WorkbenchLotLabelPrintModalProps> = ({
open,
onClose,
@@ -155,6 +161,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
hideTriggeredLot = false,
triggerLotAvailableQty = null,
triggerLotUom = null,
expectedUomId = null,
disableScanPick = false,
onWorkbenchScanPick,
submitQty = null,
@@ -257,13 +264,22 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
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<WorkbenchLotLabelPrintModalProps> =
setAnalysisLoading(false);
}
},
[],
[resolveExpectedUomId],
);

const analyzeByItem = useCallback(
@@ -290,7 +306,11 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
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<WorkbenchLotLabelPrintModalProps> =
setAnalysisLoading(false);
}
},
[],
[resolveExpectedUomId],
);

const handleAnalyze = useCallback(async () => {
@@ -381,30 +401,51 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
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();


+ 1
- 0
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<Props> = ({ inventories, printerCombo }) => {
const { t } = useTranslation(['inventory', 'common', 'item']);



+ 8
- 3
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<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const { setIsUploading } = useUploadContext();
@@ -167,11 +170,13 @@ const AssignAndRelease: React.FC<Props> = ({ 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<Props> = ({ filterArgs }) => {
) : (
groupedItems.map((group) => (
group.items.map((item, index) => (
<TableRow key={item.id}>
<TableRow key={item.id || `${group.pickOrderId}-${item.pickOrderLineId ?? item.itemId}-${index}`}>
{/* Checkbox - 只在第一个项目显示,按 pick order 选择 */}
<TableCell>
{index === 0 ? (


+ 12
- 13
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<HTMLInputElement>) => void;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */
const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
items,
groups,
@@ -65,15 +65,14 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
}) => {
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<CreatedItemsTableProps> = ({
</TableRow>
) : (
paginatedItems.map((item) => (
<TableRow key={item.itemId}>
<TableRow key={`${item.itemId}_${item.uomId}`}>
<TableCell padding="checkbox">
<Checkbox
checked={item.isSelected}
onChange={(e) => onItemSelect(item.itemId, e.target.checked)}
onChange={(e) => onItemSelect(item.itemId, e.target.checked, item.uomId)}
/>
</TableCell>
<TableCell>
@@ -134,7 +133,7 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={item.groupId?.toString() || ""}
onChange={(e) => onGroupChange(item.itemId, e.target.value)}
onChange={(e) => onGroupChange(item.itemId, e.target.value, item.uomId)}
displayEmpty
>
<MenuItem value="">
@@ -157,14 +156,14 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2">{item.uomDesc}</Typography>
<Typography variant="body2">{item.uomDesc || item.uom || "-"}</Typography>
</TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
value={item.qty || ""}
onChange={(e) => handleQtyChange(item.itemId, e.target.value)}
onChange={(e) => handleQtyChange(item.itemId, item.uomId, e.target.value)}
inputProps={{
min: 1,
step: 1,
@@ -208,4 +207,4 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({
);
};

export default CreatedItemsTable;
export default CreatedItemsTable;

+ 55
- 70
src/components/PickOrderSearch/SearchResultsTable.tsx 查看文件

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react';
import {
Box,
Typography,
Table,
TableBody,
@@ -22,8 +21,12 @@ import dayjs from 'dayjs';

interface SearchItemWithQty {
id: number;
/** Unique search-row key: itemId + uomId */
rowKey: string;
label: string;
qty: number | null;
uomId?: number;
uom?: string;
currentStockBalance?: number;
uomDesc?: string;
targetDate?: string | null;
@@ -40,17 +43,18 @@ interface SearchResultsTableProps {
items: SearchItemWithQty[];
selectedItemIds: (string | number)[];
groups: Group[];
onItemSelect: (itemId: number, checked: boolean) => void;
onQtyChange: (itemId: number, qty: number | null) => void;
onQtyBlur: (itemId: number) => void;
onGroupChange: (itemId: number, groupId: string) => void;
isItemInCreated: (itemId: number) => boolean;
onItemSelect: (rowKey: string, checked: boolean) => void;
onQtyChange: (rowKey: string, qty: number | null) => void;
onQtyBlur: (rowKey: string) => void;
onGroupChange: (rowKey: string, groupId: string) => void;
isItemInCreated: (itemId: number, uomId?: number) => boolean;
pageNum: number;
pageSize: number;
onPageChange: (event: unknown, newPage: number) => void;
onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */
const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
items,
selectedItemIds,
@@ -67,16 +71,14 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
}) => {
const { t } = useTranslation("pickOrder");

// Calculate pagination
const startIndex = (pageNum - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedResults = items.slice(startIndex, endIndex);

const handleQtyChange = useCallback((itemId: number, value: string) => {
// Only allow numbers
const handleQtyChange = useCallback((rowKey: string, value: string) => {
if (value === "" || /^\d*\.?\d+$/.test(value)) {
const numValue = value === "" ? null : Number(value);
onQtyChange(itemId, numValue);
onQtyChange(rowKey, numValue);
}
}, [onQtyChange]);

@@ -119,50 +121,45 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
</TableCell>
</TableRow>
) : (
paginatedResults.map((item) => (
<TableRow key={item.id}>
paginatedResults.map((item) => {
const rowKey = item.rowKey || `${item.id}_${item.uomId || 0}`;
const inCreated = isItemInCreated(item.id, item.uomId);
return (
<TableRow key={rowKey}>
<TableCell padding="checkbox">
<Checkbox
checked={selectedItemIds.includes(item.id)}
onChange={(e) => onItemSelect(item.id, e.target.checked)}
disabled={isItemInCreated(item.id)}
checked={selectedItemIds.includes(rowKey) || inCreated}
onChange={(e) => onItemSelect(rowKey, e.target.checked)}
disabled={inCreated}
/>
</TableCell>
{/* Item */}
<TableCell>
<Box>
<Typography variant="body2">
{item.label.split(' - ')[1] || item.label}
</Typography>
<Typography variant="caption" color="textSecondary">
{item.label.split(' - ')[0] || ''}
</Typography>
</Box>
<Typography variant="body2">
{item.label.split(' - ')[1] || item.label}
</Typography>
<Typography variant="caption" color="textSecondary">
{item.label.split(' - ')[0] || ''}
</Typography>
</TableCell>
{/* Group */}
<TableCell>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
<Select
value={item.groupId?.toString() || ""}
onChange={(e) => onGroupChange(item.id, e.target.value)}
onChange={(e) => onGroupChange(rowKey, e.target.value)}
displayEmpty
disabled={isItemInCreated(item.id)}
>
disabled={inCreated}
>
<MenuItem value="">
<em>{t("No Group")}</em>
<em>{t("No Group")}</em>
</MenuItem>
{groups.map((group) => (
<MenuItem key={group.id} value={group.id.toString()}>
<MenuItem key={group.id} value={group.id.toString()}>
{group.name}
</MenuItem>
</MenuItem>
))}
</Select>
</Select>
</FormControl>
</TableCell>
{/* Current Stock */}
</TableCell>
<TableCell align="right">
<Typography
variant="body2"
@@ -172,54 +169,42 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
{item.currentStockBalance?.toLocaleString()||0}
</Typography>
</TableCell>
{/* Stock Unit */}
<TableCell align="right">
<Typography variant="body2">
{item.uomDesc || "-"}
{item.uomDesc || item.uom || "-"}
</Typography>
</TableCell>
<TableCell align="right">
{/* Order Quantity */}

<TextField
type="number"
size="small"
value={item.qty || ""}
onChange={(e) => {
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={{
<TextField
type="number"
size="small"
value={item.qty || ""}
onChange={(e) => 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)}
}}
/>
</TableCell>
{/* Target Date */}
<TableCell align="right">
<Typography variant="body2">
{item.targetDate ? dayjs(item.targetDate).format(OUTPUT_DATE_FORMAT) : "-"}
</Typography>
</TableCell>
</TableRow>
))
);
})
)}
</TableBody>
</Table>
@@ -242,4 +227,4 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
);
};

export default SearchResultsTable;
export default SearchResultsTable;

+ 155
- 15
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<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -770,6 +776,16 @@ const WorkbenchPickExecution: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ filterArgs }) => {
pickExpectedRowForSubstitution,
lotRowIndexes,
openUnpickableScanLotLabelModal,
releaseProcessedQr,
resetScan,
submitRow,
t,
@@ -1306,10 +1441,9 @@ const WorkbenchPickExecution: React.FC<Props> = ({ 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<Props> = ({ 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))


+ 139
- 137
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<number>();
let processingItems = new Set<string>();

/** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */
const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCreated }) => {
const { t } = useTranslation("pickOrder");
const [items, setItems] = useState<ItemCombo[]>([]);
@@ -174,6 +181,7 @@ const NewCreateItem: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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) => (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Typography sx={{ textAlign: 'right' }}> {/* Add right alignment for the value */}
{item.uom || "-"}
{item.uomDesc || item.uom || "-"}
</Typography>
</Box>
),
@@ -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,


+ 4
- 0
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",


+ 4
- 0
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": "後日",


Loading…
取消
儲存