mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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 (1–5 vs 1–5)', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([rule('WAGON', 1, 5)]), {
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 5,
|
||||
}),
|
||||
).rejects.toThrow(/must start at 6/);
|
||||
});
|
||||
|
||||
it('rejects a partial overlap (4–7 after 1–5)', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([rule('WAGON', 1, 5)]), {
|
||||
minWagonCount: 4,
|
||||
maxWagonCount: 7,
|
||||
}),
|
||||
).rejects.toThrow(/must start at 6/);
|
||||
});
|
||||
|
||||
it('rejects a gap (8–9 after 1–5) — 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 (6–10 after 1–5)', 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 1–5, 6–10, 11–20; 6–10 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 (6–15 into 11–20)', 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 1–15 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 6–10 (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 1–5, [gap 6–10], 11–20: editing 11–20 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (1–5 vs 1–5) and any partial
|
||||
* overlap (1–5 vs 4–7). 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 1–5 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);
|
||||
|
||||
Reference in New Issue
Block a user