mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user