Merge pull request #968 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-27 09:00:59 +03:00
committed by GitHub
12 changed files with 434 additions and 48 deletions

View File

@@ -594,3 +594,126 @@ describe('BookingPricingService — bulk base freight units', () => {
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
});
});
/**
* A PER_WAGON container rate bills the wagons the LINE occupies — two 20ft share
* one wagon, a 40ft takes a whole one. Regression cases taken from real
* bookings on Doraleh → Gelan, where the 20ft line was being charged for the
* 40ft line's wagons as well.
*/
describe('BookingPricingService — PER_WAGON container freight', () => {
const DJ = 'yard-dj-w';
const ET = 'yard-et-w';
const perWagon20: Rate = {
id: 'rate-20-wagon',
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 1690,
rateUnit: 'PER_WAGON',
status: 'LIVE',
containerTypeId: 'ct-20',
originYardId: DJ,
destinationYardId: ET,
} as Rate;
const perContainer40: Rate = {
...perWagon20,
id: 'rate-40-container',
rateValue: 1676,
rateUnit: 'PER_CONTAINER',
containerTypeId: 'ct-40',
} as Rate;
const makeService = () =>
new BookingPricingService(
{
// Booking-wide aggregate — deliberately larger than any single line, so
// a regression that reads it instead of the line's own wagons shows up.
calculateWagonCount: jest.fn().mockResolvedValue(5),
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
} as never,
{
evaluate: jest.fn().mockResolvedValue({
priorityScore: 0,
appliedModifiers: [],
containerWeightResults: [],
warnings: [],
hardBlocked: [],
requiresDirectorApproval: false,
}),
} as never,
{
findById: jest.fn(async (id: string) => ({
id,
sizeFt: id === 'ct-40' ? 40 : 20,
isReefer: false,
code: id === 'ct-40' ? 'C40' : 'C20',
label: id === 'ct-40' ? 'C40' : 'C20',
})),
} as never,
{ findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{ findById: jest.fn() } as never,
);
const booking = (
lines: Array<{ containerTypeId: string; quantity: number }>,
) =>
({
id: 'b-wagon',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
originYardId: DJ,
destinationYardId: ET,
bookingContainers: lines.map((l) => ({
containerTypeId: l.containerTypeId,
quantity: l.quantity,
vgmPerUnitTons: 10,
})),
}) as unknown as Booking;
const price = async (
lines: Array<{ containerTypeId: string; quantity: number }>,
) => {
const service = makeService();
const result = await service.computePriceForBooking(booking(lines));
return result.lineItems.filter((l) => l.code === 'CONTAINER_IMPORT');
};
it('bills 2× 20ft as one wagon', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 2 }]);
expect(line.unit).toBe('PER_WAGON');
expect(line.quantity).toBe(1);
expect(line.amount).toBe(1690);
});
it('bills 10× 20ft as five wagons', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 10 }]);
expect(line.quantity).toBe(5);
expect(line.amount).toBe(5 * 1690);
});
it('does not charge the 20ft line for the 40ft lines wagons', async () => {
const lines = await price([
{ containerTypeId: 'ct-20', quantity: 4 },
{ containerTypeId: 'ct-40', quantity: 1 },
]);
const twenty = lines.find((l) => l.description.startsWith('C20'))!;
const forty = lines.find((l) => l.description.startsWith('C40'))!;
// 4× 20ft = 2 wagons, NOT the booking-wide 3.
expect(twenty.quantity).toBe(2);
expect(twenty.amount).toBe(2 * 1690);
// The 40ft line keeps billing per container.
expect(forty.quantity).toBe(1);
expect(forty.amount).toBe(1676);
});
it('rounds an odd 20ft count up to a whole wagon', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 5 }]);
expect(line.quantity).toBe(3);
expect(line.amount).toBe(3 * 1690);
});
});

View File

@@ -530,14 +530,6 @@ export class BookingPricingService {
const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const blocked: string[] = [];
// Bulk bookings carry no container lines, so the container-based wagon
// aggregate is 0 for them — a PER_WAGON bulk rate would bill nothing. Use
// the tonnage-derived estimate instead (the eval input already carries it
// for saved bookings; a preview derives it here).
const wagonCount = isBulk
? Number(evalInput.bulkWagons ?? 0) || (await this.bulkWagonCount(booking)) || 0
: await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
const rate = this.pickRate(
liveRates,
@@ -573,6 +565,10 @@ export class BookingPricingService {
}
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
// A PER_WAGON line bills the wagons THIS line occupies (two 20ft share
// one), never the booking-wide count — otherwise a booking with a 20ft
// and a 40ft line charges each line for the other's wagons too.
const lineWagons = await this.lineWagonCount(container);
let amount: number;
let unitAmount: number;
if (frozen) {
@@ -581,11 +577,11 @@ export class BookingPricingService {
rateUnit,
unitAmount,
container.quantity,
wagonCount,
lineWagons,
);
} else {
const unitUsd = Number(rate!.rateValue);
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
}
@@ -596,7 +592,7 @@ export class BookingPricingService {
amount,
unitAmount,
unit: rateUnit,
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, lineWagons),
currency: paymentCurrency,
});
}
@@ -625,6 +621,14 @@ export class BookingPricingService {
: undefined) ?? onLeg.find((r) => !r.cargoTypeId);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
// Bulk has no container lines to count wagons from, so a PER_WAGON bulk
// rate bills the tonnage-derived estimate for the WHOLE booking (there
// is only ever this one line).
const wagonCount = isBulk
? Number(evalInput.bulkWagons ?? 0) ||
(await this.bulkWagonCount(booking)) ||
0
: await this.resolveWagonCount(booking);
// Bulk quantity is stored in the commodity's own unit — tonnes for a
// PER_TON commodity, item count for a PER_ITEM one.
const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0);
@@ -783,6 +787,34 @@ export class BookingPricingService {
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/**
* Wagons ONE container line occupies: two 20ft share a wagon, a 40ft takes a
* whole one. This — not the booking-wide total — is what a PER_WAGON base
* freight line bills, so a booking of 4×20ft + 1×40ft charges the 20ft line
* for 2 wagons and the 40ft line for its own 1, instead of billing each line
* for all 3.
*/
private async lineWagonCount(container: {
containerTypeId: string;
quantity: number;
wagonsPerUnit?: number;
}): Promise<number> {
let perUnit = container.wagonsPerUnit;
if (perUnit == null) {
// Preview bookings build their eval input without the fraction — read it
// off the container type instead of assuming one wagon per box.
try {
const ct = await this.containerTypesService.findById(
container.containerTypeId,
);
perUnit = wagonsPerUnitForSize(Number(ct.sizeFt));
} catch {
perUnit = 1; // unknown type: never under-bill
}
}
return Math.max(1, Math.ceil(container.quantity * perUnit));
}
/**
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
* an unsaved preview booking (no id) sums the wagonsRequired already computed