mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user