This commit is contained in:
Marshal
2026-07-15 14:27:00 +00:00
parent 19c9da28ae
commit d7d9db9c3a
10 changed files with 554 additions and 25 deletions

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
@@ -24,6 +25,23 @@ export class PriorityConfigsController {
return this.service.findAll(query);
}
// Static route — must stay above `:id` (Express matches in declaration order).
@Get('next-range')
@RuleEngineView('priority-configs')
@ApiOperation({
summary:
"Where the next contiguous range for a type (and currency) must start, plus the type's ceiling",
})
nextRange(
@Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
@Query('currency') currency?: string,
) {
if (!['WAGON', 'CURRENCY', 'CUSTOMS'].includes(type)) {
throw new BadRequestException('type must be WAGON, CURRENCY, or CUSTOMS');
}
return this.service.nextRange(type, currency ?? null);
}
@Get(':id')
@RuleEngineView('priority-configs')
@ApiOperation({ summary: 'Get a priority config by ID' })

View File

@@ -0,0 +1,229 @@
import { BadRequestException } from '@nestjs/common';
import { PriorityConfig } from '../entities/priority-config.entity';
import { PriorityConfigsService } from './priority-configs.service';
/**
* Contiguous-range rules for priority configs: per type (per currency for
* CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range
* must start at the lowest uncovered wagon count. Caps: WAGON 50,
* CURRENCY 35, CUSTOMS 15.
*/
describe('PriorityConfigsService range validation', () => {
const rule = (
type: PriorityConfig['type'],
min: number,
max: number,
currency: string | null = null,
id = `${type}-${min}-${max}-${currency ?? 'none'}`,
): PriorityConfig =>
({
id,
type,
label: `${min}-${max}`,
currency,
minWagonCount: min,
maxWagonCount: max,
}) as PriorityConfig;
const serviceWith = (rules: PriorityConfig[]): PriorityConfigsService => {
const repository = {
findAll: jest.fn(async ({ where }: { where: { type: string } }) =>
rules.filter((r) => r.type === where.type),
),
findById: jest.fn(async (id: string) =>
rules.find((r) => r.id === id) ?? null,
),
};
return new PriorityConfigsService(
repository as never,
undefined as never, // DisplayOrderService — unused by range validation
);
};
const attempt = (
svc: PriorityConfigsService,
input: Partial<Parameters<PriorityConfigsService['assertNoRangeCollision']>[0]>,
) =>
svc.assertNoRangeCollision({
type: 'WAGON',
minWagonCount: 1,
maxWagonCount: 5,
...input,
});
it('accepts the first WAGON range starting at 1', async () => {
await expect(
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5 }),
).resolves.toBeUndefined();
});
it('rejects a first range that does not start at 1', async () => {
await expect(
attempt(serviceWith([]), { minWagonCount: 3, maxWagonCount: 5 }),
).rejects.toThrow(BadRequestException);
});
it('rejects an exact duplicate (15 vs 15)', async () => {
await expect(
attempt(serviceWith([rule('WAGON', 1, 5)]), {
minWagonCount: 1,
maxWagonCount: 5,
}),
).rejects.toThrow(/must start at 6/);
});
it('rejects a partial overlap (47 after 15)', async () => {
await expect(
attempt(serviceWith([rule('WAGON', 1, 5)]), {
minWagonCount: 4,
maxWagonCount: 7,
}),
).rejects.toThrow(/must start at 6/);
});
it('rejects a gap (89 after 15) — next range must start at 6', async () => {
await expect(
attempt(serviceWith([rule('WAGON', 1, 5)]), {
minWagonCount: 8,
maxWagonCount: 9,
}),
).rejects.toThrow(/must start at 6/);
});
it('accepts the contiguous continuation (610 after 15)', async () => {
await expect(
attempt(serviceWith([rule('WAGON', 1, 5)]), {
minWagonCount: 6,
maxWagonCount: 10,
}),
).resolves.toBeUndefined();
});
it('after deleting a middle rule, the next range must fill the lowest gap', async () => {
// Chain was 15, 610, 1120; 610 deleted → next must start at 6.
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
await expect(
attempt(svc, { minWagonCount: 21, maxWagonCount: 25 }),
).rejects.toThrow(/must start at 6/);
await expect(
attempt(svc, { minWagonCount: 6, maxWagonCount: 10 }),
).resolves.toBeUndefined();
});
it('rejects a gap-fill that overruns into the next rule (615 into 1120)', async () => {
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
await expect(
attempt(svc, { minWagonCount: 6, maxWagonCount: 15 }),
).rejects.toThrow(/overlaps existing rule/);
});
it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => {
await expect(
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }),
).rejects.toThrow(/may not exceed 50/);
await expect(
attempt(serviceWith([]), {
type: 'CURRENCY',
currency: 'USD',
minWagonCount: 1,
maxWagonCount: 36,
}),
).rejects.toThrow(/may not exceed 35/);
await expect(
attempt(serviceWith([]), {
type: 'CUSTOMS',
minWagonCount: 1,
maxWagonCount: 16,
}),
).rejects.toThrow(/may not exceed 15/);
});
it('rejects any new rule once the chain covers the full range', async () => {
await expect(
attempt(serviceWith([rule('WAGON', 1, 50)]), {
minWagonCount: 51,
maxWagonCount: 51,
}),
).rejects.toThrow(/may not exceed 50/);
await expect(
attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
type: 'CUSTOMS',
minWagonCount: 1,
maxWagonCount: 1,
}),
).rejects.toThrow(/already cover the full 115 range/);
});
it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => {
const svc = serviceWith([rule('CURRENCY', 1, 5, 'USD')]);
// ETB has no rules yet → starts at 1.
await expect(
attempt(svc, {
type: 'CURRENCY',
currency: 'ETB',
minWagonCount: 1,
maxWagonCount: 5,
}),
).resolves.toBeUndefined();
// USD must continue at 6.
await expect(
attempt(svc, {
type: 'CURRENCY',
currency: 'USD',
minWagonCount: 1,
maxWagonCount: 5,
}),
).rejects.toThrow(/must start at 6/);
});
it('excludes the rule being edited from its own contiguity check', async () => {
const existing = rule('WAGON', 6, 10, null, 'editing-me');
const svc = serviceWith([rule('WAGON', 1, 5), existing]);
// Re-saving 610 (e.g. changing points) keeps min 6 — allowed.
await expect(
attempt(svc, {
minWagonCount: 6,
maxWagonCount: 12,
excludeId: 'editing-me',
}),
).resolves.toBeUndefined();
});
it('lets an upper rule keep its start while a lower gap exists', async () => {
// Chain 15, [gap 610], 1120: editing 1120 keeps min 11 — a lower gap
// must not block editing an upper rule's points or max.
const upper = rule('WAGON', 11, 20, null, 'upper');
const svc = serviceWith([rule('WAGON', 1, 5), upper]);
await expect(
attempt(svc, {
minWagonCount: 11,
maxWagonCount: 25,
excludeId: 'upper',
}),
).resolves.toBeUndefined();
// But it cannot RELOCATE to an arbitrary start — only keep 11 or fill 6.
await expect(
attempt(svc, {
minWagonCount: 30,
maxWagonCount: 35,
excludeId: 'upper',
}),
).rejects.toThrow(/must start at 6/);
});
it('reports the next-range prefill for the form', async () => {
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
await expect(svc.nextRange('WAGON')).resolves.toEqual({
nextMin: 6,
maxCap: 50,
});
await expect(
serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'),
).resolves.toEqual({ nextMin: null, maxCap: 15 });
await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({
nextMin: 1,
maxCap: 35,
});
});
});

View File

@@ -10,6 +10,31 @@ import {
} from '../interfaces/priority-configs.repository.interface';
import { DisplayOrderService } from './display-order.service';
/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */
export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = {
WAGON: 50,
CURRENCY: 35,
CUSTOMS: 15,
};
/**
* Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range
* must start. Null when the chain is already complete up to the type's cap.
*/
function nextRangeStart(
rules: Pick<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[],
): number | null {
const cap = rules.length ? RANGE_CAPS[rules[0].type] : null;
const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount);
let next = 1;
for (const r of sorted) {
if (r.minWagonCount > next) break; // gap before this rule — fill it
next = Math.max(next, r.maxWagonCount + 1);
}
if (cap != null && next > cap) return null;
return next;
}
@Injectable()
export class PriorityConfigsService {
constructor(
@@ -73,10 +98,13 @@ export class PriorityConfigsService {
}
/**
* No two rules of the same type (and, for CURRENCY rules, the same currency)
* may cover overlapping wagon-count ranges — a booking must match at most one
* rule per type. Rejects an exact duplicate (15 vs 15) and any partial
* overlap (15 vs 47). Ranges are inclusive on both ends.
* Range rules per type (and, for CURRENCY rules, per currency):
* - ranges never overlap — a booking matches at most one rule per type;
* - ranges are contiguous from 1: a new range must START at the lowest
* wagon count not yet covered (after 15 the next is 6…; deleting a
* middle rule opens a gap and the next create must fill it first);
* - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15.
* Ranges are inclusive on both ends.
*/
async assertNoRangeCollision(input: {
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
@@ -90,13 +118,49 @@ export class PriorityConfigsService {
'Min wagon count cannot be greater than max wagon count',
);
}
const siblings = await this.repository.findAll({
where: { type: input.type },
});
const clash = siblings.find(
const cap = RANGE_CAPS[input.type];
if (input.maxWagonCount > cap) {
throw new BadRequestException(
`${input.type} ranges may not exceed ${cap}` +
`${input.minWagonCount}${input.maxWagonCount} goes past the ceiling.`,
);
}
const siblings = (
await this.repository.findAll({ where: { type: input.type } })
).filter(
(s) =>
s.id !== input.excludeId &&
(input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
(input.type !== 'CURRENCY' ||
(s.currency ?? null) === (input.currency ?? null)),
);
const expectedStart = nextRangeStart(siblings);
// An edited rule may always KEEP its current start (so a gap lower in the
// chain never blocks editing an upper rule's points/max) — or move down to
// fill that lowest gap.
const currentStart = input.excludeId
? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null
: null;
if (expectedStart == null && currentStart == null) {
throw new BadRequestException(
`${input.type} rules already cover the full 1${cap} range — ` +
'delete or shrink an existing rule first.',
);
}
if (
input.minWagonCount !== expectedStart &&
input.minWagonCount !== currentStart
) {
throw new BadRequestException(
`The next ${input.type} range must start at ${expectedStart} ` +
`(ranges are contiguous — no gaps, no overlaps). ` +
`You entered ${input.minWagonCount}${input.maxWagonCount}.`,
);
}
const clash = siblings.find(
(s) =>
input.minWagonCount <= s.maxWagonCount &&
input.maxWagonCount >= s.minWagonCount,
);
@@ -109,6 +173,24 @@ export class PriorityConfigsService {
}
}
/**
* Where the next range for a type/currency must start, and the type's
* ceiling — feeds the create form so the min field is auto-filled and
* locked. `nextMin` is null when the chain already covers 1..cap.
*/
async nextRange(
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
currency?: string | null,
): Promise<{ nextMin: number | null; maxCap: number }> {
const siblings = (
await this.repository.findAll({ where: { type } })
).filter(
(s) =>
type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null),
);
return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] };
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);

View File

@@ -4,12 +4,12 @@ import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
FileCheck,
FilePen,
FileSignature,
MessageSquareWarning,
RefreshCw,
ShieldCheck,
Sparkles,
XCircle,
Zap,
} from "lucide-react";
@@ -180,7 +180,7 @@ export function ContractActionsToolbar({
documentGenerated ? (
<RefreshCw size={16} />
) : (
<Sparkles size={16} />
<FileCheck size={16} />
)
}
loading={mutations.generateContract.isPending}
@@ -196,7 +196,7 @@ export function ContractActionsToolbar({
fullWidth
variant="light"
color="orange"
leftSection={<Sparkles size={16} />}
leftSection={<FileCheck size={16} />}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Check, ShieldCheck, X } from "lucide-react";
import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -31,6 +31,7 @@ export function ContractApprovalStepsCard({
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState<Freight.IContractApprovalStep | null>(null);
const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectStepRow, setRejectStepRow] =
useState<Freight.IContractApprovalStep | null>(null);
@@ -47,7 +48,17 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
// Approvers must review the GENERATED contract document before approving. If
// it has not been generated yet, block the approval and tell staff to generate
// it first (via "Generate contract" in Staff actions) — mirrors the server
// guard so the user sees a clear reason, not a generic failure toast.
const documentGenerated = Boolean(contract.contractGeneratedAt);
const openApprove = (step: Freight.IContractApprovalStep) => {
if (contract.status === "PENDING_APPROVAL" && !documentGenerated) {
setNeedsGenerateOpen(true);
return;
}
setPendingStep(step);
setConfirmOpen(true);
};
@@ -181,6 +192,47 @@ export function ContractApprovalStepsCard({
</Stack>
</Modal>
<Modal
opened={needsGenerateOpen}
onClose={() => setNeedsGenerateOpen(false)}
title={
<Group gap="xs">
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
<Text fw={700}>Generate the contract first</Text>
</Group>
}
radius="md"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
The contract document for{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
has not been generated yet. Approvers must review the generated
document before it can be approved.
</Text>
<Text size="sm" c="dimmed">
Use{" "}
<Text span fw={600} c="dark">
Generate contract
</Text>{" "}
in the Staff actions panel edit the articles first if needed then
return here to approve.
</Text>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<FileCheck size={16} />}
onClick={() => setNeedsGenerateOpen(false)}
>
Got it
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={rejectOpen}
onClose={closeReject}

View File

@@ -201,7 +201,10 @@ const RuleEngineFormDialog = ({
const payload: Record<string, unknown> = {};
for (const field of visibleFields) {
const raw = values[field.name];
// Derived fields always submit their computed value — never stale state.
const raw = field.computeValue
? (field.computeValue(values) ?? "")
: values[field.name];
if (field.type === "multiselect") {
// Always the full replacement list — the API syncs the relation to it.
payload[field.name] = Array.isArray(raw) ? raw : [];
@@ -348,6 +351,7 @@ const RuleEngineFormDialog = ({
}
const isNumber = field.type === "number";
const computed = field.computeValue ? field.computeValue(values) : undefined;
return (
<TextInput
@@ -359,7 +363,8 @@ const RuleEngineFormDialog = ({
// display order) is a non-negative magnitude — reject negatives outright
// rather than letting a typed "-" reach the API.
min={isNumber ? 0 : undefined}
value={String(values[field.name] ?? "")}
disabled={field.disabled || computed !== undefined}
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
onChange={(e) => {
const next = e.currentTarget.value;
if (isNumber && next.trim().startsWith("-")) return;

View File

@@ -246,10 +246,13 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
/**
* Priority-rule approval workflow. Every create/update/delete of a priority
* config is SUBMITTED as a change request; an approver applies or rejects it.
* Error toasts surface the backend message so range-collision rejections
* ("15 overlaps existing rule …") reach the user verbatim.
* Backend messages (range collision, gap, ceiling) surface verbatim — through
* `onErrorMessage` (the page shows them in a modal) or a toast as fallback.
*/
export const usePriorityRuleWorkflow = (enabled: boolean) => {
export const usePriorityRuleWorkflow = (
enabled: boolean,
onErrorMessage?: (message: string) => void,
) => {
const qc = useQueryClient();
const backendMessage = (err: unknown, fallback: string) => {
@@ -259,6 +262,12 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
return msg || fallback;
};
const showError = (err: unknown, fallback: string) => {
const message = backendMessage(err, fallback);
if (onErrorMessage) onErrorMessage(message);
else toast.error(message);
};
const pending = useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"),
@@ -270,6 +279,10 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges,
});
await invalidateRuleEngineList(qc, "priority-configs");
// The full order-list backs the auto-filled min field — keep it fresh too.
await qc.invalidateQueries({
queryKey: QUERY_KEYS.RULE_ENGINE.orderList("priority-configs"),
});
};
const submit = useMutation({
@@ -279,7 +292,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
toast.success("Change submitted for approval — the team has been notified");
await invalidate();
},
onError: (err) => toast.error(backendMessage(err, "Failed to submit change")),
onError: (err) => showError(err, "Failed to submit change"),
});
const approve = useMutation({
@@ -289,7 +302,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
toast.success("Change approved and applied");
await invalidate();
},
onError: (err) => toast.error(backendMessage(err, "Failed to approve change")),
onError: (err) => showError(err, "Failed to approve change"),
});
const reject = useMutation({
@@ -299,7 +312,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => {
toast.success("Change rejected");
await invalidate();
},
onError: (err) => toast.error(backendMessage(err, "Failed to reject change")),
onError: (err) => showError(err, "Failed to reject change"),
});
return { pending, submit, approve, reject };

View File

@@ -19,6 +19,7 @@ import { Navigate, useLocation, useParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
@@ -145,10 +146,13 @@ const RuleEngineResourcePage = () => {
);
// Priority rules never mutate directly: changes are filed for approval and a
// pending queue renders above the table.
// pending queue renders above the table. Validation errors (range collision,
// gap, ceiling) surface in a modal so the text is impossible to miss.
const isPriorityRules = config?.slug === "priority-configs";
const [priorityError, setPriorityError] = useState<string | null>(null);
const priorityWorkflow = usePriorityRuleWorkflow(
Boolean(isPriorityRules && canView),
setPriorityError,
);
const editingId = editing?.id ? String(editing.id) : undefined;
@@ -178,9 +182,36 @@ const RuleEngineResourcePage = () => {
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
// Full rule list backing the auto-filled "min wagon count": the next range
// always continues the chain for the selected type (per currency), so the
// form needs every existing rule, not the current page.
const { data: allPriorityRules } = useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(isPriorityRules && formOpen),
config?.orderConfig?.field,
);
const formFields = useMemo(() => {
if (!config) return [];
return config.formFields.map((field) => {
if (isPriorityRules && field.name === "minWagonCount") {
return {
...field,
// Editing keeps the rule's own start (a lower gap never forces it to
// move); creating always continues the chain / fills the lowest gap.
computeValue: (values: Record<string, unknown>) =>
editing?.minWagonCount != null
? Number(editing.minWagonCount)
: nextPriorityRangeStart(
allPriorityRules ?? [],
String(values.type ?? ""),
!values.currency || values.currency === RULE_ENGINE_SELECT_NONE
? null
: String(values.currency),
editingId,
),
};
}
if (config.slug === "cargo-types" && field.name === "parentGroupId") {
return {
...field,
@@ -226,7 +257,7 @@ const RuleEngineResourcePage = () => {
}
return field;
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]);
const rows = data?.items ?? [];
const meta = data?.meta;
@@ -452,6 +483,25 @@ const RuleEngineResourcePage = () => {
/>
) : null}
<Modal
opened={priorityError != null}
onClose={() => setPriorityError(null)}
title="Cannot save priority rule"
centered
size="md"
>
<Stack gap="md">
<Text size="sm" c="red.8">
{priorityError}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setPriorityError(null)}>
OK
</Button>
</Group>
</Stack>
</Modal>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">

View File

@@ -61,6 +61,13 @@ export interface FormFieldDef {
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
*/
getInitialValue?: (record: Record<string, unknown>) => unknown;
/**
* Fully derived field: its value is computed from the live form values on
* every render and the input is locked. Used for the priority-rule min
* wagon count, which always continues the previous range for the selected
* type. Return null/undefined to leave the field empty (e.g. chain full).
*/
computeValue?: (values: Record<string, unknown>) => number | string | null;
}
export interface RuleEngineOrderConfig {
@@ -356,8 +363,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Select a currency",
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
},
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
{
name: "minWagonCount",
label: "Min wagon count",
type: "number",
required: true,
disabled: true,
description: "Auto-filled — continues the previous range for the selected type",
},
{
name: "maxWagonCount",
label: "Max wagon count",
type: "number",
required: true,
description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15",
},
{ name: "scorePoints", label: "Score points", type: "number", required: true },
{ name: "isActive", label: "Active", type: "boolean" },
],

View File

@@ -0,0 +1,60 @@
/**
* Client mirror of the backend's contiguous-range rules for priority configs
* (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per
* currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next
* range always starts at the lowest uncovered wagon count. The backend
* re-validates on submit AND on approval; this only drives the form prefill.
*/
export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS";
/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */
export const PRIORITY_RANGE_CAPS: Record<PriorityRuleType, number> = {
WAGON: 50,
CURRENCY: 35,
CUSTOMS: 15,
};
export interface PriorityRangeRule {
id?: unknown;
type?: unknown;
currency?: unknown;
minWagonCount?: unknown;
maxWagonCount?: unknown;
}
/**
* Where the next range for `type` (+`currency`) must start, excluding
* `excludeId` (the rule being edited). Null when the chain already covers
* 1..cap — no further rule fits.
*/
export function nextPriorityRangeStart(
rules: PriorityRangeRule[],
type: string,
currency: string | null | undefined,
excludeId?: string,
): number | null {
const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType];
if (!cap) return null;
const scoped = rules
.filter(
(r) =>
String(r.type ?? "") === type &&
(excludeId === undefined || String(r.id ?? "") !== excludeId) &&
(type !== "CURRENCY" ||
String(r.currency ?? "") === String(currency ?? "")),
)
.map((r) => ({
min: Number(r.minWagonCount ?? 0),
max: Number(r.maxWagonCount ?? 0),
}))
.sort((a, b) => a.min - b.min);
let next = 1;
for (const r of scoped) {
if (r.min > next) break; // gap before this rule — fill it first
next = Math.max(next, r.max + 1);
}
return next > cap ? null : next;
}