import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { PaginatedResponse, YardCountry } from '@edr/types'; import { IsNull, Not } from 'typeorm'; import { ShippingLineStatus } from '../../shipping-lines/entities/shipping-line-company.entity'; import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line-companies.service'; 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, isCustomsClearanceTrigger } 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']; /** * Surcharges sold per cargo kind: the admin says container or bulk, a * container fee then names its container type and a bulk fee its commodity. */ const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [ 'CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'CANCELLATION', ]; /** Surcharges that keep a trade direction (everything else is direction-agnostic). */ const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [ 'CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'CANCELLATION', 'WITH_RETURN', 'LASHING', 'FUEL', ]; /** 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, private readonly shippingLineCompaniesService: ShippingLineCompaniesService, ) {} /** List rates — standard paginated envelope with server-side search. */ async findAll(query: ListRatesQueryDto): Promise> { return this.repository.findPaged(query); } /** Return all currently LIVE rates. */ async findLiveRates(): Promise { 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 { return this.repository.findLiveRatesDetailed(); } /** Get a rate by ID. */ async findById(id: string): Promise { 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 { // 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 { 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) || isCustomsClearanceTrigger(trigger) || 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' || trigger === 'CANCELLATION' ); } /** * 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 { 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 (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') { // Both fees are sold per direction + cargo kind + type: customs clearance // per lane, the wagon cancellation fee per direction only. const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance'; if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { throw new BadRequestException( `A ${fee} 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 ${fee} rate must say whether it covers containers or bulk.`, ); } if (cargoKind === 'CONTAINER' && !containerTypeId) { throw new BadRequestException( `A container ${fee} rate must name the container type it covers.`, ); } if (cargoKind === 'BULK' && containerTypeId) { throw new BadRequestException( `A bulk ${fee} rate cannot be scoped to a container type.`, ); } // The bulk fee names the commodity it covers (sugar and fertilizer // clear — and cancel — differently). if (cargoKind === 'BULK' && !cargoTypeId) { throw new BadRequestException( `A bulk ${fee} rate must name the bulk cargo type it covers.`, ); } if (cargoKind === 'CONTAINER' && cargoTypeId) { throw new BadRequestException( `A container ${fee} 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 { 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; shippingLineCompanyId: string | null; 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 { 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.', ); } } /** * Validate the shipping line a rate is scoped to, when any. * * A shipping line only ever ships import — the export leg is sold through the * customer's contract — so a line rate carrying an EXPORT direction is * rejected here as well as by `CK_rates_shipping_line_import_only`. * Returns the owner id to store (null = the standard customer rate). */ private async resolveShippingLineScope( shippingLineCompanyId: string | null | undefined, tradeDirection: string | null, ): Promise { if (!shippingLineCompanyId) return null; // Throws NotFoundException when the line does not exist. const line = await this.shippingLineCompaniesService.findById(shippingLineCompanyId); if (line.status !== ShippingLineStatus.Active) { throw new BadRequestException( `${line.name} is ${line.status} — rates can only be configured for an active shipping line.`, ); } if (tradeDirection && tradeDirection !== 'IMPORT') { throw new BadRequestException( 'Shipping line rates are import-only — the export leg is priced through the customer contract.', ); } return shippingLineCompanyId; } /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { 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: the directed surcharges (customs clearance, cancellation, // empty-container return, lashing, fuel) keep direction + cargo scope. const isSurcharge = trigger !== 'ALWAYS'; const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger) ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) : null; const containerTypeId = trigger === 'WITH_RETURN' || (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER') ? (dto.containerTypeId ?? null) : isSurcharge ? null : (dto.containerTypeId ?? null); const cargoTypeId = (CARGO_KIND_TRIGGERS.includes(trigger) && 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 = DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) ? (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 shippingLineCompanyId = await this.resolveShippingLineScope( dto.shippingLineCompanyId, tradeDirection, ); 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 }), shippingLineCompanyId, containerTypeId, cargoTypeId, tradeDirection, originYardId, destinationYardId, minKm, }); return this.repository.create({ appliesTo, trigger, rateType, shippingLineCompanyId, 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 { 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 { 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 { await this.buildUpdate(await this.findById(id), dto); } /** * The exact column changes applying this patch would make, without writing. * * A change request diffs against THIS rather than the raw patch: the form * posts selectors that are never stored (`cargoKind`, `intercityKind`), and * the real edit they encode lands on derived columns — flipping a rate to * bulk moves `rateType` and swaps `containerTypeId`/`cargoTypeId`. Diffing * the raw patch missed all of it, so the approver saw an empty change list. */ async previewUpdate(id: string, dto: UpdateRateDto): Promise> { return this.buildUpdate(await this.findById(id), dto); } private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise { 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> { const id = existing.id; const updates: Partial = {}; 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 = !CARGO_KIND_TRIGGERS.includes(trigger) ? null : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? (existing.containerTypeId ? 'CONTAINER' : 'BULK')); const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN' || (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER'); const containerTypeId = !keepsContainerType ? null : dto.containerTypeId !== undefined ? dto.containerTypeId : existing.containerTypeId; const keepsCargoType = !isSurcharge || (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || trigger === 'LASHING' || trigger === 'FUEL'; const cargoTypeId = !keepsCargoType ? null : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) ? 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; // The owning line is re-validated on every edit: a patch that flips the // direction to EXPORT has to be refused for a line rate, and a patch that // moves the rate to a suspended line too. const shippingLineCompanyId = await this.resolveShippingLineScope( dto.shippingLineCompanyId !== undefined ? dto.shippingLineCompanyId : existing.shippingLineCompanyId, updates.tradeDirection, ); updates.shippingLineCompanyId = shippingLineCompanyId; // 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 }), shippingLineCompanyId, 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 { 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 { 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 { await this.findById(id); await this.repository.softDelete(id); } }