add lashing surcharge for cargo types with hasLashing flag

add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
Marshal
2026-07-17 23:25:53 +00:00
parent 6467173c76
commit 3a1a08b1e1
59 changed files with 1919 additions and 140 deletions

View File

@@ -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);
});
});

View File

@@ -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' }),
);
});
});

View File

@@ -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:

View File

@@ -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)',

View File

@@ -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,