ソースを参照

Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-frontend into production

production
tommy 1週間前
コミット
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; truckLanceCode: string;
storeId: string; storeId: string;
qrCodeData: number; qrCodeData: number;
releaseType?: string;


} }
export interface DoPickOrderDetail { export interface DoPickOrderDetail {
@@ -372,6 +373,8 @@ export interface CompletedDoPickOrderResponse {
deliveryNoteCode: number; deliveryNoteCode: number;
/** Legacy: do_pick_order_record.handler_name; workbench: delivery_order_pick_order.handlerName */ /** Legacy: do_pick_order_record.handler_name; workbench: delivery_order_pick_order.handlerName */
handlerName?: string | null; 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 { fetchWorkbenchCompletedLotDetails } from "@/app/api/doworkbench/actions";
import SearchBox, { Criterion } from "../SearchBox"; import SearchBox, { Criterion } from "../SearchBox";
import { resolveWorkbenchRecordTargetDate } from "@/utils/workbenchTargetDate"; import { resolveWorkbenchRecordTargetDate } from "@/utils/workbenchTargetDate";
import { isWorkbenchExtraTicket } from "@/utils/workbenchReleaseType";


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


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


+ 2
- 1
src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx ファイルの表示

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


const handleLaneButtonClick = useCallback( const handleLaneButtonClick = useCallback(


+ 212
- 67
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx ファイルの表示

@@ -22,6 +22,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { normalizeTargetDateInput } from "@/utils/workbenchTargetDate"; import { normalizeTargetDateInput } from "@/utils/workbenchTargetDate";
import { isWorkbenchExtraTicket } from "@/utils/workbenchReleaseType";
import TestQrCodeProvider from "@/components/QrCodeScannerProvider/TestQrCodeProvider"; import TestQrCodeProvider from "@/components/QrCodeScannerProvider/TestQrCodeProvider";
import { fetchLotDetail } from "@/app/api/inventory/actions"; import { fetchLotDetail } from "@/app/api/inventory/actions";
import { formatDepartureTime } from "@/app/utils/formatUtil"; import { formatDepartureTime } from "@/app/utils/formatUtil";
@@ -69,6 +70,12 @@ import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig"; import { SessionWithTokens } from "@/config/authConfig";
import { fetchStockInLineInfo } from "@/app/api/po/actions"; import { fetchStockInLineInfo } from "@/app/api/po/actions";
import GoodPickExecutionForm from "@/components/FinishedGoodSearch/GoodPickExecutionForm"; 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 WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal";
import FGPickOrderCard from "@/components/FinishedGoodSearch/FGPickOrderCard"; import FGPickOrderCard from "@/components/FinishedGoodSearch/FGPickOrderCard";
import LinearProgressWithLabel from "@/components/common/LinearProgressWithLabel"; 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, itemId: number,
stockInLineId: number, stockInLineId: number,
processedByItemId: ProcessedStockOutLinesByItemId, processedByItemId: ProcessedStockOutLinesByItemId,
): boolean { ): boolean {
const rows = indexes.byStockInLineId.get(stockInLineId) ?? []; 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, itemId: number,
processedByItemId: ProcessedStockOutLinesByItemId, processedByItemId: ProcessedStockOutLinesByItemId,
): boolean { ): 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"; 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 { function isWorkbenchSourceLotExpired(lot: any): boolean {
if (!lot) return false; if (!lot) return false;
@@ -405,18 +429,26 @@ function getWorkbenchSourceLotStatusSummary(lot: any): {


type PickOrderT = (key: string, options?: Record<string, unknown>) => string; 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 [fgPickOrders, setFgPickOrders] = useState<FGPickOrderResponse[]>([]);


const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false); 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 lotFloorPrefixFilter = useMemo(() => {
const storeId = String(fgPickOrders?.[0]?.storeId ?? "") const storeId = String(fgPickOrders?.[0]?.storeId ?? "")
@@ -870,7 +902,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
) { ) {
workbenchFinishNavigateDoneRef.current = true; workbenchFinishNavigateDoneRef.current = true;
const redirectParams = new URLSearchParams(); const redirectParams = new URLSearchParams();
redirectParams.set("tab", "2");
redirectParams.set("tab", String(WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE));
redirectParams.set("ticketNo", ticketForRedirect); redirectParams.set("ticketNo", ticketForRedirect);
if (targetDateForRedirect) { if (targetDateForRedirect) {
redirectParams.set("targetDate", targetDateForRedirect); redirectParams.set("targetDate", targetDateForRedirect);
@@ -894,6 +926,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shopName: hierarchicalData.fgInfo.shopName, shopName: hierarchicalData.fgInfo.shopName,
truckLanceCode: hierarchicalData.fgInfo.truckLanceCode, truckLanceCode: hierarchicalData.fgInfo.truckLanceCode,
DepartureTime: hierarchicalData.fgInfo.departureTime, DepartureTime: hierarchicalData.fgInfo.departureTime,
releaseType: hierarchicalData.fgInfo.releaseType,
shopAddress: "", shopAddress: "",
pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
@@ -1163,12 +1196,64 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
} else { } else {
setWorkbenchLotLabelInitialPayload(null); 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. // Clear latched success so the lot-label modal effect cannot instantly re-close on open.
setQrScanSuccess(false); setQrScanSuccess(false);
setWorkbenchLotLabelModalOpen(true); 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( const shouldOpenWorkbenchLotLabelModalForFailure = useCallback(
@@ -1253,9 +1338,17 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
severity: undefined as "success" | "warning" | "error" | undefined, 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); const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot);
return { text: s.text, severity: s.severity }; return { text: s.text, severity: s.severity };
}, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]);
}, [
workbenchLotLabelModalOpen,
workbenchLotLabelContextLot,
workbenchLotLabelReminderText,
]);


const workbenchLotLabelSubmitQty = useMemo(() => { const workbenchLotLabelSubmitQty = useMemo(() => {
if (!workbenchLotLabelContextLot) return 0; if (!workbenchLotLabelContextLot) return 0;
@@ -1306,7 +1399,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
const event = new CustomEvent('pickOrderCompletionStatus', { const event = new CustomEvent('pickOrderCompletionStatus', {
detail: { detail: {
allLotsCompleted, allLotsCompleted,
tabIndex: 2 // DO workbench「Finished Good Record (mine)」分頁索引
tabIndex: WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE,
} }
}); });
window.dispatchEvent(event); 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 { try {
// 1) Parse JSON safely (parse once, reuse) // 1) Parse JSON safely (parse once, reuse)
const parseStartTime = performance.now(); const parseStartTime = performance.now();
@@ -1575,13 +1676,13 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
const scannedItemId = qrData.itemId; const scannedItemId = qrData.itemId;
const scannedStockInLineId = qrData.stockInLineId; const scannedStockInLineId = qrData.stockInLineId;


const hasPendingOnScannedSil = hasPendingActiveRowForStockInLine(
const hasPendingOnScannedSil = hasPendingUnprocessedRowForStockInLine(
indexes, indexes,
scannedItemId, scannedItemId,
scannedStockInLineId, scannedStockInLineId,
processedQrCombinations, processedQrCombinations,
); );
const hasPendingOnItem = hasPendingActiveRowForItem(
const hasPendingOnItem = hasPendingUnprocessedRowForItem(
indexes, indexes,
scannedItemId, scannedItemId,
processedQrCombinations, processedQrCombinations,
@@ -1590,6 +1691,15 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
console.log( console.log(
` [SKIP] No pending stock-out line left for itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId}`, ` [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; return;
} }
@@ -1628,14 +1738,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
`此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`
); );
}); });
if (scannedLot?.stockOutLineId != null) {
const nextProcessed = markProcessedStockOutLine(
processedQrCombinations,
scannedItemId,
scannedLot.stockOutLineId,
);
setProcessedQrCombinations(nextProcessed);
}
markUnpickableScanSessionHandled();
return; return;
} }


@@ -1645,25 +1748,27 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
setQrScanError(false); setQrScanError(false);
setQrScanSuccess(false); setQrScanSuccess(false);
}); });
openWorkbenchLotLabelModalForLot(
openUnpickableScanLotLabelModal(
scannedLot,
scannedLot, scannedLot,
t("This lot is not available, please scan another lot."), t("This lot is not available, please scan another lot."),
); );
markUnpickableScanSessionHandled();
return; 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`); console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired; opening lot-label modal`);
startTransition(() => { startTransition(() => {
setQrScanError(false); setQrScanError(false);
setQrScanSuccess(false); setQrScanSuccess(false);
}); });
openWorkbenchLotLabelModalForLot(
openUnpickableScanLotLabelModal(
scannedLot,
scannedLot, scannedLot,
`Lot is expired (expiry=${scannedLot.expiryDate || "-"})`, `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`,
); );
markUnpickableScanSessionHandled();
return; return;
} }
} }
@@ -1766,7 +1871,16 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
expectedLot expectedLot
) { ) {
openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
openUnpickableScanLotLabelModal(
expectedLot,
scannedLot ??
allLotsForItem.find(
(lot: any) => lot.stockInLineId === scannedStockInLineId,
) ??
null,
failMsg,
);
markUnpickableScanSessionHandled();
return; return;
} }
if (workbenchMode && expectedLot.stockOutLineId != null) { if (workbenchMode && expectedLot.stockOutLineId != null) {
@@ -1932,7 +2046,18 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
expectedLot expectedLot
) { ) {
openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
openUnpickableScanLotLabelModal(
expectedLot,
scannedLot ??
allLotsForItem.find(
(lot: any) => lot.stockInLineId === scannedStockInLineId,
) ?? {
stockInLineId: scannedStockInLineId,
lotNo: scannedLotNo,
},
failMsg,
);
markUnpickableScanSessionHandled();
return; return;
} }
if (workbenchMode && expectedLot.stockOutLineId != null) { if (workbenchMode && expectedLot.stockOutLineId != null) {
@@ -2119,7 +2244,8 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
exactMatch exactMatch
) { ) {
openWorkbenchLotLabelModalForLot(exactMatch, failMsg);
openUnpickableScanLotLabelModal(exactMatch, exactMatch, failMsg);
markUnpickableScanSessionHandled();
return; return;
} }
if (workbenchMode && exactMatch.stockOutLineId != null) { if (workbenchMode && exactMatch.stockOutLineId != null) {
@@ -2233,7 +2359,18 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) &&
expectedLot expectedLot
) { ) {
openWorkbenchLotLabelModalForLot(expectedLot, failMsg);
openUnpickableScanLotLabelModal(
expectedLot,
scannedLot ??
allLotsForItem.find(
(lot: any) => lot.stockInLineId === scannedStockInLineId,
) ?? {
stockInLineId: scannedStockInLineId,
lotNo: scannedLotNo,
},
failMsg,
);
markUnpickableScanSessionHandled();
return; return;
} }
if (workbenchMode && expectedLot.stockOutLineId != null) { if (workbenchMode && expectedLot.stockOutLineId != null) {
@@ -2340,6 +2477,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
refreshWorkbenchAfterScanPick, refreshWorkbenchAfterScanPick,
workbenchScanPickQtyFromLot, workbenchScanPickQtyFromLot,
openWorkbenchLotLabelModalForLot, openWorkbenchLotLabelModalForLot,
openUnpickableScanLotLabelModal,
shouldOpenWorkbenchLotLabelModalForFailure, shouldOpenWorkbenchLotLabelModalForFailure,
t, t,
]); ]);
@@ -2445,6 +2583,12 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
// If it's a different QR, allow processing // If it's a different QR, allow processing
console.log(` [QR PROCESS] Different QR detected while manual modal open, allowing 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(); const qrDetectionStartTime = performance.now();
console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`);
@@ -2457,7 +2601,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
const canRetrySamePhysicalLot = const canRetrySamePhysicalLot =
qrPayload != null && qrPayload != null &&
isNewScanEvent && isNewScanEvent &&
hasPendingActiveRowForStockInLine(
hasPendingUnprocessedRowForStockInLine(
lotDataIndexes, lotDataIndexes,
qrPayload.itemId, qrPayload.itemId,
qrPayload.stockInLineId, qrPayload.stockInLineId,
@@ -2578,6 +2722,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO
isRefreshingData, isRefreshingData,
combinedLotData.length, combinedLotData.length,
manualLotConfirmationOpen, manualLotConfirmationOpen,
workbenchLotLabelModalOpen,
lotDataIndexes, lotDataIndexes,
processedQrCombinations, processedQrCombinations,
]); ]);
@@ -2925,8 +3070,8 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe
try { try {
if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); 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 = const canonicalLotForSol =
solId > 0 solId > 0
@@ -2951,10 +3096,10 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe


const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol); const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol);
const wbJustQty = qtyPayload.qty; const wbJustQty = qtyPayload.qty;
const isUnavailableForJustComplete = isInventoryLotLineUnavailable(canonicalLotForSol);
const isZeroCompleteForJustComplete = isWorkbenchZeroCompleteLot(canonicalLotForSol);
const canPostScanPick = 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() !== "" && ( canonicalLotForSol.lotNo && String(canonicalLotForSol.lotNo).trim() !== "" && (
// explicit short submit: user typed 0 (must send qty=0 to backend) // explicit short submit: user typed 0 (must send qty=0 to backend)
(hasExplicitSubmitOverride && (hasExplicitSubmitOverride &&
@@ -2966,7 +3111,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe
); );


if (canPostScanPick) { if (canPostScanPick) {
const qtyToSend = isUnavailableForJustComplete
const qtyToSend = isZeroCompleteForJustComplete
? 0 ? 0
: hasExplicitSubmitOverride && explicitSubmitOverride === 0 : hasExplicitSubmitOverride && explicitSubmitOverride === 0
? 0 ? 0
@@ -3011,7 +3156,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe
return; return;
} }
const justCompleteErr = t( 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) { if (solId > 0) {
rememberWorkbenchScanReject(solId, justCompleteErr); rememberWorkbenchScanReject(solId, justCompleteErr);
@@ -3800,7 +3945,7 @@ paginatedData.map((row, index) => {
const solIdForKey = Number(lot.stockOutLineId) || 0; const solIdForKey = Number(lot.stockOutLineId) || 0;
const lotKeyForSubmitQty = const lotKeyForSubmitQty =
Number.isFinite(solIdForKey) && solIdForKey > 0 ? `sol:${solIdForKey}` : `${lot.pickOrderLineId}-${lot.lotId}`; 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 hasPickOverride = Object.prototype.hasOwnProperty.call(pickQtyData, lotKeyForSubmitQty);
const fromPickRow = hasPickOverride ? pickQtyData[lotKeyForSubmitQty] : undefined; const fromPickRow = hasPickOverride ? pickQtyData[lotKeyForSubmitQty] : undefined;
const workbenchSubmitQtyDisplay = 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", "Submit Qty": "Submit Qty",
"Just Completed": "Just Completed", "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; 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?", "Do you want to start?": "Do you want to start?",
"Start": "Start", "Start": "Start",
"Pick Order Code(s)": "Pick Order Code(s)", "Pick Order Code(s)": "Pick Order Code(s)",
@@ -520,5 +521,6 @@
"Report missing or bad items": "Report missing or bad items", "Report missing or bad items": "Report missing or bad items",
"passed": "Passed", "passed": "Passed",
"failed": "Failed", "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": "送貨訂單", "Delivery Order": "送貨訂單",
"Shop Name": "店鋪名稱", "Shop Name": "店鋪名稱",
"Delivery Order No.": "送貨訂單編號", "Delivery Order No.": "送貨訂單編號",
@@ -15,11 +16,8 @@
"No Records": "沒有找到記錄", "No Records": "沒有找到記錄",
"OK": "確認", "OK": "確認",
"Truck X": "車線-X", "Truck X": "車線-X",
"DO Workbench": "新版成品出倉",
"Order Date From": "訂單日期", "Order Date From": "訂單日期",
"Workbench Batch Release": "批量放單", "Workbench Batch Release": "批量放單",
"do workbench": "新版成品出倉",
"Do Workbench": "新版成品出倉",
"Delivery Order Code": "送貨訂單編號", "Delivery Order Code": "送貨訂單編號",
"Floor": "樓層", "Floor": "樓層",
"Truck lane search requires date title": "需選擇預計送貨日期", "Truck lane search requires date title": "需選擇預計送貨日期",
@@ -96,5 +94,34 @@
"Release 2/F": "放單2/F", "Release 2/F": "放單2/F",
"Release 4/F": "放單4/F", "Release 4/F": "放單4/F",
"Stock Status": "庫存狀態", "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": "確認", "Confirm": "確認",
"Cancel": "取消", "Cancel": "取消",
"Shop Name": "店鋪名稱", "Shop Name": "店鋪名稱",


+ 1
- 1
src/i18n/zh/navigation.json ファイルの表示

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


+ 3
- 1
src/i18n/zh/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.": "已完成(工作台):需有效批號與可提交數量。",
"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": "有不合格檢查項目,確認接受出庫?"
"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-")
);
}

読み込み中…
キャンセル
保存