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

@@ -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) {

View File

@@ -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
);

View File

@@ -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);
}