From 5c8c68e9900482fcbd5b63ef4ab3a50c21e5848d Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 5 Aug 2026 22:12:40 +0000 Subject: [PATCH] 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) --- .../src/common/last-mile-charge.util.spec.ts | 121 +++++++++++ .../src/common/last-mile-charge.util.ts | 202 ++++++++++++++++++ .../contract-rate-schedule.builder.ts | 1 + .../3280000000000-LastMileRateBands.ts | 68 ++++++ .../bookings/booking-pricing.service.ts | 10 +- .../contracts/contract-pricing.service.ts | 8 +- .../dto/approve-last-mile-request.dto.ts | 6 +- .../last-mile-requests.controller.ts | 10 + .../last-mile-requests.service.ts | 46 ++++ .../last-mile/last-mile-invoice.service.ts | 40 ++++ .../modules/last-mile/last-mile.guard.spec.ts | 1 + .../modules/last-mile/last-mile.service.ts | 14 +- .../rule-engine/dto/create-rate.dto.ts | 25 ++- .../rule-engine/entities/rate-unit.util.ts | 6 +- .../rule-engine/entities/rate.entity.ts | 13 ++ .../interfaces/rates.repository.interface.ts | 2 + .../repositories/rates.repository.ts | 8 + .../services/rate-change-requests.service.ts | 4 + .../rule-engine/services/rates.service.ts | 140 +++++++++++- .../operations/LastMileRequestsPanel.tsx | 45 +++- .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../backoffice/src/constants/URLS.ts | 1 + .../ruleEngine/RuleEngineResourcePage.tsx | 24 ++- .../src/pages/ruleEngine/config/resources.ts | 93 +++++++- .../services/last-mile-requests.service.ts | 10 + 25 files changed, 880 insertions(+), 20 deletions(-) create mode 100644 apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts create mode 100644 apps/edr-freight-api/src/common/last-mile-charge.util.ts create mode 100644 apps/edr-freight-api/src/migrations/3280000000000-LastMileRateBands.ts diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts new file mode 100644 index 000000000..e2ff1bfd9 --- /dev/null +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts @@ -0,0 +1,121 @@ +import { computeLastMileCharge } from './last-mile-charge.util'; +import type { Rate } from '../modules/rule-engine/entities/rate.entity'; + +const rate = (over: Partial): Rate => + ({ + appliesTo: 'LAST_MILE', + status: 'LIVE', + currency: 'ETB', + trigger: 'ALWAYS', + rateType: 'LAST_MILE', + ...over, + }) as Rate; + +const band20a = rate({ + rateUnit: 'PER_KM', + rateValue: 1800, + minKm: 0, + maxKm: 30, + containerType: { sizeFt: 20 } as Rate['containerType'], +}); +const band20b = rate({ + rateUnit: 'PER_KM', + rateValue: 1500, + minKm: 30, + maxKm: null, + containerType: { sizeFt: 20 } as Rate['containerType'], +}); +const band40a = rate({ + rateUnit: 'PER_KM', + rateValue: 2200, + minKm: 0, + maxKm: 30, + containerType: { sizeFt: 40 } as Rate['containerType'], +}); +const bulkRate = rate({ rateUnit: 'PER_TON_KM', rateValue: 25 }); + +describe('computeLastMileCharge', () => { + it('prices containers per band × size × quantity', () => { + const charge = computeLastMileCharge({ + freightType: 'CONTAINER', + tons: 0, + km: 13, + containers: [ + { sizeLabel: '20DC', qty: 5 }, + { sizeLabel: '40HC', qty: 1 }, + ], + liveRates: [band20a, band20b, band40a], + }); + // 13 × 1800 × 5 + 13 × 2200 × 1 + expect(charge).toMatchObject({ mode: 'CONTAINER', total: 117000 + 28600, currency: 'ETB' }); + expect(charge!.lines).toHaveLength(2); + }); + + it('band boundary is half-open: km = 30 falls in the 30+ band', () => { + const charge = computeLastMileCharge({ + freightType: 'CONTAINER', + tons: 0, + km: 30, + containers: [{ sizeLabel: '20DC', qty: 1 }], + liveRates: [band20a, band20b], + }); + expect(charge!.lines[0].unitRate).toBe(1500); + expect(charge!.total).toBe(30 * 1500); + }); + + it('returns null when a size has no matching band', () => { + const charge = computeLastMileCharge({ + freightType: 'CONTAINER', + tons: 0, + km: 50, + containers: [{ sizeLabel: '40HC', qty: 2 }], + liveRates: [band40a], // 40ft only covers 0–30 + }); + expect(charge).toBeNull(); + }); + + it('prices bulk as tons × km × rate', () => { + const charge = computeLastMileCharge({ + freightType: 'BULK', + tons: 60, + km: 26, + containers: [], + liveRates: [bulkRate], + }); + expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' }); + }); + + it('returns null on mixed currencies, unknown km, and uncovered freight types', () => { + const usd40 = rate({ ...band40a, currency: 'USD' }); + expect( + computeLastMileCharge({ + freightType: 'CONTAINER', + tons: 0, + km: 10, + containers: [ + { sizeLabel: '20DC', qty: 1 }, + { sizeLabel: '40HC', qty: 1 }, + ], + liveRates: [band20a, usd40], + }), + ).toBeNull(); + expect( + computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 0, + containers: [], + liveRates: [bulkRate], + }), + ).toBeNull(); + expect( + computeLastMileCharge({ + freightType: 'BREAK_BULK', + tons: 10, + km: 10, + containers: [], + liveRates: [bulkRate], + }), + ).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.ts new file mode 100644 index 000000000..706e21238 --- /dev/null +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.ts @@ -0,0 +1,202 @@ +import type { DataSource } from 'typeorm'; +import type { Rate } from '../modules/rule-engine/entities/rate.entity'; +import { bookingContainerSizes } from './truck-load.util'; + +/** One priced line of a rule-based last-mile charge. */ +export interface LastMileChargeLine { + description: string; + quantity: number; + unitRate: number; + amount: number; +} + +/** A fully-resolved rule-based last-mile charge. */ +export interface LastMileCharge { + mode: 'BULK' | 'CONTAINER'; + total: number; + currency: string; + lines: LastMileChargeLine[]; +} + +/** What the last-mile leg is hauling, in the shape the rate rules price. */ +export interface LastMileShipmentShape { + freightType: string | null; + tons: number; + containers: Array<{ sizeLabel: string; qty: number }>; +} + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in. + * + * BULK: one PER_TON_KM rate → price = tons × km × rate. + * CONTAINER: per container size, the PER_KM rate whose distance band holds the + * km (bands are half-open [minKm, maxKm), NULL maxKm = open-ended) → price = + * km × rate × quantity, summed across sizes. + * + * Returns null whenever the rules don't fully cover the shipment (no rate, a + * container size without a matching band, mixed currencies, km/tons unknown) — + * callers keep their existing pricing as the fallback. Never throws. + */ +export function computeLastMileCharge(input: { + freightType: string | null; + tons: number; + km: number; + containers: Array<{ sizeLabel: string; qty: number }>; + /** LIVE rates with the containerType relation loaded (findLiveRatesDetailed). */ + liveRates: Rate[]; +}): LastMileCharge | null { + const { freightType, tons, km, containers, liveRates } = input; + if (!km || km <= 0) return null; + + const candidates = liveRates.filter( + (rate) => rate.appliesTo === 'LAST_MILE' && rate.status === 'LIVE', + ); + + if (freightType === 'BULK') { + if (!tons || tons <= 0) return null; + const rate = candidates.find((r) => r.rateUnit === 'PER_TON_KM'); + if (!rate) return null; + const unitRate = Number(rate.rateValue); + const amount = round2(tons * km * unitRate); + return { + mode: 'BULK', + total: amount, + currency: rate.currency, + lines: [ + { + description: `Last-mile bulk delivery — ${tons} t × ${km} km × ${unitRate}/t·km`, + quantity: tons, + unitRate, + amount, + }, + ], + }; + } + + if (freightType === 'CONTAINER') { + if (!containers.length) return null; + const lines: LastMileChargeLine[] = []; + const currencies = new Set(); + for (const group of containers) { + const rate = candidates.find( + (r) => + r.rateUnit === 'PER_KM' && + r.minKm !== null && + r.minKm !== undefined && + r.containerType?.sizeFt !== null && + r.containerType?.sizeFt !== undefined && + group.sizeLabel.includes(String(r.containerType.sizeFt)) && + Number(r.minKm) <= km && + (r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)), + ); + // A size the rules don't cover means the rule set can't price this job. + if (!rate) return null; + const unitRate = Number(rate.rateValue); + const amount = round2(km * unitRate * group.qty); + currencies.add(rate.currency); + lines.push({ + description: `Last-mile delivery — ${group.qty} × ${group.sizeLabel} container, ${km} km @ ${unitRate}/km`, + quantity: group.qty, + unitRate, + amount, + }); + } + // A charge can't mix birr and dollar lines on one invoice. + if (currencies.size !== 1) return null; + return { + mode: 'CONTAINER', + total: round2(lines.reduce((sum, line) => sum + line.amount, 0)), + currency: [...currencies][0], + lines, + }; + } + + return null; +} + +/** + * Rule-based charge for an operational last-mile record: prices what its + * trucks actually haul (last_mile_vehicle_containers / weighed net tons) + * against the given km. Shared by setDistances (writes remainingPayment) and + * DELIVERY_FEE invoicing so the two never disagree on the math. Null = the + * rules don't cover this job — callers keep the per-vehicle price/km path. + */ +export async function ruleBasedLastMileCharge( + dataSource: DataSource, + liveRates: Rate[], + lastMileId: string, + km: number, +): Promise { + if (!km || km <= 0) return null; + const [record]: Array<{ bookingId: string }> = await dataSource.query( + `SELECT booking_id AS "bookingId" + FROM freight.last_mile + WHERE id = $1 AND deleted_at IS NULL`, + [lastMileId], + ); + if (!record) return null; + + const containerRows: Array<{ containerNumber: string }> = await dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.last_mile_vehicle_containers + WHERE last_mile_id = $1 AND deleted_at IS NULL`, + [lastMileId], + ); + const shape = await lastMileShipmentShape( + dataSource, + record.bookingId, + containerRows.map((r) => r.containerNumber), + ); + + // Bulk: bill the weighed tonnage on this record's trucks when known, + // falling back to the booking's declared VGM total. + const [tonsRow]: Array<{ tons: string | null }> = await dataSource.query( + `SELECT SUM(net_weight_tons) AS "tons" + FROM freight.last_mile_vehicle_assignments + WHERE last_mile_id = $1 AND deleted_at IS NULL`, + [lastMileId], + ); + const weighedTons = Number(tonsRow?.tons ?? 0); + + return computeLastMileCharge({ + ...shape, + tons: weighedTons > 0 ? weighedTons : shape.tons, + km, + liveRates, + }); +} + +/** + * Load a booking's shipment shape for the charge resolver: freight type, bulk + * tonnage, and the container numbers grouped into size × quantity. + */ +export async function lastMileShipmentShape( + dataSource: DataSource, + bookingId: string, + containerNumbers: string[], +): Promise { + const [booking]: Array<{ freightType: string | null; tons: string | null }> = + await dataSource.query( + `SELECT freight_type AS "freightType", cargo_total_weight_vgm AS "tons" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + const sizes = await bookingContainerSizes( + dataSource, + bookingId, + containerNumbers.map((n) => n.trim().toUpperCase()), + ); + const bySize = new Map(); + for (const size of sizes) { + if (!size) continue; + bySize.set(size, (bySize.get(size) ?? 0) + 1); + } + return { + freightType: booking?.freightType ?? null, + tons: Number(booking?.tons ?? 0), + containers: [...bySize.entries()].map(([sizeLabel, qty]) => ({ sizeLabel, qty })), + }; +} diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 48eadb6c7..c62a875bf 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -43,6 +43,7 @@ const UNIT_LABELS: Record = { PER_TON: 'per ton', PER_CONTAINER: 'per container', PER_KM: 'per km', + PER_TON_KM: 'per ton per km', PER_INVOICE: 'per invoice', FLAT: 'flat', }; diff --git a/apps/edr-freight-api/src/migrations/3280000000000-LastMileRateBands.ts b/apps/edr-freight-api/src/migrations/3280000000000-LastMileRateBands.ts new file mode 100644 index 000000000..973ff00a4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3280000000000-LastMileRateBands.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Last-mile rate bands: adds min_km/max_km to freight.rates so container + * last-mile rates can be one row per (container size × distance band), + * and extends UQ_rates_pattern with the band start so sibling bands don't + * collide. Existing rows all have NULL min_km (COALESCE → -1), so the + * uniqueness semantics for every current rate are unchanged. + */ +export class LastMileRateBands3280000000000 implements MigrationInterface { + name = 'LastMileRateBands3280000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS min_km numeric(10,2) + `); + await queryRunner.query(` + ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS max_km numeric(10,2) + `); + + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'CK_rates_km_band' + AND conrelid = 'freight.rates'::regclass + ) THEN + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_km_band" + CHECK (max_km IS NULL OR (min_km IS NOT NULL AND max_km > min_km)); + END IF; + END $$ + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''::character varying), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit, + COALESCE(min_km, '-1'::numeric) + ) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text)) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''::character varying), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit + ) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text)) + `); + await queryRunner.query(` + ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_km_band" + `); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS max_km`); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS min_km`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 522bac47e..df81fd5f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -727,8 +727,16 @@ export class BookingPricingService { for (const leg of legs) { if (!leg.active) continue; + // New-style last-mile rates (PER_TON_KM bulk / distance-banded PER_KM) + // price the operational leg via last-mile-charge.util, not the booking + // quote — this legacy lookup must never pick one up. const rate = liveRates.find( - (r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE', + (r) => + r.rateType === leg.rateType && + r.currency === 'USD' && + r.status === 'LIVE' && + r.rateUnit !== 'PER_TON_KM' && + r.minKm == null, ); if (!rate) continue; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 3f395c369..924c1a72d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -177,8 +177,14 @@ export class ContractPricingService { } } if (contract.lastMileDeliveryAddress) { + // New-style last-mile rates (PER_TON_KM / distance-banded PER_KM) are + // priced operationally per job, not as a single contract unit price. const lm = liveRates.find( - (r) => r.rateType === 'LAST_MILE' && r.currency === 'USD', + (r) => + r.rateType === 'LAST_MILE' && + r.currency === 'USD' && + r.rateUnit !== 'PER_TON_KM' && + r.minKm == null, ); if (lm && Number(lm.rateValue) > 0) { lineItems.push({ diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts index 170eafb25..56be0c545 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/approve-last-mile-request.dto.ts @@ -3,10 +3,8 @@ import { Transform } from 'class-transformer'; import { IsNumber, Min } from 'class-validator'; export class ApproveLastMileRequestDto { - // ponytail: flat manual advance amount — no rate model exists yet at this - // pre-distance stage (delivery-fee invoicing needs assigned-truck distance, - // which isn't known until after payment). Wire a FeeRule-based estimate - // (see double-handling/truck-detention fee rules) once one exists. + // The approve dialog prefills this from GET :id/price-estimate (rule-based), + // but the chief can still override — the typed value is what's invoiced. @ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 }) @Transform(({ value }) => Number(value)) @IsNumber() diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index 0c71a21c6..d02d00adb 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -42,6 +42,16 @@ export class LastMileRequestsController { return this.requestsService.freeTruckCount().then((count) => ({ count })); } + @Get(':id/price-estimate') + @BookingStaff(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ + summary: + 'Rule-based last-mile price estimate (estimated km × live last-mile rates) — informational context for approval', + }) + priceEstimate(@Param('id', ParseUUIDPipe) id: string) { + return this.requestsService.priceEstimate(id); + } + @Get(':id') @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'Get a last-mile confirmation request by ID' }) diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index e21706c9f..0ef297216 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -3,7 +3,14 @@ import { Cron } from '@nestjs/schedule'; import { DataSource, FindOptionsWhere } from 'typeorm'; import { Freight, LastMileRequestStatus } from '@edr/types'; +import { + LastMileCharge, + computeLastMileCharge, + lastMileShipmentShape, +} from '../../common/last-mile-charge.util'; +import { estimateMileKm } from '../../common/mile-distance.util'; import { usesEdrMileService } from '../../common/mile-haulage.util'; +import { RatesService } from '../rule-engine/services/rates.service'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsService } from '../bookings/bookings.service'; @@ -38,6 +45,7 @@ export class LastMileRequestsService { private readonly lastMileService: LastMileService, private readonly billing: BillingService, private readonly notifications: NotificationInboxService, + private readonly ratesService: RatesService, private readonly dataSource: DataSource, ) {} @@ -199,6 +207,44 @@ export class LastMileRequestsService { return record; } + /** + * Rule-based price estimate for the approval dialog: estimated km (yard GPS → + * delivery point, straight-line) × the LIVE last-mile rate rules against the + * containers the customer confirmed (or the booking's bulk tonnage). All + * nulls when km or rate coverage is missing — the dialog then behaves as + * before (manually typed advance). + */ + async priceEstimate(id: string): Promise<{ + estimatedKm: number | null; + mode: LastMileCharge['mode'] | null; + currency: string | null; + total: number | null; + lines: Array<{ description: string; amount: number }>; + }> { + const request = await this.findById(id); + const estimatedKm = await estimateMileKm(this.dataSource, request.bookingId, 'LAST'); + if (!estimatedKm) { + return { estimatedKm: null, mode: null, currency: null, total: null, lines: [] }; + } + const shape = await lastMileShipmentShape( + this.dataSource, + request.bookingId, + request.requestedContainerNumbers ?? [], + ); + const charge = computeLastMileCharge({ + ...shape, + km: estimatedKm, + liveRates: await this.ratesService.findLiveRatesDetailed(), + }); + return { + estimatedKm, + mode: charge?.mode ?? null, + currency: charge?.currency ?? null, + total: charge?.total ?? null, + lines: (charge?.lines ?? []).map(({ description, amount }) => ({ description, amount })), + }; + } + /** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */ async freeTruckCount(): Promise { return this.dataSource.manager.count(Vehicle, { diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index 8a6ec0779..36d0567ad 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -1,13 +1,16 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource } from 'typeorm'; import { Freight } from '@edr/types'; +import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util'; import { BillingService, GenerateInvoiceInput, InvoiceEventPayload, } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; +import { RatesService } from '../rule-engine/services/rates.service'; import { LastMileRepository } from './last-mile.repository'; import { LastMile } from './entities/last-mile.entity'; @@ -26,6 +29,8 @@ export class LastMileInvoiceService { constructor( private readonly billing: BillingService, private readonly lastMileRepo: LastMileRepository, + private readonly ratesService: RatesService, + private readonly dataSource: DataSource, ) {} /** @@ -54,6 +59,41 @@ export class LastMileInvoiceService { return null; } + // Rule-based pricing first (bulk per-ton-km / container distance bands + // against the exact km): when a LIVE last-mile rate covers the job, it — + // not the per-vehicle price/km — is the delivery fee, with its own + // currency and per-size breakdown. Same resolver setDistances used to + // write remainingPayment, recomputed here so a rate change between the + // two moments settles on the invoice's side. + const exactKm = Number(record.exactKm) || 0; + const rule = + exactKm > 0 + ? await ruleBasedLastMileCharge( + this.dataSource, + await this.ratesService.findLiveRatesDetailed(), + record.id, + exactKm, + ) + : null; + if (rule && rule.total > 0) { + return this.billing.generateInvoice({ + source: 'last_mile' as Freight.InvoiceSource, + sourceId: record.id, + type: 'DELIVERY_FEE', + companyId: lm.booking!.companyId, + companyProfileId: lm.booking!.companyProfileId || '', + currency: rule.currency, + lines: rule.lines.map((line) => ({ + chargeType: 'DELIVERY', + description: line.description, + quantity: line.quantity, + unitRate: line.unitRate, + amount: line.amount, + })), + totalAmount: rule.total, + }); + } + // numeric columns come back as strings — coerce before billing. const totalAmount = Number(record.remainingPayment) || 0; if (!Number.isFinite(totalAmount) || totalAmount <= 0) { diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.guard.spec.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.guard.spec.ts index 232f59d50..78fe4933f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.guard.spec.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.guard.spec.ts @@ -42,6 +42,7 @@ function makeService(opts: { booking?: BookingRow; hasCustomerTruck?: boolean }) { query } as unknown as DataSource, { record: jest.fn() } as never, // history {} as never, // billing + { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService {} as never, // filesService ); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 080c3bbe6..ec7d4ccf6 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -13,7 +13,9 @@ import { usesEdrMileService, } from '../../common/mile-haulage.util'; import { attachMileFinancials } from '../../common/mile-financials.util'; +import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util'; import { estimateMileKm } from '../../common/mile-distance.util'; +import { RatesService } from '../rule-engine/services/rates.service'; import { assertBulkTonnageRemains, assertTruckCountWithinContainers, @@ -70,6 +72,7 @@ export class LastMileService { private readonly dataSource: DataSource, private readonly history: FleetHistoryService, private readonly billing: BillingService, + private readonly ratesService: RatesService, private readonly filesService: FilesService, ) {} @@ -986,9 +989,18 @@ export class LastMileService { (s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0), 0, ); + // Prefer the rule-based last-mile rate (bulk per-ton-km / container + // distance bands) over the per-vehicle price; the truck math stays as the + // fallback when no LIVE rule covers this job. + const rule = await ruleBasedLastMileCharge( + this.dataSource, + await this.ratesService.findLiveRatesDetailed(), + id, + total, + ); await this.lastMileRepository.update(id, { exactKm: total, - remainingPayment: amount, + remainingPayment: rule?.total ?? amount, } as any); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 4f9d06870..45a5838bd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -8,7 +8,8 @@ import { } from '../entities/rate.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; -const CURRENCIES = ['USD'] as const; +// ETB is accepted only for last-mile rates; the service forces USD elsewhere. +const CURRENCIES = ['USD', 'ETB'] as const; export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const; export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const; @@ -92,6 +93,28 @@ export class CreateRateDto { @IsOptional() @IsIn([...RATE_UNITS]) rateUnit?: string; + + @ApiPropertyOptional({ + description: + 'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value))) + minKm?: number; + + @ApiPropertyOptional({ + description: + 'Distance band end (km, exclusive). Null/omitted = open-ended band. Container last-mile rates only.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value))) + maxKm?: number; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 1de36bcfd..a3d336605 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -93,8 +93,12 @@ function unitsForShape(input: { case 'INTERCITY': return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM']; case 'FIRST_MILE': - case 'LAST_MILE': return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT']; + case 'LAST_MILE': + // PER_KM = container mode (banded by distance + container size), + // PER_TON_KM = bulk mode (tons × km × rate). Legacy units kept for + // existing rows. + return ['PER_KM', 'PER_TON_KM', 'PER_CONTAINER', 'PER_TON', 'FLAT']; default: return ['FLAT']; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index d62d5647f..e7089f08c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -39,6 +39,8 @@ export const RATE_UNITS = [ 'PER_ITEM', 'PER_CONTAINER', 'PER_KM', + // Last-mile bulk: price = tons × km × rateValue. + 'PER_TON_KM', 'PER_INVOICE', 'FLAT', ] as const; @@ -156,6 +158,17 @@ export class Rate extends BaseEntity { @Column({ name: 'rate_unit', type: 'varchar', length: 30 }) rateUnit!: RateUnit; + /** + * Distance band for container last-mile rates (rateUnit = PER_KM, scoped by + * containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL = + * open-ended). NULL on every other rate shape. + */ + @Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + minKm?: number | null; + + @Column({ name: 'max_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + maxKm?: number | null; + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) status!: RateStatus; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 1570e0d23..9797ff715 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -21,6 +21,8 @@ export interface IRatesRepository { tradeDirection?: string | null; originYardId?: string | null; destinationYardId?: string | null; + /** Band start for container last-mile rates; omitted/null elsewhere. */ + minKm?: number | null; }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 43c716a3b..7417987dc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -74,6 +74,7 @@ export class RatesRepository implements IRatesRepository { tradeDirection?: string | null; originYardId?: string | null; destinationYardId?: string | null; + minKm?: number | null; }): Promise { const qb = this.repo .createQueryBuilder('rate') @@ -111,6 +112,13 @@ export class RatesRepository implements IRatesRepository { } else { qb.andWhere('rate.destination_yard_id IS NULL'); } + // Band start distinguishes sibling last-mile bands, mirroring the + // COALESCE(min_km, -1) column of UQ_rates_pattern. + if (pattern.minKm !== null && pattern.minKm !== undefined) { + qb.andWhere('rate.min_km = :minKm', { minKm: pattern.minKm }); + } else { + qb.andWhere('rate.min_km IS NULL'); + } return qb.getOne(); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 36c55dad3..6dde40d7d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -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; /** diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index d8ceb97ab..5645334e5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -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 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: { + containerTypeId: string; + minKm: number; + maxKm: number | null; + ignoreId?: string; + }): Promise { + 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 { 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; } diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx index 6b5e15d99..3c4781fbe 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Badge, Box, @@ -76,6 +76,21 @@ export function LastMileRequestsPanel() { queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data, }); + // Rule-based estimate for the approve dialog (estimated km × live last-mile + // rates). Prefills the advance once, without clobbering a typed value. + const { data: estimate } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.priceEstimate(approveTarget?.id ?? ""), + queryFn: async () => + (await lastMileRequestsService.priceEstimate(approveTarget!.id)).data, + enabled: Boolean(approveTarget), + }); + useEffect(() => { + if (approveTarget && estimate?.total != null && advanceAmount === "") { + setAdvanceAmount(estimate.total); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [estimate, approveTarget]); + const invalidate = () => qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT }); @@ -227,11 +242,29 @@ export function LastMileRequestsPanel() { setApproveTarget(null)} + onClose={() => { + setApproveTarget(null); + setAdvanceAmount(""); + }} title={Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}} centered > + {estimate?.total != null && ( + + {estimate.lines.map((line) => ( + + {line.description} — {line.amount.toLocaleString()} + + ))} + + Estimated total: {estimate.total.toLocaleString()} {estimate.currency} + {estimate.estimatedKm != null + ? ` · ${estimate.estimatedKm} km (straight-line estimate)` + : ""} + + + )} -