mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
220 lines
7.9 KiB
TypeScript
220 lines
7.9 KiB
TypeScript
import { PaginatedResponse } from '@edr/types';
|
||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||
import {
|
||
IPriorityConfigsRepository,
|
||
PRIORITY_CONFIGS_REPOSITORY,
|
||
} 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(
|
||
@Inject(PRIORITY_CONFIGS_REPOSITORY)
|
||
private readonly repository: IPriorityConfigsRepository,
|
||
private readonly displayOrder: DisplayOrderService,
|
||
) {}
|
||
|
||
/** List priority configs — standard paginated envelope with server-side search. */
|
||
async findAll(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>> {
|
||
return this.repository.findPaged(query);
|
||
}
|
||
|
||
async findById(id: string): Promise<PriorityConfig> {
|
||
const entity = await this.repository.findById(id);
|
||
if (!entity) throw new NotFoundException(`Priority config ${id} not found`);
|
||
return entity;
|
||
}
|
||
|
||
async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> {
|
||
this.validateCurrencyField(dto.type, dto.currency);
|
||
await this.assertNoRangeCollision({
|
||
type: dto.type,
|
||
currency: dto.currency ?? null,
|
||
minWagonCount: dto.minWagonCount,
|
||
maxWagonCount: dto.maxWagonCount,
|
||
});
|
||
|
||
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
|
||
|
||
return this.repository.create({
|
||
type: dto.type,
|
||
label: dto.label,
|
||
currency: dto.currency ?? null,
|
||
minWagonCount: dto.minWagonCount,
|
||
maxWagonCount: dto.maxWagonCount,
|
||
scorePoints: dto.scorePoints ?? 0,
|
||
isActive: dto.isActive ?? false,
|
||
displayOrder,
|
||
});
|
||
}
|
||
|
||
async update(id: string, dto: UpdatePriorityConfigDto): Promise<PriorityConfig> {
|
||
const existing = await this.findById(id);
|
||
|
||
const type = dto.type ?? existing.type;
|
||
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
|
||
this.validateCurrencyField(type, currency);
|
||
await this.assertNoRangeCollision({
|
||
type,
|
||
currency: currency ?? null,
|
||
minWagonCount: dto.minWagonCount ?? existing.minWagonCount,
|
||
maxWagonCount: dto.maxWagonCount ?? existing.maxWagonCount,
|
||
excludeId: id,
|
||
});
|
||
|
||
const { ...patch } = dto;
|
||
const updated = await this.repository.update(id, patch);
|
||
if (!updated) throw new NotFoundException(`Priority config ${id} not found`);
|
||
return updated;
|
||
}
|
||
|
||
/**
|
||
* 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';
|
||
currency?: string | null;
|
||
minWagonCount: number;
|
||
maxWagonCount: number;
|
||
excludeId?: string;
|
||
}): Promise<void> {
|
||
if (input.minWagonCount > input.maxWagonCount) {
|
||
throw new BadRequestException(
|
||
'Min wagon count cannot be greater than max wagon count',
|
||
);
|
||
}
|
||
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)),
|
||
);
|
||
|
||
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,
|
||
);
|
||
if (clash) {
|
||
throw new BadRequestException(
|
||
`Wagon range ${input.minWagonCount}–${input.maxWagonCount} overlaps existing rule ` +
|
||
`"${clash.label}" (${clash.minWagonCount}–${clash.maxWagonCount}). ` +
|
||
'Adjust the range so rules do not collide.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
async reorder(ids: string[]): Promise<void> {
|
||
await this.displayOrder.reorderByIds(PriorityConfig, 'displayOrder', ids);
|
||
}
|
||
|
||
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||
await this.findById(id);
|
||
await this.displayOrder.moveOne(PriorityConfig, 'displayOrder', id, direction);
|
||
}
|
||
|
||
private validateCurrencyField(
|
||
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||
currency: string | undefined | null,
|
||
): void {
|
||
if (type === 'CURRENCY' && !currency) {
|
||
throw new BadRequestException('currency field is required when type is CURRENCY');
|
||
}
|
||
if (type !== 'CURRENCY' && currency) {
|
||
throw new BadRequestException(`currency field must be null when type is ${type}`);
|
||
}
|
||
}
|
||
}
|