fix train build issue

This commit is contained in:
Marshal
2026-08-29 20:26:16 +00:00
parent ef1d310413
commit 678c5d7d49

View File

@@ -508,12 +508,12 @@ export class RuleEngineService {
if (input.tradeDirection === 'IMPORT') {
appliedModifiers.push(
...this.derivedImportOverweight(
...(await this.derivedImportOverweight(
input,
containerWeightResults,
lineMaxVgmTons,
liveRates,
),
)),
);
}
@@ -540,23 +540,37 @@ export class RuleEngineService {
}
/**
* Import overweight — derived, never configured. Each overweight container
* line bills its excess tons at (its own base import freight on the booking's
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
* Note: derives from the LIVE route rate even for frozen-rate contract
* bookings — the frozen snapshot has no route-scoped container price to
* divide.
* Import overweight — derived, never configured. Excess tons are billed on a
* PER-WAGON basis: (the wagon's base import freight on the booking's route)
* ÷ (2 × the container's weight limit).
*
* The rate is normalised to a wagon before dividing, because a 20ft rate
* quoted PER_CONTAINER prices only HALF a wagon — two 20ft ride one wagon —
* while a 40ft container IS the whole wagon. So a PER_CONTAINER 20ft rate is
* doubled first; 40ft (and any rate already quoted PER_WAGON) is taken as is:
* - 20ft PER_CONTAINER 845 USD, 20 t limit → (845 × 2) / (2 × 20) = 42.25
* - 40ft PER_CONTAINER 1676 USD, 40 t limit → 1676 / (2 × 40) = 20.95
* Halving over 2 × the limit keeps the original meaning: filling one wagon's
* worth of excess costs one extra wagon of freight.
*
* Export keeps the configured OVERWEIGHT rate. Note: derives from the LIVE
* route rate even for frozen-rate contract bookings — the frozen snapshot has
* no route-scoped container price to divide.
*/
private derivedImportOverweight(
private async derivedImportOverweight(
input: BookingEvaluationInput,
weightResults: ContainerWeightResult[],
lineMaxVgmTons: Array<number | null>,
liveRates: Rate[],
): AppliedCargoModifier[] {
): Promise<AppliedCargoModifier[]> {
const modifiers: AppliedCargoModifier[] = [];
if (!input.originYardId || !input.destinationYardId) return modifiers;
// How many of each container type ride one wagon: a 40ft fills a wagon,
// two 20ft share one. Keyed by container type so a PER_CONTAINER rate can
// be scaled up to the wagon the overweight formula prices against.
const sizeByTypeId = await this.containersPerWagonByTypeId(weightResults);
for (let i = 0; i < weightResults.length; i++) {
const wr = weightResults[i];
const excess = Number(wr?.overweightExcessTons ?? 0);
@@ -578,7 +592,16 @@ export class RuleEngineService {
// No base rate → the base-freight line hard-blocks this booking anyway.
if (!base) continue;
const perTon = Number(base.rateValue) / (2 * maxVgm);
// Normalise the rate to ONE WAGON before dividing. A PER_CONTAINER 20ft
// rate covers half a wagon, so it is scaled by the 2 containers that ride
// one; 40ft scales by 1. A rate already quoted PER_WAGON is the wagon
// price already — never scale it again.
const perWagonRate =
base.rateUnit === 'PER_CONTAINER'
? Number(base.rateValue) * (sizeByTypeId.get(wr.containerTypeId) ?? 1)
: Number(base.rateValue);
const perTon = perWagonRate / (2 * maxVgm);
const amount = excess * perTon;
if (!(amount > 0)) continue;
@@ -595,6 +618,42 @@ export class RuleEngineService {
return modifiers;
}
/**
* Containers of each type that ride ONE wagon, derived from the type's
* size_ft against a 40ft wagon slot: 20ft → 2, 40ft → 1. Only the types the
* caller actually needs are looked up. Unknown or non-positive sizes fall
* back to 1, which leaves a PER_CONTAINER rate unscaled — the pre-existing
* behaviour, so a missing size can never inflate a bill.
*/
private async containersPerWagonByTypeId(
weightResults: ContainerWeightResult[],
): Promise<Map<string, number>> {
const perWagon = new Map<string, number>();
const ids = [...new Set(weightResults.map((w) => w.containerTypeId).filter(Boolean))];
if (ids.length === 0) return perWagon;
let rows: Array<{ id: string; size_ft: string | number | null }> = [];
try {
rows = await this.dataSource.query(
'SELECT id, size_ft FROM freight.container_types WHERE id = ANY($1)',
[ids],
);
} catch {
// Size lookup unavailable — fall back to an unscaled (×1) rate, the
// behaviour before per-wagon normalisation. Never fail pricing over it.
return perWagon;
}
const WAGON_SLOT_FT = 40;
for (const row of rows) {
const sizeFt = Number(row.size_ft ?? 0);
perWagon.set(
row.id,
sizeFt > 0 ? Math.max(1, Math.floor(WAGON_SLOT_FT / sizeFt)) : 1,
);
}
return perWagon;
}
/**
* Empty-container return — sold per direction + route + container type, like
* base freight. Each container line that opted in (returnQuantity, or every