Pārlūkot izejas kodu

if rules same different warehouse,slot can combine

controllQRcodescanWarehouse
CANCERYS\kw093 pirms 16 stundām
vecāks
revīzija
8035b97b29
5 mainītis faili ar 292 papildinājumiem un 34 dzēšanām
  1. +8
    -2
      src/app/api/settings/deliveryOrderFloor/client.ts
  2. +102
    -4
      src/app/api/settings/deliveryOrderFloor/constants.ts
  3. +172
    -28
      src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx
  4. +5
    -0
      src/i18n/en/deliveryOrderFloor.json
  5. +5
    -0
      src/i18n/zh/deliveryOrderFloor.json

+ 8
- 2
src/app/api/settings/deliveryOrderFloor/client.ts Parādīt failu

@@ -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. */


+ 102
- 4
src/app/api/settings/deliveryOrderFloor/constants.ts Parādīt failu

@@ -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<number, string>();
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<string, string> {
const map = new Map<string, string>();
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<string>,
): { 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];


+ 172
- 28
src/components/DeliveryOrderFloorSettings/ExcludeWarehouseRuleList.tsx Parādīt failu

@@ -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<string, string>();
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<string, string[]>();
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<string, string>();
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<Record<string, string>>({});
const declinedCombineRef = React.useRef(new Set<string>());
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 (
<Stack spacing={1.5}>
{rules.length === 0 ? (
{readyRules.length === 0 ? (
<Typography variant="body2" color="text.secondary">
{t("Empty exclude rules")}
</Typography>
) : (
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)))}
>
<DeleteOutline fontSize="small" />
</IconButton>
</Stack>
{overlappedBy.length > 0 ? (
<Alert severity="warning" sx={{ mb: 1.5 }}>
{t("Putaway rule overlap", { rules: overlappedBy.map((index) => index + 1).join(", ") })}
{t("Exclude rule overlap", { rules: overlappedBy.map((index) => index + 1).join(", ") })}
</Alert>
) : null}
<Stack spacing={1.5}>
@@ -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: "",
},
],
)
}
/>
<ToggleRows
@@ -342,14 +463,16 @@ export function ExcludeWarehouseRuleList({
disabled={saving || !rule.floor}
value={rule.warehouse || null}
onChange={(next) =>
applyGroup([
{
...rule,
warehouse: typeof next === "string" ? next : "",
areas: "",
slots: "",
},
])
applyGroup(
[
{
...rule,
warehouse: typeof next === "string" ? next : "",
areas: "",
slots: "",
},
],
)
}
/>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, minWidth: 0 }}>
@@ -502,11 +625,32 @@ export function ExcludeWarehouseRuleList({
<Button
variant="outlined"
disabled={saving}
onClick={() => onSave([...rules, emptyExcludeWarehouseRule()])}
onClick={() => onSave([...readyRules, emptyExcludeWarehouseRule()])}
sx={{ alignSelf: "flex-start" }}
>
{t("Add exclude rule")}
</Button>
<Dialog open={combineAsk != null} onClose={declineCombine} fullWidth maxWidth="xs">
<DialogTitle>{t("Combine exclude rules title")}</DialogTitle>
<DialogContent>
<Typography variant="body2">
{combineAsk
? t("Combine exclude rules", {
from: combineAsk.fromNumber,
to: combineAsk.targetNumber,
floor: combineAsk.floor,
warehouse: combineAsk.warehouse,
})
: ""}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={declineCombine}>{t("Combine decline")}</Button>
<Button variant="contained" onClick={acceptCombine}>
{t("Combine accept")}
</Button>
</DialogActions>
</Dialog>
</Stack>
);
}

+ 5
- 0
src/i18n/en/deliveryOrderFloor.json Parādīt failu

@@ -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",


+ 5
- 0
src/i18n/zh/deliveryOrderFloor.json Parādīt failu

@@ -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": "全部貨品",


Notiek ielāde…
Atcelt
Saglabāt