Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts

901 lines
33 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 {
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 030 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);
}
}