Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts
2026-07-15 14:27:00 +00:00

220 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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';
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}`);
}
}
}