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

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

View File

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

View File

@@ -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<number> {
return this.dataSource.manager.count(Vehicle, {