mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +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:
121
apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts
Normal file
121
apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts
Normal file
@@ -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>): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
202
apps/edr-freight-api/src/common/last-mile-charge.util.ts
Normal file
202
apps/edr-freight-api/src/common/last-mile-charge.util.ts
Normal file
@@ -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<string>();
|
||||||
|
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<LastMileCharge | null> {
|
||||||
|
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<LastMileShipmentShape> {
|
||||||
|
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<string, number>();
|
||||||
|
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 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ const UNIT_LABELS: Record<string, string> = {
|
|||||||
PER_TON: 'per ton',
|
PER_TON: 'per ton',
|
||||||
PER_CONTAINER: 'per container',
|
PER_CONTAINER: 'per container',
|
||||||
PER_KM: 'per km',
|
PER_KM: 'per km',
|
||||||
|
PER_TON_KM: 'per ton per km',
|
||||||
PER_INVOICE: 'per invoice',
|
PER_INVOICE: 'per invoice',
|
||||||
FLAT: 'flat',
|
FLAT: 'flat',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -727,8 +727,16 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
for (const leg of legs) {
|
for (const leg of legs) {
|
||||||
if (!leg.active) continue;
|
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(
|
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;
|
if (!rate) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -177,8 +177,14 @@ export class ContractPricingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (contract.lastMileDeliveryAddress) {
|
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(
|
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) {
|
if (lm && Number(lm.rateValue) > 0) {
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { Transform } from 'class-transformer';
|
|||||||
import { IsNumber, Min } from 'class-validator';
|
import { IsNumber, Min } from 'class-validator';
|
||||||
|
|
||||||
export class ApproveLastMileRequestDto {
|
export class ApproveLastMileRequestDto {
|
||||||
// ponytail: flat manual advance amount — no rate model exists yet at this
|
// The approve dialog prefills this from GET :id/price-estimate (rule-based),
|
||||||
// pre-distance stage (delivery-fee invoicing needs assigned-truck distance,
|
// but the chief can still override — the typed value is what's invoiced.
|
||||||
// which isn't known until after payment). Wire a FeeRule-based estimate
|
|
||||||
// (see double-handling/truck-detention fee rules) once one exists.
|
|
||||||
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
|
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
|
||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value))
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ export class LastMileRequestsController {
|
|||||||
return this.requestsService.freeTruckCount().then((count) => ({ count }));
|
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')
|
@Get(':id')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||||
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
|
@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 { DataSource, FindOptionsWhere } from 'typeorm';
|
||||||
import { Freight, LastMileRequestStatus } from '@edr/types';
|
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 { usesEdrMileService } from '../../common/mile-haulage.util';
|
||||||
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
@@ -38,6 +45,7 @@ export class LastMileRequestsService {
|
|||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
private readonly notifications: NotificationInboxService,
|
private readonly notifications: NotificationInboxService,
|
||||||
|
private readonly ratesService: RatesService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -199,6 +207,44 @@ export class LastMileRequestsService {
|
|||||||
return record;
|
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. */
|
/** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */
|
||||||
async freeTruckCount(): Promise<number> {
|
async freeTruckCount(): Promise<number> {
|
||||||
return this.dataSource.manager.count(Vehicle, {
|
return this.dataSource.manager.count(Vehicle, {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { Freight } from '@edr/types';
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util';
|
||||||
import {
|
import {
|
||||||
BillingService,
|
BillingService,
|
||||||
GenerateInvoiceInput,
|
GenerateInvoiceInput,
|
||||||
InvoiceEventPayload,
|
InvoiceEventPayload,
|
||||||
} from '../billing/billing.service';
|
} from '../billing/billing.service';
|
||||||
import { Invoice } from '../billing/entities/invoice.entity';
|
import { Invoice } from '../billing/entities/invoice.entity';
|
||||||
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { LastMile } from './entities/last-mile.entity';
|
import { LastMile } from './entities/last-mile.entity';
|
||||||
|
|
||||||
@@ -26,6 +29,8 @@ export class LastMileInvoiceService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
private readonly lastMileRepo: LastMileRepository,
|
private readonly lastMileRepo: LastMileRepository,
|
||||||
|
private readonly ratesService: RatesService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +59,41 @@ export class LastMileInvoiceService {
|
|||||||
return null;
|
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.
|
// numeric columns come back as strings — coerce before billing.
|
||||||
const totalAmount = Number(record.remainingPayment) || 0;
|
const totalAmount = Number(record.remainingPayment) || 0;
|
||||||
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function makeService(opts: { booking?: BookingRow; hasCustomerTruck?: boolean })
|
|||||||
{ query } as unknown as DataSource,
|
{ query } as unknown as DataSource,
|
||||||
{ record: jest.fn() } as never, // history
|
{ record: jest.fn() } as never, // history
|
||||||
{} as never, // billing
|
{} as never, // billing
|
||||||
|
{ findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
usesEdrMileService,
|
usesEdrMileService,
|
||||||
} from '../../common/mile-haulage.util';
|
} from '../../common/mile-haulage.util';
|
||||||
import { attachMileFinancials } from '../../common/mile-financials.util';
|
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||||
|
import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util';
|
||||||
import { estimateMileKm } from '../../common/mile-distance.util';
|
import { estimateMileKm } from '../../common/mile-distance.util';
|
||||||
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import {
|
import {
|
||||||
assertBulkTonnageRemains,
|
assertBulkTonnageRemains,
|
||||||
assertTruckCountWithinContainers,
|
assertTruckCountWithinContainers,
|
||||||
@@ -70,6 +72,7 @@ export class LastMileService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly history: FleetHistoryService,
|
private readonly history: FleetHistoryService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
private readonly ratesService: RatesService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -986,9 +989,18 @@ export class LastMileService {
|
|||||||
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
|
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
|
||||||
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, {
|
await this.lastMileRepository.update(id, {
|
||||||
exactKm: total,
|
exactKm: total,
|
||||||
remainingPayment: amount,
|
remainingPayment: rule?.total ?? amount,
|
||||||
} as any);
|
} as any);
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
} from '../entities/rate.entity';
|
} from '../entities/rate.entity';
|
||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
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 INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
|
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
|
|
||||||
@@ -92,6 +93,28 @@ export class CreateRateDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...RATE_UNITS])
|
@IsIn([...RATE_UNITS])
|
||||||
rateUnit?: string;
|
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 {
|
export class SubmitRateForApprovalDto {
|
||||||
|
|||||||
@@ -93,8 +93,12 @@ function unitsForShape(input: {
|
|||||||
case 'INTERCITY':
|
case 'INTERCITY':
|
||||||
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
|
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
|
||||||
case 'FIRST_MILE':
|
case 'FIRST_MILE':
|
||||||
case 'LAST_MILE':
|
|
||||||
return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT'];
|
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:
|
default:
|
||||||
return ['FLAT'];
|
return ['FLAT'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const RATE_UNITS = [
|
|||||||
'PER_ITEM',
|
'PER_ITEM',
|
||||||
'PER_CONTAINER',
|
'PER_CONTAINER',
|
||||||
'PER_KM',
|
'PER_KM',
|
||||||
|
// Last-mile bulk: price = tons × km × rateValue.
|
||||||
|
'PER_TON_KM',
|
||||||
'PER_INVOICE',
|
'PER_INVOICE',
|
||||||
'FLAT',
|
'FLAT',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -156,6 +158,17 @@ export class Rate extends BaseEntity {
|
|||||||
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
|
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
|
||||||
rateUnit!: RateUnit;
|
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' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||||
status!: RateStatus;
|
status!: RateStatus;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface IRatesRepository {
|
|||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
originYardId?: string | null;
|
originYardId?: string | null;
|
||||||
destinationYardId?: string | null;
|
destinationYardId?: string | null;
|
||||||
|
/** Band start for container last-mile rates; omitted/null elsewhere. */
|
||||||
|
minKm?: number | null;
|
||||||
}): Promise<Rate | null>;
|
}): Promise<Rate | null>;
|
||||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
originYardId?: string | null;
|
originYardId?: string | null;
|
||||||
destinationYardId?: string | null;
|
destinationYardId?: string | null;
|
||||||
|
minKm?: number | null;
|
||||||
}): Promise<Rate | null> {
|
}): Promise<Rate | null> {
|
||||||
const qb = this.repo
|
const qb = this.repo
|
||||||
.createQueryBuilder('rate')
|
.createQueryBuilder('rate')
|
||||||
@@ -111,6 +112,13 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
} else {
|
} else {
|
||||||
qb.andWhere('rate.destination_yard_id IS NULL');
|
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();
|
return qb.getOne();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ const DIFFABLE_FIELDS = [
|
|||||||
// diffed to nothing and the submit was refused as "nothing changed".
|
// diffed to nothing and the submit was refused as "nothing changed".
|
||||||
'originYardId',
|
'originYardId',
|
||||||
'destinationYardId',
|
'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;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PaginatedResponse, YardCountry } from '@edr/types';
|
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||||
|
import { Not } from 'typeorm';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||||
@@ -340,6 +341,96 @@ export class RatesService {
|
|||||||
return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK';
|
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<void> {
|
||||||
|
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
|
* 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
|
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
||||||
@@ -360,6 +451,8 @@ export class RatesService {
|
|||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
originYardId: string | null;
|
originYardId: string | null;
|
||||||
destinationYardId: string | null;
|
destinationYardId: string | null;
|
||||||
|
/** Band start — part of the identity for container last-mile bands only. */
|
||||||
|
minKm?: number | null;
|
||||||
ignoreId?: string;
|
ignoreId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const existing = await this.repository.findByPattern(pattern);
|
const existing = await this.repository.findByPattern(pattern);
|
||||||
@@ -438,6 +531,17 @@ export class RatesService {
|
|||||||
cargoTypeId,
|
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({
|
await this.assertNoDuplicatePattern({
|
||||||
rateType,
|
rateType,
|
||||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||||
@@ -446,6 +550,7 @@ export class RatesService {
|
|||||||
tradeDirection,
|
tradeDirection,
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
|
minKm,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
@@ -457,9 +562,13 @@ export class RatesService {
|
|||||||
tradeDirection,
|
tradeDirection,
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
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,
|
rateValue: dto.rateValue,
|
||||||
rateUnit,
|
rateUnit,
|
||||||
|
minKm,
|
||||||
|
maxKm,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
proposedByStaffId,
|
proposedByStaffId,
|
||||||
});
|
});
|
||||||
@@ -622,6 +731,29 @@ export class RatesService {
|
|||||||
);
|
);
|
||||||
updates.rateUnit = rateUnit;
|
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.
|
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||||
await this.assertNoDuplicatePattern({
|
await this.assertNoDuplicatePattern({
|
||||||
rateType,
|
rateType,
|
||||||
@@ -631,10 +763,14 @@ export class RatesService {
|
|||||||
tradeDirection: updates.tradeDirection,
|
tradeDirection: updates.tradeDirection,
|
||||||
originYardId: updates.originYardId,
|
originYardId: updates.originYardId,
|
||||||
destinationYardId: updates.destinationYardId,
|
destinationYardId: updates.destinationYardId,
|
||||||
|
minKm,
|
||||||
ignoreId: id,
|
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;
|
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -76,6 +76,21 @@ export function LastMileRequestsPanel() {
|
|||||||
queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data,
|
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 = () =>
|
const invalidate = () =>
|
||||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
|
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
|
||||||
|
|
||||||
@@ -227,11 +242,29 @@ export function LastMileRequestsPanel() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(approveTarget)}
|
opened={Boolean(approveTarget)}
|
||||||
onClose={() => setApproveTarget(null)}
|
onClose={() => {
|
||||||
|
setApproveTarget(null);
|
||||||
|
setAdvanceAmount("");
|
||||||
|
}}
|
||||||
title={<Text fw={700}>Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}</Text>}
|
title={<Text fw={700}>Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}</Text>}
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{estimate?.total != null && (
|
||||||
|
<Stack gap={4}>
|
||||||
|
{estimate.lines.map((line) => (
|
||||||
|
<Text key={line.description} size="xs" c="dimmed">
|
||||||
|
{line.description} — {line.amount.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Estimated total: {estimate.total.toLocaleString()} {estimate.currency}
|
||||||
|
{estimate.estimatedKm != null
|
||||||
|
? ` · ${estimate.estimatedKm} km (straight-line estimate)`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Advance amount"
|
label="Advance amount"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
@@ -241,7 +274,13 @@ export function LastMileRequestsPanel() {
|
|||||||
onChange={setAdvanceAmount}
|
onChange={setAdvanceAmount}
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => setApproveTarget(null)}>
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
setApproveTarget(null);
|
||||||
|
setAdvanceAmount("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ export const QUERY_KEYS = {
|
|||||||
list: (filter?: Record<string, unknown>) =>
|
list: (filter?: Record<string, unknown>) =>
|
||||||
["last-mile-requests", "list", filter ?? {}] as const,
|
["last-mile-requests", "list", filter ?? {}] as const,
|
||||||
freeTruckCount: ["last-mile-requests", "free-truck-count"] as const,
|
freeTruckCount: ["last-mile-requests", "free-truck-count"] as const,
|
||||||
|
priceEstimate: (id: string) =>
|
||||||
|
["last-mile-requests", "price-estimate", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
RULE_ENGINE: {
|
RULE_ENGINE: {
|
||||||
|
|||||||
@@ -711,6 +711,7 @@ export const URL_CONSTANTS = {
|
|||||||
BASE: "/last-mile-requests",
|
BASE: "/last-mile-requests",
|
||||||
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
||||||
FREE_TRUCK_COUNT: "/last-mile-requests/free-truck-count",
|
FREE_TRUCK_COUNT: "/last-mile-requests/free-truck-count",
|
||||||
|
PRICE_ESTIMATE: (id: string) => `/last-mile-requests/${id}/price-estimate`,
|
||||||
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
|
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
|
||||||
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
|
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -584,10 +584,30 @@ const RuleEngineResourcePage = () => {
|
|||||||
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||||
// chosen trigger.
|
// chosen trigger.
|
||||||
const isSurcharge = values.appliesTo === "OTHER";
|
const isSurcharge = values.appliesTo === "OTHER";
|
||||||
|
// Last mile: the form's calculation mode picks the unit (bulk = per
|
||||||
|
// ton·km, container = per km + distance band) and the currency stays as
|
||||||
|
// chosen (birr or dollar). Everything else remains USD-only.
|
||||||
|
const isLastMile = values.appliesTo === "LAST_MILE";
|
||||||
|
const { lastMileMode, ...rest } = values;
|
||||||
payload = {
|
payload = {
|
||||||
...values,
|
...rest,
|
||||||
currency: "USD",
|
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
||||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||||
|
...(isLastMile
|
||||||
|
? lastMileMode === "BULK"
|
||||||
|
? {
|
||||||
|
rateUnit: "PER_TON_KM",
|
||||||
|
containerTypeId: undefined,
|
||||||
|
minKm: undefined,
|
||||||
|
maxKm: undefined,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
rateUnit: "PER_KM",
|
||||||
|
// Empty "To km" means an open-ended band — send null so an
|
||||||
|
// edit can clear a previously-set ceiling.
|
||||||
|
maxKm: values.maxKm ?? null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
// Editing a LIVE rate files a change request — the rate keeps charging
|
// Editing a LIVE rate files a change request — the rate keeps charging
|
||||||
// its current value until an approver applies it. DRAFT rates fall
|
// its current value until an approver applies it. DRAFT rates fall
|
||||||
|
|||||||
@@ -267,8 +267,10 @@ const unitsForShape = (
|
|||||||
case "INTERCITY":
|
case "INTERCITY":
|
||||||
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
|
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
|
||||||
case "FIRST_MILE":
|
case "FIRST_MILE":
|
||||||
case "LAST_MILE":
|
|
||||||
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
|
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
|
||||||
|
case "LAST_MILE":
|
||||||
|
// PER_KM = container mode (distance-banded), PER_TON_KM = bulk mode.
|
||||||
|
return ["PER_KM", "PER_TON_KM", "PER_CONTAINER", "PER_TON", "FLAT"];
|
||||||
default:
|
default:
|
||||||
return ["FLAT"];
|
return ["FLAT"];
|
||||||
}
|
}
|
||||||
@@ -846,6 +848,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
},
|
},
|
||||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
||||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||||
|
// Container last-mile distance bands; blank for every other rate shape.
|
||||||
|
{ id: "minKm", header: "From km", accessorKey: "minKm", format: "number" },
|
||||||
|
{ id: "maxKm", header: "To km", accessorKey: "maxKm", format: "number" },
|
||||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
@@ -958,6 +963,65 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
getInitialValue: (record) =>
|
getInitialValue: (record) =>
|
||||||
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
||||||
},
|
},
|
||||||
|
// ── Last mile — two calculation modes ─────────────────────────────────
|
||||||
|
// Bulk bills per ton per km (price = tons × km × rate); Container bills
|
||||||
|
// per km, banded by distance range with one rate row per container type
|
||||||
|
// per band (price = km × rate × quantity).
|
||||||
|
{
|
||||||
|
name: "lastMileMode",
|
||||||
|
label: "Calculation mode",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ label: "Bulk (per ton per km)", value: "BULK" },
|
||||||
|
{ label: "Container (per km, distance-banded)", value: "CONTAINER" },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Bulk: price = tons × km × rate. Container: price = km × band rate × quantity, one rate per container type per distance band.",
|
||||||
|
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||||
|
// Not a stored column: the mode is recorded in the unit the API keeps.
|
||||||
|
getInitialValue: (record) =>
|
||||||
|
record.rateUnit === "PER_TON_KM" ? "BULK" : "CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "containerTypeId",
|
||||||
|
label: "Container type",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
placeholder: "Which container type this band prices",
|
||||||
|
description: "20ft and 40ft price differently — one rate per type per band.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "minKm",
|
||||||
|
label: "From km",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
placeholder: "0",
|
||||||
|
description: "Band start (inclusive). Use 0 for the first band.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maxKm",
|
||||||
|
label: "To km",
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
placeholder: "Leave empty for no upper limit",
|
||||||
|
description: "Band end (exclusive) — a 0–30 band covers up to but not including 30 km.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "currency",
|
||||||
|
label: "Currency",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: CURRENCIES,
|
||||||
|
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||||
|
getInitialValue: (record) => String(record.currency ?? "ETB"),
|
||||||
|
},
|
||||||
// ── Container type — Container freight, container-kind intercity, and
|
// ── Container type — Container freight, container-kind intercity, and
|
||||||
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
||||||
{
|
{
|
||||||
@@ -1002,10 +1066,29 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
placeholder: "Where the leg ends",
|
placeholder: "Where the leg ends",
|
||||||
showIf: isRouteScopedRate,
|
showIf: isRouteScopedRate,
|
||||||
},
|
},
|
||||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
{
|
||||||
|
name: "rateValue",
|
||||||
|
label: "Rate value",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
suffix: "USD",
|
||||||
|
showIf: (v) => v.appliesTo !== "LAST_MILE",
|
||||||
|
},
|
||||||
|
// Last-mile rates carry their own currency (birr or dollar) and the
|
||||||
|
// value is a per-km / per-ton·km price, so no hardcoded USD suffix.
|
||||||
|
{
|
||||||
|
name: "rateValue",
|
||||||
|
label: "Rate value",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
description:
|
||||||
|
"Container mode: price per km for this band. Bulk mode: price per ton per km.",
|
||||||
|
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||||
|
},
|
||||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||||
// is always per excess ton, so the unit field is hidden for it — the API
|
// is always per excess ton, so the unit field is hidden for it — the API
|
||||||
// forces PER_TON regardless.
|
// forces PER_TON regardless. Last mile derives its unit from the
|
||||||
|
// calculation mode instead.
|
||||||
{
|
{
|
||||||
name: "rateUnit",
|
name: "rateUnit",
|
||||||
label: "Rate unit",
|
label: "Rate unit",
|
||||||
@@ -1014,7 +1097,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
optionsFromValues: rateUnitOptions,
|
optionsFromValues: rateUnitOptions,
|
||||||
description:
|
description:
|
||||||
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).",
|
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).",
|
||||||
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
|
showIf: (v) =>
|
||||||
|
String(v.trigger ?? "") !== "OVERWEIGHT" &&
|
||||||
|
String(v.appliesTo ?? "") !== "LAST_MILE",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ export interface LastMileRequestListResponse {
|
|||||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rule-based estimate for the approve dialog — all nulls when no rule covers the job. */
|
||||||
|
export interface LastMilePriceEstimate {
|
||||||
|
estimatedKm: number | null;
|
||||||
|
mode: 'BULK' | 'CONTAINER' | null;
|
||||||
|
currency: string | null;
|
||||||
|
total: number | null;
|
||||||
|
lines: Array<{ description: string; amount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
const LMR = URL_CONSTANTS.LAST_MILE_REQUESTS;
|
const LMR = URL_CONSTANTS.LAST_MILE_REQUESTS;
|
||||||
|
|
||||||
export const lastMileRequestsService = {
|
export const lastMileRequestsService = {
|
||||||
@@ -44,6 +53,7 @@ export const lastMileRequestsService = {
|
|||||||
api.get<LastMileRequestListResponse>(LMR.BASE, { params }),
|
api.get<LastMileRequestListResponse>(LMR.BASE, { params }),
|
||||||
getById: (id: string) => api.get<LastMileRequest>(LMR.BY_ID(id)),
|
getById: (id: string) => api.get<LastMileRequest>(LMR.BY_ID(id)),
|
||||||
freeTruckCount: () => api.get<{ count: number }>(LMR.FREE_TRUCK_COUNT),
|
freeTruckCount: () => api.get<{ count: number }>(LMR.FREE_TRUCK_COUNT),
|
||||||
|
priceEstimate: (id: string) => api.get<LastMilePriceEstimate>(LMR.PRICE_ESTIMATE(id)),
|
||||||
approve: (id: string, advanceAmount: number) =>
|
approve: (id: string, advanceAmount: number) =>
|
||||||
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
|
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
|
||||||
reject: (id: string, reason: string) =>
|
reject: (id: string, reason: string) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user