Browse Source

do update

production
CANCERYS\kw093 3 weeks ago
parent
commit
96450a515b
3 changed files with 453 additions and 246 deletions
  1. +1
    -1
      src/components/DoSearchWorkbench/DoSearchWorkbench.tsx
  2. +44
    -21
      src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx
  3. +408
    -224
      src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx

+ 1
- 1
src/components/DoSearchWorkbench/DoSearchWorkbench.tsx View File

@@ -83,7 +83,7 @@ const DoSearchWorkbench: React.FC<Props> = ({
//console.log("🔍 DoSearch - session:", session);
//console.log("🔍 DoSearch - currentUserId:", currentUserId);
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
/** 使用者明確取消勾選的送貨單 id;未在此集合中的搜結果視為「已選」以便跨頁記憶 */
/** 使用者明確取消勾選的送貨單 id;未在此集合中的搜結果視為「已選」以便跨頁記憶 */
const [excludedRowIds, setExcludedRowIds] = useState<number[]>([]);

const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]);


+ 44
- 21
src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx View File

@@ -442,12 +442,15 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
sos.forEach((so: any) => {
flatLotData.push({
pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode,
pickOrderLineId: line.id,
itemCode: line.item?.code,
itemName: line.item?.name,
lotNo: so.lotNo || lot.lotNo,
location: so.location || lot.location,
deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode,
requiredQty: lineRequiredQty,
pickOrderLineRequiredQty: lineRequiredQty,
requiredQty:
so?.requiredQty ?? so?.suggestedPickLotQty ?? lot?.requiredQty ?? null,
actualPickQty: so.qty ?? lot.actualPickQty ?? 0,
processingStatus: toProc(so.status),
stockOutLineStatus: so.status,
@@ -457,12 +460,14 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
} else {
flatLotData.push({
pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode,
pickOrderLineId: line.id,
itemCode: line.item?.code,
itemName: line.item?.name,
lotNo: lot.lotNo,
location: lot.location,
deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode,
requiredQty: lot.requiredQty,
pickOrderLineRequiredQty: lineRequiredQty,
requiredQty: lot.requiredQty ?? null,
actualPickQty: lot.actualPickQty ?? 0,
processingStatus: lot.processingStatus || "pending",
stockOutLineStatus: lot.stockOutLineStatus || "pending",
@@ -474,12 +479,14 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
lineStockouts.forEach((so: any) => {
flatLotData.push({
pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode,
pickOrderLineId: line.id,
itemCode: line.item?.code,
itemName: line.item?.name,
lotNo: so.lotNo || "",
location: so.location || "",
deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode,
requiredQty: line.requiredQty ?? 0,
pickOrderLineRequiredQty: lineRequiredQty,
requiredQty: so?.requiredQty ?? so?.suggestedPickLotQty ?? null,
actualPickQty: so.qty ?? 0,
processingStatus: toProc(so.status),
stockOutLineStatus: so.status,
@@ -581,24 +588,40 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
</TableRow>
</TableHead>
<TableBody>
{data.lots.map((lot: any, index: number) => (
<TableRow key={index}>
<TableCell>{index + 1}</TableCell>
<TableCell>{lot.itemCode || "N/A"}</TableCell>
<TableCell>{lot.itemName || "N/A"}</TableCell>
<TableCell>{lot.lotNo || "N/A"}</TableCell>
<TableCell>{lot.location || "N/A"}</TableCell>
<TableCell align="right">{lot.requiredQty || 0}</TableCell>
<TableCell align="right">{lot.actualPickQty || 0}</TableCell>
<TableCell align="center">
<Chip
label={t(lot.processingStatus || "unknown")}
color={lot.processingStatus === "completed" ? "success" : "default"}
size="small"
/>
</TableCell>
</TableRow>
))}
{(() => {
const seenGroupKeys = new Set<string>();
return data.lots.map((lot: any, index: number) => {
const groupKey =
lot.pickOrderLineId != null
? `pol:${lot.pickOrderLineId}`
: `item:${lot.itemCode || ""}__do:${lot.deliveryOrderCode || ""}`;
const isGroupFirst = !seenGroupKeys.has(groupKey);
if (isGroupFirst) {
seenGroupKeys.add(groupKey);
}
const requiredQtyDisplay = isGroupFirst
? lot.pickOrderLineRequiredQty ?? null
: null;
return (
<TableRow key={index}>
<TableCell>{index + 1}</TableCell>
<TableCell>{lot.itemCode || "N/A"}</TableCell>
<TableCell>{lot.itemName || "N/A"}</TableCell>
<TableCell>{lot.lotNo || "N/A"}</TableCell>
<TableCell>{lot.location || "N/A"}</TableCell>
<TableCell align="right">{requiredQtyDisplay}</TableCell>
<TableCell align="right">{lot.actualPickQty || 0}</TableCell>
<TableCell align="center">
<Chip
label={t(lot.processingStatus || "unknown")}
color={lot.processingStatus === "completed" ? "success" : "default"}
size="small"
/>
</TableCell>
</TableRow>
);
});
})()}
</TableBody>
</Table>
</TableContainer>


+ 408
- 224
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx View File

@@ -81,20 +81,115 @@ interface Props {
onWorkbenchHierarchyEmpty?: () => void;
}

/** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */
function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null {
if (!activeSuggestedLots?.length) return null;
const withLotNo = activeSuggestedLots.filter(
(l) => l.lotNo != null && String(l.lotNo).trim() !== ""
type ProcessedStockOutLinesByItemId = Map<number, Set<number>>;

function isLotRowPending(lot: any): boolean {
const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
return (
st === "pending" ||
st === "partially_completed" ||
st === "partially_complete" ||
st === ""
);
if (withLotNo.length === 1) return withLotNo[0];
if (withLotNo.length > 1) {
const pending = withLotNo.find(
(l) => (l.stockOutLineStatus || "").toLowerCase() === "pending"
);
return pending || withLotNo[0];
}

function isStockOutLineAlreadyProcessed(
processedByItemId: ProcessedStockOutLinesByItemId,
itemId: number,
stockOutLineId: number | null | undefined,
): boolean {
const solId = Number(stockOutLineId);
if (!Number.isFinite(solId) || solId <= 0) return false;
return processedByItemId.get(itemId)?.has(solId) ?? false;
}

function markProcessedStockOutLine(
prev: ProcessedStockOutLinesByItemId,
itemId: number,
stockOutLineId: number | null | undefined,
): ProcessedStockOutLinesByItemId {
const solId = Number(stockOutLineId);
if (!Number.isFinite(solId) || solId <= 0) return prev;
const newMap = new Map(prev);
if (!newMap.has(itemId)) newMap.set(itemId, new Set());
newMap.get(itemId)!.add(solId);
return newMap;
}

function parseWorkbenchQrPayload(
latestQr: string,
): { itemId: number; stockInLineId: number } | null {
try {
const qrData = JSON.parse(latestQr);
const itemId = Number(qrData?.itemId);
const stockInLineId = Number(qrData?.stockInLineId);
if (!Number.isFinite(itemId) || !Number.isFinite(stockInLineId)) return null;
return { itemId, stockInLineId };
} catch {
return null;
}
return activeSuggestedLots[0];
}

function hasPendingActiveRowForStockInLine(
indexes: {
byStockInLineId: Map<number, any[]>;
activeLotsByItemId: Map<number, any[]>;
},
itemId: number,
stockInLineId: number,
processedByItemId: ProcessedStockOutLinesByItemId,
): boolean {
const rows = indexes.byStockInLineId.get(stockInLineId) ?? [];
const activeSet = new Set(indexes.activeLotsByItemId.get(itemId) ?? []);
return rows.some(
(lot) =>
lot.itemId === itemId &&
activeSet.has(lot) &&
isLotRowPending(lot) &&
!isStockOutLineAlreadyProcessed(processedByItemId, itemId, lot.stockOutLineId),
);
}

function findExactActiveMatchForStockInLine(
stockInLineLots: any[],
scannedItemId: number,
activeSuggestedLots: any[],
processedByItemId: ProcessedStockOutLinesByItemId,
): any | null {
const activeSet = new Set(activeSuggestedLots);
const candidates = stockInLineLots.filter(
(lot) =>
lot.itemId === scannedItemId &&
activeSet.has(lot) &&
!isStockOutLineAlreadyProcessed(
processedByItemId,
scannedItemId,
lot.stockOutLineId,
),
);
if (candidates.length === 0) return null;
return candidates.find((lot) => isLotRowPending(lot)) ?? candidates[0];
}

/** 同物料多行时,优先对「有建议批次号」且未完成的出库行做替换 */
function pickExpectedLotForSubstitution(
activeSuggestedLots: any[],
processedByItemId?: ProcessedStockOutLinesByItemId,
): any | null {
if (!activeSuggestedLots?.length) return null;
const itemId = activeSuggestedLots[0]?.itemId;
const processed = processedByItemId ?? new Map();
const unprocessed = activeSuggestedLots.filter(
(l) =>
!itemId ||
!isStockOutLineAlreadyProcessed(processed, itemId, l.stockOutLineId),
);
const pool = unprocessed.length > 0 ? unprocessed : activeSuggestedLots;
const withLotNo = pool.filter((l) => l.lotNo != null && String(l.lotNo).trim() !== "");
const searchPool = withLotNo.length > 0 ? withLotNo : pool;
if (searchPool.length === 1) return searchPool[0];
const pending = searchPool.find((l) => isLotRowPending(l));
return pending ?? searchPool[0];
}

const ManualLotConfirmationModal: React.FC<{
@@ -580,8 +675,9 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false);

// Add these missing state variables after line 352
const [isManualScanning, setIsManualScanning] = useState<boolean>(false);
// Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling
const [processedQrCombinations, setProcessedQrCombinations] = useState<Map<number, Set<number>>>(new Map());
// Track processed stock-out lines per item (allow same physical lot QR for next SOL)
const [processedQrCombinations, setProcessedQrCombinations] =
useState<ProcessedStockOutLinesByItemId>(new Map());
const [processedQrCodes, setProcessedQrCodes] = useState<Set<string>>(new Set());
const [lastProcessedQr, setLastProcessedQr] = useState<string>('');
const [isRefreshingData, setIsRefreshingData] = useState<boolean>(false);
@@ -596,6 +692,7 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
// Use refs for processed QR tracking to avoid useEffect dependency issues and delays
const processedQrCodesRef = useRef<Set<string>>(new Set());
const lastProcessedQrRef = useRef<string>('');
const qrPickInFlightRef = useRef(false);
// Store callbacks in refs to avoid useEffect dependency issues
const processOutsideQrCodeRef = useRef<
@@ -847,21 +944,25 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
: mergedPickOrder.pickOrderLines || [];

pickOrderLinesForDisplay.forEach((line: any) => {
// 用来记录这一行已经通过 lots 出现过的 lotId
// 用来记录这一行已经通过 lots 出现过的 lotId / stockOutLineId
const lotIdSet = new Set<number>();
const stockOutLineIdSet = new Set<number>();

// ✅ lots:按 lotId 去重并合并 requiredQty
// ✅ lots:按 SOL 优先去重(其次 lotId),保持 requiredQty 原值,不在前端累计
if (line.lots && line.lots.length > 0) {
const lotMap = new Map<number, any>();
const lotMap = new Map<string, any>();
line.lots.forEach((lot: any) => {
line.lots.forEach((lot: any, lotIdx: number) => {
const lotId = lot.id;
if (lotMap.has(lotId)) {
const existingLot = lotMap.get(lotId);
existingLot.requiredQty =
(existingLot.requiredQty || 0) + (lot.requiredQty || 0);
} else {
lotMap.set(lotId, { ...lot });
const solId = Number(lot?.stockOutLineId);
const lotKey =
Number.isFinite(solId) && solId > 0
? `sol:${solId}`
: lotId != null
? `lot:${lotId}`
: `idx:${lotIdx}`;
if (!lotMap.has(lotKey)) {
lotMap.set(lotKey, { ...lot });
}
});
@@ -869,6 +970,10 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
if (lot.id != null) {
lotIdSet.add(lot.id);
}
const solId = Number(lot?.stockOutLineId);
if (Number.isFinite(solId) && solId > 0) {
stockOutLineIdSet.add(solId);
}
flatLotData.push({
pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes)
@@ -921,20 +1026,19 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
if (line.stockouts && line.stockouts.length > 0) {
line.stockouts.forEach((stockout: any) => {
const hasLot = stockout.lotId != null;
const hasSolId = stockout.id != null;
const lotAlreadyInLots =
hasLot && lotIdSet.has(stockout.lotId as number);
const solAlreadyInLots =
hasSolId && stockOutLineIdSet.has(Number(stockout.id));
// 有批次 & 已经通过 lots 渲染过 → 跳过,避免一条变两行
if (!stockout.noLot && lotAlreadyInLots) {
if (!stockout.noLot && (solAlreadyInLots || lotAlreadyInLots)) {
return;
}
const stockoutRequiredQty = Number(
stockout?.requiredQty ?? stockout?.suggestedPickLotQty,
);
const effectiveStockoutRequiredQty = Number.isFinite(stockoutRequiredQty)
? stockoutRequiredQty
: Number(line.requiredQty) || 0;
const effectiveStockoutRequiredQty =
stockout?.requiredQty ?? stockout?.suggestedPickLotQty ?? null;
const fallbackRouteFromLine =
line?.lots?.[0]?.router?.route ?? line?.lots?.[0]?.location ?? null;

@@ -1399,16 +1503,33 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
resetScanRef.current = resetScan;
const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => {
if (qrPickInFlightRef.current) {
console.log(" [SKIP] QR pick already in flight");
return;
}
qrPickInFlightRef.current = true;

const totalStartTime = performance.now();
console.log(` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`);
console.log(` Start time: ${new Date().toISOString()}`);
// ✅ Measure index access time

const indexAccessStart = performance.now();
const indexes = lotDataIndexes; // Access the memoized indexes
const indexes = lotDataIndexes;
const indexAccessTime = performance.now() - indexAccessStart;
console.log(` [PERF] Index access time: ${indexAccessTime.toFixed(2)}ms`);

const maybeReleaseQrForNextSol = (
itemId: number,
stockInLineId: number,
processedAfterMark: ProcessedStockOutLinesByItemId,
) => {
if (hasPendingActiveRowForStockInLine(indexes, itemId, stockInLineId, processedAfterMark)) {
lastProcessedQrRef.current = "";
processedQrCodesRef.current.delete(latestQr);
}
};

try {
// 1) Parse JSON safely (parse once, reuse)
const parseStartTime = performance.now();
let qrData: any = null;
@@ -1441,17 +1562,20 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
const scannedItemId = qrData.itemId;
const scannedStockInLineId = qrData.stockInLineId;
// ✅ Check if this combination was already processed
const duplicateCheckStartTime = performance.now();
const itemProcessedSet = processedQrCombinations.get(scannedItemId);
if (itemProcessedSet?.has(scannedStockInLineId)) {
const duplicateCheckTime = performance.now() - duplicateCheckStartTime;
console.log(` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed(2)}ms)`);

if (
!hasPendingActiveRowForStockInLine(
indexes,
scannedItemId,
scannedStockInLineId,
processedQrCombinations,
)
) {
console.log(
` [SKIP] No pending stock-out line left for itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId}`,
);
return;
}
const duplicateCheckTime = performance.now() - duplicateCheckStartTime;
console.log(` [PERF] Duplicate check time: ${duplicateCheckTime.toFixed(2)}ms`);
// ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed)
const lookupStartTime = performance.now();
@@ -1463,9 +1587,15 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
// ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots
// This allows users to scan other lots even when all suggested lots are rejected
const scannedLot = allLotsForItem.find(
(lot: any) => lot.stockInLineId === scannedStockInLineId
);
const stockInLineRows = indexes.byStockInLineId.get(scannedStockInLineId) ?? [];
const scannedLot =
findExactActiveMatchForStockInLine(
stockInLineRows,
scannedItemId,
activeSuggestedLots,
processedQrCombinations,
) ??
allLotsForItem.find((lot: any) => lot.stockInLineId === scannedStockInLineId);
if (scannedLot) {
const isRejected =
@@ -1482,13 +1612,14 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
`此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`
);
});
// Mark as processed to prevent re-processing
setProcessedQrCombinations(prev => {
const newMap = new Map(prev);
if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
newMap.get(scannedItemId)!.add(scannedStockInLineId);
return newMap;
});
if (scannedLot?.stockOutLineId != null) {
const nextProcessed = markProcessedStockOutLine(
processedQrCombinations,
scannedItemId,
scannedLot.stockOutLineId,
);
setProcessedQrCombinations(nextProcessed);
}
return;
}

@@ -1675,12 +1806,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
);
});

setProcessedQrCombinations((prev) => {
const newMap = new Map(prev);
if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
newMap.get(scannedItemId)!.add(scannedStockInLineId);
return newMap;
});
const nextProcessedNoActive = markProcessedStockOutLine(
processedQrCombinations,
scannedItemId,
expectedLot.stockOutLineId,
);
setProcessedQrCombinations(nextProcessedNoActive);
maybeReleaseQrForNextSol(scannedItemId, scannedStockInLineId, nextProcessedNoActive);

if (workbenchMode) {
await refreshWorkbenchAfterScanPick();
@@ -1691,16 +1823,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
// ✅ OPTIMIZATION: Direct Map lookup for stockInLineId match (O(1))
const matchStartTime = performance.now();
let exactMatch: any = null;
const stockInLineLots = indexes.byStockInLineId.get(scannedStockInLineId) || [];
// Find exact match from stockInLineId index, then verify it's in active lots
for (let i = 0; i < stockInLineLots.length; i++) {
const lot = stockInLineLots[i];
if (lot.itemId === scannedItemId && activeSuggestedLots.includes(lot)) {
exactMatch = lot;
break;
}
}
const exactMatch = findExactActiveMatchForStockInLine(
stockInLineLots,
scannedItemId,
activeSuggestedLots,
processedQrCombinations,
);
const matchTime = performance.now() - matchStartTime;
console.log(` [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${exactMatch ? 'yes' : 'no'}`);
@@ -1709,7 +1838,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
// Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined)
if (!exactMatch) {
const expectedLot =
pickExpectedLotForSubstitution(activeSuggestedLots) || allLotsForItem[0];
pickExpectedLotForSubstitution(activeSuggestedLots, processedQrCombinations) ||
allLotsForItem[0];
if (expectedLot) {
const shouldAutoSwitch =
!scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId);
@@ -1842,12 +1972,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
);
});

setProcessedQrCombinations((prev) => {
const newMap = new Map(prev);
if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
newMap.get(scannedItemId)!.add(scannedStockInLineId);
return newMap;
});
const nextProcessedAuto = markProcessedStockOutLine(
processedQrCombinations,
scannedItemId,
expectedLot.stockOutLineId,
);
setProcessedQrCombinations(nextProcessedAuto);
maybeReleaseQrForNextSol(scannedItemId, scannedStockInLineId, nextProcessedAuto);
if (workbenchMode) {
await refreshWorkbenchAfterScanPick();
}
@@ -1940,14 +2071,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
// Mark this combination as processed
const markProcessedStartTime = performance.now();
setProcessedQrCombinations(prev => {
const newMap = new Map(prev);
if (!newMap.has(scannedItemId)) {
newMap.set(scannedItemId, new Set());
}
newMap.get(scannedItemId)!.add(scannedStockInLineId);
return newMap;
});
const nextProcessedExact = markProcessedStockOutLine(
processedQrCombinations,
scannedItemId,
exactMatch.stockOutLineId,
);
setProcessedQrCombinations(nextProcessedExact);
maybeReleaseQrForNextSol(scannedItemId, scannedStockInLineId, nextProcessedExact);
const markProcessedTime = performance.now() - markProcessedStartTime;
console.log(` [PERF] Mark processed time: ${markProcessedTime.toFixed(2)}ms`);

@@ -1958,7 +2088,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
const totalTime = performance.now() - totalStartTime;
console.log(`✅ [PROCESS OUTSIDE QR END] Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`);
console.log(` End time: ${new Date().toISOString()}`);
console.log(`📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, duplicateCheck=${duplicateCheckTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, api=${apiTime.toFixed(2)}ms, stateUpdate=${stateUpdateTime.toFixed(2)}ms, markProcessed=${markProcessedTime.toFixed(2)}ms`);
console.log(`📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, api=${apiTime.toFixed(2)}ms, stateUpdate=${stateUpdateTime.toFixed(2)}ms, markProcessed=${markProcessedTime.toFixed(2)}ms`);
console.log(
workbenchMode
? "✅ Workbench scan-pick: list refreshed from server"
@@ -2000,20 +2130,11 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
// ✅ Case 2: itemId 匹配但 stockInLineId 不匹配
// Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描
const mismatchCheckStartTime = performance.now();
const itemProcessedSet2 = processedQrCombinations.get(scannedItemId);
if (itemProcessedSet2?.has(scannedStockInLineId)) {
const mismatchCheckTime = performance.now() - mismatchCheckStartTime;
console.log(
` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed(2)}ms)`,
);
return;
}
const mismatchCheckTime = performance.now() - mismatchCheckStartTime;
console.log(` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`);

const expectedLotStartTime = performance.now();
const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots);
const expectedLot = pickExpectedLotForSubstitution(
activeSuggestedLots,
processedQrCombinations,
);
if (!expectedLot) {
console.error("Could not determine expected lot for auto-switch");
startTransition(() => {
@@ -2152,12 +2273,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
);
});

setProcessedQrCombinations((prev) => {
const newMap = new Map(prev);
if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
newMap.get(scannedItemId)!.add(scannedStockInLineId);
return newMap;
});
const nextProcessedMismatch = markProcessedStockOutLine(
processedQrCombinations,
scannedItemId,
expectedLot.stockOutLineId,
);
setProcessedQrCombinations(nextProcessedMismatch);
maybeReleaseQrForNextSol(scannedItemId, scannedStockInLineId, nextProcessedMismatch);

if (workbenchMode) {
await refreshWorkbenchAfterScanPick();
@@ -2175,7 +2297,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
);
console.log(` End time: ${new Date().toISOString()}`);
console.log(
`📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, duplicateCheck=${duplicateCheckTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, mismatchCheck=${mismatchCheckTime.toFixed(2)}ms, expectedLot=${expectedLotTime.toFixed(2)}ms`,
`📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, expectedLot=${expectedLotTime.toFixed(2)}ms`,
);
} catch (error) {
const totalTime = performance.now() - totalStartTime;
@@ -2187,6 +2309,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
});
return;
}
} finally {
qrPickInFlightRef.current = false;
}
}, [
lotDataIndexes,
processedQrCombinations,
@@ -2310,13 +2435,32 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`);
console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`);
// Skip if already processed (use refs to avoid dependency issues and delays)
const qrPayload = parseWorkbenchQrPayload(latestQr);
const canRetrySamePhysicalLot =
qrPayload != null &&
hasPendingActiveRowForStockInLine(
lotDataIndexes,
qrPayload.itemId,
qrPayload.stockInLineId,
processedQrCombinations,
);

// Skip if already processed (allow same QR when another pending SOL needs this lot)
const checkProcessedStartTime = performance.now();
if (processedQrCodesRef.current.has(latestQr) || lastProcessedQrRef.current === latestQr) {
if (
(processedQrCodesRef.current.has(latestQr) || lastProcessedQrRef.current === latestQr) &&
!canRetrySamePhysicalLot
) {
const checkTime = performance.now() - checkProcessedStartTime;
console.log(` [QR PROCESS] Already processed check time: ${checkTime.toFixed(2)}ms`);
return;
}
if (canRetrySamePhysicalLot) {
processedQrCodesRef.current.delete(latestQr);
if (lastProcessedQrRef.current === latestQr) {
lastProcessedQrRef.current = "";
}
}
const checkTime = performance.now() - checkProcessedStartTime;
console.log(` [QR PROCESS] Not processed check time: ${checkTime.toFixed(2)}ms`);
@@ -2351,8 +2495,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
}
// Process new QR code immediately (background mode - no modal)
// Check against refs to avoid state update delays
if (latestQr && latestQr !== lastProcessedQrRef.current) {
if (latestQr && (latestQr !== lastProcessedQrRef.current || canRetrySamePhysicalLot)) {
const processingStartTime = performance.now();
console.log(` [QR PROCESS] Starting processing at: ${new Date().toISOString()}`);
console.log(` [QR PROCESS] Time since detection: ${(processingStartTime - qrDetectionStartTime).toFixed(2)}ms`);
@@ -2416,7 +2559,15 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
qrProcessingTimeoutRef.current = null;
}
};
}, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, manualLotConfirmationOpen]);
}, [
qrValues,
isManualScanning,
isRefreshingData,
combinedLotData.length,
manualLotConfirmationOpen,
lotDataIndexes,
processedQrCombinations,
]);
const renderCountRef = useRef(0);
const renderStartTimeRef = useRef<number | null>(null);

@@ -2642,7 +2793,9 @@ useEffect(() => {
type RowMeta = {
lot: any;
isGroupFirst: boolean;
isGroupLast: boolean;
groupDisplayIndex: number;
isMultiLotGroup: boolean;
};

const isCompletedStatus = (lot: any) => {
@@ -2676,15 +2829,14 @@ useEffect(() => {
{ firstIndex: number; items: { lot: any; originalIndex: number }[] }
>();
combinedLotData.forEach((lot: any, originalIndex: number) => {
const routeKey = String(lot?.routerRoute ?? "").trim();
const pickOrderLineKey =
lot?.pickOrderLineId != null ? `pol:${String(lot.pickOrderLineId)}` : "pol:unknown";
const itemKey =
lot?.itemId != null
? `itemId:${String(lot.itemId)}`
: `itemCode:${String(lot?.itemCode ?? "").trim()}`;
// Group by pickOrderLine first so no-lot row stays with its lot rows even when route is empty.
const key = `${pickOrderLineKey}__${itemKey}__${routeKey}`;
// Group by pick order line + item so split lots (different routes) share one display index.
const key = `${pickOrderLineKey}__${itemKey}`;
const g = groups.get(key);
if (!g) {
groups.set(key, { firstIndex: originalIndex, items: [{ lot, originalIndex }] });
@@ -2712,7 +2864,9 @@ useEffect(() => {
flattened.push({
lot: it.lot,
isGroupFirst: idx === 0,
isGroupLast: idx === sortedWithin.length - 1,
groupDisplayIndex,
isMultiLotGroup: sortedWithin.length > 1,
});
});
}
@@ -3331,12 +3485,12 @@ const handleSubmitAllScanned = useCallback(async () => {
// 添加调试日志
const noLotCount = filtered.filter(l => l.noLot === true).length;
const normalCount = filtered.filter(l => l.noLot !== true).length;
console.log(`📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`);
console.log(`📊 All items breakdown:`, {
total: combinedLotData.length,
noLot: combinedLotData.filter(l => l.noLot === true).length,
normal: combinedLotData.filter(l => l.noLot !== true).length
});
//console.log(`📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`);
//console.log(`📊 All items breakdown:`, {
//total: combinedLotData.length,
//noLot: combinedLotData.filter(l => l.noLot === true).length,
//normal: combinedLotData.filter(l => l.noLot !== true).length
//});
return filtered.length;
}, [combinedLotData]);
@@ -3647,16 +3801,42 @@ paginatedData.map((row, index) => {
const solSt = String(lot.stockOutLineStatus || "").toLowerCase();
const isSolRejected =
solSt === "rejected" || String(lot.lotAvailability || "").toLowerCase() === "rejected";

const isFirstInGroup = row.isGroupFirst;
const isLastInGroup = row.isGroupLast;
const shouldOutline = row.isMultiLotGroup;
const outlineColor = "#008000";
const groupUsesAltBg = row.groupDisplayIndex % 2 === 0;
const groupRowBg = groupUsesAltBg ? "neutral.50" : "background.paper";
return (
<TableRow
key={`${lot.pickOrderLineId}-${lot.lotId || 'null'}`}
key={`${lot.pickOrderLineId}-${lot.stockOutLineId ?? "null"}-${lot.lotId ?? "nolot"}`}
sx={{
//backgroundColor: isIssueLot ? '#fff3e0' : 'inherit',
// opacity: isIssueLot ? 0.6 : 1,
'& .MuiTableCell-root': {
//color: isIssueLot ? 'warning.main' : 'inherit'
}
backgroundColor: groupRowBg,
"&:nth-of-type(even)": { backgroundColor: groupRowBg },
"& .MuiTableCell-root": {
backgroundColor: groupRowBg,
...(shouldOutline
? {
borderTop: isFirstInGroup ? `2px solid ${outlineColor}` : "none",
borderBottom: isLastInGroup ? `2px solid ${outlineColor}` : "none",
}
: {}),
},
"& .MuiTableCell-root:first-of-type": shouldOutline
? {
borderLeft: `2px solid ${outlineColor}`,
borderTopLeftRadius: isFirstInGroup ? 6 : 0,
borderBottomLeftRadius: isLastInGroup ? 6 : 0,
}
: {},
"& .MuiTableCell-root:last-of-type": shouldOutline
? {
borderRight: `2px solid ${outlineColor}`,
borderTopRightRadius: isFirstInGroup ? 6 : 0,
borderBottomRightRadius: isLastInGroup ? 6 : 0,
}
: {},
}}
>
<TableCell>
@@ -3671,7 +3851,7 @@ paginatedData.map((row, index) => {
</TableCell>
<TableCell>
<Typography variant="body2">
{lot.routerRoute || '-'}
{lot.noLot ? "-" : lot.routerRoute || "-"}
</Typography>
</TableCell>
<TableCell>
@@ -3746,8 +3926,13 @@ paginatedData.map((row, index) => {
</TableCell>
<TableCell align="right">
{(() => {
const requiredQty = lot.requiredQty || 0;
return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')';
if (!row.isGroupFirst) return null;
const requiredQty = lot.pickOrderLineRequiredQty;
if (requiredQty == null) return null;
const requiredQtyNum = Number(requiredQty);
if (!Number.isFinite(requiredQtyNum)) return String(requiredQty);
const uom = lot.uomShortDesc ? `(${lot.uomShortDesc})` : "";
return requiredQtyNum.toLocaleString() + uom;
})()}
</TableCell>
@@ -3848,7 +4033,6 @@ paginatedData.map((row, index) => {
const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected';
const isNoLot = !lot.lotNo;
const isUnavailableRow = isInventoryLotLineUnavailable(lot);
// ✅ rejected lot:显示提示文本(换行显示)
if (isRejected && !isNoLot) {
const rejectHint = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t);
@@ -3908,103 +4092,103 @@ paginatedData.map((row, index) => {
? String(pickQtyData[lotKey])
: String(displayedSubmitQty)
: String(displayedSubmitQty);
const isRowPicked =
status === "completed" ||
status === "checked" ||
status === "partially_completed" ||
status === "partially_complete";

return (
<Stack direction="row" spacing={1} alignItems="center">
{/*
<Button
variant="contained"
onClick={() => {
const submitQty = displayedSubmitQty;
handlePickQtyChange(lotKey, submitQty);
handleSubmitPickQtyWithQty(lot, submitQty, 'singleSubmit');
}}
disabled={
lot.lotAvailability === 'expired' ||
isInventoryLotLineUnavailable(lot) ||
lot.lotAvailability === 'rejected' ||
lot.stockOutLineStatus === 'completed' ||
lot.stockOutLineStatus === 'pending' ||
(Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
}
sx={{ fontSize: '0.75rem', py: 0.5, minHeight: '28px', minWidth: '70px' }}
>
{t("Submit")}
</Button>
*/}

<TextField
type="number"
size="small"
disabled={!qtyFieldEnabled}
value={textFieldValue}
onKeyDown={(e) => {
if (!qtyFieldEnabled) return;
if (e.key !== "{") return;
e.preventDefault();
setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({
...prev,
[lotKey]: false,
}));
(e.currentTarget as HTMLInputElement).blur();
}}
onChange={(e) => {
if (!qtyFieldEnabled) return;
const n = Number(e.target.value);
if (Number.isFinite(n) && n < 0) return;
handlePickQtyChange(lotKey, e.target.value);
}}
inputProps={{ min: 0, step: 1 }}
sx={{
width: 96,
'& .MuiInputBase-input': { fontSize: '0.75rem', py: 0.5, textAlign: 'center' },
}}
/>

<Button
variant="outlined"
size="small"
onClick={() => {
setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({
...prev,
[lotKey]: !(prev[lotKey] === true),
}));
}}
disabled={
lot.stockOutLineStatus === 'completed' ||
(Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
}
sx={{
fontSize: '0.7rem',
py: 0.5,
minHeight: '28px',
minWidth: '60px',
borderColor: 'warning.main',
color: 'warning.main',
}}
title={qtyFieldEnabled ? t('Lock quantity') : t('Edit quantity')}
>
{t("Edit")}
</Button>
<Button
variant="outlined"
size="small"
onClick={() => handleSkip(lot)}
disabled={
lot.stockOutLineStatus === 'completed' ||
lot.stockOutLineStatus === 'checked' ||
lot.stockOutLineStatus === 'partially_completed' ||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
{isRowPicked ? (
<Typography
variant="body2"
sx={{
width: 96,
textAlign: "center",
fontSize: "0.75rem",
fontWeight: 500,
}}
>
{textFieldValue}
</Typography>
) : (
<TextField
type="number"
size="small"
disabled={!qtyFieldEnabled}
value={textFieldValue}
onKeyDown={(e) => {
if (!qtyFieldEnabled) return;
if (e.key !== "{") return;
e.preventDefault();
setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({
...prev,
[lotKey]: false,
}));
(e.currentTarget as HTMLInputElement).blur();
}}
onChange={(e) => {
if (!qtyFieldEnabled) return;
const n = Number(e.target.value);
if (Number.isFinite(n) && n < 0) return;
handlePickQtyChange(lotKey, e.target.value);
}}
inputProps={{ min: 0, step: 1 }}
sx={{
width: 96,
"& .MuiInputBase-input": { fontSize: "0.75rem", py: 0.5, textAlign: "center" },
}}
/>
)}

// 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交)
(Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) ||
(Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true)
}
sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '60px' }}
>
{t("Just Completed")}
</Button>
{isRowPicked ? (
<Typography variant="body2" color="success.main" sx={{ fontSize: "0.75rem", fontWeight: 500 }}>
{t("")}
</Typography>
) : (
<>
<Button
variant="outlined"
size="small"
onClick={() => {
setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({
...prev,
[lotKey]: !(prev[lotKey] === true),
}));
}}
disabled={
Number(lot.stockOutLineId) > 0 &&
actionBusyBySolId[Number(lot.stockOutLineId)] === true
}
sx={{
fontSize: "0.7rem",
py: 0.5,
minHeight: "28px",
minWidth: "60px",
borderColor: "warning.main",
color: "warning.main",
}}
title={qtyFieldEnabled ? t("Lock quantity") : t("Edit quantity")}
>
{t("Edit")}
</Button>
<Button
variant="outlined"
size="small"
onClick={() => handleSkip(lot)}
disabled={
(Number(lot.stockOutLineId) > 0 &&
issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) ||
(Number(lot.stockOutLineId) > 0 &&
actionBusyBySolId[Number(lot.stockOutLineId)] === true)
}
sx={{ fontSize: "0.7rem", py: 0.5, minHeight: "28px", minWidth: "60px" }}
>
{t("Just Completed")}
</Button>
</>
)}
</Stack>
);
}


Loading…
Cancel
Save