feat(rates): last-mile rate rules with bulk per-ton-km and container distance bands

- rates: min_km/max_km columns, PER_TON_KM unit, ETB|USD currency for last mile
- extend UQ_rates_pattern with band start; overlap + shape validation
- shared last-mile charge resolver; approve-dialog price estimate endpoint
- delivery-fee invoice prices via rules, falls back to vehicle price/km
- backoffice: last-mile rate form (mode, container type, band, currency)
This commit is contained in:
Hagernesh
2026-08-05 22:12:40 +00:00
parent 6125f644b1
commit 5c8c68e990
25 changed files with 880 additions and 20 deletions

View File

@@ -37,6 +37,10 @@ const DIFFABLE_FIELDS = [
// diffed to nothing and the submit was refused as "nothing changed".
'originYardId',
'destinationYardId',
// Container last-mile distance bands. Missing here, a band-range edit on a
// LIVE last-mile rate would diff to "nothing changed".
'minKm',
'maxKm',
] as const;
/**

View File

@@ -7,6 +7,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PaginatedResponse, YardCountry } from '@edr/types';
import { 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';
@@ -340,6 +341,96 @@ export class RatesService {
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 — price =
* tons × km × rate, one row, no scope) and container (PER_KM — one row per
* container type per distance band, price = km × rate × quantity). 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.',
);
}
return { minKm: null, maxKm: null };
}
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 container last-mile band that overlaps an existing band for the
* same container type. 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: {
containerTypeId: string;
minKm: number;
maxKm: number | null;
ignoreId?: string;
}): Promise<void> {
const siblings = await this.repository.findAll({
where: {
rateType: 'LAST_MILE',
rateUnit: 'PER_KM',
containerTypeId: input.containerTypeId,
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 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
@@ -360,6 +451,8 @@ export class RatesService {
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);
@@ -438,6 +531,17 @@ export class RatesService {
cargoTypeId,
);
const { minKm, maxKm } = this.resolveLastMileBand({
appliesTo,
rateUnit,
containerTypeId,
minKm: dto.minKm,
maxKm: dto.maxKm,
});
if (appliesTo === 'LAST_MILE' && rateUnit === 'PER_KM' && containerTypeId && minKm !== null) {
await this.assertNoBandOverlap({ containerTypeId, minKm, maxKm });
}
await this.assertNoDuplicatePattern({
rateType,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
@@ -446,6 +550,7 @@ export class RatesService {
tradeDirection,
originYardId,
destinationYardId,
minKm,
});
return this.repository.create({
@@ -457,9 +562,13 @@ export class RatesService {
tradeDirection,
originYardId,
destinationYardId,
currency: dto.currency ?? 'USD',
// 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,
minKm,
maxKm,
status: 'DRAFT',
proposedByStaffId,
});
@@ -622,6 +731,29 @@ export class RatesService {
);
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' &&
updates.containerTypeId &&
minKm !== null
) {
await this.assertNoBandOverlap({
containerTypeId: updates.containerTypeId,
minKm,
maxKm,
ignoreId: id,
});
}
// Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({
rateType,
@@ -631,10 +763,14 @@ export class RatesService {
tradeDirection: updates.tradeDirection,
originYardId: updates.originYardId,
destinationYardId: updates.destinationYardId,
minKm,
ignoreId: id,
});
updates.currency = dto.currency ?? existing.currency ?? 'USD';
updates.currency =
appliesTo === 'LAST_MILE'
? (dto.currency ?? existing.currency ?? 'ETB')
: 'USD';
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
return updates;
}