From f700045a91f2d28b13bb9fc93aec6dc19717a155 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 6 Oct 2026 11:10:30 +0800 Subject: [PATCH] dosearch not allow input withour date. if 4/F truck is not match that day count as truck X truck x tab have warn mesage --- src/app/api/do/actions.tsx | 5 + src/components/DoSearch/DoSearch.tsx | 290 +++++++++++++++++++++---- src/components/SearchBox/SearchBox.tsx | 9 + src/i18n/en/do.json | 21 ++ src/i18n/zh/do.json | 21 ++ 5 files changed, 299 insertions(+), 47 deletions(-) diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index 0a60d3cf..394f64ae 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -398,6 +398,8 @@ export async function fetchDoSearch( floor?: string | null, /** null:不篩;true/false:只顯示加單或非加單 DO */ isExtra?: boolean | null, + /** true:2/F、4/F 分頁排除車線-X */ + excludeUnassignedTruck?: boolean, ): Promise { // 构建请求体 const requestBody: any = { @@ -410,6 +412,7 @@ export async function fetchDoSearch( pageSize: pageSize || 10, floor: floor && floor !== "All" ? floor : null, ...(isExtra !== undefined && isExtra !== null ? { isExtra } : {}), + ...(excludeUnassignedTruck ? { excludeUnassignedTruck: true } : {}), }; // 如果日期不为空,转换为 LocalDateTime 格式 @@ -654,6 +657,7 @@ export async function fetchAllDoSearch( truckLanceCode?: string, floor?: string | null, isExtra?: boolean | null, + excludeUnassignedTruck?: boolean, ): Promise { // 使用一个很大的 pageSize 来获取所有匹配的记录 const requestBody: any = { @@ -666,6 +670,7 @@ export async function fetchAllDoSearch( pageSize: 10000, // 使用一个很大的值来获取所有记录 floor: floor && floor !== "All" ? floor : null, ...(isExtra !== undefined && isExtra !== null ? { isExtra } : {}), + ...(excludeUnassignedTruck ? { excludeUnassignedTruck: true } : {}), }; if (estArrStartDate) { diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index e4da4268..ec23b8ad 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -11,6 +11,8 @@ import { releaseDo, startBatchReleaseAsync, getBatchReleaseProgress, + check4FTrucksBatch, + type ProblemDoDto, } from "@/app/api/do/actions"; import { startWorkbenchBatchReleaseAsyncV2, @@ -18,7 +20,7 @@ import { } from "@/app/api/doworkbench/actions"; import { useRouter } from "next/navigation"; -import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react"; +import React, { ForwardedRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Criterion } from "../SearchBox"; import { isEmpty, sortBy, uniqBy } from "lodash"; @@ -41,7 +43,7 @@ import { SubmitHandler, useForm, } from "react-hook-form"; -import { Box, Button, Paper, Stack, Tab, Tabs, TablePagination, Typography } from "@mui/material"; +import { Alert, Box, Button, Paper, Stack, Tab, Tabs, TablePagination, Typography } from "@mui/material"; import StyledDataGrid from "../StyledDataGrid"; import Swal from "sweetalert2"; import { useSession } from "next-auth/react"; @@ -62,7 +64,7 @@ type Props = { searchQuery?: Record; onDeliveryOrderSearch?: () => void; }; -type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>; +type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "floor" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo" | "floorTo", string>; type SearchParamNames = keyof SearchBoxInputs; type DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA" | "REPLENISH"; type TabFilter = { floor: "2F" | "4F" | null; isExtra: boolean; forceTruckKeyword?: string }; @@ -76,9 +78,141 @@ type EntryError = | undefined; type DoRow = TableRow, EntryError>; -/** 已填車線但未選預計送貨日:後端會掃全量再篩,需擋下。 */ -function isTruckLaneSearchMissingEta(truckLanceCode: string, estimatedArrivalDate: string): boolean { - return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === ""; +/** 2/F、4/F、車線-X 必須有預計送貨日,否則後端無法判斷當天車線。 */ +function deliveryDateRequired(tab: DoSearchTab): boolean { + return tab === "2F" || tab === "4F" || tab === "TRUCK_X"; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function formatCheckDate(value: unknown): string { + if (Array.isArray(value) && value.length >= 3) { + const year = Number(value[0]); + const month = Number(value[1]); + const day = Number(value[2]); + if (!year || !month || !day) return ""; + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + } + if (typeof value === "string" && value.length >= 10) return value.slice(0, 10); + return ""; +} + +const WEEKDAY_LABEL_KEY = ["Weekday Sun", "Weekday Mon", "Weekday Tue", "Weekday Wed", "Weekday Thu", "Weekday Fri", "Weekday Sat"]; + +function weekdayLabelKey(isoDate: string): string { + const parsed = new Date(`${isoDate}T00:00:00`); + if (Number.isNaN(parsed.getTime())) return ""; + return WEEKDAY_LABEL_KEY[parsed.getDay()] ?? ""; +} + +const DAY_TRUCK_WARN_LIMIT = 12; + +const LANE_DAY_KEYS: Array<[string, string]> = [ + ["sat", "Weekday Sat"], + ["sun", "Weekday Sun"], + ["mon", "Weekday Mon"], + ["tue", "Weekday Tue"], + ["wed", "Weekday Wed"], + ["thu", "Weekday Thu"], + ["fri", "Weekday Fri"], +]; + +function shortShopLabel(shopCode: string, shopName: string, fallback: string): string { + const branch = shopName.includes(" - ") + ? shopName.split(" - ").slice(1).join(" - ").trim() + : shopName.trim(); + const label = [shopCode, branch].filter(Boolean).join(" "); + return label || fallback; +} + +function weekdayNamesInLanes( + lanes: string[], + t: (key: string, options?: Record) => string, +): string[] { + const names: string[] = []; + const blob = lanes.join(" ").toLowerCase(); + for (const [token, key] of LANE_DAY_KEYS) { + if (blob.includes(token)) names.push(t(key)); + } + return names; +} + +function buildDayTruckWarnHtml( + problems: ProblemDoDto[], + skipCount: number, + releaseCount: number, + t: (key: string, options?: Record) => string, +): { html: string; title: string } { + const groups = new Map(); + for (const problem of problems) { + const date = formatCheckDate(problem.targetDate); + const firstTruck = problem.availableTrucks?.[0]; + const shopCode = firstTruck?.shopCode?.trim() || ""; + const shopName = firstTruck?.shopName?.trim() || ""; + const shop = shortShopLabel( + shopCode, + shopName, + problem.deliveryOrderCode || String(problem.deliveryOrderId), + ); + const key = `${shopCode || problem.deliveryOrderCode}|${date}`; + const current = groups.get(key) ?? { + shop, + date, + weekday: weekdayLabelKey(date) ? t(weekdayLabelKey(date)) : "", + lanes: [], + }; + for (const truck of problem.availableTrucks ?? []) { + const lane = truck.truckLanceCode?.trim(); + if (lane && !current.lanes.includes(lane)) current.lanes.push(lane); + } + groups.set(key, current); + } + + const rows = Array.from(groups.values()); + const visible = rows.slice(0, DAY_TRUCK_WARN_LIMIT); + const hiddenCount = rows.length - visible.length; + const allSkipped = releaseCount === 0; + const sameDate = visible.every((row) => row.date && row.date === visible[0]?.date); + const sameWeekday = visible.every((row) => row.weekday && row.weekday === visible[0]?.weekday); + const missingDay = sameWeekday && visible[0]?.weekday + ? visible[0].weekday + : t("4F day truck warn day fallback"); + const reason = sameDate && sameWeekday && visible[0]?.date + ? t("4F day truck warn reason", { date: visible[0].date, weekday: visible[0].weekday }) + : t("4F day truck warn reason mixed"); + const items = visible + .map((row) => { + const dayNames = weekdayNamesInLanes(row.lanes, t); + const prefix = sameDate ? "" : `${row.date} ${row.weekday} `; + const line = dayNames.length > 0 + ? t("4F day truck warn line", { shop: `${prefix}${row.shop}`, lanes: dayNames.join("、") }) + : t("4F day truck warn line none", { shop: `${prefix}${row.shop}` }); + return `
  • ${escapeHtml(line)}
  • `; + }) + .join(""); + const more = hiddenCount > 0 + ? `

    ${escapeHtml(t("4F day truck warn more", { count: hiddenCount }))}

    ` + : ""; + const action = t(allSkipped ? "4F day truck warn action none" : "4F day truck warn action some", { day: missingDay }); + const title = t(allSkipped ? "4F day truck warn title none" : "4F day truck warn title some", { + count: skipCount, + skip: skipCount, + }); + return { + title, + html: ` +

    ${escapeHtml(reason)}

    +
      ${items}
    + ${more} +

    ${escapeHtml(action)}

    + `, + }; } /** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */ @@ -96,6 +230,8 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea const router = useRouter(); const { data: session } = useSession() as { data: SessionWithTokens | null }; const currentUserId = session?.id ? parseInt(session.id) : undefined; + const batchReleaseLockRef = useRef(false); + const [isBatchReleasing, setIsBatchReleasing] = useState(false); //console.log("🔍 DoSearch - session:", session); //console.log("🔍 DoSearch - currentUserId:", currentUserId); const [searchTimeout, setSearchTimeout] = useState(null); @@ -119,6 +255,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea shopName: "", deliveryOrderLines: "", truckLanceCode: "", + floor: "All", codeTo: "", statusTo: "", estimatedArrivalDateTo: "", @@ -127,6 +264,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea shopNameTo: "", deliveryOrderLinesTo: "", truckLanceCodeTo: "", + floorTo: "", }); const createClearedSearchParams = useCallback( (source: SearchBoxInputs): SearchBoxInputs => ({ @@ -138,6 +276,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea shopName: "", deliveryOrderLines: "", truckLanceCode: "", + floor: "All", codeTo: "", statusTo: "", estimatedArrivalDateTo: source.estimatedArrivalDateTo || "", @@ -146,12 +285,14 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea shopNameTo: "", deliveryOrderLinesTo: "", truckLanceCodeTo: "", + floorTo: "", }), [], ); const [hasSearched, setHasSearched] = useState(false); const [hasResults, setHasResults] = useState(false); + const [dateFieldError, setDateFieldError] = useState(false); const { rowSelectionModel, @@ -172,6 +313,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea currentSearchParams.status, currentSearchParams.estimatedArrivalDate, currentSearchParams.truckLanceCode, + currentSearchParams.floor, ]); @@ -180,11 +322,23 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea { label: t("Code"), paramName: "code", type: "text" }, { label: t("Shop Name"), paramName: "shopName", type: "text" }, { label: t("Truck Lance Code"), paramName: "truckLanceCode", type: "text" }, + ...(activeTab === "TRUCK_X" + ? [{ + label: t("Floor"), + paramName: "floor" as const, + type: "select-labelled" as const, + options: [ + { label: "2/F", value: "2F" }, + { label: "4/F", value: "4F" }, + ], + }] + : []), { label: t("Estimated Arrival"), paramName: "estimatedArrivalDate", type: "date", preFilledValue: currentSearchParams.estimatedArrivalDate || "", + errorText: dateFieldError ? t("Estimated arrival required") : undefined, }, { label: t("Status"), @@ -197,7 +351,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea ] } ], - [t, currentSearchParams.estimatedArrivalDate], + [t, activeTab, currentSearchParams.estimatedArrivalDate, dateFieldError], ); const onReset = useCallback(async () => { @@ -206,6 +360,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea setTotalCount(0); setHasSearched(false); setHasResults(false); + setDateFieldError(false); resetSelection(); setPagingController({ pageNum: 1, pageSize: 10 }); } @@ -351,20 +506,19 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea const tabFilter = resolveTabFilter(effectiveTab); const tabTruckKeyword = tabFilter.forceTruckKeyword ?? ""; const effectiveTruckLanceCode = tabTruckKeyword || query.truckLanceCode || ""; - const shouldValidateTruckLane = effectiveTab !== "TRUCK_X"; + const searchFloor = + effectiveTab === "TRUCK_X" + ? query.floor === "2F" || query.floor === "4F" + ? query.floor + : null + : tabFilter.floor; + const excludeUnassigned = effectiveTab === "2F" || effectiveTab === "4F"; - if ( - shouldValidateTruckLane && - isTruckLaneSearchMissingEta(effectiveTruckLanceCode, query.estimatedArrivalDate ?? "") - ) { - await Swal.fire({ - icon: "warning", - title: t("Truck lane search requires date title"), - text: t("Truck lane search requires date message"), - confirmButtonText: t("Confirm"), - }); + if (deliveryDateRequired(effectiveTab) && !(query.estimatedArrivalDate ?? "").trim()) { + setDateFieldError(true); return false; } + setDateFieldError(false); let estArrStartDate = query.estimatedArrivalDate; const time = "T00:00:00"; @@ -385,8 +539,9 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea pageNum, pageSize, effectiveTruckLanceCode, - tabFilter.floor, + searchFloor, tabFilter.isExtra, + excludeUnassigned, ); setSearchAllDos(response.records); @@ -509,6 +664,10 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea ); const handleBatchRelease = useCallback(async (isWorkbench: boolean) => { + if (batchReleaseLockRef.current) return; + batchReleaseLockRef.current = true; + setIsBatchReleasing(true); + try { try { if (!currentUserId) { await Swal.fire({ @@ -523,22 +682,18 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea const tabFilter = resolveTabFilter(activeTab); const tabTruckKeyword = tabFilter.forceTruckKeyword ?? ""; const effectiveTruckLanceCode = tabTruckKeyword || currentSearchParams.truckLanceCode || ""; - const shouldValidateTruckLane = activeTab !== "TRUCK_X"; - if ( - shouldValidateTruckLane && - isTruckLaneSearchMissingEta( - effectiveTruckLanceCode, - currentSearchParams.estimatedArrivalDate ?? "", - ) - ) { - await Swal.fire({ - icon: "warning", - title: t("Truck lane search requires date title"), - text: t("Truck lane search requires date message"), - confirmButtonText: t("Confirm"), - }); + const searchFloor = + activeTab === "TRUCK_X" + ? currentSearchParams.floor === "2F" || currentSearchParams.floor === "4F" + ? currentSearchParams.floor + : null + : tabFilter.floor; + const excludeUnassigned = activeTab === "2F" || activeTab === "4F"; + if (deliveryDateRequired(activeTab) && !(currentSearchParams.estimatedArrivalDate ?? "").trim()) { + setDateFieldError(true); return; } + setDateFieldError(false); // 根据当前搜索条件获取所有匹配的记录(不分页) let estArrStartDate = currentSearchParams.estimatedArrivalDate; const time = "T00:00:00"; @@ -574,8 +729,9 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea status, estArrStartDate, effectiveTruckLanceCode, - tabFilter.floor, + searchFloor, tabFilter.isExtra, + excludeUnassigned, ); Swal.close(); @@ -606,7 +762,23 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea return; } - const dosForRelease = allMatchingDos.filter((row) => idsToRelease.includes(row.id)); + let dayTruckProblems: ProblemDoDto[] = []; + if (activeTab === "2F" || activeTab === "4F") { + try { + const truckCheck = await check4FTrucksBatch(idsToRelease); + dayTruckProblems = truckCheck?.problems ?? []; + } catch (checkError) { + console.error("4F day truck check failed:", checkError); + } + } + const skipIdSet = new Set(dayTruckProblems.map((problem) => problem.deliveryOrderId)); + const releaseIds = idsToRelease.filter((id) => !skipIdSet.has(id)); + const allDayTrucksSkipped = dayTruckProblems.length > 0 && releaseIds.length === 0; + const dayTruckWarn = dayTruckProblems.length > 0 + ? buildDayTruckWarnHtml(dayTruckProblems, skipIdSet.size, releaseIds.length, t) + : null; + + const dosForRelease = allMatchingDos.filter((row) => releaseIds.includes(row.id)); const replenishmentFetchParams = deriveReplenishmentFetchParams( dosForRelease, currentSearchParams.shopName || "", @@ -624,21 +796,32 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea ); const hasReplenishmentPreview = pendingReplenishments.length > 0; + const widenReleaseDialog = hasReplenishmentPreview || dayTruckWarn != null; + const confirmText = allDayTrucksSkipped + ? t("4F day truck warn ack") + : dayTruckWarn != null && !showMergeExtraOption + ? t("4F day truck warn release rest") + : showMergeExtraOption + ? t("Confirm merge release") + : t("Confirm"); const result = await Swal.fire({ - icon: "question", - title: t("Batch Release"), - width: hasReplenishmentPreview ? BATCH_RELEASE_SWAL_WIDTH_WITH_REPLENISH : undefined, + icon: dayTruckWarn != null ? "warning" : "question", + title: dayTruckWarn?.title ?? t("Batch Release"), + width: widenReleaseDialog ? BATCH_RELEASE_SWAL_WIDTH_WITH_REPLENISH : undefined, html: `
    -

    ${t("Selected Shop(s): ")}${idsToRelease.length}

    + ${ + dayTruckWarn == null + ? `

    ${t("Selected Shop(s): ")}${idsToRelease.length}

    - ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} ${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""} ${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""} ${status ? `${t("Status")}: ${t(status)} ` : ""} -

    +

    ` + : dayTruckWarn.html + } ${ showMergeExtraOption ? `

    ${t("Merge extra orders into lane batch ticket")}

    ` @@ -647,21 +830,22 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea ${replenishmentSectionHtml}
    `, - showCancelButton: true, - showDenyButton: showMergeExtraOption, - confirmButtonText: showMergeExtraOption ? t("Confirm merge release") : t("Confirm"), + showCancelButton: !allDayTrucksSkipped, + showDenyButton: showMergeExtraOption && !allDayTrucksSkipped, + confirmButtonText: confirmText, denyButtonText: t("Confirm release without merge"), cancelButtonText: t("Cancel"), confirmButtonColor: "#8dba00", denyButtonColor: "#6366f1", cancelButtonColor: "#F04438", didOpen: (popup) => { - applyBatchReleaseSwalLayout(popup, { wide: hasReplenishmentPreview }); + applyBatchReleaseSwalLayout(popup, { wide: widenReleaseDialog }); }, }); if (result.isDismissed) return; if (!result.isConfirmed && !result.isDenied) return; + if (releaseIds.length === 0) return; const mergeExtraIntoLaneTicket = showMergeExtraOption ? result.isConfirmed : true; @@ -669,13 +853,13 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea let startRes ; if(isWorkbench){ startRes = await startWorkbenchBatchReleaseAsyncV2({ - ids: idsToRelease, + ids: releaseIds, userId: currentUserId, mergeExtraIntoLaneTicket, }); } else{ - startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId }); + startRes = await startBatchReleaseAsync({ ids: releaseIds, userId: currentUserId }); } //await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); const jobId = startRes?.entity?.jobId; @@ -760,6 +944,10 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea didOpen: (popup) => applyMainContentAreaSwalOffset(popup), }); } + } finally { + batchReleaseLockRef.current = false; + setIsBatchReleasing(false); + } }, [t, currentUserId, currentSearchParams, handleSearch, resolveIdsForBatchRelease, activeTab, resolveTabFilter]); const handleTabChange = useCallback( @@ -776,6 +964,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea setTotalCount(0); setHasSearched(false); setHasResults(false); + setDateFieldError(false); }, [ activeTab, @@ -813,6 +1002,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea