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[], ): 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> { return this.repository.findPaged(query); } async findById(id: string): Promise { const entity = await this.repository.findById(id); if (!entity) throw new NotFoundException(`Priority config ${id} not found`); return entity; } async create(dto: CreatePriorityConfigDto): Promise { 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 { 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 { 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 { await this.findById(id); await this.repository.softDelete(id); } async reorder(ids: string[]): Promise { await this.displayOrder.reorderByIds(PriorityConfig, 'displayOrder', ids); } async moveOrder(id: string, direction: 'up' | 'down'): Promise { 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}`); } } }