diff --git a/src/app/api/settings/deliveryOrderFloor/client.ts b/src/app/api/settings/deliveryOrderFloor/client.ts index 6f7081e9..af23c2ca 100644 --- a/src/app/api/settings/deliveryOrderFloor/client.ts +++ b/src/app/api/settings/deliveryOrderFloor/client.ts @@ -29,6 +29,7 @@ import { SETTING_PO_AUTO_PUTAWAY_WAREHOUSE, codesToExcludeRules, compactExcludeRules, + ensureExcludeCardIds, defaultJoAutoPutAwayRules, defaultPoAutoPutAwayRules, type AutoPutAwayException, @@ -277,6 +278,7 @@ function normalizeExcludeRule(row: unknown, fallbackMode: ExcludePrintMode): Exc slots: text("slots"), printMode: mode === "hideQr" ? "hideQr" : mode === "hideList" ? "hideList" : fallbackMode, enabled: value.enabled !== false, + cardId: text("cardId").trim(), }; } @@ -287,14 +289,18 @@ function resolveExcludeRules(stored: string | undefined, csv: string, fallbackMo const parsed = JSON.parse(trimmed) as unknown; if (Array.isArray(parsed)) { return compactExcludeRules( - parsed.map((row) => normalizeExcludeRule(row, fallbackMode)).filter((row): row is ExcludeWarehouseRule => row != null), + ensureExcludeCardIds( + parsed.map((row) => normalizeExcludeRule(row, fallbackMode)).filter((row): row is ExcludeWarehouseRule => row != null), + ), ); } } catch { // Fall through to the warehouse-code list. } } - return compactExcludeRules(codesToExcludeRules(csv).map((rule) => ({ ...rule, printMode: fallbackMode }))); + return compactExcludeRules( + ensureExcludeCardIds(codesToExcludeRules(csv).map((rule) => ({ ...rule, printMode: fallbackMode }))), + ); } /** Missing hide-QR row means the old single print mode still applies. `-` means no hide-QR warehouses. */ diff --git a/src/app/api/settings/deliveryOrderFloor/constants.ts b/src/app/api/settings/deliveryOrderFloor/constants.ts index 8ca0ebc2..361ea3d3 100644 --- a/src/app/api/settings/deliveryOrderFloor/constants.ts +++ b/src/app/api/settings/deliveryOrderFloor/constants.ts @@ -151,8 +151,17 @@ export type ExcludeWarehouseRule = { slots: string; printMode: ExcludePrintMode; enabled: boolean; + /** Same id stays one card. A new id stays separate until the user agrees to combine. */ + cardId: string; }; +let excludeCardSeq = 0; + +export function newExcludeCardId(): string { + excludeCardSeq += 1; + return `c${Date.now().toString(36)}${excludeCardSeq.toString(36)}`; +} + function listTokens(raw: string): string[] { return raw .split(",") @@ -192,8 +201,9 @@ export function codesToExcludeRules(csv: string): ExcludeWarehouseRule[] { warehouse: group?.warehouse ?? "", areas: group?.area ?? "", slots: group?.slots.join(",") ?? "", - printMode: "hideList", + printMode: "hideList" as const, enabled: true, + cardId: "", }; }); } @@ -209,6 +219,7 @@ export function compactExcludeRules(rules: ExcludeWarehouseRule[]): ExcludeWareh listTokens(rule.slots).sort().join(","), rule.printMode === "hideQr" ? "hideQr" : "hideList", rule.enabled === false ? "off" : "on", + rule.cardId, ].join("|"); const existing = grouped.get(key); const area = rule.areas.trim(); @@ -268,7 +279,15 @@ export function partitionExcludeCodes( } export function emptyExcludeWarehouseRule(): ExcludeWarehouseRule { - return { floor: "", warehouse: "", areas: "", slots: "", printMode: "hideList", enabled: true }; + return { + floor: "", + warehouse: "", + areas: "", + slots: "", + printMode: "hideList", + enabled: true, + cardId: newExcludeCardId(), + }; } function excludePlaceKey(rule: ExcludeWarehouseRule): string { @@ -309,8 +328,7 @@ function excludeRulesOverlap(a: ExcludeWarehouseRule, b: ExcludeWarehouseRule): return excludeSlotsOverlap(a.slots, b.slots); } -/** Same floor, warehouse, and list handling stay one card when their areas do not overlap. */ -export function groupExcludeRuleIndexes(rules: ExcludeWarehouseRule[]): number[][] { +function groupExcludeRuleIndexesInternal(rules: ExcludeWarehouseRule[], respectCardId: boolean): number[][] { const groups: number[][] = []; rules.forEach((rule, index) => { const incomplete = !rule.floor.trim() || !rule.warehouse.trim(); @@ -319,6 +337,7 @@ export function groupExcludeRuleIndexes(rules: ExcludeWarehouseRule[]): number[] : groups.find((indexes) => { const first = rules[indexes[0]]; if (!first || excludePlaceKey(first) !== excludePlaceKey(rule)) return false; + if (respectCardId && first.cardId !== rule.cardId) return false; return indexes.every((earlier) => { const other = rules[earlier]; return other != null && !excludeAreasIntersect(other, rule); @@ -330,6 +349,85 @@ export function groupExcludeRuleIndexes(rules: ExcludeWarehouseRule[]): number[] return groups; } +/** Same card stays together. A different card id is never combined. */ +export function groupExcludeRuleIndexes(rules: ExcludeWarehouseRule[]): number[][] { + return groupExcludeRuleIndexesInternal(rules, true); +} + +/** Rules saved before card ids keep the card they already share. */ +export function ensureExcludeCardIds(rules: ExcludeWarehouseRule[]): ExcludeWarehouseRule[] { + if (rules.every((rule) => rule.cardId)) return rules; + const missingIndexes = rules.flatMap((rule, index) => (rule.cardId ? [] : [index])); + const missingGroups = groupExcludeRuleIndexesInternal( + missingIndexes.map((index) => rules[index] as ExcludeWarehouseRule), + false, + ); + const idByMissingPos = new Map(); + missingGroups.forEach((group) => { + const cardId = newExcludeCardId(); + group.forEach((position) => idByMissingPos.set(position, cardId)); + }); + return rules.map((rule, index) => { + if (rule.cardId) return rule; + const missingPos = missingIndexes.indexOf(index); + return { ...rule, cardId: idByMissingPos.get(missingPos) || newExcludeCardId() }; + }); +} + +export function excludeCombinePairKey(leftId: string, rightId: string, rule: ExcludeWarehouseRule): string { + const [left, right] = [leftId, rightId].sort(); + return `${left}|${right}|${excludePlaceKey(rule)}`; +} + +function areaSlotMap(card: ExcludeWarehouseRule[]): Map { + const map = new Map(); + for (const rule of card) { + for (const area of listTokens(rule.areas)) map.set(area, rule.slots); + } + return map; +} + +/** Shared areas are allowed when their slots do not overlap. `""` slots mean every slot. */ +function cardsCanCombine(left: ExcludeWarehouseRule[], right: ExcludeWarehouseRule[]): boolean { + const a = areaSlotMap(left); + const b = areaSlotMap(right); + const starA = a.get(EXCLUDE_ALL_AREAS); + const starB = b.get(EXCLUDE_ALL_AREAS); + if (starA != null && starB != null) return !excludeSlotsOverlap(starA, starB); + if (starA != null) return [...b.values()].every((slots) => !excludeSlotsOverlap(starA, slots)); + if (starB != null) return [...a.values()].every((slots) => !excludeSlotsOverlap(starB, slots)); + for (const [area, slots] of a) { + const other = b.get(area); + if (other != null && excludeSlotsOverlap(slots, other)) return false; + } + return true; +} + +/** Another card on the same floor, warehouse, and list handling, with no overlapping slots. */ +export function combineCandidate( + rules: ExcludeWarehouseRule[], + index: number, + declined: ReadonlySet, +): { cardId: string; groupNumber: number; fromNumber: number } | null { + const rule = rules[index]; + if (!rule?.floor.trim() || !rule.warehouse.trim() || !rule.cardId) return null; + const groups = groupExcludeRuleIndexes(rules); + const fromNumber = groups.findIndex((group) => group.includes(index)) + 1; + const myCard = rules.filter((item) => item.cardId === rule.cardId); + for (let otherIndex = 0; otherIndex < rules.length; otherIndex += 1) { + const other = rules[otherIndex]; + if (!other?.cardId || other.cardId === rule.cardId) continue; + if (excludePlaceKey(other) !== excludePlaceKey(rule)) continue; + const sameCard = rules.filter((item) => item.cardId === other.cardId); + if (!cardsCanCombine(myCard, sameCard)) continue; + if (declined.has(excludeCombinePairKey(rule.cardId, other.cardId, rule))) continue; + const groupNumber = groups.findIndex((group) => group.includes(otherIndex)) + 1; + if (fromNumber <= 0 || groupNumber <= 0) continue; + return { cardId: other.cardId, groupNumber, fromNumber }; + } + return null; +} + export function earlierOverlappingExcludeGroups(rules: ExcludeWarehouseRule[], groupIndex: number): number[] { const groups = groupExcludeRuleIndexes(rules); const group = groups[groupIndex]; diff --git a/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx b/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx index 1e03e428..11cb7772 100644 --- a/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx +++ b/src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx @@ -8,6 +8,10 @@ import { Box, Button, Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, FormControlLabel, IconButton, Stack, @@ -18,8 +22,11 @@ import { import type { WarehousePickRow } from "@/app/api/settings/deliveryOrderFloor/client"; import { EXCLUDE_ALL_AREAS, + combineCandidate, earlierOverlappingExcludeGroups, emptyExcludeWarehouseRule, + ensureExcludeCardIds, + excludeCombinePairKey, excludeRuleHasTarget, groupExcludeRuleIndexes, type ExcludePrintMode, @@ -100,6 +107,69 @@ function withoutArea(group: ExcludeWarehouseRule[], area: string, areaNames: str .filter((rule) => tokens(rule.areas).length > 0); } +function unionSlotCsv(leftCsv: string, rightCsv: string): string { + const left = tokens(leftCsv); + const right = tokens(rightCsv); + if (left.includes("-")) return right.includes("-") ? "-" : rightCsv; + if (right.includes("-")) return leftCsv; + if (left.length === 0 || right.length === 0) return ""; + const seen = new Map(); + for (const slot of [...left, ...right]) seen.set(slot.toUpperCase(), slot); + return [...seen.values()].join(","); +} + +function mergeExcludeCards( + rules: ExcludeWarehouseRule[], + sourceId: string, + targetId: string, + warehouses: WarehousePickRow[], +): ExcludeWarehouseRule[] { + const source = rules.filter((rule) => rule.cardId === sourceId); + const target = rules.filter((rule) => rule.cardId === targetId); + const base = target[0] ?? source[0]; + if (!base) return rules; + const template: ExcludeWarehouseRule = { ...base, cardId: targetId, areas: "", slots: "", enabled: true }; + const matched = warehouses.filter( + (row) => + row.storeId.toUpperCase() === template.floor.trim().toUpperCase() && + row.warehouse.toUpperCase() === template.warehouse.trim().toUpperCase(), + ); + const areaNames = uniqueSorted(matched.map((row) => row.area)); + const slotsByArea = new Map(); + matched.forEach((row) => { + const slot = slotCode(row.code); + const list = slotsByArea.get(row.area) ?? []; + if (slot && !list.some((item) => item.toUpperCase() === slot.toUpperCase())) list.push(slot); + slotsByArea.set(row.area, list); + }); + const slotMap = new Map(); + const absorb = (card: ExcludeWarehouseRule[]) => { + for (const rule of card) { + const names = tokens(rule.areas).some((area) => area.toUpperCase() === EXCLUDE_ALL_AREAS) ? areaNames : tokens(rule.areas); + for (const area of names) { + const key = area.toUpperCase(); + const previous = slotMap.get(key); + slotMap.set(key, previous == null ? rule.slots : unionSlotCsv(previous, rule.slots)); + } + } + }; + absorb(target); + absorb(source); + let built: ExcludeWarehouseRule[] = []; + for (const [key, slots] of slotMap) { + const area = areaNames.find((name) => name.toUpperCase() === key) ?? key; + const known = uniqueSorted(slotsByArea.get(area) ?? []); + const picked = tokens(slots); + const stored = known.length === 0 ? slots : slotsToStore(picked.includes("-") ? [] : picked.length === 0 ? known : known.filter((slot) => picked.some((item) => item.toUpperCase() === slot.toUpperCase())), known); + built = withAreaSlots(built, area, stored, areaNames, template); + } + built = built.map((rule) => ({ ...rule, cardId: targetId, enabled: true, printMode: template.printMode })); + const firstTarget = rules.findIndex((rule) => rule.cardId === targetId); + const rest = rules.filter((rule) => rule.cardId !== sourceId && rule.cardId !== targetId); + const insertAt = rules.slice(0, Math.max(firstTarget, 0)).filter((rule) => rule.cardId !== sourceId && rule.cardId !== targetId).length; + return [...rest.slice(0, insertAt), ...built, ...rest.slice(insertAt)]; +} + function withAreaSlots( group: ExcludeWarehouseRule[], area: string, @@ -203,24 +273,72 @@ export function ExcludeWarehouseRuleList({ }) { const { t } = useTranslation("deliveryOrderFloor"); const [viewByGroup, setViewByGroup] = React.useState>({}); + const declinedCombineRef = React.useRef(new Set()); + const [combineAsk, setCombineAsk] = React.useState<{ + next: ExcludeWarehouseRule[]; + editedIndexes: number[]; + targetCardId: string; + targetNumber: number; + fromNumber: number; + floor: string; + warehouse: string; + } | null>(null); + const readyRules = React.useMemo(() => ensureExcludeCardIds(rules), [rules]); + + const saveGroup = (indexes: number[], nextGroup: ExcludeWarehouseRule[], askCombine = false) => { + const next = replaceGroup(readyRules, indexes, nextGroup); + if (askCombine) { + const editedIndex = indexes[0] ?? 0; + const candidate = combineCandidate(next, editedIndex, declinedCombineRef.current); + const edited = next[editedIndex]; + if (candidate && edited) { + setCombineAsk({ + next, + editedIndexes: Array.from({ length: nextGroup.length }, (_, offset) => editedIndex + offset), + targetCardId: candidate.cardId, + targetNumber: candidate.groupNumber, + fromNumber: candidate.fromNumber, + floor: edited.floor, + warehouse: edited.warehouse, + }); + return; + } + } + onSave(next); + }; + + const acceptCombine = () => { + if (!combineAsk) return; + const edited = combineAsk.next[combineAsk.editedIndexes[0] ?? -1]; + const merged = edited + ? mergeExcludeCards(combineAsk.next, edited.cardId, combineAsk.targetCardId, warehouses) + : combineAsk.next; + setCombineAsk(null); + onSave(merged); + }; - const saveGroup = (indexes: number[], nextGroup: ExcludeWarehouseRule[]) => { - onSave(replaceGroup(rules, indexes, nextGroup)); + const declineCombine = () => { + if (!combineAsk) return; + const edited = combineAsk.next[combineAsk.editedIndexes[0] ?? -1]; + if (edited) declinedCombineRef.current.add(excludeCombinePairKey(edited.cardId, combineAsk.targetCardId, edited)); + const next = combineAsk.next; + setCombineAsk(null); + onSave(next); }; const floors = uniqueSorted(warehouses.map((row) => row.storeId)); - const groups = groupExcludeRuleIndexes(rules).map((indexes) => ({ key: indexes.join(","), indexes })); + const groups = groupExcludeRuleIndexes(readyRules).map((indexes) => ({ key: indexes.join(","), indexes })); return ( - {rules.length === 0 ? ( + {readyRules.length === 0 ? ( {t("Empty exclude rules")} ) : ( groups.map((group, groupIndex) => { - const overlappedBy = earlierOverlappingExcludeGroups(rules, groupIndex); - const groupRules = group.indexes.map((index) => rules[index]).filter((rule): rule is ExcludeWarehouseRule => rule != null); + const overlappedBy = earlierOverlappingExcludeGroups(readyRules, groupIndex); + const groupRules = group.indexes.map((index) => readyRules[index]).filter((rule): rule is ExcludeWarehouseRule => rule != null); const rule = groupRules[0]; if (!rule) return null; const warehousesForFloor = warehouses.filter( @@ -258,9 +376,9 @@ export function ExcludeWarehouseRuleList({ ); const selectableRules = groupRules.filter((item) => excludeRuleHasTarget(item)); const canEnable = selectableRules.length > 0; - const applyGroup = (nextGroup: ExcludeWarehouseRule[]) => { + const applyGroup = (nextGroup: ExcludeWarehouseRule[], askCombine = false) => { const kept = nextGroup.length > 0 ? nextGroup : [{ ...rule, areas: "", slots: "" }]; - saveGroup(group.indexes, kept); + saveGroup(group.indexes, kept, askCombine); }; const setAreaIncluded = (area: string, included: boolean) => { if (!included) { @@ -295,6 +413,7 @@ export function ExcludeWarehouseRuleList({ ...item, enabled: event.target.checked && excludeRuleHasTarget(item), })), + event.target.checked, ) } /> @@ -306,14 +425,14 @@ export function ExcludeWarehouseRuleList({ aria-label={t("Delete row")} size="small" disabled={saving} - onClick={() => onSave(rules.filter((_, ruleIndex) => !group.indexes.includes(ruleIndex)))} + onClick={() => onSave(readyRules.filter((_, ruleIndex) => !group.indexes.includes(ruleIndex)))} > {overlappedBy.length > 0 ? ( - {t("Putaway rule overlap", { rules: overlappedBy.map((index) => index + 1).join(", ") })} + {t("Exclude rule overlap", { rules: overlappedBy.map((index) => index + 1).join(", ") })} ) : null} @@ -324,15 +443,17 @@ export function ExcludeWarehouseRuleList({ disabled={saving} value={rule.floor || null} onChange={(next) => - applyGroup([ - { - ...rule, - floor: typeof next === "string" ? next : "", - warehouse: "", - areas: "", - slots: "", - }, - ]) + applyGroup( + [ + { + ...rule, + floor: typeof next === "string" ? next : "", + warehouse: "", + areas: "", + slots: "", + }, + ], + ) } /> - applyGroup([ - { - ...rule, - warehouse: typeof next === "string" ? next : "", - areas: "", - slots: "", - }, - ]) + applyGroup( + [ + { + ...rule, + warehouse: typeof next === "string" ? next : "", + areas: "", + slots: "", + }, + ], + ) } /> @@ -502,11 +625,32 @@ export function ExcludeWarehouseRuleList({ + + {t("Combine exclude rules title")} + + + {combineAsk + ? t("Combine exclude rules", { + from: combineAsk.fromNumber, + to: combineAsk.targetNumber, + floor: combineAsk.floor, + warehouse: combineAsk.warehouse, + }) + : ""} + + + + + + + ); } diff --git a/src/i18n/en/deliveryOrderFloor.json b/src/i18n/en/deliveryOrderFloor.json index 26c76c98..07933908 100644 --- a/src/i18n/en/deliveryOrderFloor.json +++ b/src/i18n/en/deliveryOrderFloor.json @@ -9,6 +9,11 @@ "Putaway rule n": "Rule {{n}}", "Rule enabled": "Enabled", "Putaway rule overlap": "Overlaps rule {{rules}}. Within the same order type, the first match wins, so overlapping items will not use this rule.", + "Exclude rule overlap": "Overlaps rule {{rules}}.", + "Combine exclude rules title": "Combine into one card?", + "Combine exclude rules": "Rule {{from}} and rule {{to}} are both {{floor}} / {{warehouse}}, and their slots do not overlap. Combine them before enabling? If not, this rule stays separate and is still enabled.", + "Combine accept": "Combine", + "Combine decline": "Keep separate", "Empty putaway rules": "No automatic put-away rules.", "Rule removed pending save": "This rule is removed. Save to keep the change.", "All items": "All items", diff --git a/src/i18n/zh/deliveryOrderFloor.json b/src/i18n/zh/deliveryOrderFloor.json index 4ac2ae3f..7f10086b 100644 --- a/src/i18n/zh/deliveryOrderFloor.json +++ b/src/i18n/zh/deliveryOrderFloor.json @@ -9,6 +9,11 @@ "Putaway rule n": "規則 {{n}}", "Rule enabled": "啟用", "Putaway rule overlap": "與規則 {{rules}} 條件重疊。", + "Exclude rule overlap": "與規則 {{rules}} 條件重疊。", + "Combine exclude rules title": "合併成同一張卡?", + "Combine exclude rules": "規則 {{from}} 和規則 {{to}} 都是 {{floor}} / {{warehouse}},儲位沒有重疊。要合併後再啟用嗎?不合併就分開保留,這條規則仍然啟用。", + "Combine accept": "合併", + "Combine decline": "分開保留", "Empty putaway rules": "目前沒有自動上架規則。", "Rule removed pending save": "這條規則已移除,按儲存變更後才會寫入。", "All items": "全部貨品",