Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts
Marshal 6467173c76 fix
2026-07-17 13:57:59 +00:00

479 lines
18 KiB
TypeScript

import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PaginatedResponse, YardCountry } from '@edr/types';
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 { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
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,
) {}
/** 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();
}
/** 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`).
*/
private resolveRateUnit(
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
requestedUnit: Rate['rateUnit'],
): Rate['rateUnit'] {
// Overweight is per-ton, full stop.
if (trigger === 'OVERWEIGHT') return 'PER_TON';
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', ');
throw new BadRequestException(
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`,
);
}
return requestedUnit;
}
/** 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);
}
/**
* 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 } {
if (appliesTo === 'INTERCITY') {
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.isBaseFreight(appliesTo, trigger)) {
return { originYardId: null, destinationYardId: null };
}
const originYardId = input.originYardId ?? null;
const destinationYardId = input.destinationYardId ?? null;
if (!originYardId || !destinationYardId) {
throw new BadRequestException(
'Base freight rates are 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;
containerTypeId: string | null;
cargoTypeId: string | null;
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
const { containerTypeId, cargoTypeId } = input;
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';
}
/**
* 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.
*/
private async assertNoDuplicatePattern(pattern: {
rateType: string;
rateUnit: string;
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
originYardId: string | null;
destinationYardId: string | 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.
const isSurcharge = trigger !== 'ALWAYS';
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
const cargoTypeId = 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.
const tradeDirection =
isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null);
const intercityKind = dto.intercityKind ?? null;
this.assertScopeCoherent({
appliesTo,
trigger,
tradeDirection,
intercityKind,
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 = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
await this.assertNoDuplicatePattern({
rateType,
rateUnit,
containerTypeId,
cargoTypeId,
tradeDirection,
originYardId,
destinationYardId,
});
return this.repository.create({
appliesTo,
trigger,
rateType,
containerTypeId,
cargoTypeId,
tradeDirection,
originYardId,
destinationYardId,
currency: dto.currency ?? 'USD',
rateValue: dto.rateValue,
rateUnit,
status: 'DRAFT',
proposedByStaffId,
});
}
/**
* 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;
const containerTypeId = isSurcharge
? null
: dto.containerTypeId !== undefined
? dto.containerTypeId
: existing.containerTypeId;
const cargoTypeId = isSurcharge
? null
: dto.cargoTypeId !== undefined
? dto.cargoTypeId
: existing.cargoTypeId;
const 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,
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;
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit);
// Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({
rateType,
rateUnit: updates.rateUnit,
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
tradeDirection: updates.tradeDirection,
originYardId: updates.originYardId,
destinationYardId: updates.destinationYardId,
ignoreId: id,
});
updates.currency = dto.currency ?? existing.currency ?? '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);
}
}