From d564c2a6add2a8c136c516fa7163585bfa614852 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 23 Sep 2026 13:12:21 +0800 Subject: [PATCH] po/do/jo settings --- .../settings/deliveryOrderFloor/page.tsx | 2 +- .../api/settings/deliveryOrderFloor/client.ts | 221 +++ .../settings/deliveryOrderFloor/constants.ts | 137 +- .../DeliveryOrderFloorSettings.tsx | 1467 ++++++++++++++++- .../WorkbenchGoodPickExecutionDetail.tsx | 1 + .../WorkbenchLotLabelPrintModal.tsx | 66 +- .../JoWorkbench/newJobPickExecution.tsx | 1 + src/components/Qc/QcStockInModal.tsx | 70 +- src/i18n/en/deliveryOrderFloor.json | 101 +- src/i18n/en/navigation.json | 4 +- src/i18n/zh/deliveryOrderFloor.json | 101 +- src/i18n/zh/navigation.json | 4 +- 12 files changed, 2057 insertions(+), 118 deletions(-) diff --git a/src/app/(main)/settings/deliveryOrderFloor/page.tsx b/src/app/(main)/settings/deliveryOrderFloor/page.tsx index 88701b16..bad6ea41 100644 --- a/src/app/(main)/settings/deliveryOrderFloor/page.tsx +++ b/src/app/(main)/settings/deliveryOrderFloor/page.tsx @@ -4,7 +4,7 @@ import { Stack, Typography } from "@mui/material"; import { Metadata } from "next"; export const metadata: Metadata = { - title: "Delivery order floor", + title: "Pick rules", }; export default async function DeliveryOrderFloorPage() { diff --git a/src/app/api/settings/deliveryOrderFloor/client.ts b/src/app/api/settings/deliveryOrderFloor/client.ts index 8f0367a9..ca8d87fc 100644 --- a/src/app/api/settings/deliveryOrderFloor/client.ts +++ b/src/app/api/settings/deliveryOrderFloor/client.ts @@ -3,8 +3,33 @@ import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { + DEFAULT_AUTO_PUTAWAY_ITEM_KEYWORDS, + DEFAULT_AUTO_PUTAWAY_WAREHOUSE, + DEFAULT_DO_PICK_EXCLUDE_WAREHOUSES, + DEFAULT_JO_AUTO_PUTAWAY_BOM_TYPES, + DEFAULT_JO_PICK_EXCLUDE_WAREHOUSES, + EMPTY_CODE_LIST_MARKER, SETTING_DO_FLOOR_SUPPLIERS_2F, SETTING_DO_FLOOR_SUPPLIERS_4F, + SETTING_DO_PICK_EXCLUDE_PRINT_MODE, + SETTING_DO_PICK_EXCLUDE_WAREHOUSES, + SETTING_JO_AUTO_PUTAWAY_BOM_TYPES, + SETTING_JO_AUTO_PUTAWAY_ITEM_KEYWORDS, + SETTING_JO_AUTO_PUTAWAY_RULES, + SETTING_JO_AUTO_PUTAWAY_WAREHOUSE, + SETTING_JO_PICK_EXCLUDE_PRINT_MODE, + SETTING_JO_PICK_EXCLUDE_WAREHOUSES, + SETTING_PO_AUTO_PUTAWAY_BOM_TYPES, + SETTING_PO_AUTO_PUTAWAY_ITEM_KEYWORDS, + SETTING_PO_AUTO_PUTAWAY_RULES, + SETTING_PO_AUTO_PUTAWAY_WAREHOUSE, + defaultJoAutoPutAwayRules, + defaultPoAutoPutAwayRules, + type AutoPutAwayException, + type AutoPutAwayRule, + type AutoPutAwayScope, + type ExcludePrintMode, + type PutAwayLocationMode, } from "./constants"; const base = NEXT_PUBLIC_API_URL; @@ -17,6 +42,12 @@ export type ShopComboRow = { label: string; }; +export type WarehousePickRow = ShopComboRow & { + storeId: string; + warehouse: string; + area: string; +}; + export type SettingsRow = { id: number; name: string; @@ -50,15 +81,205 @@ export async function fetchAllSettingsClient(): Promise { return parseJson(res); } +function resolveExcludeCsv(raw: string | undefined, fallback: string): string { + if (raw == null) return fallback; + const trimmed = raw.trim(); + if (!trimmed || trimmed === EMPTY_CODE_LIST_MARKER) return ""; + return trimmed; +} + +export async function fetchWarehouseCodeRowsClient(): Promise { + const res = await clientAuthFetch(`${base}/warehouse`, { method: "GET" }); + const rows = await parseJson< + Array<{ + id?: number; + code?: string | null; + name?: string | null; + store_id?: string | null; + warehouse?: string | null; + area?: string | null; + }> + >(res); + return rows + .map((r) => { + const code = (r.code ?? "").trim(); + const name = (r.name ?? "").trim(); + const parts = code.split("-"); + return { + id: r.id ?? 0, + code, + name, + value: r.id ?? 0, + label: [code, name].filter(Boolean).join(" "), + storeId: (r.store_id ?? "").trim() || parts[0] || "", + warehouse: (r.warehouse ?? "").trim() || parts[1] || "", + area: (r.area ?? "").trim() || parts[2] || "", + }; + }) + .filter((r) => r.code); +} + +function resolvePrintMode(raw: string | undefined): ExcludePrintMode { + return raw?.trim() === "hideQr" ? "hideQr" : "hideList"; +} + +/** Missing row keeps the historical default. Saved `-` or blank turns that value off. */ +function resolveOptionalCsv(raw: string | undefined, fallbackWhenMissing: string): string { + if (raw == null) return fallbackWhenMissing; + const trimmed = raw.trim(); + if (!trimmed || trimmed === EMPTY_CODE_LIST_MARKER) return ""; + return trimmed; +} + +function asLocationMode(value: unknown): PutAwayLocationMode { + return value === "itemLocation" ? "itemLocation" : "warehouse"; +} + +function normalizeException(value: unknown): AutoPutAwayException | null { + if (!value || typeof value !== "object") return null; + const row = value as Partial; + return { + itemKeywords: String(row.itemKeywords ?? "").trim(), + }; +} + +function normalizeRule(value: unknown, scope: AutoPutAwayScope): AutoPutAwayRule | null { + if (!value || typeof value !== "object") return null; + const row = value as Partial & { listMode?: string }; + const rawExceptions = Array.isArray(row.exceptions) ? row.exceptions : []; + return { + itemKeywords: String(row.itemKeywords ?? "").trim(), + bomTypes: scope === "jo" ? String(row.bomTypes ?? "").trim() : "", + locationMode: asLocationMode(row.locationMode), + warehouseCode: String(row.warehouseCode ?? "").trim() || DEFAULT_AUTO_PUTAWAY_WAREHOUSE, + enabled: row.enabled !== false && row.listMode !== "blacklist", + exceptions: rawExceptions + .map((item) => normalizeException(item)) + .filter((item): item is AutoPutAwayException => item != null), + }; +} + +function legacyAutoPutAwayRules( + scope: AutoPutAwayScope, + warehouseCode: string, + itemKeywords: string, + bomTypes: string, +): AutoPutAwayRule[] { + const warehouse = warehouseCode || DEFAULT_AUTO_PUTAWAY_WAREHOUSE; + if (scope === "po") { + return [ + { + itemKeywords: itemKeywords || DEFAULT_AUTO_PUTAWAY_ITEM_KEYWORDS, + bomTypes: "", + locationMode: "warehouse", + warehouseCode: warehouse, + enabled: true, + exceptions: [], + }, + ]; + } + const boms = bomTypes + .split(",") + .map((part) => part.trim().toUpperCase()) + .filter(Boolean); + const rules: AutoPutAwayRule[] = []; + if (boms.includes("FG")) { + rules.push({ + itemKeywords: "", + bomTypes: "FG", + locationMode: "itemLocation", + warehouseCode: warehouse, + enabled: true, + exceptions: [], + }); + } + const otherBoms = boms.filter((bom) => bom !== "FG"); + if (otherBoms.length > 0) { + rules.push({ + itemKeywords: "", + bomTypes: otherBoms.join(","), + locationMode: "warehouse", + warehouseCode: warehouse, + enabled: true, + exceptions: [], + }); + } + return rules.length > 0 ? rules : defaultJoAutoPutAwayRules(); +} + +/** Drop the seeded JO item-code FA rule. Job orders use BOM rules only. */ +function withoutSeededJoFaRule(rules: AutoPutAwayRule[]): AutoPutAwayRule[] { + if (rules.length !== 3) return rules; + const [fg, wip, fa] = rules; + const isFg = !fg.itemKeywords.trim() && fg.bomTypes.trim().toUpperCase() === "FG" && fg.locationMode === "itemLocation"; + const isWip = !wip.itemKeywords.trim() && wip.bomTypes.trim().toUpperCase() === "WIP" && wip.locationMode === "warehouse"; + const isFa = + fa.itemKeywords.trim().toUpperCase() === "FA" && + !fa.bomTypes.trim() && + fa.locationMode === "warehouse"; + return isFg && isWip && isFa ? rules.slice(0, 2) : rules; +} + +function resolveAutoPutAwayRules( + raw: (name: string) => string | undefined, + scope: AutoPutAwayScope, +): AutoPutAwayRule[] { + const rulesKey = scope === "po" ? SETTING_PO_AUTO_PUTAWAY_RULES : SETTING_JO_AUTO_PUTAWAY_RULES; + const stored = raw(rulesKey); + if (stored != null) { + const trimmed = stored.trim(); + if (!trimmed || trimmed === EMPTY_CODE_LIST_MARKER) return []; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) { + const rules = parsed + .map((row) => normalizeRule(row, scope)) + .filter((row): row is AutoPutAwayRule => row != null); + return scope === "jo" ? withoutSeededJoFaRule(rules) : rules; + } + } catch { + // Fall through to legacy columns. + } + } + if (stored == null) { + const warehouseKey = scope === "po" ? SETTING_PO_AUTO_PUTAWAY_WAREHOUSE : SETTING_JO_AUTO_PUTAWAY_WAREHOUSE; + const keywordKey = scope === "po" ? SETTING_PO_AUTO_PUTAWAY_ITEM_KEYWORDS : SETTING_JO_AUTO_PUTAWAY_ITEM_KEYWORDS; + const bomKey = scope === "po" ? SETTING_PO_AUTO_PUTAWAY_BOM_TYPES : SETTING_JO_AUTO_PUTAWAY_BOM_TYPES; + const hasLegacy = raw(warehouseKey) != null || raw(keywordKey) != null || raw(bomKey) != null; + if (hasLegacy) { + return legacyAutoPutAwayRules( + scope, + resolveOptionalCsv(raw(warehouseKey), DEFAULT_AUTO_PUTAWAY_WAREHOUSE), + resolveOptionalCsv(raw(keywordKey), DEFAULT_AUTO_PUTAWAY_ITEM_KEYWORDS), + resolveOptionalCsv(raw(bomKey), scope === "jo" ? DEFAULT_JO_AUTO_PUTAWAY_BOM_TYPES : ""), + ); + } + } + return scope === "po" ? defaultPoAutoPutAwayRules() : defaultJoAutoPutAwayRules(); +} + export async function fetchDoFloorSettingsClient(): Promise<{ suppliers2F: string; suppliers4F: string; + doExcludeWarehouses: string; + joExcludeWarehouses: string; + doExcludePrintMode: ExcludePrintMode; + joExcludePrintMode: ExcludePrintMode; + poAutoPutAway: AutoPutAwayRule[]; + joAutoPutAway: AutoPutAwayRule[]; }> { const all = await fetchAllSettingsClient(); const get = (name: string) => all.find((s) => s.name === name)?.value ?? ""; + const raw = (name: string) => all.find((s) => s.name === name)?.value; return { suppliers2F: get(SETTING_DO_FLOOR_SUPPLIERS_2F), suppliers4F: get(SETTING_DO_FLOOR_SUPPLIERS_4F), + doExcludeWarehouses: resolveExcludeCsv(raw(SETTING_DO_PICK_EXCLUDE_WAREHOUSES), DEFAULT_DO_PICK_EXCLUDE_WAREHOUSES), + joExcludeWarehouses: resolveExcludeCsv(raw(SETTING_JO_PICK_EXCLUDE_WAREHOUSES), DEFAULT_JO_PICK_EXCLUDE_WAREHOUSES), + doExcludePrintMode: resolvePrintMode(raw(SETTING_DO_PICK_EXCLUDE_PRINT_MODE)), + joExcludePrintMode: resolvePrintMode(raw(SETTING_JO_PICK_EXCLUDE_PRINT_MODE)), + poAutoPutAway: resolveAutoPutAwayRules(raw, "po"), + joAutoPutAway: resolveAutoPutAwayRules(raw, "jo"), }; } diff --git a/src/app/api/settings/deliveryOrderFloor/constants.ts b/src/app/api/settings/deliveryOrderFloor/constants.ts index 25ce37e4..667f7021 100644 --- a/src/app/api/settings/deliveryOrderFloor/constants.ts +++ b/src/app/api/settings/deliveryOrderFloor/constants.ts @@ -2,4 +2,139 @@ export const SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F"; export const SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F"; -export const SETTING_DO_FLOOR_CATEGORY = "DO_FLOOR"; \ No newline at end of file +/** 逗號分隔倉庫 code。存 `-` 代表不排除任何倉。 */ +export const SETTING_DO_PICK_EXCLUDE_WAREHOUSES = "DO.pick.excludeWarehouses"; +export const SETTING_JO_PICK_EXCLUDE_WAREHOUSES = "JO.pick.excludeWarehouses"; +export const SETTING_DO_PICK_EXCLUDE_PRINT_MODE = "DO.pick.excludePrintMode"; +export const SETTING_JO_PICK_EXCLUDE_PRINT_MODE = "JO.pick.excludePrintMode"; +/** hideList:清單不出現,掃到實物 QR 也不通過。hideQr:清單仍顯示但不顯示 QR,掃到實物 QR 可以通過。 */ +export type ExcludePrintMode = "hideList" | "hideQr"; +export const EMPTY_CODE_LIST_MARKER = "-"; + +export const DEFAULT_DO_PICK_EXCLUDE_WAREHOUSES = "2F-W202-01-00,2F-W200-#A-00"; +export const DEFAULT_JO_PICK_EXCLUDE_WAREHOUSES = + "4F-W402-01-00,4F-W402-02-00,4F-W402-03-00,4F-W402-04-00,4F-W402-05-00,4F-W402-#A-00,4F-W402-#B-00,4F-W402-#C-00,4F-W402-#D-00,4F-W402-#E-00,4F-W402-#F-00,4F-W402-#G-00,4F-W402-#H-00,4F-W402-#I-00,4F-W402-#J-00,4F-W402-#K-00,4F-W402-#L-00,4F-W402-#M-00,4F-W402-#N-00,4F-W402-#O-00,4F-W402-#P-00,4F-W402-#Q-00,4F-W402-#R-00,4F-W402-#S-00"; + +export const SETTING_DO_FLOOR_CATEGORY = "DO_FLOOR"; + +/** 舊版單筆欄位。新資料改存 rules JSON。 */ +export const SETTING_PO_AUTO_PUTAWAY_WAREHOUSE = "PO.autoPutAway.warehouse"; +export const SETTING_PO_AUTO_PUTAWAY_ITEM_KEYWORDS = "PO.autoPutAway.itemKeywords"; +export const SETTING_PO_AUTO_PUTAWAY_BOM_TYPES = "PO.autoPutAway.bomTypes"; +export const SETTING_JO_AUTO_PUTAWAY_WAREHOUSE = "JO.autoPutAway.warehouse"; +export const SETTING_JO_AUTO_PUTAWAY_ITEM_KEYWORDS = "JO.autoPutAway.itemKeywords"; +export const SETTING_JO_AUTO_PUTAWAY_BOM_TYPES = "JO.autoPutAway.bomTypes"; +export const SETTING_PO_AUTO_PUTAWAY_RULES = "PO.autoPutAway.rules"; +export const SETTING_JO_AUTO_PUTAWAY_RULES = "JO.autoPutAway.rules"; + +/** Warehouse id 1141. */ +export const DEFAULT_AUTO_PUTAWAY_WAREHOUSE = "2F-W200-#A-00"; +export const DEFAULT_AUTO_PUTAWAY_ITEM_KEYWORDS = "FA"; +export const DEFAULT_JO_AUTO_PUTAWAY_BOM_TYPES = "WIP,FG"; + +export type AutoPutAwayScope = "po" | "jo"; +export type PutAwayLocationMode = "itemLocation" | "warehouse"; + +/** Item keywords on a rule that skip automatic put-away. */ +export type AutoPutAwayException = { + itemKeywords: string; +}; + +export type AutoPutAwayRule = { + /** Empty means every item. */ + itemKeywords: string; + /** Job order only. Empty means every BOM. */ + bomTypes: string; + locationMode: PutAwayLocationMode; + warehouseCode: string; + /** Missing or true means the rule is used. False skips it. */ + enabled: boolean; + exceptions: AutoPutAwayException[]; +}; + +export type AutoPutAwayTarget = { + locationMode: PutAwayLocationMode; + warehouseCode: string; +}; + +export function defaultPoAutoPutAwayRules(): AutoPutAwayRule[] { + return [ + { + itemKeywords: DEFAULT_AUTO_PUTAWAY_ITEM_KEYWORDS, + bomTypes: "", + locationMode: "warehouse", + warehouseCode: DEFAULT_AUTO_PUTAWAY_WAREHOUSE, + enabled: true, + exceptions: [], + }, + ]; +} + +export function defaultJoAutoPutAwayRules(): AutoPutAwayRule[] { + return [ + { + itemKeywords: "", + bomTypes: "FG", + locationMode: "itemLocation", + warehouseCode: DEFAULT_AUTO_PUTAWAY_WAREHOUSE, + enabled: true, + exceptions: [], + }, + { + itemKeywords: "", + bomTypes: "WIP", + locationMode: "warehouse", + warehouseCode: DEFAULT_AUTO_PUTAWAY_WAREHOUSE, + enabled: true, + exceptions: [], + }, + ]; +} + +export function emptyAutoPutAwayRule(scope: AutoPutAwayScope): AutoPutAwayRule { + return { + itemKeywords: "", + bomTypes: scope === "jo" ? DEFAULT_JO_AUTO_PUTAWAY_BOM_TYPES : "", + locationMode: "warehouse", + warehouseCode: DEFAULT_AUTO_PUTAWAY_WAREHOUSE, + enabled: true, + exceptions: [], + }; +} + +function csvTokensUpper(raw: string): string[] { + return raw + .split(",") + .map((part) => part.trim().toUpperCase()) + .filter(Boolean); +} + +function ruleMatchesItem(rule: AutoPutAwayRule, scope: AutoPutAwayScope, item: string, bom: string): boolean { + if (rule.enabled === false) return false; + const keywords = csvTokensUpper(rule.itemKeywords); + const keywordOk = keywords.length === 0 || keywords.some((keyword) => item.includes(keyword)); + const boms = scope === "jo" ? csvTokensUpper(rule.bomTypes) : []; + const bomOk = scope !== "jo" || boms.length === 0 || (Boolean(bom) && boms.includes(bom)); + return keywordOk && bomOk; +} + +/** First rule that matches and is not excluded by its own exception. An exception skips that rule and continues. */ +export function resolveAutoPutAwayTarget( + rules: AutoPutAwayRule[], + scope: AutoPutAwayScope, + itemNo: string | null | undefined, + bomDescription: string | null | undefined, +): AutoPutAwayTarget | null { + const item = (itemNo ?? "").toUpperCase(); + const bom = (bomDescription ?? "").trim().toUpperCase(); + for (const rule of rules) { + if (!ruleMatchesItem(rule, scope, item, bom)) continue; + const exception = (rule.exceptions ?? []).find((row) => { + const keywords = csvTokensUpper(row.itemKeywords); + return keywords.length > 0 && keywords.some((keyword) => item.includes(keyword)); + }); + if (exception) continue; + return { locationMode: rule.locationMode, warehouseCode: rule.warehouseCode }; + } + return null; +} \ No newline at end of file diff --git a/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx b/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx index 2ffb8f94..d816f502 100644 --- a/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx +++ b/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx @@ -2,19 +2,30 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import Check from "@mui/icons-material/Check"; import EditOutlined from "@mui/icons-material/EditOutlined"; import DeleteOutline from "@mui/icons-material/DeleteOutline"; import Add from "@mui/icons-material/Add"; +import LocalShippingOutlined from "@mui/icons-material/LocalShippingOutlined"; +import PrecisionManufacturingOutlined from "@mui/icons-material/PrecisionManufacturingOutlined"; +import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined"; +import ShoppingCartOutlined from "@mui/icons-material/ShoppingCartOutlined"; +import WarningAmberOutlined from "@mui/icons-material/WarningAmberOutlined"; +import Search from "@mui/icons-material/Search"; +import ExpandMore from "@mui/icons-material/ExpandMore"; import { Alert, Box, Button, + Checkbox, + Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, + FormControlLabel, IconButton, InputLabel, MenuItem, @@ -27,18 +38,37 @@ import { TableContainer, TableHead, TableRow, + Tab, + Tabs, TextField, + ToggleButton, + ToggleButtonGroup, Typography, } from "@mui/material"; import { fetchDoFloorSettingsClient, fetchSupplierComboClient, + fetchWarehouseCodeRowsClient, postSettingClient, type ShopComboRow, + type WarehousePickRow, } from "@/app/api/settings/deliveryOrderFloor/client"; import { + EMPTY_CODE_LIST_MARKER, SETTING_DO_FLOOR_SUPPLIERS_2F, SETTING_DO_FLOOR_SUPPLIERS_4F, + SETTING_DO_PICK_EXCLUDE_PRINT_MODE, + SETTING_DO_PICK_EXCLUDE_WAREHOUSES, + DEFAULT_AUTO_PUTAWAY_WAREHOUSE, + SETTING_JO_AUTO_PUTAWAY_RULES, + SETTING_JO_PICK_EXCLUDE_PRINT_MODE, + SETTING_JO_PICK_EXCLUDE_WAREHOUSES, + SETTING_PO_AUTO_PUTAWAY_RULES, + emptyAutoPutAwayRule, + type AutoPutAwayRule, + type AutoPutAwayScope, + type ExcludePrintMode, + type PutAwayLocationMode, } from "@/app/api/settings/deliveryOrderFloor/constants"; function normalizeCodesCsv(raw: string): string { @@ -49,14 +79,96 @@ function normalizeCodesCsv(raw: string): string { .join(","); } -/** 顯示為 `[XXX, YYY]`;無代碼時為 `[]` */ -function formatBracketList(codesCsv: string): string { - const n = normalizeCodesCsv(codesCsv); - if (!n) return "[]"; - return `[${n.split(",").join(", ")}]`; +type EditFloor = "2F" | "4F"; +type ExcludeKind = "do" | "jo"; +type PageTab = "do" | "jo" | "po"; +type WarehousePickTarget = "exclude" | "putaway"; +type PutAwayRowField = "keywords" | "bom" | "location" | "exceptionKeywords"; +type PutAwayEditCell = { kind: AutoPutAwayScope; index: number; field: PutAwayRowField; exceptionIndex?: number }; + +function exceptionOutsideParent(parentKeywords: string, exceptionKeywords: string): boolean { + const parent = csvTokens(parentKeywords).map((token) => token.toUpperCase()); + if (parent.length === 0) return false; + const keywords = csvTokens(exceptionKeywords).map((token) => token.toUpperCase()); + if (keywords.length === 0) return false; + return keywords.some((keyword) => !parent.some((item) => keyword === item || keyword.includes(item) || item.includes(keyword))); } -type EditFloor = "2F" | "4F"; +function csvTokens(raw: string): string[] { + const n = normalizeCodesCsv(raw); + return n ? n.split(",") : []; +} + +type BomChoice = "WIP" | "FG" | "BOTH"; + +function bomChoice(raw: string): BomChoice { + const tokens = new Set(csvTokens(raw).map((token) => token.toUpperCase())); + const hasWip = tokens.has("WIP"); + const hasFg = tokens.has("FG"); + if (hasWip && !hasFg) return "WIP"; + if (hasFg && !hasWip) return "FG"; + return "BOTH"; +} + +function bomCsv(choice: BomChoice): string { + if (choice === "WIP") return "WIP"; + if (choice === "FG") return "FG"; + return "FG,WIP"; +} + +function keywordsOverlap(leftRaw: string, rightRaw: string): boolean { + const left = csvTokens(leftRaw).map((token) => token.toUpperCase()); + const right = csvTokens(rightRaw).map((token) => token.toUpperCase()); + if (left.length === 0 || right.length === 0) return true; + return left.some((a) => right.some((b) => a === b || a.includes(b) || b.includes(a))); +} + +function bomsOverlap(scope: AutoPutAwayScope, leftRaw: string, rightRaw: string): boolean { + if (scope !== "jo") return true; + const left = csvTokens(leftRaw).map((token) => token.toUpperCase()); + const right = csvTokens(rightRaw).map((token) => token.toUpperCase()); + if (left.length === 0 || right.length === 0) return true; + return left.some((token) => right.includes(token)); +} + +function exceptionKeywords(rule: AutoPutAwayRule): string[] { + return (rule.exceptions ?? []).flatMap((row) => csvTokens(row.itemKeywords).map((token) => token.toUpperCase())); +} + +/** Later keywords are all caught by the earlier rule's exceptions, so those items are not claimed. */ +function laterCoveredByEarlierExceptions(earlier: AutoPutAwayRule, later: AutoPutAwayRule): boolean { + const exceptions = exceptionKeywords(earlier); + const laterKeywords = csvTokens(later.itemKeywords).map((token) => token.toUpperCase()); + if (exceptions.length === 0 || laterKeywords.length === 0) return false; + return laterKeywords.every((keyword) => exceptions.some((exception) => keyword === exception || keyword.includes(exception))); +} + +function earlierOverlappingRules(scope: AutoPutAwayScope, rules: AutoPutAwayRule[], index: number): number[] { + const rule = rules[index]; + if (!rule) return []; + const hits: number[] = []; + for (let earlier = 0; earlier < index; earlier += 1) { + const other = rules[earlier]; + if (!other || other.enabled === false) continue; + if (!keywordsOverlap(other.itemKeywords, rule.itemKeywords)) continue; + if (laterCoveredByEarlierExceptions(other, rule)) continue; + if (!bomsOverlap(scope, other.bomTypes, rule.bomTypes)) continue; + hits.push(earlier); + } + return hits; +} + +function disableOverlappingRules(scope: AutoPutAwayScope, rules: AutoPutAwayRule[]): AutoPutAwayRule[] { + const next = rules.map((rule) => ({ ...rule, exceptions: (rule.exceptions ?? []).map((row) => ({ ...row })) })); + for (let index = 0; index < next.length; index += 1) { + if (earlierOverlappingRules(scope, next, index).length > 0) next[index].enabled = false; + } + return next; +} + +function ValueChip({ label, onDelete }: { label: string; onDelete?: () => void }) { + return ; +} type FloorRow = { code: string; name: string }; @@ -83,10 +195,51 @@ function floorRowsToCsv(rows: FloorRow[]): string { return rows.map((r) => r.code.trim()).filter(Boolean).join(","); } +const LOCATION_ALL = "ALL"; +const BUTTONS_PER_ROW = 15; +const BUTTON_WIDTH = 80; + +type ExcludePick = { storeId: string; warehouse: string; area: string; slotCode: string }; + +const emptyExcludePick = (): ExcludePick => ({ storeId: "", warehouse: "", area: "", slotCode: "" }); + +const compareAlphanumeric = (a: string, b: string) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); + +const chunk = (items: T[], size: number): T[][] => { + const rows: T[][] = []; + for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size)); + return rows; +}; + +const withAllOption = (options: string[]) => (options.length > 1 ? [LOCATION_ALL, ...options] : options); + +type WarehouseChipGroup = { key: string; title: string; tails: string[] }; + +function groupWarehouseCodes(csv: string): WarehouseChipGroup[] { + const codes = normalizeCodesCsv(csv).split(",").filter(Boolean); + const order: string[] = []; + const map = new Map(); + for (const code of codes) { + const parts = code.split("-"); + const floor = parts[0] ?? ""; + const zone = parts[1] ?? ""; + const tail = parts.length > 2 ? parts.slice(2).join("-") : code; + const key = floor && zone ? `${floor} · ${zone}` : code; + if (!map.has(key)) { + map.set(key, []); + order.push(key); + } + map.get(key)?.push(tail); + } + return order.map((key) => ({ key, title: key, tails: map.get(key) ?? [] })); +} + const DeliveryOrderFloorSettings: React.FC = () => { const { t } = useTranslation("deliveryOrderFloor"); const saveInFlightRef = useRef(false); const addInFlightRef = useRef(false); + const printModeInFlightRef = useRef(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -94,6 +247,12 @@ const DeliveryOrderFloorSettings: React.FC = () => { const [codes2F, setCodes2F] = useState(""); const [codes4F, setCodes4F] = useState(""); + const [doExclude, setDoExclude] = useState(""); + const [joExclude, setJoExclude] = useState(""); + const [doPrintMode, setDoPrintMode] = useState("hideList"); + const [joPrintMode, setJoPrintMode] = useState("hideList"); + const [poPutAway, setPoPutAway] = useState([]); + const [joPutAway, setJoPutAway] = useState([]); const [editOpen, setEditOpen] = useState(false); const [editFloor, setEditFloor] = useState("2F"); @@ -107,6 +266,24 @@ const DeliveryOrderFloorSettings: React.FC = () => { const [addCodeInput, setAddCodeInput] = useState(""); const [addError, setAddError] = useState(null); + const [excludeOpen, setExcludeOpen] = useState(false); + const [excludeKind, setExcludeKind] = useState("do"); + const [excludeDraft, setExcludeDraft] = useState([]); + const [warehouseCombo, setWarehouseCombo] = useState(null); + const [warehouseLoading, setWarehouseLoading] = useState(false); + const [excludeAddOpen, setExcludeAddOpen] = useState(false); + const [pageTab, setPageTab] = useState("do"); + const [putAwayEdit, setPutAwayEdit] = useState(null); + const [editCsv, setEditCsv] = useState(""); + const [editToken, setEditToken] = useState(""); + const [editLocationMode, setEditLocationMode] = useState("warehouse"); + const [editWarehouseCode, setEditWarehouseCode] = useState(""); + const [warehousePickTarget, setWarehousePickTarget] = useState("exclude"); + const [expandedGroupKeys, setExpandedGroupKeys] = useState>({}); + const [excludeQuery, setExcludeQuery] = useState(""); + const [excludePick, setExcludePick] = useState(emptyExcludePick); + const [excludeAddError, setExcludeAddError] = useState(null); + const load = useCallback(async () => { setLoading(true); setError(null); @@ -115,6 +292,12 @@ const DeliveryOrderFloorSettings: React.FC = () => { const floor = await fetchDoFloorSettingsClient(); setCodes2F(floor.suppliers2F); setCodes4F(floor.suppliers4F); + setDoExclude(floor.doExcludeWarehouses); + setJoExclude(floor.joExcludeWarehouses); + setDoPrintMode(floor.doExcludePrintMode); + setJoPrintMode(floor.joExcludePrintMode); + setPoPutAway(floor.poAutoPutAway); + setJoPutAway(floor.joAutoPutAway); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -126,8 +309,18 @@ const DeliveryOrderFloorSettings: React.FC = () => { void load(); }, [load]); - const display2F = useMemo(() => formatBracketList(codes2F), [codes2F]); - const display4F = useMemo(() => formatBracketList(codes4F), [codes4F]); + const codes2FList = useMemo(() => { + const n = normalizeCodesCsv(codes2F); + return n ? n.split(",") : []; + }, [codes2F]); + const codes4FList = useMemo(() => { + const n = normalizeCodesCsv(codes4F); + return n ? n.split(",") : []; + }, [codes4F]); + const doExcludeGroups = useMemo(() => groupWarehouseCodes(doExclude), [doExclude]); + const joExcludeGroups = useMemo(() => groupWarehouseCodes(joExclude), [joExclude]); + const doExcludeCount = doExcludeGroups.reduce((n, g) => n + g.tails.length, 0); + const joExcludeCount = joExcludeGroups.reduce((n, g) => n + g.tails.length, 0); const currentDraftRows = editFloor === "2F" ? draftRows2F : draftRows4F; const setCurrentDraftRows = editFloor === "2F" ? setDraftRows2F : setDraftRows4F; @@ -254,6 +447,837 @@ const DeliveryOrderFloorSettings: React.FC = () => { } }; + const openExclude = async (kind: ExcludeKind) => { + setExcludeKind(kind); + setExcludeOpen(true); + setError(null); + setSuccess(null); + setExcludeAddOpen(false); + setExcludePick(emptyExcludePick()); + setExcludeAddError(null); + setWarehouseLoading(true); + try { + const combo = await fetchWarehouseCodeRowsClient(); + setWarehouseCombo(combo); + const csv = kind === "do" ? doExclude : joExclude; + setExcludeDraft(csvToFloorRows(csv, combo)); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + setExcludeDraft([]); + setWarehouseCombo(null); + } finally { + setWarehouseLoading(false); + } + }; + + const closeExclude = () => { + if (dialogSaving) return; + setExcludeOpen(false); + setExcludeAddOpen(false); + setExcludePick(emptyExcludePick()); + setExcludeAddError(null); + }; + + const saveExclude = async () => { + if (saveInFlightRef.current) return; + saveInFlightRef.current = true; + setDialogSaving(true); + setError(null); + setSuccess(null); + try { + const normalized = normalizeCodesCsv(floorRowsToCsv(excludeDraft)); + if (normalized.length > 1000) { + setError(t("List too long")); + return; + } + const key = + excludeKind === "do" ? SETTING_DO_PICK_EXCLUDE_WAREHOUSES : SETTING_JO_PICK_EXCLUDE_WAREHOUSES; + await postSettingClient(key, normalized || EMPTY_CODE_LIST_MARKER); + if (excludeKind === "do") setDoExclude(normalized); + else setJoExclude(normalized); + setSuccess(t("Saved")); + setExcludeOpen(false); + setExcludeAddOpen(false); + setExcludePick(emptyExcludePick()); + setExcludeAddError(null); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setDialogSaving(false); + saveInFlightRef.current = false; + } + }; + + const removeExcludeRow = (code: string) => { + setExcludeDraft((rows) => rows.filter((r) => r.code !== code)); + }; + + const warehouses = warehouseCombo ?? []; + + const floorOptions = useMemo(() => { + const set = new Set(); + warehouses.forEach((w) => { + if (w.storeId) set.add(w.storeId); + }); + return withAllOption(Array.from(set).sort(compareAlphanumeric)); + }, [warehouses]); + + const warehouseZones = useMemo(() => { + if (!excludePick.storeId) return []; + const set = new Set(); + warehouses.forEach((w) => { + if (excludePick.storeId !== LOCATION_ALL && w.storeId !== excludePick.storeId) return; + if (w.warehouse) set.add(w.warehouse); + }); + return withAllOption(Array.from(set).sort(compareAlphanumeric)); + }, [warehouses, excludePick.storeId]); + + const areaOptions = useMemo(() => { + if (!excludePick.storeId || !excludePick.warehouse) return []; + const set = new Set(); + warehouses.forEach((w) => { + if (excludePick.storeId !== LOCATION_ALL && w.storeId !== excludePick.storeId) return; + if (excludePick.warehouse !== LOCATION_ALL && w.warehouse !== excludePick.warehouse) return; + if (w.area) set.add(w.area); + }); + return withAllOption(Array.from(set).sort(compareAlphanumeric)); + }, [warehouses, excludePick.storeId, excludePick.warehouse]); + + const warehouseRows = useMemo(() => chunk(warehouseZones, BUTTONS_PER_ROW), [warehouseZones]); + const areaRows = useMemo(() => chunk(areaOptions, BUTTONS_PER_ROW), [areaOptions]); + const putAwaySlotChoices = useMemo(() => { + if (!excludePick.storeId || !excludePick.warehouse || !excludePick.area) return []; + return warehouses.filter((w) => { + if (excludePick.storeId !== LOCATION_ALL && w.storeId !== excludePick.storeId) return false; + if (excludePick.warehouse !== LOCATION_ALL && w.warehouse !== excludePick.warehouse) return false; + if (excludePick.area !== LOCATION_ALL && w.area !== excludePick.area) return false; + return Boolean(w.code); + }); + }, [warehouses, excludePick.storeId, excludePick.warehouse, excludePick.area]); + const slotRows = useMemo(() => chunk(putAwaySlotChoices, BUTTONS_PER_ROW), [putAwaySlotChoices]); + + const confirmAddWarehouse = () => { + if (addInFlightRef.current) return; + addInFlightRef.current = true; + setExcludeAddError(null); + try { + if (!excludePick.storeId) { + setExcludeAddError(t("Select floor first")); + return; + } + if (!excludePick.warehouse) { + setExcludeAddError(t("Select warehouse first")); + return; + } + if (!excludePick.area) { + setExcludeAddError(t("Select area first")); + return; + } + const matched = warehouses.filter((w) => { + if (excludePick.storeId !== LOCATION_ALL && w.storeId !== excludePick.storeId) return false; + if (excludePick.warehouse !== LOCATION_ALL && w.warehouse !== excludePick.warehouse) return false; + if (excludePick.area !== LOCATION_ALL && w.area !== excludePick.area) return false; + return Boolean(w.code); + }); + if (matched.length === 0) { + setExcludeAddError(t("No warehouse matched")); + return; + } + if (warehousePickTarget === "putaway") { + const picked = matched.length === 1 ? matched[0] : matched.find((w) => w.code === excludePick.slotCode); + if (!picked) { + setExcludeAddError(t("Select one warehouse")); + return; + } + setEditWarehouseCode(picked.code.trim()); + setEditLocationMode("warehouse"); + setExcludeAddOpen(false); + setExcludePick(emptyExcludePick()); + setWarehousePickTarget("exclude"); + return; + } + const existing = new Set(excludeDraft.map((r) => r.code.toLowerCase())); + const adding = matched.filter((w) => !existing.has(w.code.toLowerCase())); + if (adding.length === 0) { + setExcludeAddError(t("Duplicate warehouse")); + return; + } + setExcludeDraft((rows) => [ + ...rows, + ...adding.map((w) => ({ code: w.code.trim(), name: w.name?.trim() || "" })), + ]); + setExcludeAddOpen(false); + setExcludePick(emptyExcludePick()); + } finally { + addInFlightRef.current = false; + } + }; + + const ensureWarehouseCombo = async () => { + if (warehouseCombo?.length) return warehouseCombo; + setWarehouseLoading(true); + try { + const combo = await fetchWarehouseCodeRowsClient(); + setWarehouseCombo(combo); + return combo; + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + setWarehouseCombo(null); + return []; + } finally { + setWarehouseLoading(false); + } + }; + + const persistPutAwayRules = async (kind: AutoPutAwayScope, rules: AutoPutAwayRule[], silent = false) => { + if (saveInFlightRef.current) return; + saveInFlightRef.current = true; + setDialogSaving(true); + setError(null); + setSuccess(null); + try { + const next = disableOverlappingRules( + kind, + rules.map((rule) => ({ + itemKeywords: normalizeCodesCsv(rule.itemKeywords), + bomTypes: kind === "jo" ? normalizeCodesCsv(rule.bomTypes) : "", + locationMode: rule.locationMode, + warehouseCode: rule.warehouseCode.trim() || DEFAULT_AUTO_PUTAWAY_WAREHOUSE, + enabled: rule.enabled !== false, + exceptions: (rule.exceptions ?? []).map((row) => ({ + itemKeywords: normalizeCodesCsv(row.itemKeywords), + })), + })), + ); + if (next.some((rule) => rule.locationMode === "warehouse" && !rule.warehouseCode)) { + setError(t("Select one warehouse")); + return; + } + const payload = JSON.stringify(next); + if (payload.length > 1000) { + setError(t("List too long")); + return; + } + const key = kind === "po" ? SETTING_PO_AUTO_PUTAWAY_RULES : SETTING_JO_AUTO_PUTAWAY_RULES; + await postSettingClient(key, next.length === 0 ? EMPTY_CODE_LIST_MARKER : payload); + if (kind === "po") setPoPutAway(next); + else setJoPutAway(next); + if (!silent) { + setSuccess(t("Saved")); + setPutAwayEdit(null); + setEditToken(""); + } + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setDialogSaving(false); + saveInFlightRef.current = false; + } + }; + + const rulesOf = (kind: AutoPutAwayScope) => (kind === "po" ? poPutAway : joPutAway); + + useEffect(() => { + if (loading) return; + const jobs: Array<[AutoPutAwayScope, AutoPutAwayRule[]]> = []; + (["po", "jo"] as const).forEach((kind) => { + const current = kind === "po" ? poPutAway : joPutAway; + const fixed = disableOverlappingRules(kind, current); + const changed = fixed.some((rule, index) => rule.enabled !== (current[index]?.enabled !== false)); + if (changed) jobs.push([kind, fixed]); + }); + if (jobs.length === 0) return; + void (async () => { + for (const [kind, rules] of jobs) { + await persistPutAwayRules(kind, rules, true); + } + })(); + }, [poPutAway, joPutAway, loading]); + + const startRowEdit = async ( + kind: AutoPutAwayScope, + index: number, + field: PutAwayRowField, + rule: AutoPutAwayRule, + exceptionIndex?: number, + ) => { + setPutAwayEdit({ kind, index, field, exceptionIndex }); + if (field === "bom") setEditCsv(rule.bomTypes); + else if (field === "exceptionKeywords") { + setEditCsv((rule.exceptions ?? []).map((row) => row.itemKeywords).filter(Boolean).join(",")); + } else setEditCsv(rule.itemKeywords); + setEditToken(""); + setEditLocationMode(rule.locationMode); + setEditWarehouseCode(rule.warehouseCode); + setError(null); + setSuccess(null); + if (field === "location") await ensureWarehouseCombo(); + }; + + const saveRowEdit = async (csvOverride?: string) => { + if (!putAwayEdit) return; + const csv = csvOverride ?? editCsv; + const { kind, index, field } = putAwayEdit; + const next = rulesOf(kind).map((rule) => ({ + ...rule, + exceptions: (rule.exceptions ?? []).map((row) => ({ ...row })), + })); + const rule = next[index]; + if (!rule) return; + if (field === "keywords") rule.itemKeywords = csv; + if (field === "bom") rule.bomTypes = bomCsv(bomChoice(csv)); + if (field === "location") { + rule.locationMode = editLocationMode; + rule.warehouseCode = editWarehouseCode; + } + if (field === "exceptionKeywords") { + rule.exceptions = csvTokens(csv).length === 0 ? [] : [{ itemKeywords: csv }]; + } + await persistPutAwayRules(kind, next); + }; + + const addEditToken = () => { + const raw = editToken.trim().toUpperCase(); + if (!raw) return; + const parts = csvTokens(editCsv); + if (parts.some((part) => part.toUpperCase() === raw)) return; + const next = [...parts, raw].join(","); + setEditCsv(next); + setEditToken(""); + void saveRowEdit(next); + }; + + const savePrintMode = async (kind: ExcludeKind, mode: ExcludePrintMode) => { + if (printModeInFlightRef.current) return; + printModeInFlightRef.current = true; + const previous = kind === "do" ? doPrintMode : joPrintMode; + if (kind === "do") setDoPrintMode(mode); + else setJoPrintMode(mode); + setError(null); + try { + const key = kind === "do" ? SETTING_DO_PICK_EXCLUDE_PRINT_MODE : SETTING_JO_PICK_EXCLUDE_PRINT_MODE; + await postSettingClient(key, mode); + setSuccess(t("Saved")); + } catch (e: unknown) { + if (kind === "do") setDoPrintMode(previous); + else setJoPrintMode(previous); + setError(e instanceof Error ? e.message : String(e)); + } finally { + printModeInFlightRef.current = false; + } + }; + + const cardSx = { + border: "1px solid", + borderColor: "divider", + borderRadius: 2, + bgcolor: "background.paper", + p: 2.5, + }; + + const renderSlotChip = (key: string, tail: string) => ( + + + {tail} + + } + sx={{ + borderRadius: 2, + fontFamily: "monospace", + height: 40, + fontSize: 16, + "& .MuiChip-label": { px: 1.5 }, + }} + /> + ); + + const renderExcludeCard = (groups: WarehouseChipGroup[], kind: ExcludeKind) => { + return ( + + + + + + + + + + {kind === "do" ? t("Section exclude warehouses") : t("JO exclude warehouses")} + + + + + {kind === "do" ? t("DO exclude hint") : t("JO exclude hint")} + + + + + + + + {t("Print list label")} + + { + if (next) void savePrintMode(kind, next); + }} + sx={{ flexWrap: "wrap" }} + > + + + + {t("Print hide list")} + + + {t("Print hide list hint")} + + + + + + + {t("Print hide qr")} + + + {t("Print hide qr hint")} + + + + + + {groups.length === 0 ? ( + + {t("Empty exclude chips")} + + ) : ( + + {groups.map((group) => { + const expanded = Boolean(expandedGroupKeys[group.key]); + const preview = !expanded && group.tails.length > 8 ? group.tails.slice(0, 8) : group.tails; + const hidden = group.tails.length - preview.length; + return ( + + + {group.title} + + + + {preview.map((tail) => renderSlotChip(`${group.key}-${tail}`, tail))} + + {group.tails.length > 8 ? ( + + + + ) : null} + + ); + })} + + )} + + ); + }; + + const isEditingRow = (kind: AutoPutAwayScope, index: number, field: PutAwayRowField, exceptionIndex?: number) => + putAwayEdit?.kind === kind && + putAwayEdit.index === index && + putAwayEdit.field === field && + (putAwayEdit.exceptionIndex ?? -1) === (exceptionIndex ?? -1); + + const renderRowActions = ( + kind: AutoPutAwayScope, + index: number, + field: PutAwayRowField, + rule: AutoPutAwayRule, + exceptionIndex?: number, + ) => { + if (isEditingRow(kind, index, field, exceptionIndex)) { + const keywordRow = field === "keywords" || field === "exceptionKeywords"; + return ( + + {keywordRow ? null : ( + void saveRowEdit()}> + {dialogSaving ? : } + + )} + + + ); + } + return ( + void startRowEdit(kind, index, field, rule, exceptionIndex)} + > + + + ); + }; + + const renderTokenEditor = (emptyText: string) => { + const tokens = csvTokens(editCsv); + return ( + + + {tokens.length === 0 ? ( + + {emptyText} + + ) : ( + tokens.map((token) => ( + { + const next = csvTokens(editCsv).filter((part) => part !== token).join(","); + setEditCsv(next); + void saveRowEdit(next); + }} + /> + )) + )} + + + setEditToken(e.target.value)} + placeholder={t("Putaway token placeholder")} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + addEditToken(); + }} + sx={{ "& .MuiInputBase-input::placeholder": { color: "#9e9e9e", opacity: 1 } }} + /> + + + + ); + }; + + const renderPutAwayCard = (kind: AutoPutAwayScope) => { + const rules = kind === "po" ? poPutAway : joPutAway; + const rowSx = { border: "1px solid", borderColor: "divider", borderRadius: 2, px: 2, py: 1.25 }; + return ( + + + + + + + + + {t("Section auto putaway")} + + + + + {rules.length === 0 ? ( + + {t("Empty putaway rules")} + + ) : ( + + {rules.map((rule, index) => { + const overlappedBy = earlierOverlappingRules(kind, rules, index); + return ( + + + + {t("Putaway rule n", { n: index + 1 })} + 0} + onChange={(event) => { + const next = rules.map((row, rowIndex) => + rowIndex === index ? { ...row, enabled: event.target.checked } : row, + ); + void persistPutAwayRules(kind, next); + }} + /> + } + label={t("Rule enabled")} + /> + + void persistPutAwayRules(kind, rules.filter((_, i) => i !== index))} + > + + + + {overlappedBy.length > 0 ? ( + + {t("Putaway rule overlap", { rules: overlappedBy.map((i) => i + 1).join(", ") })} + + ) : null} + + + + {t("Putaway keywords")} + + {isEditingRow(kind, index, "keywords") ? ( + renderTokenEditor(t("All items")) + ) : ( + + {csvTokens(rule.itemKeywords).length === 0 ? ( + + ) : ( + csvTokens(rule.itemKeywords).map((token) => ) + )} + + )} + {renderRowActions(kind, index, "keywords", rule)} + + {kind === "jo" ? ( + + + {t("Putaway bom")} + + {isEditingRow(kind, index, "bom") ? ( + + { + if (next) setEditCsv(bomCsv(next)); + }} + > + + {t("BOM WIP only")} + + + {t("BOM FG only")} + + + {t("BOM both")} + + + + ) : ( + + {csvTokens(rule.bomTypes).length === 0 ? ( + + ) : ( + csvTokens(rule.bomTypes).map((token) => ) + )} + + )} + {renderRowActions(kind, index, "bom", rule)} + + ) : null} + + + {t("Putaway location")} + + {isEditingRow(kind, index, "location") ? ( + + { + if (next) setEditLocationMode(next); + }} + > + + {t("Location item code")} + + + {t("Location warehouse")} + + + {editLocationMode === "warehouse" ? ( + + + + + ) : null} + + ) : ( + + {rule.locationMode === "itemLocation" ? ( + + ) : ( + + )} + + )} + {renderRowActions(kind, index, "location", rule)} + + {(() => { + const exceptionKeywords = (rule.exceptions ?? []).map((row) => row.itemKeywords).filter(Boolean).join(","); + return ( + <> + {exceptionOutsideParent(rule.itemKeywords, exceptionKeywords) ? ( + {t("Exception outside rule")} + ) : null} + + + {t("Exception keywords")} + + {isEditingRow(kind, index, "exceptionKeywords") ? ( + renderTokenEditor(t("Empty putaway condition")) + ) : ( + + {csvTokens(exceptionKeywords).length === 0 ? ( + + ) : ( + csvTokens(exceptionKeywords).map((token) => ) + )} + + )} + {renderRowActions(kind, index, "exceptionKeywords", rule)} + + + ); + })()} + + + ); + })} + + )} + + + ); + }; + + const renderSupplierRow = (floor: EditFloor, codes: string[]) => ( + + + {t("Floor label")} + + {floor} + + {t("Supplier list")} + + + {codes.length === 0 ? ( + + {t("Empty floor list")} + + ) : ( + codes.map((code) => ) + )} + + void openEdit(floor)} size="small"> + + + + ); + + const excludeQueryNorm = excludeQuery.trim().toLowerCase(); + const visibleExcludeRows = excludeQueryNorm + ? excludeDraft.filter( + (row) => + row.code.toLowerCase().includes(excludeQueryNorm) || + row.name.toLowerCase().includes(excludeQueryNorm), + ) + : excludeDraft; + if (loading) { return ( @@ -271,73 +1295,88 @@ const DeliveryOrderFloorSettings: React.FC = () => { {error ? {error} : null} {success ? {success} : null} - - - - {t("2F supplier")} - - - {display2F} - - void openEdit("2F")} - size="small" - > - - - + setPageTab(value)} + sx={{ borderBottom: 1, borderColor: "divider", "& .MuiTab-root": { textTransform: "none", minHeight: 48 } }} + > + } + iconPosition="start" + label={ + + {t("Tab delivery order")} + + + } + /> + } + iconPosition="start" + label={ + + {t("Tab job order")} + + + } + /> + } + iconPosition="start" + label={t("Tab purchase order")} + /> + - - - {t("4F supplier")} - - - {display4F} - - void openEdit("4F")} - size="small" - > - - - - + {pageTab === "do" ? ( + + + + + + + + + + {t("Section suppliers")} + + + {t("Supplier card hint")} + + + + + + + {renderSupplierRow("2F", codes2FList)} + {renderSupplierRow("4F", codes4FList)} + + + {renderExcludeCard(doExcludeGroups, "do")} + + ) : pageTab === "jo" ? ( + + {renderExcludeCard(joExcludeGroups, "jo")} + {renderPutAwayCard("jo")} + + ) : ( + renderPutAwayCard("po") + )} {t("Edit dialog title")} @@ -475,6 +1514,286 @@ const DeliveryOrderFloorSettings: React.FC = () => { + + + + {excludeKind === "do" ? t("Edit DO exclude title") : t("Edit JO exclude title")} + + {t("Exclude dialog hint")} + + + + + {error ? {error} : null} + + setExcludeQuery(e.target.value)} + placeholder={t("Search warehouse")} + InputProps={{ + startAdornment: , + }} + /> + + + {warehouseLoading ? ( + + + + ) : ( + + + + + {t("Col warehouse code")} + {t("Col warehouse name")} + {t("Floor label")} + + {t("Col actions")} + + + + + {visibleExcludeRows.length === 0 ? ( + + + + {t("Empty exclude list")} + + + + ) : ( + visibleExcludeRows.map((row) => ( + + {row.code} + + {row.name || ( + + {t("Unknown warehouse name")} + + )} + + {row.code.split("-")[0] || ""} + + removeExcludeRow(row.code)} + disabled={dialogSaving} + > + + + + + )) + )} + +
+
+ )} +
+
+ + + {t("Row count", { count: visibleExcludeRows.length })} + + + + + + +
+ + setExcludeAddOpen(false)} fullWidth maxWidth="lg"> + {warehousePickTarget === "putaway" ? t("Choose warehouse") : t("Add warehouse title")} + + + + + {t("Floor")} + + { + setExcludeAddError(null); + setExcludePick({ storeId: next ?? "", warehouse: "", area: "", slotCode: "" }); + }} + > + {floorOptions.map((floor) => ( + + {floor === LOCATION_ALL ? t("All") : floor} + + ))} + + + + + + {t("Warehouse")} + + + {warehouseRows.map((row, rowIndex) => ( + { + if (next == null) return; + setExcludeAddError(null); + setExcludePick((prev) => ({ ...prev, warehouse: next, area: "", slotCode: "" })); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: BUTTON_WIDTH, + minWidth: BUTTON_WIDTH, + maxWidth: BUTTON_WIDTH, + px: 0, + }, + }} + > + {row.map((zone) => ( + + {zone === LOCATION_ALL ? t("All") : zone} + + ))} + + ))} + + {!excludePick.storeId ? ( + + {t("Select floor first")} + + ) : null} + + + + + {t("Area")} + + + {areaRows.map((row, rowIndex) => ( + { + if (next == null) return; + setExcludeAddError(null); + setExcludePick((prev) => ({ ...prev, area: next, slotCode: "" })); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: BUTTON_WIDTH, + minWidth: BUTTON_WIDTH, + maxWidth: BUTTON_WIDTH, + px: 0, + }, + }} + > + {row.map((area) => ( + + {area === LOCATION_ALL ? t("All") : area} + + ))} + + ))} + + {!excludePick.warehouse ? ( + + {excludePick.storeId ? t("Select warehouse first") : t("Select floor first")} + + ) : null} + + {warehousePickTarget === "putaway" && excludePick.area && putAwaySlotChoices.length > 0 ? ( + + + {t("Putaway slot")} + + + {slotRows.map((row, rowIndex) => ( + item.code).join("-") || rowIndex} + exclusive + size="small" + value={excludePick.slotCode || (putAwaySlotChoices.length === 1 ? putAwaySlotChoices[0].code : null)} + onChange={(_, next: string | null) => { + setExcludeAddError(null); + setExcludePick((prev) => ({ ...prev, slotCode: next ?? "" })); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: BUTTON_WIDTH, + minWidth: BUTTON_WIDTH, + maxWidth: BUTTON_WIDTH, + px: 0, + }, + }} + > + {row.map((item) => { + const parts = item.code.split("-"); + const slot = parts[parts.length - 1] || item.code; + return ( + + {slot} + + ); + })} + + ))} + + + ) : null} + {excludeAddError ? {excludeAddError} : null} + + + + + + +
); }; diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 29536591..c892984c 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -4696,6 +4696,7 @@ paginatedData.map((row, index) => { statusTitleText={workbenchLotLabelStatusBanner.text} statusTitleSeverity={workbenchLotLabelStatusBanner.severity} warehouseCodePrefixFilter={lotFloorPrefixFilter} + pickRuleScope="do" triggerLotAvailableQty={ workbenchLotLabelContextLot != null ? Number(workbenchLotLabelContextLot.availableQty) diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx index 2863a5b9..23c03ce5 100644 --- a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx +++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx @@ -36,6 +36,8 @@ import { fetchWorkbenchPrinters, printWorkbenchLotLabel, } from "@/app/api/doworkbench/actions"; +import { fetchDoFloorSettingsClient } from "@/app/api/settings/deliveryOrderFloor/client"; +import type { ExcludePrintMode } from "@/app/api/settings/deliveryOrderFloor/constants"; import { QRCodeSVG } from "qrcode.react"; type ScanPayload = { @@ -114,6 +116,8 @@ export interface WorkbenchLotLabelPrintModalProps { /** Global submit qty shared with outer "Qty will submit". */ submitQty?: number | null; onSubmitQtyChange?: (qty: number) => void; + /** 揀貨規則:do 用送貨單排除倉,jo 用工單排除倉,控制列印清單/QR。 */ + pickRuleScope?: "do" | "jo"; } function safeParseScanPayload(raw: string): ScanPayload | null { @@ -166,6 +170,7 @@ const WorkbenchLotLabelPrintModal: React.FC = onWorkbenchScanPick, submitQty = null, onSubmitQtyChange, + pickRuleScope, }) => { const scanInputRef = useRef(null); const [scanInput, setScanInput] = useState(""); @@ -187,6 +192,10 @@ const WorkbenchLotLabelPrintModal: React.FC = const [qrVisibleLotLineId, setQrVisibleLotLineId] = useState( null, ); + const [excludePrintRule, setExcludePrintRule] = useState<{ + codes: Set; + mode: ExcludePrintMode; + } | null>(null); const [snackbar, setSnackbar] = useState<{ open: boolean; @@ -214,6 +223,35 @@ const WorkbenchLotLabelPrintModal: React.FC = return () => clearTimeout(t); }, [open, resetAll]); + useEffect(() => { + if (!open || !pickRuleScope) { + setExcludePrintRule(null); + return; + } + let cancelled = false; + void fetchDoFloorSettingsClient() + .then((settings) => { + if (cancelled) return; + const csv = + pickRuleScope === "do" ? settings.doExcludeWarehouses : settings.joExcludeWarehouses; + const mode = + pickRuleScope === "do" ? settings.doExcludePrintMode : settings.joExcludePrintMode; + const codes = new Set( + csv + .split(",") + .map((code) => code.trim().toUpperCase()) + .filter(Boolean), + ); + setExcludePrintRule({ codes, mode }); + }) + .catch(() => { + if (!cancelled) setExcludePrintRule(null); + }); + return () => { + cancelled = true; + }; + }, [open, pickRuleScope]); + const loadPrinters = useCallback(async () => { setPrintersLoading(true); try { @@ -449,13 +487,20 @@ const WorkbenchLotLabelPrintModal: React.FC = const filteredLots = useMemo(() => { const prefix = String(warehouseCodePrefixFilter ?? "").trim(); - if (!prefix) return availableLots; + const excluded = excludePrintRule?.codes; + const hideList = excludePrintRule?.mode === "hideList"; return availableLots.filter((lot) => { - // 使用者從本列開啟視窗:即使 API 未帶 warehouseCode,仍應顯示目前這筆批號 - if (lot._scanned) return true; - return String(lot.warehouseCode ?? "").startsWith(prefix); + if (prefix && !lot._scanned) { + const code = String(lot.warehouseCode ?? ""); + if (!code.startsWith(prefix)) return false; + } + if (hideList && excluded && !lot._scanned) { + const code = String(lot.warehouseCode ?? "").trim().toUpperCase(); + if (code && excluded.has(code)) return false; + } + return true; }); - }, [availableLots, warehouseCodePrefixFilter]); + }, [availableLots, warehouseCodePrefixFilter, excludePrintRule]); const selectedPrinter = useMemo(() => { if (selectedPrinterId === "") return null; @@ -682,11 +727,15 @@ const WorkbenchLotLabelPrintModal: React.FC = const isPrinting = printingLotLineId === lot.inventoryLotLineId; const loc = String(lot.warehouseCode ?? "").trim(); + const suppressQr = Boolean( + excludePrintRule?.codes.has(loc.toUpperCase()), + ); const canShowLotQr = !!onWorkbenchScanPick && !!analysis && !analysisLoading && - !disableScanPick; + !disableScanPick && + !suppressQr; const lotQrPayload = Number.isFinite(Number(analysis?.itemId)) && Number.isFinite(Number(lot.stockInLineId)) @@ -750,7 +799,7 @@ const WorkbenchLotLabelPrintModal: React.FC = "列印標籤" )} - {onWorkbenchScanPick ? ( + {onWorkbenchScanPick && !suppressQr ? (