mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
add lashing surcharge for cargo types with hasLashing flag
add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
@@ -4,6 +4,12 @@ import type { Rate } from '../rule-engine/entities/rate.entity';
|
||||
|
||||
const MOCK_CBE_RATE = 130;
|
||||
|
||||
// Base freight is configured per leg, so every rate and every booking names the
|
||||
// route it runs. MOJO → DIRE is the corridor these rates are priced for.
|
||||
const MOJO = 'yard-mojo';
|
||||
const DIRE = 'yard-dire-dawa';
|
||||
const LEBU = 'yard-lebu';
|
||||
|
||||
describe('BookingPricingService — domestic corridor', () => {
|
||||
const intercityBulkUsd: Rate = {
|
||||
id: 'rate-intercity-bulk-usd',
|
||||
@@ -13,6 +19,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
rateUnit: 'PER_TON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
} as Rate;
|
||||
|
||||
const intercityContainerUsd: Rate = {
|
||||
@@ -23,6 +31,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
} as Rate;
|
||||
|
||||
let service: BookingPricingService;
|
||||
@@ -56,6 +66,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
@@ -81,6 +93,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
@@ -106,6 +120,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 50,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
@@ -126,4 +142,59 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
|
||||
expect(line.currency).toBe('ETB');
|
||||
});
|
||||
|
||||
// Rates are quoted per leg, so one configured for MOJO → DIRE must not price a
|
||||
// shipment that runs LEBU → DIRE. Charging the wrong corridor's price because
|
||||
// nobody configured this one yet is worse than billing no base freight.
|
||||
it('does not price bulk off a rate configured for a different leg', async () => {
|
||||
const booking = {
|
||||
id: 'b-3',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: LEBU,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not price containers off a rate configured for a different leg', async () => {
|
||||
const booking = {
|
||||
id: 'b-4',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 50,
|
||||
originYardId: LEBU,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||||
});
|
||||
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
|
||||
/**
|
||||
@@ -116,3 +116,98 @@ describe('BookingTransitionService — operation review', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Export over-book gate at the customer's requestOperation step: export never
|
||||
* splits, so the free-space check runs the moment the customer commits to a
|
||||
* shipment day. When no single export train that day can carry the whole
|
||||
* booking, `pickExportSchedule` throws and the request is refused BEFORE the
|
||||
* booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated
|
||||
* here (they are batched + splittable later).
|
||||
*/
|
||||
describe('BookingTransitionService — requestOperation export space gate', () => {
|
||||
function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
reference: 'BKG-1',
|
||||
status: 'CLEARANCE_READY',
|
||||
tradeDirection,
|
||||
originYardId: 'o-1',
|
||||
destinationYardId: 'd-1',
|
||||
totalAmount: 1000,
|
||||
contractId: null,
|
||||
serviceType: { code: 'RAIL_CONTAINER' },
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
checkDayCompatibilityForBooking: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
|
||||
};
|
||||
const bookingBatchService = {
|
||||
// Over-book → the export gate rejects; otherwise it returns a schedule id.
|
||||
pickExportSchedule: overbook
|
||||
? jest.fn().mockRejectedValue(new ConflictException('Not enough train space'))
|
||||
: jest.fn().mockResolvedValue('sched-1'),
|
||||
};
|
||||
const notifier = { operationRequestedToStaff: jest.fn() };
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
notifier as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
it('rejects an over-booked export request and does NOT advance the booking', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } = makeService(
|
||||
'EXPORT',
|
||||
true,
|
||||
);
|
||||
await expect(
|
||||
service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an export request through when a train fits the whole booking', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } = makeService(
|
||||
'EXPORT',
|
||||
false,
|
||||
);
|
||||
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
|
||||
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('never runs the export gate for an import request', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } = makeService(
|
||||
'IMPORT',
|
||||
true, // would reject IF called — proves it is not called
|
||||
);
|
||||
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
|
||||
expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled();
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1036,6 +1036,22 @@ export class BookingTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
// Export is FCFS and never splits — a booking must ride one train whole. So
|
||||
// the free-space check belongs HERE, the moment the customer commits to a
|
||||
// shipment day, not later at staff operation-accept. Blocking now stops the
|
||||
// customer booking more wagons than any single export train that day can
|
||||
// still carry; `exportSpaceReport` throws a 409 whose message carries the
|
||||
// largest bookable leftover ("reduce to N wagons or pick another day").
|
||||
// Import/domestic bookings are batched + splittable, so they are NOT gated
|
||||
// here — they get an advisory count below and the batch engine sizes them.
|
||||
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
|
||||
const isExportTrain =
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
!isRoadService(booking.serviceType);
|
||||
if (isExportTrain) {
|
||||
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
scheduledDate: date,
|
||||
@@ -1045,6 +1061,47 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory availability for a shipment day the customer is considering — a
|
||||
* planning hint for the day picker, computed but never enforced. For EXPORT it
|
||||
* mirrors the real request-time gate: `fits` is whether a single open train
|
||||
* that day can carry the WHOLE booking (export never splits), and `freeWagons`
|
||||
* is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the
|
||||
* TOTAL room across the day's trains for the booking's wagon type (the batch
|
||||
* engine may still split or defer a remainder), and `fits` is whether that
|
||||
* total covers the booking. `trainsForDay` is false when no departure carries
|
||||
* the leg — the day is unbookable regardless of space.
|
||||
*/
|
||||
async dayAvailabilityForBooking(
|
||||
bookingId: string,
|
||||
scheduledDate: string,
|
||||
): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const date = new Date(scheduledDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException("A valid schedule date is required");
|
||||
}
|
||||
const day = eatDay(date);
|
||||
const isExportTrain =
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
!isRoadService(booking.serviceType);
|
||||
|
||||
if (isExportTrain) {
|
||||
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
|
||||
const report =
|
||||
await this.bookingBatchService.exportSpaceReport(scheduledBooking);
|
||||
return {
|
||||
fits: report.scheduleId != null,
|
||||
freeWagons: report.bestAvailable?.wagons ?? 0,
|
||||
trainsForDay: report.trainsForDay && report.corridorMatched,
|
||||
};
|
||||
}
|
||||
|
||||
const { freeWagons, need, trainsForDay } =
|
||||
await this.bookingBatchService.dayImportAvailability(booking, day);
|
||||
return { fits: freeWagons >= need, freeWagons, trainsForDay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations team reviews a pending operation request (capacity, documents,
|
||||
* route). Two outcomes:
|
||||
|
||||
@@ -370,6 +370,31 @@ export class BookingsController {
|
||||
return this.bookingsService.availableDaysForBooking(id);
|
||||
}
|
||||
|
||||
@Get(':id/day-availability')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' +
|
||||
'Export: whole-booking fit + largest single-train leftover. ' +
|
||||
'Import/domestic: total room across the day for the booking\'s wagon type.',
|
||||
})
|
||||
async dayAvailability(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query('date') date: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||
) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.dayAvailabilityForBooking(id, date);
|
||||
}
|
||||
|
||||
@Get(':id/mile-summary')
|
||||
@ApiOperation({
|
||||
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||||
|
||||
@@ -47,6 +47,7 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
|
||||
import { ContractRateScheduleBuilder } from "../../contracts/contract-rate-schedule.builder";
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
|
||||
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
|
||||
@@ -106,6 +107,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRateScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
CustomerTruckAssignmentsRepository,
|
||||
|
||||
Reference in New Issue
Block a user