mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Merge pull request #968 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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 line’s 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user