mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 07:45:45 +00:00
fix(contracts): price base freight from contract lane only
Contract pricing matched base container/bulk rates by type+currency with no origin/destination filter, so a lane with no configured rate silently froze another lane's price into the contract snapshot (CTR-2026-00065: DCT-Sebeta contract froze DCT-GMP rates) and bookings then billed off it. Now scoped to the contract's first route and a missing lane rate hard-blocks pricing with a 422, matching the customs-clearance and booking-side behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,26 @@ export class ContractPricingService {
|
||||
const lineItems: ContractUnitRateLineItem[] = [];
|
||||
const baseType = this.baseRateType(contract);
|
||||
|
||||
// Base rail freight is quoted per route (CK_rates_yard_scope) — only rates
|
||||
// on the contract's own lane may price it. Matching without the yard filter
|
||||
// is how a DCT → Sebeta contract froze DCT → GMP (Indode) prices, and the
|
||||
// frozen snapshot then bills bookings that the route-scoped booking lookup
|
||||
// would have hard-blocked (CTR-2026-00065).
|
||||
// ponytail: multi-route contracts price the first lane (same as customs
|
||||
// clearance below); per-lane pricing needs per-route breakdowns.
|
||||
const route = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
)[0];
|
||||
const onLane = route
|
||||
? liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === baseType &&
|
||||
r.currency === 'USD' &&
|
||||
r.originYardId === route.originYardId &&
|
||||
r.destinationYardId === route.destinationYardId,
|
||||
)
|
||||
: [];
|
||||
|
||||
if (contract.freightType === 'CONTAINER') {
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
@@ -97,17 +117,14 @@ export class ContractPricingService {
|
||||
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
|
||||
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
|
||||
const rate =
|
||||
liveRates.find(
|
||||
(r) =>
|
||||
r.rateType === baseType &&
|
||||
r.currency === 'USD' &&
|
||||
r.containerTypeId &&
|
||||
matchedIds.has(r.containerTypeId),
|
||||
) ??
|
||||
liveRates.find(
|
||||
(r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId,
|
||||
onLane.find(
|
||||
(r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
|
||||
) ?? onLane.find((r) => !r.containerTypeId);
|
||||
if (!rate || Number(rate.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
`No rail freight rate is configured for ${size} containers on this direction and route — the contract cannot be priced. Ask the rates team to set a live ${baseType} rate for this container type and origin → destination.`,
|
||||
);
|
||||
if (!rate) continue;
|
||||
}
|
||||
lineItems.push({
|
||||
code: `CONTAINER_${size.toUpperCase()}`,
|
||||
label: `${size} container`,
|
||||
@@ -120,25 +137,26 @@ export class ContractPricingService {
|
||||
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
// Freeze the rate for the contract's own commodity when one is configured
|
||||
// — a per-item machinery rate and a per-ton wheat rate live side by side.
|
||||
const bulkRates = liveRates.filter(
|
||||
(r) => r.rateType === baseType && r.currency === 'USD',
|
||||
);
|
||||
// No arbitrary-rate fallback: another commodity's rate must never price
|
||||
// this contract.
|
||||
const bulkRate =
|
||||
(cargoScope?.cargoTypeId
|
||||
? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
|
||||
? onLane.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
|
||||
: undefined) ??
|
||||
bulkRates.find((r) => !r.cargoTypeId) ??
|
||||
bulkRates[0] ??
|
||||
onLane.find((r) => !r.cargoTypeId) ??
|
||||
null;
|
||||
if (bulkRate) {
|
||||
lineItems.push({
|
||||
code: 'BULK_FREIGHT',
|
||||
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
|
||||
unit: toContractUnit(bulkRate.rateUnit),
|
||||
unitPrice: convert(Number(bulkRate.rateValue)),
|
||||
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
|
||||
});
|
||||
if (!bulkRate || Number(bulkRate.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No bulk rail freight rate is configured for this cargo type on this direction and route — the contract cannot be priced. Ask the rates team to set a live rate for this commodity and origin → destination.',
|
||||
);
|
||||
}
|
||||
lineItems.push({
|
||||
code: 'BULK_FREIGHT',
|
||||
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
|
||||
unit: toContractUnit(bulkRate.rateUnit),
|
||||
unitPrice: convert(Number(bulkRate.rateValue)),
|
||||
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// First / last mile trucking unit rates — shown when the contract carries
|
||||
@@ -241,9 +259,6 @@ export class ContractPricingService {
|
||||
// one display line per contract size that has a configured rate. A size
|
||||
// with no rate shows nothing here and hard-blocks at booking time.
|
||||
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
||||
const route = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
)[0];
|
||||
const onLeg = route
|
||||
? liveRates.filter(
|
||||
(r) =>
|
||||
@@ -291,9 +306,6 @@ export class ContractPricingService {
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Strict, no route-less fallback.
|
||||
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||
const route = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
)[0];
|
||||
const onLeg = route
|
||||
? liveRates.filter(
|
||||
(r) =>
|
||||
|
||||
Reference in New Issue
Block a user