mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
901 lines
33 KiB
TypeScript
901 lines
33 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
ForbiddenException,
|
||
Inject,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||
import { IsNull, Not } from 'typeorm';
|
||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||
import { Rate } from '../entities/rate.entity';
|
||
import { deriveRateType } from '../entities/rate-type.util';
|
||
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||
import {
|
||
CARGO_TYPES_REPOSITORY,
|
||
ICargoTypesRepository,
|
||
} from '../interfaces/cargo-types.repository.interface';
|
||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||
|
||
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
|
||
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
|
||
|
||
/** The yard pair a rate scopes to, already validated against its direction. */
|
||
interface YardScope {
|
||
originYardId: string | null;
|
||
destinationYardId: string | null;
|
||
}
|
||
|
||
@Injectable()
|
||
export class RatesService {
|
||
constructor(
|
||
@Inject(RATES_REPOSITORY)
|
||
private readonly repository: IRatesRepository,
|
||
@Inject(YARDS_REPOSITORY)
|
||
private readonly yardsRepository: IYardsRepository,
|
||
@Inject(CARGO_TYPES_REPOSITORY)
|
||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||
) {}
|
||
|
||
/** List rates — standard paginated envelope with server-side search. */
|
||
async findAll(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
||
return this.repository.findPaged(query);
|
||
}
|
||
|
||
/** Return all currently LIVE rates. */
|
||
async findLiveRates(): Promise<Rate[]> {
|
||
return this.repository.findLiveRates();
|
||
}
|
||
|
||
/**
|
||
* LIVE rates with yard / container / cargo relations joined — used to render
|
||
* the origin → destination rate schedule inside generated contracts.
|
||
*/
|
||
async findLiveRatesDetailed(): Promise<Rate[]> {
|
||
return this.repository.findLiveRatesDetailed();
|
||
}
|
||
|
||
/** Get a rate by ID. */
|
||
async findById(id: string): Promise<Rate> {
|
||
const entity = await this.repository.findById(id);
|
||
if (!entity) throw new NotFoundException(`Rate ${id} not found`);
|
||
return entity;
|
||
}
|
||
|
||
/**
|
||
* Normalise + validate the weighting unit for a rate shape. Overweight is
|
||
* always billed per excess ton, so its unit is forced to PER_TON regardless
|
||
* of what the client sent. Every other shape must pick a unit the pricing
|
||
* engine can actually apply (see `allowedRateUnits`) — for a rate scoped to a
|
||
* bulk commodity that means the commodity's own unit of measure: a PER_ITEM
|
||
* commodity bills per item where a weighed one bills per ton.
|
||
*/
|
||
private async resolveRateUnit(
|
||
appliesTo: Rate['appliesTo'],
|
||
trigger: Rate['trigger'],
|
||
requestedUnit: Rate['rateUnit'] | undefined,
|
||
cargoKind?: 'CONTAINER' | 'BULK' | null,
|
||
cargoTypeId?: string | null,
|
||
): Promise<Rate['rateUnit']> {
|
||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||
// for it and omits rateUnit from the payload entirely.
|
||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||
|
||
const cargoUnitOfMeasure = await this.cargoUnitOfMeasure(cargoTypeId);
|
||
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind, cargoUnitOfMeasure });
|
||
if (!requestedUnit) {
|
||
throw new BadRequestException(
|
||
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
||
);
|
||
}
|
||
if (
|
||
!isRateUnitAllowed({
|
||
appliesTo,
|
||
trigger,
|
||
cargoKind,
|
||
cargoUnitOfMeasure,
|
||
unit: requestedUnit,
|
||
})
|
||
) {
|
||
throw new BadRequestException(
|
||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
||
);
|
||
}
|
||
return requestedUnit;
|
||
}
|
||
|
||
/** Unit of measure of the bulk commodity a rate is scoped to; null when unscoped. */
|
||
private async cargoUnitOfMeasure(cargoTypeId?: string | null): Promise<CargoUom> {
|
||
if (!cargoTypeId) return null;
|
||
const cargo = await this.cargoTypesRepository.findById(cargoTypeId);
|
||
return cargo?.unitOfMeasure ?? null;
|
||
}
|
||
|
||
/** Base rail freight is priced per leg; surcharges and truck legs are not. */
|
||
private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
|
||
}
|
||
|
||
/**
|
||
* Rates sold per direction + route. Base freight always; customs clearance,
|
||
* empty-container return and fuel are the surcharges that are too — their
|
||
* fee depends on the lane (and, for returns, the container type).
|
||
*/
|
||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||
return (
|
||
this.isBaseFreight(appliesTo, trigger) ||
|
||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||
trigger === 'WITH_RETURN' ||
|
||
trigger === 'FUEL'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* True when pricing resolves exactly ONE rate for this shape (base freight,
|
||
* customs clearance, lashing, empty-container return — all `find()`-based
|
||
* lookups). For those the unit is not part of the rate's identity: two rows
|
||
* for the same lane differing only by unit are a duplicate the engine cannot
|
||
* choose between.
|
||
*
|
||
* The additive surcharges are the opposite — the engine bills EVERY matching
|
||
* rate by its own unit, which is how hazard can be per-container for boxes
|
||
* and per-ton for bulk at the same time — so their unit stays part of the key.
|
||
*/
|
||
private resolvesSingleRate(
|
||
appliesTo: Rate['appliesTo'],
|
||
trigger: Rate['trigger'],
|
||
): boolean {
|
||
return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING';
|
||
}
|
||
|
||
/**
|
||
* Which country each end of the leg must sit in, given what the rate is for.
|
||
* The railway only sells three shapes: import lands at the Djibouti ports and
|
||
* rails inland, export is the reverse, and intercity stays inside Ethiopia.
|
||
*/
|
||
private expectedYardCountries(
|
||
appliesTo: Rate['appliesTo'],
|
||
tradeDirection: string | null,
|
||
): { origin: YardCountry; destination: YardCountry } {
|
||
// DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays
|
||
// inside Ethiopia exactly like intercity base freight.
|
||
if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') {
|
||
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
||
}
|
||
return tradeDirection === 'EXPORT'
|
||
? { origin: YardCountry.ETHIOPIA, destination: YardCountry.DJIBOUTI }
|
||
: { origin: YardCountry.DJIBOUTI, destination: YardCountry.ETHIOPIA };
|
||
}
|
||
|
||
/**
|
||
* Validate and normalise the leg a rate prices.
|
||
*
|
||
* Base freight must name both yards and they must match the direction, so a
|
||
* "container import" rate cannot be quoted Ethiopia → Ethiopia. Everything
|
||
* else (surcharges, first/last mile) is route-agnostic and has its yards
|
||
* cleared, mirroring how container/cargo scope is cleared for surcharges.
|
||
*/
|
||
private async resolveYardScope(input: {
|
||
appliesTo: Rate['appliesTo'];
|
||
trigger: Rate['trigger'];
|
||
tradeDirection: string | null;
|
||
originYardId?: string | null;
|
||
destinationYardId?: string | null;
|
||
}): Promise<YardScope> {
|
||
const { appliesTo, trigger, tradeDirection } = input;
|
||
if (!this.isRouteScoped(appliesTo, trigger)) {
|
||
return { originYardId: null, destinationYardId: null };
|
||
}
|
||
|
||
const originYardId = input.originYardId ?? null;
|
||
const destinationYardId = input.destinationYardId ?? null;
|
||
if (!originYardId || !destinationYardId) {
|
||
throw new BadRequestException(
|
||
'This rate is priced per leg — pick both an origin and a destination yard.',
|
||
);
|
||
}
|
||
if (originYardId === destinationYardId) {
|
||
throw new BadRequestException('Origin and destination yard must be different.');
|
||
}
|
||
|
||
const [origin, destination] = await Promise.all([
|
||
this.yardsRepository.findById(originYardId),
|
||
this.yardsRepository.findById(destinationYardId),
|
||
]);
|
||
if (!origin) throw new BadRequestException(`Origin yard ${originYardId} not found`);
|
||
if (!destination) {
|
||
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
|
||
}
|
||
|
||
const expected = this.expectedYardCountries(appliesTo, tradeDirection);
|
||
if (origin.country !== expected.origin || destination.country !== expected.destination) {
|
||
const shape =
|
||
appliesTo === 'INTERCITY' ? 'Intercity' : `${tradeDirection ?? 'Import'} freight`;
|
||
throw new BadRequestException(
|
||
`${shape} runs ${expected.origin} → ${expected.destination}, but ${origin.label} is in ` +
|
||
`${origin.country} and ${destination.label} is in ${destination.country}.`,
|
||
);
|
||
}
|
||
|
||
return { originYardId, destinationYardId };
|
||
}
|
||
|
||
/**
|
||
* Guard the scope fields a base-freight category needs before we derive its
|
||
* rateType: import/export must say which, and intercity must say whether it
|
||
* carries containers or bulk (the two price differently and an unstated kind
|
||
* would silently file the rate as one of them).
|
||
*/
|
||
private assertScopeCoherent(input: {
|
||
appliesTo: Rate['appliesTo'];
|
||
trigger: Rate['trigger'];
|
||
tradeDirection: string | null;
|
||
intercityKind: string | null;
|
||
cargoKind: string | null;
|
||
containerTypeId: string | null;
|
||
cargoTypeId: string | null;
|
||
}): void {
|
||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||
const { containerTypeId, cargoTypeId } = input;
|
||
if (trigger === 'CUSTOMS_CLEARANCE') {
|
||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||
throw new BadRequestException(
|
||
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
|
||
);
|
||
}
|
||
// Sold per cargo kind: a container fee names the container type it covers
|
||
// (20ft and 40ft price differently); a bulk fee carries no type at all —
|
||
// that absence is what marks it as the bulk fee.
|
||
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
||
throw new BadRequestException(
|
||
'A customs clearance rate must say whether it covers containers or bulk.',
|
||
);
|
||
}
|
||
if (cargoKind === 'CONTAINER' && !containerTypeId) {
|
||
throw new BadRequestException(
|
||
'A container customs clearance rate must name the container type it covers.',
|
||
);
|
||
}
|
||
if (cargoKind === 'BULK' && containerTypeId) {
|
||
throw new BadRequestException(
|
||
'A bulk customs clearance rate cannot be scoped to a container type.',
|
||
);
|
||
}
|
||
// The bulk customs fee names the commodity it covers (sugar and
|
||
// fertilizer clear differently).
|
||
if (cargoKind === 'BULK' && !cargoTypeId) {
|
||
throw new BadRequestException(
|
||
'A bulk customs clearance rate must name the bulk cargo type it covers.',
|
||
);
|
||
}
|
||
if (cargoKind === 'CONTAINER' && cargoTypeId) {
|
||
throw new BadRequestException(
|
||
'A container customs clearance rate cannot be scoped to a bulk cargo type.',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (trigger === 'LASHING') {
|
||
// Bulk-only cargo securing, sold per direction. May narrow to one leaf
|
||
// commodity (specific wins over the commodity-wide catch-all).
|
||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||
throw new BadRequestException(
|
||
'A lashing rate must say whether it covers IMPORT or EXPORT.',
|
||
);
|
||
}
|
||
if (containerTypeId) {
|
||
throw new BadRequestException(
|
||
'Lashing is bulk-only — it cannot be scoped to a container type.',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (trigger === 'FUEL') {
|
||
// Fuel is sold per lane + commodity: the direction says which countries
|
||
// the leg spans (DOMESTIC = intercity, inside Ethiopia) and the cargo
|
||
// type names the commodity — different commodities price differently.
|
||
if (
|
||
tradeDirection !== 'IMPORT' &&
|
||
tradeDirection !== 'EXPORT' &&
|
||
tradeDirection !== 'DOMESTIC'
|
||
) {
|
||
throw new BadRequestException(
|
||
'A fuel rate must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).',
|
||
);
|
||
}
|
||
if (containerTypeId) {
|
||
throw new BadRequestException(
|
||
'A fuel rate cannot be scoped to a container type.',
|
||
);
|
||
}
|
||
if (!cargoTypeId) {
|
||
throw new BadRequestException(
|
||
'A fuel rate must name the cargo type it covers.',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (trigger === 'WITH_RETURN') {
|
||
// Returning the empty box only exists on imports (the box goes back to
|
||
// the port) — export return rates are rejected until the business sells
|
||
// that.
|
||
if (tradeDirection !== 'IMPORT') {
|
||
throw new BadRequestException(
|
||
'An empty container return rate is import-only for now.',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (!this.isBaseFreight(appliesTo, trigger)) return;
|
||
|
||
if (appliesTo === 'INTERCITY') {
|
||
if (intercityKind !== 'CONTAINER' && intercityKind !== 'BULK') {
|
||
throw new BadRequestException(
|
||
'An intercity rate must say whether it covers containers or bulk.',
|
||
);
|
||
}
|
||
// The scope field has to agree with the kind, or the rate would advertise
|
||
// one cargo kind and narrow by the other.
|
||
if (intercityKind === 'CONTAINER' && cargoTypeId) {
|
||
throw new BadRequestException(
|
||
'An intercity container rate cannot be scoped to a bulk cargo type.',
|
||
);
|
||
}
|
||
if (intercityKind === 'BULK' && containerTypeId) {
|
||
throw new BadRequestException(
|
||
'An intercity bulk rate cannot be scoped to a container type.',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||
throw new BadRequestException(
|
||
`${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Whether a rate covers bulk cargo — the flag `deriveRateType` splits
|
||
* INTERCITY_BULK from INTERCITY_CONTAINER on. Intercity states its kind
|
||
* explicitly; for BULK/CONTAINER the category already says it.
|
||
*/
|
||
private resolvesToBulk(appliesTo: Rate['appliesTo'], intercityKind: string | null): boolean {
|
||
return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK';
|
||
}
|
||
|
||
/**
|
||
* Validate and normalise the last-mile band fields for a rate shape.
|
||
*
|
||
* Last-mile rates come in two calculation modes: bulk (PER_TON_KM — one row
|
||
* per distance band, price = tons × km × rate) and container (PER_KM — one
|
||
* row per container type per distance band, price = km × rate × quantity).
|
||
* A bandless bulk row (NULL minKm) is the legacy pre-band shape and still
|
||
* prices every distance. Every other rate shape has its band fields cleared,
|
||
* mirroring how yard scope is cleared for non-route rates.
|
||
*/
|
||
private resolveLastMileBand(input: {
|
||
appliesTo: Rate['appliesTo'];
|
||
rateUnit: Rate['rateUnit'];
|
||
containerTypeId: string | null;
|
||
minKm?: number | null;
|
||
maxKm?: number | null;
|
||
}): { minKm: number | null; maxKm: number | null } {
|
||
const { appliesTo, rateUnit, containerTypeId } = input;
|
||
if (appliesTo !== 'LAST_MILE') return { minKm: null, maxKm: null };
|
||
|
||
if (rateUnit === 'PER_TON_KM') {
|
||
if (containerTypeId) {
|
||
throw new BadRequestException(
|
||
'A bulk last-mile rate (per ton per km) cannot be scoped to a container type.',
|
||
);
|
||
}
|
||
const minKm = input.minKm ?? null;
|
||
const maxKm = input.maxKm ?? null;
|
||
if (minKm === null) {
|
||
if (maxKm !== null) {
|
||
throw new BadRequestException(
|
||
'"To km" needs a "From km" — set the band start (0 for the first tier).',
|
||
);
|
||
}
|
||
// Legacy bandless bulk rate — prices every distance.
|
||
return { minKm: null, maxKm: null };
|
||
}
|
||
if (maxKm !== null && maxKm <= minKm) {
|
||
throw new BadRequestException('"To km" must be greater than "From km".');
|
||
}
|
||
return { minKm, maxKm };
|
||
}
|
||
|
||
if (rateUnit === 'PER_KM') {
|
||
if (!containerTypeId) {
|
||
throw new BadRequestException(
|
||
'A container last-mile rate must name the container type it covers (20ft and 40ft price differently).',
|
||
);
|
||
}
|
||
const minKm = input.minKm ?? null;
|
||
const maxKm = input.maxKm ?? null;
|
||
if (minKm === null) {
|
||
throw new BadRequestException(
|
||
'A container last-mile rate needs a distance band — set "From km" (0 for the first band).',
|
||
);
|
||
}
|
||
if (maxKm !== null && maxKm <= minKm) {
|
||
throw new BadRequestException('"To km" must be greater than "From km".');
|
||
}
|
||
return { minKm, maxKm };
|
||
}
|
||
|
||
// Legacy last-mile shapes (FLAT / PER_CONTAINER / PER_TON) carry no band.
|
||
return { minKm: null, maxKm: null };
|
||
}
|
||
|
||
/**
|
||
* Reject a last-mile band that overlaps an existing band for the same scope —
|
||
* container bands collide per container type (PER_KM), bulk bands collide
|
||
* with each other (PER_TON_KM, no container scope). Bands are half-open
|
||
* [minKm, maxKm) with NULL maxKm = open-ended, so 0–30 and 30–∞ tile
|
||
* cleanly. Checked across every non-superseded row (DRAFT included) — two
|
||
* drafts with colliding bands would only defer the conflict to approval.
|
||
*/
|
||
private async assertNoBandOverlap(input: {
|
||
rateUnit: 'PER_KM' | 'PER_TON_KM';
|
||
containerTypeId: string | null;
|
||
minKm: number;
|
||
maxKm: number | null;
|
||
ignoreId?: string;
|
||
}): Promise<void> {
|
||
const siblings = await this.repository.findAll({
|
||
where: {
|
||
rateType: 'LAST_MILE',
|
||
rateUnit: input.rateUnit,
|
||
containerTypeId: input.containerTypeId ?? IsNull(),
|
||
status: Not('SUPERSEDED'),
|
||
},
|
||
});
|
||
const newMax = input.maxKm ?? Number.POSITIVE_INFINITY;
|
||
for (const sibling of siblings) {
|
||
if (sibling.id === input.ignoreId) continue;
|
||
if (sibling.minKm === null || sibling.minKm === undefined) continue; // legacy row, no band
|
||
const sibMin = Number(sibling.minKm);
|
||
const sibMax =
|
||
sibling.maxKm === null || sibling.maxKm === undefined
|
||
? Number.POSITIVE_INFINITY
|
||
: Number(sibling.maxKm);
|
||
if (input.minKm < sibMax && sibMin < newMax) {
|
||
const sibLabel = `${sibMin}–${sibMax === Number.POSITIVE_INFINITY ? 'open' : sibMax} km`;
|
||
throw new ConflictException(
|
||
`This distance band overlaps the existing ${sibLabel} band for ${input.rateUnit === 'PER_TON_KM' ? 'bulk last-mile' : 'this container type'}. Adjust the ranges so each distance falls in exactly one band.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reject a second rate with the same identity pattern (rateType + scope). With
|
||
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
||
* make pricing ambiguous — so we allow exactly one per pattern.
|
||
*
|
||
* The UNIT is not part of that identity. Pricing resolves one rate per lane +
|
||
* scope and then applies whatever unit it carries; a per-container and a
|
||
* per-wagon row for the same 20ft lane are two answers to one question, and
|
||
* the engine silently picked one of them. Changing how a lane is billed means
|
||
* editing its rate, not adding a second.
|
||
*/
|
||
private async assertNoDuplicatePattern(pattern: {
|
||
rateType: string;
|
||
/** Passed only for additive surcharges — see {@link resolvesSingleRate}. */
|
||
rateUnit?: string;
|
||
containerTypeId: string | null;
|
||
cargoTypeId: string | null;
|
||
tradeDirection: string | null;
|
||
originYardId: string | null;
|
||
destinationYardId: string | null;
|
||
/** Band start — part of the identity for container last-mile bands only. */
|
||
minKm?: number | null;
|
||
ignoreId?: string;
|
||
}): Promise<void> {
|
||
const existing = await this.repository.findByPattern(pattern);
|
||
if (existing && existing.id !== pattern.ignoreId) {
|
||
throw new ConflictException(
|
||
'A rate for this exact combination already exists on this route. Edit or delete the existing rate instead of creating a duplicate.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Create a rate in DRAFT status. */
|
||
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
||
const appliesTo = dto.appliesTo as Rate['appliesTo'];
|
||
const trigger = dto.trigger as Rate['trigger'];
|
||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||
// the engine never accidentally narrows a surcharge by container/direction.
|
||
// Exceptions: customs clearance and empty-container return keep direction +
|
||
// container type — both are sold per lane (and per container type).
|
||
const isSurcharge = trigger !== 'ALWAYS';
|
||
const cargoKind =
|
||
trigger === 'CUSTOMS_CLEARANCE'
|
||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||
: null;
|
||
const containerTypeId =
|
||
trigger === 'WITH_RETURN' ||
|
||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
|
||
? (dto.containerTypeId ?? null)
|
||
: isSurcharge
|
||
? null
|
||
: (dto.containerTypeId ?? null);
|
||
const cargoTypeId =
|
||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||
trigger === 'LASHING' ||
|
||
trigger === 'FUEL'
|
||
? (dto.cargoTypeId ?? null)
|
||
: isSurcharge
|
||
? null
|
||
: (dto.cargoTypeId ?? null);
|
||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||
// its yard pair already says where it runs. (Fuel is the exception: its
|
||
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
||
// nothing about the direction.)
|
||
const tradeDirection =
|
||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||
trigger === 'WITH_RETURN' ||
|
||
trigger === 'LASHING' ||
|
||
trigger === 'FUEL'
|
||
? (dto.tradeDirection ?? null)
|
||
: isSurcharge || appliesTo === 'INTERCITY'
|
||
? null
|
||
: (dto.tradeDirection ?? null);
|
||
|
||
const intercityKind = dto.intercityKind ?? null;
|
||
this.assertScopeCoherent({
|
||
appliesTo,
|
||
trigger,
|
||
tradeDirection,
|
||
intercityKind,
|
||
cargoKind,
|
||
containerTypeId,
|
||
cargoTypeId,
|
||
});
|
||
const { originYardId, destinationYardId } = await this.resolveYardScope({
|
||
appliesTo,
|
||
trigger,
|
||
tradeDirection,
|
||
originYardId: dto.originYardId,
|
||
destinationYardId: dto.destinationYardId,
|
||
});
|
||
|
||
const rateType = deriveRateType({
|
||
appliesTo,
|
||
trigger,
|
||
tradeDirection,
|
||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||
});
|
||
const rateUnit = await this.resolveRateUnit(
|
||
appliesTo,
|
||
trigger,
|
||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||
cargoKind,
|
||
cargoTypeId,
|
||
);
|
||
|
||
const { minKm, maxKm } = this.resolveLastMileBand({
|
||
appliesTo,
|
||
rateUnit,
|
||
containerTypeId,
|
||
minKm: dto.minKm,
|
||
maxKm: dto.maxKm,
|
||
});
|
||
if (
|
||
appliesTo === 'LAST_MILE' &&
|
||
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
|
||
minKm !== null
|
||
) {
|
||
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
|
||
}
|
||
|
||
const baseLiters = this.resolveBaseLiters(rateUnit, dto.baseLiters);
|
||
|
||
await this.assertNoDuplicatePattern({
|
||
rateType,
|
||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||
containerTypeId,
|
||
cargoTypeId,
|
||
tradeDirection,
|
||
originYardId,
|
||
destinationYardId,
|
||
minKm,
|
||
});
|
||
|
||
return this.repository.create({
|
||
appliesTo,
|
||
trigger,
|
||
rateType,
|
||
containerTypeId,
|
||
cargoTypeId,
|
||
tradeDirection,
|
||
originYardId,
|
||
destinationYardId,
|
||
// Last-mile is the one shape sold in birr (or USD); everything else is
|
||
// USD by contract.
|
||
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
|
||
rateValue: dto.rateValue,
|
||
rateUnit,
|
||
baseLiters,
|
||
minKm,
|
||
maxKm,
|
||
status: 'DRAFT',
|
||
proposedByStaffId,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The liters a PER_LITER fuel rate bills (price = baseLiters × rateValue,
|
||
* once per booking). Required there; cleared on every other rate shape —
|
||
* a PER_WAGON fuel rate bills wagons × rateValue and carries none.
|
||
*/
|
||
private resolveBaseLiters(
|
||
rateUnit: Rate['rateUnit'],
|
||
baseLiters?: number | null,
|
||
): number | null {
|
||
if (rateUnit !== 'PER_LITER') return null;
|
||
const liters = Number(baseLiters);
|
||
if (!(liters > 0)) {
|
||
throw new BadRequestException(
|
||
'A per-liter fuel rate needs a base liters amount — the price is base liters × rate value.',
|
||
);
|
||
}
|
||
return liters;
|
||
}
|
||
|
||
/**
|
||
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
|
||
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
|
||
*/
|
||
async update(id: string, dto: UpdateRateDto): Promise<Rate> {
|
||
const existing = await this.findById(id);
|
||
if (existing.status !== 'DRAFT') {
|
||
throw new BadRequestException(
|
||
existing.status === 'LIVE'
|
||
? 'A LIVE rate cannot be edited directly — file a rate change request so an approver can apply it.'
|
||
: 'Only DRAFT rates can be updated',
|
||
);
|
||
}
|
||
return this.applyUpdate(existing, dto);
|
||
}
|
||
|
||
/**
|
||
* Apply an approved change request to a LIVE rate. Same validation as a
|
||
* DRAFT edit — it just skips the DRAFT guard, because a LIVE rate reaching
|
||
* here has already been through approval. Only ever called by
|
||
* RateChangeRequestsService.approve.
|
||
*/
|
||
async applyApprovedUpdate(id: string, dto: UpdateRateDto): Promise<Rate> {
|
||
const existing = await this.findById(id);
|
||
if (existing.status !== 'LIVE') {
|
||
throw new BadRequestException(
|
||
`Rate change requests apply to LIVE rates only — this rate is ${existing.status}.`,
|
||
);
|
||
}
|
||
return this.applyUpdate(existing, dto);
|
||
}
|
||
|
||
/**
|
||
* Validate a proposed patch against a rate without writing anything — lets a
|
||
* change request be refused at submit time instead of surprising the
|
||
* approver. Throws exactly what applying it would throw.
|
||
*/
|
||
async assertUpdateValid(id: string, dto: UpdateRateDto): Promise<void> {
|
||
await this.buildUpdate(await this.findById(id), dto);
|
||
}
|
||
|
||
private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
|
||
const updates = await this.buildUpdate(existing, dto);
|
||
const updated = await this.repository.update(existing.id, updates);
|
||
if (!updated) throw new NotFoundException(`Rate ${existing.id} not found`);
|
||
return updated;
|
||
}
|
||
|
||
/**
|
||
* The shared edit body: re-derives rateType, re-validates the unit against
|
||
* the (possibly changed) shape, and guards pattern uniqueness. Status is
|
||
* never touched — an approved edit to a LIVE rate stays LIVE. Pure apart
|
||
* from the uniqueness read, so it doubles as the dry-run validator.
|
||
*/
|
||
private async buildUpdate(existing: Rate, dto: UpdateRateDto): Promise<Partial<Rate>> {
|
||
const id = existing.id;
|
||
const updates: Partial<Rate> = {};
|
||
|
||
const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo;
|
||
const trigger = (dto.trigger as Rate['trigger']) ?? existing.trigger;
|
||
const isSurcharge = trigger !== 'ALWAYS';
|
||
|
||
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
||
if (dto.trigger) updates.trigger = trigger;
|
||
|
||
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
||
// has — read back off its container scope (container fees carry the type).
|
||
const cargoKind =
|
||
trigger !== 'CUSTOMS_CLEARANCE'
|
||
? null
|
||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||
|
||
const keepsContainerType =
|
||
!isSurcharge ||
|
||
trigger === 'WITH_RETURN' ||
|
||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
|
||
const containerTypeId = !keepsContainerType
|
||
? null
|
||
: dto.containerTypeId !== undefined
|
||
? dto.containerTypeId
|
||
: existing.containerTypeId;
|
||
const keepsCargoType =
|
||
!isSurcharge ||
|
||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||
trigger === 'LASHING' ||
|
||
trigger === 'FUEL';
|
||
const cargoTypeId = !keepsCargoType
|
||
? null
|
||
: dto.cargoTypeId !== undefined
|
||
? dto.cargoTypeId
|
||
: existing.cargoTypeId;
|
||
const tradeDirection =
|
||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||
trigger === 'WITH_RETURN' ||
|
||
trigger === 'LASHING' ||
|
||
trigger === 'FUEL'
|
||
? dto.tradeDirection !== undefined
|
||
? dto.tradeDirection
|
||
: existing.tradeDirection
|
||
: isSurcharge || appliesTo === 'INTERCITY'
|
||
? null
|
||
: dto.tradeDirection !== undefined
|
||
? dto.tradeDirection
|
||
: existing.tradeDirection;
|
||
|
||
updates.containerTypeId = containerTypeId ?? null;
|
||
updates.cargoTypeId = cargoTypeId ?? null;
|
||
updates.tradeDirection = tradeDirection ?? null;
|
||
|
||
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
||
// has — read back off its rateType, the only place it is recorded.
|
||
const intercityKind =
|
||
dto.intercityKind ?? (existing.rateType === 'INTERCITY_BULK' ? 'BULK' : 'CONTAINER');
|
||
|
||
this.assertScopeCoherent({
|
||
appliesTo,
|
||
trigger,
|
||
tradeDirection: updates.tradeDirection,
|
||
intercityKind,
|
||
cargoKind,
|
||
containerTypeId: updates.containerTypeId,
|
||
cargoTypeId: updates.cargoTypeId,
|
||
});
|
||
// Re-validate the leg: changing direction can invalidate a yard pair that
|
||
// was legal under the old one (an import route is not an export route).
|
||
const yardScope = await this.resolveYardScope({
|
||
appliesTo,
|
||
trigger,
|
||
tradeDirection: updates.tradeDirection,
|
||
originYardId:
|
||
dto.originYardId !== undefined ? dto.originYardId : existing.originYardId,
|
||
destinationYardId:
|
||
dto.destinationYardId !== undefined
|
||
? dto.destinationYardId
|
||
: existing.destinationYardId,
|
||
});
|
||
updates.originYardId = yardScope.originYardId;
|
||
updates.destinationYardId = yardScope.destinationYardId;
|
||
|
||
// Keep the derived rateType in sync with whatever changed.
|
||
const rateType = deriveRateType({
|
||
appliesTo,
|
||
trigger,
|
||
tradeDirection,
|
||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||
});
|
||
updates.rateType = rateType;
|
||
|
||
// Re-validate the unit against the (possibly changed) shape; overweight is
|
||
// forced to PER_TON.
|
||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||
const rateUnit = await this.resolveRateUnit(
|
||
appliesTo,
|
||
trigger,
|
||
requestedUnit,
|
||
cargoKind,
|
||
updates.cargoTypeId,
|
||
);
|
||
updates.rateUnit = rateUnit;
|
||
|
||
const { minKm, maxKm } = this.resolveLastMileBand({
|
||
appliesTo,
|
||
rateUnit,
|
||
containerTypeId: updates.containerTypeId,
|
||
minKm: dto.minKm !== undefined ? dto.minKm : existing.minKm,
|
||
maxKm: dto.maxKm !== undefined ? dto.maxKm : existing.maxKm,
|
||
});
|
||
updates.minKm = minKm;
|
||
updates.maxKm = maxKm;
|
||
if (
|
||
appliesTo === 'LAST_MILE' &&
|
||
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
|
||
minKm !== null
|
||
) {
|
||
await this.assertNoBandOverlap({
|
||
rateUnit,
|
||
containerTypeId: updates.containerTypeId ?? null,
|
||
minKm,
|
||
maxKm,
|
||
ignoreId: id,
|
||
});
|
||
}
|
||
|
||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||
await this.assertNoDuplicatePattern({
|
||
rateType,
|
||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||
containerTypeId: updates.containerTypeId,
|
||
cargoTypeId: updates.cargoTypeId,
|
||
tradeDirection: updates.tradeDirection,
|
||
originYardId: updates.originYardId,
|
||
destinationYardId: updates.destinationYardId,
|
||
minKm,
|
||
ignoreId: id,
|
||
});
|
||
|
||
updates.baseLiters = this.resolveBaseLiters(
|
||
rateUnit,
|
||
dto.baseLiters !== undefined ? dto.baseLiters : existing.baseLiters,
|
||
);
|
||
|
||
updates.currency =
|
||
appliesTo === 'LAST_MILE'
|
||
? (dto.currency ?? existing.currency ?? 'ETB')
|
||
: 'USD';
|
||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||
return updates;
|
||
}
|
||
|
||
/** Submit a DRAFT rate for CEO approval. */
|
||
async submitForApproval(id: string): Promise<Rate> {
|
||
const rate = await this.findById(id);
|
||
if (rate.status !== 'DRAFT') {
|
||
throw new BadRequestException('Only DRAFT rates can be submitted for approval');
|
||
}
|
||
const updated = await this.repository.update(id, { status: 'PENDING_APPROVAL' });
|
||
return updated!;
|
||
}
|
||
|
||
/** CEO approves a rate — moves to LIVE. */
|
||
async approve(id: string, approverUserId: string, canSelfApprove = false): Promise<Rate> {
|
||
const rate = await this.findById(id);
|
||
if (rate.status !== 'PENDING_APPROVAL') {
|
||
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
||
}
|
||
// Separation of duties: the proposer cannot approve their own rate — except
|
||
// super admins, who have full backoffice authority (propose + approve).
|
||
// TODO: split approval into a distinct CEO/approver permission — a normal
|
||
// proposer who also holds the approve permission is still the wrong signer.
|
||
if (!canSelfApprove && approverUserId === rate.proposedByStaffId) {
|
||
throw new ForbiddenException('You cannot approve a rate you proposed');
|
||
}
|
||
const updated = await this.repository.update(id, {
|
||
status: 'LIVE',
|
||
approvedByCeoId: approverUserId,
|
||
approvedAt: new Date(),
|
||
});
|
||
return updated!;
|
||
}
|
||
|
||
/** Soft-delete a rate. */
|
||
async remove(id: string): Promise<void> {
|
||
await this.findById(id);
|
||
await this.repository.softDelete(id);
|
||
}
|
||
}
|