tommy 1 неделю назад
Родитель
Сommit
b40acc5190
12 измененных файлов: 461 добавлений и 77 удалений
  1. +3
    -0
      src/app/api/pickOrder/actions.ts
  2. +5
    -1
      src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx
  3. +2
    -1
      src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx
  4. +212
    -67
      src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
  5. +19
    -0
      src/components/DoWorkbench/workbenchTabConstants.ts
  6. +3
    -1
      src/i18n/en/pickOrder.json
  7. +31
    -4
      src/i18n/zh/do.json
  8. +1
    -1
      src/i18n/zh/doWorkbench.json
  9. +1
    -1
      src/i18n/zh/navigation.json
  10. +3
    -1
      src/i18n/zh/pickOrder.json
  11. +167
    -0
      src/utils/workbenchPickLotUtils.ts
  12. +14
    -0
      src/utils/workbenchReleaseType.ts

+ 3
- 0
src/app/api/pickOrder/actions.ts Просмотреть файл

@@ -297,6 +297,7 @@ export interface FGPickOrderResponse {
truckLanceCode: string;
storeId: string;
qrCodeData: number;
releaseType?: string;

}
export interface DoPickOrderDetail {
@@ -372,6 +373,8 @@ export interface CompletedDoPickOrderResponse {
deliveryNoteCode: number;
/** Legacy: do_pick_order_record.handler_name; workbench: delivery_order_pick_order.handlerName */
handlerName?: string | null;
/** Workbench: delivery_order_pick_order.releaseType (e.g. isExtrabatch). */
releaseType?: string | null;
}

// 新增:搜索参数接口


+ 5
- 1
src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx Просмотреть файл

@@ -39,6 +39,7 @@ import { printDNWorkbench, printDNLabelsWorkbench, printDNLabelsReprintWorkbench
import { fetchWorkbenchCompletedLotDetails } from "@/app/api/doworkbench/actions";
import SearchBox, { Criterion } from "../SearchBox";
import { resolveWorkbenchRecordTargetDate } from "@/utils/workbenchTargetDate";
import { isWorkbenchExtraTicket } from "@/utils/workbenchReleaseType";

type Props = {
printerCombo: PrinterCombo[];
@@ -522,6 +523,9 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
<Typography variant="h6">
{t("Pick Order Details")}: {selectedRecord.ticketNo}
</Typography>
{isWorkbenchExtraTicket(selectedRecord.releaseType, selectedRecord.ticketNo) && (
<Chip label={t("Etra")} color="secondary" size="small" />
)}
</Box>

<Paper sx={{ mb: 2, p: 2 }}>
@@ -674,7 +678,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({
<Box>
<Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap">
<Typography variant="h6">{row.deliveryNoteCode || "-"}</Typography>
{(row.ticketNo ?? "").trim().toUpperCase().startsWith("TI-E-") && (
{isWorkbenchExtraTicket(row.releaseType, row.ticketNo) && (
<Chip
label={t("Etra")}
color="secondary"


+ 2
- 1
src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx Просмотреть файл

@@ -295,6 +295,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
truckDepartureTime,
loadingSequence,
requiredDate: dateParam,
releaseType: inEtraUi ? "isExtra" : releaseType,
});
console.log("assignByLane result:", res);
if (res.code === "SUCCESS") {
@@ -309,7 +310,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
setIsAssigning(false);
}
},
[currentUserId, inEtraUi, loadEtraSummaries, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, t],
[currentUserId, inEtraUi, loadEtraSummaries, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, releaseType, t],
);

const handleLaneButtonClick = useCallback(


+ 212
- 67
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx Просмотреть файл

@@ -22,6 +22,7 @@ import {
} from "@mui/material";
import dayjs from 'dayjs';
import { normalizeTargetDateInput } from "@/utils/workbenchTargetDate";
import { isWorkbenchExtraTicket } from "@/utils/workbenchReleaseType";
import TestQrCodeProvider from "@/components/QrCodeScannerProvider/TestQrCodeProvider";
import { fetchLotDetail } from "@/app/api/inventory/actions";
import { formatDepartureTime } from "@/app/utils/formatUtil";
@@ -69,6 +70,12 @@ import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import { fetchStockInLineInfo } from "@/app/api/po/actions";
import GoodPickExecutionForm from "@/components/FinishedGoodSearch/GoodPickExecutionForm";
import { WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE } from "./workbenchTabConstants";
import {
inferUnpickableScanAvailability,
translateWorkbenchRejectMessage,
type UnpickableScanAvailability,
} from "@/utils/workbenchPickLotUtils";
import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal";
import FGPickOrderCard from "@/components/FinishedGoodSearch/FGPickOrderCard";
import LinearProgressWithLabel from "@/components/common/LinearProgressWithLabel";
@@ -131,37 +138,49 @@ function parseWorkbenchQrPayload(
}
}

function hasPendingActiveRowForStockInLine(
indexes: {
byStockInLineId: Map<number, any[]>;
activeLotsByItemId: Map<number, any[]>;
},
/**
* QR entry gate: pending / partial SOL not yet processed this session.
* Includes expired & unavailable rows (still need modal /换批); excludes completed/rejected/checked.
* `processedQrCombinations` still prevents re-picking the same SOL (e.g. two lines, same lot).
*/
function isLotRowEligibleForQrEntryGate(
lot: any,
itemId: number,
processedByItemId: ProcessedStockOutLinesByItemId,
): boolean {
if (Number(lot?.itemId) !== itemId) return false;
if (!isLotRowPending(lot)) return false;
const st = String(lot?.stockOutLineStatus ?? "").toLowerCase();
if (st === "rejected" || st === "completed" || st === "checked") return false;
if (String(lot?.lotAvailability ?? "").toLowerCase() === "rejected") return false;
return !isStockOutLineAlreadyProcessed(
processedByItemId,
itemId,
lot.stockOutLineId,
);
}

function hasPendingUnprocessedRowForStockInLine(
indexes: { byStockInLineId: 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),
return rows.some((lot) =>
isLotRowEligibleForQrEntryGate(lot, itemId, processedByItemId),
);
}

/** Any pending active SOL for this item (e.g. scan different location, same lot no → auto-switch). */
function hasPendingActiveRowForItem(
indexes: { activeLotsByItemId: Map<number, any[]> },
/** Any pending unprocessed SOL for this item (e.g. scan different stockInLineId → auto-switch). */
function hasPendingUnprocessedRowForItem(
indexes: { byItemId: Map<number, any[]> },
itemId: number,
processedByItemId: ProcessedStockOutLinesByItemId,
): boolean {
const activeLots = indexes.activeLotsByItemId.get(itemId) ?? [];
return activeLots.some(
(lot) =>
isLotRowPending(lot) &&
!isStockOutLineAlreadyProcessed(processedByItemId, itemId, lot.stockOutLineId),
const rows = indexes.byItemId.get(itemId) ?? [];
return rows.some((lot) =>
isLotRowEligibleForQrEntryGate(lot, itemId, processedByItemId),
);
}

@@ -343,6 +362,11 @@ function isInventoryLotLineUnavailable(lot: any): boolean {
return String(lot.lotStatus || "").toLowerCase() === "unavailable";
}

/** 過期或不可用:單筆 Just Complete / 顯示數量與批量提交一致,固定 qty=0 */
function isWorkbenchZeroCompleteLot(lot: any): boolean {
return isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot);
}

/** 提貨台「列印標籤」彈窗頂部:依目前表格列判斷可提貨/已用畢/已過期等 */
function isWorkbenchSourceLotExpired(lot: any): boolean {
if (!lot) return false;
@@ -405,18 +429,26 @@ function getWorkbenchSourceLotStatusSummary(lot: any): {

type PickOrderT = (key: string, options?: Record<string, unknown>) => string;

function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string {
const msg = raw.trim();
if (!msg) return msg;
function isExpiredWorkbenchReminderMessage(msg: string): boolean {
const trimmed = msg.trim();
if (!trimmed) return false;
if (/^lot is expired \(expiry=/i.test(trimmed)) return true;
return /已過期/.test(trimmed) || /掃描批號已過期/.test(trimmed);
}

const expiredMatch = msg.match(/^Lot is expired \(expiry=([^)]+)\)\.?$/i);
if (expiredMatch) {
return t("Lot is expired (expiry={{expiry}})", {
expiry: expiredMatch[1],
});
function buildUnpickableScanRowPatch(
scannedLot: any | null | undefined,
availability: UnpickableScanAvailability,
): Record<string, unknown> {
const patch: Record<string, unknown> = { lotAvailability: availability };
if (availability === "status_unavailable") {
patch.lotStatus = "unavailable";
}

return t(msg);
if (scannedLot?.lotNo) patch.lotNo = scannedLot.lotNo;
if (scannedLot?.stockInLineId) patch.stockInLineId = scannedLot.stockInLineId;
if (scannedLot?.expiryDate) patch.expiryDate = scannedLot.expiryDate;
if (scannedLot?.lotId) patch.lotId = scannedLot.lotId;
return patch;
}

/**
@@ -646,10 +678,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
const [fgPickOrders, setFgPickOrders] = useState<FGPickOrderResponse[]>([]);

const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false);
const isExtraTicket = useMemo(() => {
const ticketNo = String(fgPickOrders?.[0]?.ticketNo ?? "").trim().toUpperCase();
return ticketNo.startsWith("TI-E-");
}, [fgPickOrders]);
const isExtraTicket = useMemo(
() => isWorkbenchExtraTicket(fgPickOrders?.[0]?.releaseType, fgPickOrders?.[0]?.ticketNo),
[fgPickOrders],
);

const lotFloorPrefixFilter = useMemo(() => {
const storeId = String(fgPickOrders?.[0]?.storeId ?? "")
@@ -870,7 +902,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
) {
workbenchFinishNavigateDoneRef.current = true;
const redirectParams = new URLSearchParams();
redirectParams.set("tab", "2");
redirectParams.set("tab", String(WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE));
redirectParams.set("ticketNo", ticketForRedirect);
if (targetDateForRedirect) {
redirectParams.set("targetDate", targetDateForRedirect);
@@ -894,6 +926,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shopName: hierarchicalData.fgInfo.shopName,
truckLanceCode: hierarchicalData.fgInfo.truckLanceCode,
DepartureTime: hierarchicalData.fgInfo.departureTime,
releaseType: hierarchicalData.fgInfo.releaseType,
shopAddress: "",
pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
@@ -1163,12 +1196,64 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
} else {
setWorkbenchLotLabelInitialPayload(null);
}
setWorkbenchLotLabelReminderText(reminderText ?? null);
setWorkbenchLotLabelReminderText(
reminderText ? translateWorkbenchRejectMessage(reminderText, t) : null,
);
// Clear latched success so the lot-label modal effect cannot instantly re-close on open.
setQrScanSuccess(false);
setWorkbenchLotLabelModalOpen(true);
},
[],
[t],
);

/** Patch pick row locally so table shows 已過期/不可用 without full refresh. */
const patchWorkbenchRowForUnpickableScan = useCallback(
(
pickRow: any,
scannedLot: any | null | undefined,
availability: UnpickableScanAvailability,
) => {
const solId = Number(pickRow?.stockOutLineId);
if (!solId) return;
const rowPatch = buildUnpickableScanRowPatch(scannedLot, availability);
const mapRows = (prev: any[]) =>
prev.map((lot) =>
Number(lot.stockOutLineId) === solId ? { ...lot, ...rowPatch } : lot,
);
setCombinedLotData(mapRows);
setOriginalCombinedData(mapRows);
clearWorkbenchScanReject(solId);
},
[clearWorkbenchScanReject],
);

const openUnpickableScanLotLabelModal = useCallback(
(
pickRow: any,
scannedLot: any | null | undefined,
reminderText: string,
) => {
const fromMsg = inferUnpickableScanAvailability(reminderText);
const availability =
fromMsg ??
(isWorkbenchSourceLotExpired(scannedLot ?? pickRow)
? "expired"
: isInventoryLotLineUnavailable(scannedLot ?? pickRow)
? "status_unavailable"
: null);
const mergedPickRow =
availability != null
? { ...pickRow, ...buildUnpickableScanRowPatch(scannedLot, availability) }
: pickRow;
if (availability != null) {
patchWorkbenchRowForUnpickableScan(pickRow, scannedLot, availability);
}
openWorkbenchLotLabelModalForLot(mergedPickRow, reminderText);
},
[
patchWorkbenchRowForUnpickableScan,
openWorkbenchLotLabelModalForLot,
],
);

const shouldOpenWorkbenchLotLabelModalForFailure = useCallback(
@@ -1253,9 +1338,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
severity: undefined as "success" | "warning" | "error" | undefined,
};
}
const reminder = workbenchLotLabelReminderText?.trim() ?? "";
if (reminder && isExpiredWorkbenchReminderMessage(reminder)) {
return { text: "此批號狀態:已過期", severity: "error" as const };
}
const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot);
return { text: s.text, severity: s.severity };
}, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]);
}, [
workbenchLotLabelModalOpen,
workbenchLotLabelContextLot,
workbenchLotLabelReminderText,
]);

const workbenchLotLabelSubmitQty = useMemo(() => {
if (!workbenchLotLabelContextLot) return 0;
@@ -1306,7 +1399,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
const event = new CustomEvent('pickOrderCompletionStatus', {
detail: {
allLotsCompleted,
tabIndex: 2 // DO workbench「Finished Good Record (mine)」分頁索引
tabIndex: WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE,
}
});
window.dispatchEvent(event);
@@ -1541,6 +1634,14 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
}
};

/**
* Stop QR effect re-entry after unpickable modal (expired/unavailable API fail).
* Do NOT mark stock-out line as processed — pick was not completed; user may scan another lot.
*/
const markUnpickableScanSessionHandled = () => {
recordHandledQrScanCount(qrScanCountAtInvoke);
};

try {
// 1) Parse JSON safely (parse once, reuse)
const parseStartTime = performance.now();
@@ -1575,13 +1676,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
const scannedItemId = qrData.itemId;
const scannedStockInLineId = qrData.stockInLineId;

const hasPendingOnScannedSil = hasPendingActiveRowForStockInLine(
const hasPendingOnScannedSil = hasPendingUnprocessedRowForStockInLine(
indexes,
scannedItemId,
scannedStockInLineId,
processedQrCombinations,
);
const hasPendingOnItem = hasPendingActiveRowForItem(
const hasPendingOnItem = hasPendingUnprocessedRowForItem(
indexes,
scannedItemId,
processedQrCombinations,
@@ -1590,6 +1691,15 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
console.log(
` [SKIP] No pending stock-out line left for itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId}`,
);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
t(
"No pending pick line left for this item. It may already be completed or fully processed.",
),
);
});
return;
}
@@ -1628,14 +1738,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
`此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`
);
});
if (scannedLot?.stockOutLineId != null) {
const nextProcessed = markProcessedStockOutLine(
processedQrCombinations,
scannedItemId,
scannedLot.stockOutLineId,
);
setProcessedQrCombinations(nextProcessed);
}
markUnpickableScanSessionHandled();
return;
}

@@ -1645,25 +1748,27 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
setQrScanError(false);
setQrScanSuccess(false);
});
openWorkbenchLotLabelModalForLot(
openUnpickableScanLotLabelModal(
scannedLot,
scannedLot,
t("This lot is not available, please scan another lot."),
);
markUnpickableScanSessionHandled();
return;
}

const isExpired =
String(scannedLot.lotAvailability || '').toLowerCase() === 'expired';
if (isExpired) {
if (isWorkbenchSourceLotExpired(scannedLot)) {
console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired; opening lot-label modal`);
startTransition(() => {
setQrScanError(false);
setQrScanSuccess(false);
});
openWorkbenchLotLabelModalForLot(
openUnpickableScanLotLabelModal(
scannedLot,
scannedLot,
`Lot is expired (expiry=${scannedLot.expiryDate || "-"})`,
);
markUnpickableScanSessionHandled();
return;
}
}
@@ -1766,7 +1871,16 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
expectedLot
) {
openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
openUnpickableScanLotLabelModal(
expectedLot,
scannedLot ??
allLotsForItem.find(
(lot: any) => lot.stockInLineId === scannedStockInLineId,
) ??
null,
failMsg,
);
markUnpickableScanSessionHandled();
return;
}
if (workbenchMode && expectedLot.stockOutLineId != null) {
@@ -1932,7 +2046,18 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
expectedLot
) {
openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
openUnpickableScanLotLabelModal(
expectedLot,
scannedLot ??
allLotsForItem.find(
(lot: any) => lot.stockInLineId === scannedStockInLineId,
) ?? {
stockInLineId: scannedStockInLineId,
lotNo: scannedLotNo,
},
failMsg,
);
markUnpickableScanSessionHandled();
return;
}
if (workbenchMode && expectedLot.stockOutLineId != null) {
@@ -2119,7 +2244,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
exactMatch
) {
openWorkbenchLotLabelModalForLot(exactMatch, failMsg);
openUnpickableScanLotLabelModal(exactMatch, exactMatch, failMsg);
markUnpickableScanSessionHandled();
return;
}
if (workbenchMode && exactMatch.stockOutLineId != null) {
@@ -2233,7 +2359,18 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
expectedLot
) {
openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
openUnpickableScanLotLabelModal(
expectedLot,
scannedLot ??
allLotsForItem.find(
(lot: any) => lot.stockInLineId === scannedStockInLineId,
) ?? {
stockInLineId: scannedStockInLineId,
lotNo: scannedLotNo,
},
failMsg,
);
markUnpickableScanSessionHandled();
return;
}
if (workbenchMode && expectedLot.stockOutLineId != null) {
@@ -2340,6 +2477,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
refreshWorkbenchAfterScanPick,
workbenchScanPickQtyFromLot,
openWorkbenchLotLabelModalForLot,
openUnpickableScanLotLabelModal,
shouldOpenWorkbenchLotLabelModalForFailure,
t,
]);
@@ -2445,6 +2583,12 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
// If it's a different QR, allow processing
console.log(` [QR PROCESS] Different QR detected while manual modal open, allowing processing`);
}

// Skip re-processing while lot-label modal is already open for this scan
if (workbenchLotLabelModalOpen && latestQr === lastProcessedQrRef.current) {
console.log(` [QR PROCESS] Skipping - lot-label modal open for same QR`);
return;
}
const qrDetectionStartTime = performance.now();
console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`);
@@ -2457,7 +2601,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
const canRetrySamePhysicalLot =
qrPayload != null &&
isNewScanEvent &&
hasPendingActiveRowForStockInLine(
hasPendingUnprocessedRowForStockInLine(
lotDataIndexes,
qrPayload.itemId,
qrPayload.stockInLineId,
@@ -2578,6 +2722,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
isRefreshingData,
combinedLotData.length,
manualLotConfirmationOpen,
workbenchLotLabelModalOpen,
lotDataIndexes,
processedQrCombinations,
]);
@@ -2925,8 +3070,8 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe
try {
if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true }));
const targetUnavailable = isInventoryLotLineUnavailable(lot);
const effectiveSubmitQty = targetUnavailable && submitQty > 0 ? 0 : submitQty;
const targetZeroComplete = isWorkbenchZeroCompleteLot(lot);
const effectiveSubmitQty = targetZeroComplete && submitQty > 0 ? 0 : submitQty;

const canonicalLotForSol =
solId > 0
@@ -2951,10 +3096,10 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe

const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol);
const wbJustQty = qtyPayload.qty;
const isUnavailableForJustComplete = isInventoryLotLineUnavailable(canonicalLotForSol);
const isZeroCompleteForJustComplete = isWorkbenchZeroCompleteLot(canonicalLotForSol);
const canPostScanPick =
// unavailable lot: Just Completed must always submit qty=0, even without lotNo
isUnavailableForJustComplete || (
// expired / unavailable: Just Completed always submits qty=0, even without lotNo
isZeroCompleteForJustComplete || (
canonicalLotForSol.lotNo && String(canonicalLotForSol.lotNo).trim() !== "" && (
// explicit short submit: user typed 0 (must send qty=0 to backend)
(hasExplicitSubmitOverride &&
@@ -2966,7 +3111,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe
);

if (canPostScanPick) {
const qtyToSend = isUnavailableForJustComplete
const qtyToSend = isZeroCompleteForJustComplete
? 0
: hasExplicitSubmitOverride && explicitSubmitOverride === 0
? 0
@@ -3011,7 +3156,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe
return;
}
const justCompleteErr = t(
"Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.",
"Just Completed (workbench): requires a valid lot number and quantity.",
);
if (solId > 0) {
rememberWorkbenchScanReject(solId, justCompleteErr);
@@ -3800,7 +3945,7 @@ paginatedData.map((row, index) => {
const solIdForKey = Number(lot.stockOutLineId) || 0;
const lotKeyForSubmitQty =
Number.isFinite(solIdForKey) && solIdForKey > 0 ? `sol:${solIdForKey}` : `${lot.pickOrderLineId}-${lot.lotId}`;
const lockedSubmitQtyDisplay = isInventoryLotLineUnavailable(lot) ? 0 : resolveSingleSubmitQty(lot);
const lockedSubmitQtyDisplay = isWorkbenchZeroCompleteLot(lot) ? 0 : resolveSingleSubmitQty(lot);
const hasPickOverride = Object.prototype.hasOwnProperty.call(pickQtyData, lotKeyForSubmitQty);
const fromPickRow = hasPickOverride ? pickQtyData[lotKeyForSubmitQty] : undefined;
const workbenchSubmitQtyDisplay =


+ 19
- 0
src/components/DoWorkbench/workbenchTabConstants.ts Просмотреть файл

@@ -0,0 +1,19 @@
/** Tab index for「Finished Good Record (mine)」— used by post-pick redirect. */
export const WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE = 2;
/** Map legacy tab indices to current 7-tab layout (Etra at 1). */
export function normalizeWorkbenchTabFromUrl(raw: number): number | null {
const allowed = new Set([0, 1, 2, 3, 4, 5, 6]);
if (allowed.has(raw)) return raw;
/** Hidden-Etra 6-tab layout: mine was at 1 */
const legacyWithoutEtra: Record<number, number> = {
0: 0,
1: WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE,
2: 3,
3: 4,
4: 5,
5: 6,
};
const mapped = legacyWithoutEtra[raw];
return mapped != null && allowed.has(mapped) ? mapped : null;
}

+ 3
- 1
src/i18n/en/pickOrder.json Просмотреть файл

@@ -39,6 +39,7 @@
"Submit Qty": "Submit Qty",
"Just Completed": "Just Completed",
"Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.": "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.",
"Just Completed (workbench): requires a valid lot number and quantity.": "Just Completed (workbench): requires a valid lot number and quantity.",
"Do you want to start?": "Do you want to start?",
"Start": "Start",
"Pick Order Code(s)": "Pick Order Code(s)",
@@ -520,5 +521,6 @@
"Report missing or bad items": "Report missing or bad items",
"passed": "Passed",
"failed": "Failed",
"confirm_accept_with_fail": "There are failed QC items. Confirm to accept stock out?"
"confirm_accept_with_fail": "There are failed QC items. Confirm to accept stock out?",
"No pending pick line left for this item. It may already be completed or fully processed.": "No pending pick line left for this item. It may already be completed or fully processed."
}

+ 31
- 4
src/i18n/zh/do.json Просмотреть файл

@@ -1,4 +1,5 @@
{

"Delivery Order": "送貨訂單",
"Shop Name": "店鋪名稱",
"Delivery Order No.": "送貨訂單編號",
@@ -15,11 +16,8 @@
"No Records": "沒有找到記錄",
"OK": "確認",
"Truck X": "車線-X",
"DO Workbench": "新版成品出倉",
"Order Date From": "訂單日期",
"Workbench Batch Release": "批量放單",
"do workbench": "新版成品出倉",
"Do Workbench": "新版成品出倉",
"Delivery Order Code": "送貨訂單編號",
"Floor": "樓層",
"Truck lane search requires date title": "需選擇預計送貨日期",
@@ -96,5 +94,34 @@
"Release 2/F": "放單2/F",
"Release 4/F": "放單4/F",
"Stock Status": "庫存狀態",
"Delivery": "送貨訂單"
"Delivery": "送貨訂單",
"Replenishment page title": "送貨單補貨",
"Replenishment demo banner": "示範模式:候選送貨單與補貨提交均使用假資料,後端 API 完成後會切換為真實資料。",
"Replenishment input section": "補貨資料",
"Replenishment item code": "貨品編號",
"Replenishment search candidates": "搜尋候選送貨單",
"Replenishment reset": "重設",
"Replenishment candidate section": "候選送貨單(待放單)",
"Replenishment candidate hint": "輸入店鋪與貨品編號後搜尋;僅顯示預計送貨日不早於今天且狀態為待處理的送貨單。",
"Replenishment candidate count": "共 {{count}} 張候選送貨單",
"Replenishment select": "選擇",
"Replenishment existing lines": "已有同品項行",
"Replenishment apply": "加入補貨行",
"Replenishment shop required title": "請輸入店鋪",
"Replenishment shop required message": "請輸入店鋪名稱以搜尋候選送貨單。",
"Replenishment item required title": "請輸入貨品編號",
"Replenishment item required message": "請輸入要補貨的貨品編號。",
"Replenishment demo items title": "示範貨品編號",
"Replenishment demo items message": "目前假資料僅接受以下貨品編號:",
"Replenishment no candidates message": "沒有符合條件的待放單送貨單,請調整店鋪名稱或稍後再試。",
"Replenishment qty invalid title": "數量不正確",
"Replenishment qty invalid message": "請輸入大於 0 的補貨數量後再提交。",
"Replenishment confirm title": "確認補貨",
"Replenishment success title": "補貨行已加入(示範)",
"Replenishment success message": "已模擬新增 delivery_order_line(is_replenishment = 1)。",
"Replenishment line id": "補貨行 ID",
"Replenishment demo note": "此為前端假資料回應;正式環境將呼叫後端 API。",
"Search Delivery Order": "搜尋送貨單",
"DO Replenishment": "送貨單補貨",
"Error": "錯誤"
}

+ 1
- 1
src/i18n/zh/doWorkbench.json Просмотреть файл

@@ -1,5 +1,5 @@
{
"DO Workbench": "新版成品出倉",
"DO Workbench": "成品出倉",
"Confirm": "確認",
"Cancel": "取消",
"Shop Name": "店鋪名稱",


+ 1
- 1
src/i18n/zh/navigation.json Просмотреть файл

@@ -86,7 +86,7 @@
"nav.settings.shopAndTruck": "車線店鋪管理",
"nav.settings.user": "用戶",
"nav.settings.warehouse": "倉庫",
"nav.store.doWorkbench": "新版成品出倉",
"nav.store.doWorkbench": "成品出倉",
"nav.store.finishedGoodManagement": "成品出倉管理",
"nav.store.inventoryLedger": "查看物品出入庫及庫存日誌",
"nav.store.pickOrder": "提料單",


+ 3
- 1
src/i18n/zh/pickOrder.json Просмотреть файл

@@ -39,6 +39,7 @@
"Submit Qty": "提交數量",
"Just Completed": "已完成",
"Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.": "已完成(工作台):需有效批號與可提交數量;過期列請勿使用此按鈕。",
"Just Completed (workbench): requires a valid lot number and quantity.": "已完成(工作台):需有效批號與可提交數量。",
"Do you want to start?": "確定開始嗎?",
"Start": "開始",
"Pick Order Code(s)": "提料單編號",
@@ -520,5 +521,6 @@
"Report missing or bad items": "報告缺失或不良物品",
"passed": "合格",
"failed": "不合格",
"confirm_accept_with_fail": "有不合格檢查項目,確認接受出庫?"
"confirm_accept_with_fail": "有不合格檢查項目,確認接受出庫?",
"No pending pick line left for this item. It may already be completed or fully processed.": "此貨品已無待處理的提貨行(可能已完成或已處理完畢)。"
}

+ 167
- 0
src/utils/workbenchPickLotUtils.ts Просмотреть файл

@@ -0,0 +1,167 @@
import dayjs from "dayjs";

export type WorkbenchPickLotLike = {
status?: string;
stockOutLineStatus?: string;
lotAvailability?: string;
lotStatus?: string;
expiryDate?: string;
noLot?: boolean;
lotNo?: string;
availableQty?: number;
};

export type PickOrderT = (key: string, options?: Record<string, unknown>) => string;

function solStatusOf(lot: WorkbenchPickLotLike | null | undefined): string {
return String(lot?.stockOutLineStatus || lot?.status || "").toLowerCase();
}

/** lotAvailability === expired(後端標記) */
export function isLotAvailabilityExpired(
lot: WorkbenchPickLotLike | null | undefined,
): boolean {
return String(lot?.lotAvailability || "").toLowerCase() === "expired";
}

/** inventory_lot_line.status = unavailable */
export function isInventoryLotLineUnavailable(
lot: WorkbenchPickLotLike | null | undefined,
): boolean {
if (!lot) return false;
const solSt = solStatusOf(lot);
if (solSt === "completed" || solSt === "partially_completed" || solSt === "partially_complete") {
return false;
}
if (String(lot.lotAvailability || "").toLowerCase() === "status_unavailable") return true;
return String(lot.lotStatus || "").toLowerCase() === "unavailable";
}

/** 含 expiryDate 日期判斷 */
export function isWorkbenchSourceLotExpired(
lot: WorkbenchPickLotLike | null | undefined,
): boolean {
if (!lot) return false;
if (isLotAvailabilityExpired(lot)) return true;
if (String(lot.lotAvailability || "").toLowerCase() === "expired") return true;
if (lot.expiryDate) {
const d = dayjs(lot.expiryDate).startOf("day");
if (d.isValid() && d.isBefore(dayjs().startOf("day"))) return true;
}
return false;
}

/** 過期或不可用:單筆 Just Complete / 顯示數量與批量提交一致,固定 qty=0 */
export function isWorkbenchZeroCompleteLot(
lot: WorkbenchPickLotLike | null | undefined,
): boolean {
return isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot);
}

export function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string {
const msg = raw.trim();
if (!msg) return msg;

const expiredMatch = msg.match(/^Lot is expired \(expiry=([^)]+)\)\.?$/i);
if (expiredMatch) {
return t("Lot is expired (expiry={{expiry}})", {
expiry: expiredMatch[1],
});
}

return t(msg);
}

export function isExpiredWorkbenchReminderMessage(msg: string): boolean {
const trimmed = msg.trim();
if (!trimmed) return false;
if (/^lot is expired \(expiry=/i.test(trimmed)) return true;
return /已過期/.test(trimmed) || /掃描批號已過期/.test(trimmed);
}

export type UnpickableScanAvailability = "expired" | "status_unavailable";

export function inferUnpickableScanAvailability(
failMsg: string | null | undefined,
): UnpickableScanAvailability | null {
const m = String(failMsg ?? "").trim().toLowerCase();
if (!m) return null;
if (
m.includes("expired") ||
m.includes("过期") ||
m.includes("已過期") ||
/^lot is expired/.test(m)
) {
return "expired";
}
if (
m.includes("unavailable") ||
m.includes("not available") ||
m.includes("not yet putaway") ||
m.includes("不可用") ||
m.includes("未上架")
) {
return "status_unavailable";
}
return null;
}

export function buildUnpickableScanRowPatch(
scannedLot: WorkbenchPickLotLike | null | undefined,
availability: UnpickableScanAvailability,
): Record<string, unknown> {
const patch: Record<string, unknown> = { lotAvailability: availability };
if (availability === "status_unavailable") {
patch.lotStatus = "unavailable";
}
if (scannedLot && "lotNo" in scannedLot && scannedLot.lotNo) {
patch.lotNo = scannedLot.lotNo;
}
if (scannedLot && "stockInLineId" in scannedLot && scannedLot.stockInLineId) {
patch.stockInLineId = scannedLot.stockInLineId;
}
if (scannedLot?.expiryDate) patch.expiryDate = scannedLot.expiryDate;
return patch;
}

export function getWorkbenchSourceLotStatusSummary(lot: WorkbenchPickLotLike | null | undefined): {
severity: "success" | "warning" | "error";
text: string;
} {
if (!lot) {
return { severity: "warning", text: "無法判斷此批號狀態" };
}
if (isWorkbenchSourceLotExpired(lot)) {
return { severity: "error", text: "此批號狀態:已過期" };
}
const solSt = solStatusOf(lot);
if (solSt === "rejected") {
return { severity: "warning", text: "此出庫行:已拒絕,請改掃其他批號" };
}
if (solSt === "completed" || solSt === "partially_completed" || solSt === "partially_complete") {
return { severity: "warning", text: "此出庫行:已完成,無需再提貨" };
}
const isNoLotRow =
lot.noLot === true || !lot.lotNo || String(lot.lotNo || "").trim() === "";
if (isNoLotRow) {
return {
severity: "warning",
text: "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR",
};
}
const av = String(lot.lotAvailability || "").toLowerCase();
if (av === "insufficient_stock") {
return { severity: "warning", text: "此批號狀態:已用畢(無剩餘庫存)" };
}
const avail = Number(lot.availableQty);
if (lot.lotNo && Number.isFinite(avail) && avail <= 0) {
return { severity: "warning", text: "此批號狀態:已用畢(可用量為 0)" };
}
if (isInventoryLotLineUnavailable(lot)) {
return {
severity: "warning",
text: "此批號狀態:庫存不可用(未上架或行狀態不可用)",
};
}
return { severity: "success", text: "此批號狀態:可提貨" };
}

+ 14
- 0
src/utils/workbenchReleaseType.ts Просмотреть файл

@@ -0,0 +1,14 @@
/** Workbench ticket carries extra DOs when releaseType or legacy TI-E- ticket. */
export function isWorkbenchExtraTicket(
releaseType?: string | null,
ticketNo?: string | null,
): boolean {
const rt = (releaseType ?? "").trim().toLowerCase();
const tn = (ticketNo ?? "").trim().toUpperCase();
return (
rt === "isextrabatch" ||
rt === "isextrasingle" ||
rt === "isextra" ||
tn.startsWith("TI-E-")
);
}

Загрузка…
Отмена
Сохранить