Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
2026-08-06 22:34:53 +00:00

2328 lines
97 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { insertWithGeneratedReference } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import {
ContractsRepository,
TERMINAL_BOOKING_STATUSES,
} from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import {
CreateBookingContainerLineDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
// TERMINAL_BOOKING_STATUSES (the statuses that free the ONE_TIME active-booking
// slot) lives in contracts.repository.ts — the contract cancel gate needs the
// same list.
/** Bookings that never shipped release their quantity hold on the contract. */
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
export interface CreateBookingUnderContractResult {
booking: Booking;
warnings: string[];
}
/**
* Outstanding split remainder of a contract: what was booked in the first split
* booking's pre-split snapshot MINUS everything currently booked. Container
* contracts report per size; bulk reports one tonnage figure. `null` when the
* contract has no live split chain. Consumed by the remainder-placement engine
* to size the auto-created remainder booking.
*/
export type SplitOutstanding = {
bySize: Map<string, { total: number; outstanding: number }>;
bulk: { total: number; outstanding: number } | null;
};
/**
* The single create path for shipment bookings under a contract.
*
* - Path A (transport only): the customer creates the booking once the contract
* is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled.
* - Path B (customs clearance): only GL Ethiopia creates the booking, once the
* contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters
* shipment data.
*
* From booking creation onward the existing batch/payment/allocation pipeline
* runs unchanged. See docs/new-doc.md §8, §13.
*/
@Injectable()
export class ContractBookingService {
private readonly logger = new Logger(ContractBookingService.name);
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly bookingPricingService: BookingPricingService,
private readonly consolidationService: ConsolidationService,
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly invoiceService: BookingInvoiceService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingTransitionService))
private readonly bookingTransitionService: BookingTransitionService,
) {}
async createUnderContract(
contractId: string,
dto: CreateBookingUnderContractDto,
user?: { id?: string } | null,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
// A contract whose quantity cap was fully booked is completed — no further
// bookings, even while contract validity and a booking window are still
// open. Capacity released after closure (a cancelled/expired booking)
// reopens the contract on the next booking attempt. A ONE_TIME contract
// only closes via a finished split chain, so its room is the outstanding
// split remainder rather than a cap line.
if (contract.status === 'CONTRACT_CLOSED') {
let hasRoom: boolean;
if (contract.contractKind !== 'GENERAL') {
const outstanding = await this.splitOutstanding(contract);
hasRoom = outstanding
? contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].some((s) => s.outstanding > 0)
: (outstanding.bulk?.outstanding ?? 0) > 0.001
: false;
} else {
const capacity = await this.computeCapacity(contract);
hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
}
if (!hasRoom) {
throw new BadRequestException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_ACTIVE',
} as never);
contract.status = 'CONTRACT_ACTIVE';
}
// GL Ethiopia is identified by the dedicated contract create-booking permission
// (granted to the edr_gl_ethiopia preset).
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
const createdByRole = await this.assertGate(contract, isGlActor);
// Validity window must still be open.
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
// letting the customer re-book within contract validity (doc §10.4).
// EXCEPTION — split chain: a paid partial split (booking.isSplit) releases
// the slot for the leftover, but the next booking must take the WHOLE
// remainder; the customer cannot start any other booking on the contract.
// If the remainder splits again the same rule repeats until the cap is
// exhausted and the contract completes.
if (contract.contractKind === 'ONE_TIME') {
if (await this.hasSplitBooking(contractId)) {
await this.assertExactRemainder(contract, dto);
} else {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
}
} else {
// GENERAL: draw down against the cargo quantity cap until it is full.
await this.assertWithinQuantityCap(contract, dto);
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
const warnings: string[] = [];
const freightType = contract.freightType;
// EVERY contract booking clears per booking now — both contract kinds, both
// paths, intercity included. Customs (Path B): GL runs the phased ET/DJ
// workflow on this booking. Non-customs (Path A) and intercity: the customer
// uploads his own document set on the booking and Operations reviews it
// (AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation; intercity finalize goes straight to the ride-along pool).
// So the booking is always born in the clearance gate, never in the
// operations queue, and no contract-level clearance cycle exists to link.
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
// finalize time, so both the window gate and scheduledDate are skipped.
const isIntercity = contract.tradeDirection === 'DOMESTIC';
if (isIntercity && dto.scheduledDate) {
throw new BadRequestException(
'Intercity bookings do not pick a date — staff assign them to a passing train',
);
}
// Every other direction keeps the binding shipment day (the DTO field went
// optional only for intercity).
if (!isIntercity && !dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// No booking-window / export-space gate here any more: every contract
// booking enters the clearance gate first and is scheduled only once the
// documents are approved. Both checks run at that point instead —
// `completeUnderContract` (bare instances) and `requestOperation` (bookings
// created with cargo) — against the day the customer actually picks.
// Hard capacity gate: a container line whose total weight exceeds the
// container type's max capacity can never be booked — no surcharge path,
// no override. Checked before any row is written.
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
// 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ
// ≤ the cap, and drawdown bookings never pass through submit — so this is
// their only chance to hard-block an unbalanceable set. Entry order is
// irrelevant (the check sorts by weight before pairing).
await this.assert20ftPairableAtCreate(dto);
// A container number may appear once per train (same day + route).
await this.assertContainerNumbersAvailable(dto, {
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
});
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
// Retry past a concurrent insert that grabbed the same BK sequence number.
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
contractKind: contract.contractKind,
createdByRole,
createdByUserId: user?.id ?? null,
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
freightType,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoFreeText: dto.cargoFreeText?.trim() || null,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never),
);
// Everything between the insert and the priced update must be all-or-nothing:
// a throw part-way (container persist, weight rules, pricing) would otherwise
// leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that
// occupies the one-time contract's single active-booking slot until the
// doc-review sweep expires it — and the clearance cycle still points at the
// previous booking, so the hub keeps offering "Rebook" against a dead draft.
try {
// Persist container lines + per-unit container numbers (container freight only).
if (freightType === 'CONTAINER') {
await this.persistContainers(booking.id, contract, dto);
}
// Reload with containers to compute the total from contract unit rates × qty.
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (loaded) {
if (freightType === 'CONTAINER') {
await this.applyWeightResults(loaded);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has
// a positive total, so the zero-price gate below misses it — enforce
// the pricing hard blocks first. The catch below rolls everything back.
if (computed.hardBlocked.length > 0) {
throw new BadRequestException(computed.hardBlocked.join('; '));
}
// Reject a zero-price booking outright. A total of 0 means no contract rate
// matched the route/container (or the rate is unset), so the booking is not
// valid to ship or invoice. The catch below rolls back the row + its lines.
if (!(computed.totalAmount > 0)) {
throw new BadRequestException(
'Booking price came out as 0 — no contract rate matches this ' +
'route/cargo. Set the contract rate and try again.',
);
}
await this.bookingsRepository.update(booking.id, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
booking.id,
computed.usedRates,
computed.appliedModifiers,
);
warnings.push(...computed.warnings);
}
} catch (err) {
await this.bookingsRepository
.deleteContainers(booking.id)
.catch(() => undefined);
await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined);
throw err;
}
// Wagon consolidation gate. A container drawdown whose lines leave a partial
// wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner
// before it can ship. Direct bookings do this at submit; drawdowns have no
// submit step, so we run it here — BEFORE invoicing/milestones. When it parks
// for a partner the booking is NOT invoiced or scheduled: those steps run
// later in finalizeContractBooking, triggered by the pairing event. When it
// pairs (or needs no consolidation) we finalize inline.
const withContainers = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
// Tell staff the booking exists. Placed after the zero-price rollback (which
// hard-deletes the row) and before the consolidation gate, so it fires
// exactly once whether the booking parks for a partner or finalizes inline.
this.bookingNotifier.createdToStaff(withContainers ?? booking);
const intendedStatus = 'AWAITING_DOCUMENTS';
if (
withContainers &&
freightType === 'CONTAINER' &&
(await this.consolidationService.needsConsolidationFromBooking(
withContainers,
))
) {
const parked = await this.consolidateDrawdown(
withContainers,
intendedStatus,
);
warnings.push(parked.message);
if (!parked.paired) {
// Waiting for a partner — stop here. The booking sits in
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
// A parked booking still holds contract capacity, so the cap may
// already be exhausted by it.
await this.maybeCompleteContract(contract);
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
return { booking: pendingResult ?? booking, warnings };
}
}
await this.finalizeContractBooking(booking.id, contract);
await this.maybeCompleteContract(contract);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: result ?? booking, warnings };
}
/**
* Initiate a BARE booking instance under an import/export contract — ONE_TIME
* or GENERAL, customs or not. One click, zero input: no schedule date, no
* cargo, no window check, no pricing. The instance starts in the clearance
* gate (AWAITING_DOCUMENTS) and is where ALL clearance documents live:
*
* - Path A (self-clearance): the customer initiates, uploads his clearance
* proof, Operations reviews and finalizes.
* - Path B (customs, ONE_TIME): the customer initiates too, then uploads the
* GL-input documents on the instance; GL approves them and runs the phased
* ET/DJ workflow (pre-booking milestones are seeded here). GL may still
* initiate on his behalf. GENERAL customs instances come from a shipment
* request ({@link initiateForShipmentRequest}), not from here.
*
* Only after the clearance is finalized is the booking completed (cargo +
* binding day + window check) via {@link completeUnderContract} — by the
* customer on Path A, by GL on Path B.
*/
async initiateUnderContract(
contractId: string,
dto: Pick<CreateBookingUnderContractDto, 'contractRouteId'>,
user?: { id?: string } | null,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
// Intercity has no shipment day to defer to, so it is booked directly with
// its cargo (the documents still live on that booking). Everything else —
// ONE_TIME or GENERAL, customs or self-clear — starts as a bare instance.
if (contract.tradeDirection === 'DOMESTIC') {
throw new BadRequestException(
'Intercity shipments are booked directly with their cargo — there is no initiate step.',
);
}
if (contract.status === 'CONTRACT_CLOSED') {
throw new BadRequestException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
// The customer initiates his own shipment instance on ONE_TIME contracts
// (customs or self-clearance); GL may also initiate on a customs contract.
// GENERAL customs instances come from a shipment request, not from here.
const createdByRole = await this.assertGate(contract, isGlActor, true);
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
// slot from the moment it is initiated (it is not a terminal status). The
// split chain is the one exception — a paid partial frees the slot and
// completion enforces that the next booking takes the whole remainder.
if (contract.contractKind === 'ONE_TIME' && !(await this.hasSplitBooking(contractId))) {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
// Bare instance: no cargo, no date, no price. Draws no contract capacity
// until the customer completes it after clearance.
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
contractKind: contract.contractKind,
createdByRole,
createdByUserId: user?.id ?? null,
scheduledDate: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: this.resolveShipmentCurrency(contract, null),
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: this.resolveCargoTypeId(contract, {}),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
cargoTotalWeightVgm: 0,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never),
);
// Customs: the instance runs the phased ET/DJ workflow, so its pre-booking
// milestones exist from initiation (the post-booking half is seeded when the
// booking is completed). Self-clearance has no milestone timeline.
if (contract.customsClearingEnabled) {
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
contract.tradeDirection,
);
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
this.bookingNotifier.createdToStaff(result ?? booking);
return { booking: result ?? booking, warnings: [] };
}
/**
* Initiate a BARE booking instance for a GENERAL + customs shipment request
* (Path B, clearance-first). Called by BookingRequestService.submit AFTER it
* validated the contract (general customs, active, capacity) — the request
* itself carries the quantities; the instance carries none. Pre-booking
* customs milestones are seeded immediately so the instance enters the same
* phased ET/DJ clearance a ONE_TIME customs contract runs, just per booking.
* GL completes the booking (cargo + day) via {@link completeUnderContract}
* once the clearance reaches CLEARANCE_READY.
*/
async initiateForShipmentRequest(
contract: Contract,
opts: {
contractRouteId?: string;
userId?: string | null;
/** Billing currency the customer chose on the shipment request. */
paymentCurrency?: string | null;
},
): Promise<Booking> {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
if (!generalCustoms) {
throw new BadRequestException(
'Shipment-request initiation applies only to general customs contracts.',
);
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
const route = await this.resolveRoute(contract, opts.contractRouteId);
// No prepay gate: the clearance service fee is billed on the booking
// invoice at completion, so the document step opens immediately.
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
contractKind: contract.contractKind,
createdByRole: 'CUSTOMER',
createdByUserId: opts.userId ?? null,
scheduledDate: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency),
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: this.resolveCargoTypeId(contract, {}),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
cargoTotalWeightVgm: 0,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never),
);
// Pre-booking phase only — the post-booking milestones (loading, transit)
// are seeded when GL completes the booking, mirroring the ONE_TIME flow
// where GL's booking creation seeds them.
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
contract.tradeDirection,
);
const created =
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
this.bookingNotifier.createdToStaff(created);
return created;
}
/**
* Complete a bare initiated booking after its per-booking clearance is
* finalized (CLEARANCE_READY) or operations returned it for changes
* (OPERATION_CHANGES_REQUESTED). This is the deferred half of
* {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking
* window + open-departure checks, pricing, consolidation and invoicing all run
* here — the same gates a one-time shipment passes at creation.
*
* Actor rules mirror {@link assertGate}: a customs (Path B) instance is
* completed by GL Ethiopia only; a non-customs (Path A) instance by the
* customer (or staff).
*/
async completeUnderContract(
contractId: string,
bookingId: string,
dto: CreateBookingUnderContractDto,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.contractId !== contract.id) {
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
}
if (
!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes(
booking.status,
)
) {
throw new BadRequestException(
'Clearance must be finalized before the booking can be completed.',
);
}
// An unpaid booking that expired at train dispatch keeps its finished
// per-booking clearance — GL rebooks it onto a new shipment day instead of
// forcing the customer through a new shipment request + clearance fee.
if (booking.status === 'EXPIRED') {
// Only a booking that completed once (it has a price, so its clearance
// finished and cargo is persisted) can be rebooked after expiry.
if (!(Number(booking.totalAmount) > 0)) {
throw new BadRequestException(
'Only a previously completed booking can be rebooked after it expires.',
);
}
// Expiry released the booking's contract-capacity hold; if the payload
// re-states the cargo, make sure the released share is still free.
if (dto.containers?.length || dto.bulkLines?.length) {
await this.assertWithinQuantityCap(contract, dto);
}
// Drop the departed train's link and fall into the day-only resubmit
// path below — same machinery as OPERATION_CHANGES_REQUESTED.
await this.bookingsRepository.update(booking.id, {
status: 'OPERATION_CHANGES_REQUESTED',
trainScheduleId: null,
} as never);
booking.status = 'OPERATION_CHANGES_REQUESTED';
booking.trainScheduleId = null;
}
// Path B: only GL Ethiopia completes a customs instance — the customer
// never enters shipment data on a customs contract.
if (contract.customsClearingEnabled) {
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
if (!isGlActor) {
throw new ForbiddenException(
'Customs-clearance bookings are completed by Global Logistics on behalf of the customer.',
);
}
}
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// Completion is booking time: the route's booking window must be open —
// the same config-driven gate a direct one-time booking passes at create.
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
scheduledDate: dto.scheduledDate,
direction: contract.tradeDirection ?? null,
});
const freightType = contract.freightType;
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// Operations may return a booking asking for the CARGO to change (fewer or
// more containers), not just the day. A resubmit whose payload restates the
// cargo therefore starts the completion over: cancel the unpaid invoice
// first (it throws if money is already recorded — cargo must not change
// under a paid invoice), then wipe the persisted cargo so the fresh-
// completion path below re-persists, re-prices and re-invoices from the
// payload. A resubmit without cargo keeps today's day-only behavior.
const restatesCargo = Boolean(
dto.containers?.length || dto.bulkLines?.length,
);
if (hasCargo && restatesCargo) {
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
hasCargo = false;
}
// EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
// persisted, only the day re-picked) sizes from the booking itself.
if (contract.tradeDirection === 'EXPORT') {
if (hasCargo) {
const probe = Object.assign(
Object.create(Object.getPrototypeOf(booking)),
booking,
{ scheduledDate: new Date(dto.scheduledDate) },
) as Booking;
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (!report.scheduleId) {
throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.',
);
}
} else {
await this.assertExportTrainSpace(contract, null, dto, {
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
});
}
}
// First completion persists cargo and draws contract capacity; a resubmit
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
// the shipment day.
if (!hasCargo) {
// ONE_TIME split chain: the instance that follows a paid partial must take
// the WHOLE outstanding remainder — same rule a booking created with cargo
// passes at creation.
if (
contract.contractKind === 'ONE_TIME' &&
(await this.hasSplitBooking(contract.id))
) {
await this.assertExactRemainder(contract, dto);
}
await this.assertWithinQuantityCap(contract, dto);
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
await this.assert20ftPairableAtCreate(dto);
// A container number may appear once per train (same day + route).
await this.assertContainerNumbersAvailable(
dto,
{
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
},
booking.id,
);
await this.persistContainers(booking.id, contract, dto);
}
await this.bookingsRepository.update(booking.id, {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
// Completion is where the cargo — and therefore the price — is fixed, so
// it is also where the billing currency is chosen. A bare instance was
// created before the customer had any figure to look at.
paymentCurrency: this.resolveShipmentCurrency(
contract,
dto.paymentCurrency ?? booking.paymentCurrency,
),
} as never);
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (loaded) {
if (freightType === 'CONTAINER') {
await this.applyWeightResults(loaded);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// A zero price means no contract rate matches — roll the cargo back so
// the instance stays CLEARANCE_READY and can be completed again once
// the contract rates are fixed (the clearance work is not lost). A
// pricing hard block (e.g. one of two container sizes has no rate)
// rolls back the same way: a partially-priced total is positive but
// the booking must not proceed.
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
throw new BadRequestException(
computed.hardBlocked.length > 0
? computed.hardBlocked.join('; ')
: 'Booking price came out as 0 — no contract rate matches this ' +
'route/cargo. Set the contract rate and try again.',
);
}
await this.bookingsRepository.update(booking.id, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
booking.id,
computed.usedRates,
computed.appliedModifiers,
);
warnings.push(...computed.warnings);
}
// Wagon consolidation gate — a partial-wagon 20ft set parks for a partner
// exactly like a drawdown created with cargo does. The shipment day is
// stored first so the pairing event can resume straight into the
// operations queue.
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (
withContainers &&
freightType === 'CONTAINER' &&
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
) {
await this.bookingsRepository.update(booking.id, {
scheduledDate: new Date(dto.scheduledDate),
} as never);
const parked = await this.consolidateDrawdown(
withContainers,
'OPERATION_REQUEST_PENDING',
);
warnings.push(parked.message);
if (!parked.paired) {
await this.maybeCompleteContract(contract);
const pendingResult = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: pendingResult ?? booking, warnings };
}
}
// Invoice the now-priced booking and, for a customs instance, seed the
// post-booking milestones (pre-booking ones exist since initiation —
// ensure* fills only what is missing). Idempotent, non-blocking.
await this.finalizeContractBooking(booking.id, contract);
await this.maybeCompleteContract(contract);
} else if (freightType === 'CONTAINER') {
// Resubmit only re-picks the shipment day — the persisted container
// numbers must be free on the newly chosen train day too.
await this.assertPersistedContainersAvailable(booking, dto.scheduledDate);
}
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
// and the staff notification — the exact machine a one-time booking uses.
const completed = await this.bookingTransitionService.requestOperation(
booking.id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
);
return { booking: completed, warnings };
}
/**
* Search for a complementary partner for a parked-eligible drawdown, pair it or
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
* Pairing (via BookingsRepository.pairConsolidation) resumes both partners and
* emits booking.consolidation.paired, which finalizes any deferred contract
* booking. Returns whether a partner was found plus a customer-facing message.
*/
private async consolidateDrawdown(
booking: Booking,
resumeStatus: string,
): Promise<{ paired: boolean; message: string }> {
const slots = await this.consolidationService.slotsFromBooking(booking);
if (!slots.length) {
return { paired: false, message: '' };
}
const partner = await this.bookingsRepository.findConsolidationPartner(
booking,
slots,
);
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
return {
paired: true,
message: this.consolidationService.describePaired(
partner.reference,
slots,
),
};
}
await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus);
return {
paired: false,
message: this.consolidationService.describePending(booking, slots),
};
}
/**
* Finalize a contract booking once it is cleared to proceed (needed no
* consolidation, or has just paired): seed clearance milestones / link the
* contract cycle, then generate the invoice. Idempotent — safe to call again
* for a booking that pairs after having waited. Skips a booking that is still
* PENDING_CONSOLIDATION (guards the pairing event against a stray partner).
*/
private async finalizeContractBooking(
bookingId: string,
contract: Contract,
): Promise<void> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
// Customs runs per booking for BOTH contract kinds: seed the full milestone
// timeline on the booking. ensure* skips codes that already exist — an
// initiated instance carries its pre-booking milestones from initiation, and
// a consolidation pairing replay must not duplicate the timeline. The
// contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it
// holds no clearance state at all.
if (contract.customsClearingEnabled) {
await this.milestoneService.ensureBookingMilestones(
bookingId,
contract.tradeDirection,
);
}
// Contract bookings are born past the billable gate (the contract is already
// executed), so the invoice is generated here — they never pass through the
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
// Skips silently when unbillable (no company / no priced amount).
await this.invoiceService
.ensureInvoiceForBooking(booking)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for contract booking ${booking.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
// On a customs contract the customer never books — GL Ethiopia does it for
// them (assertGate enforces that) — so tell them their shipment now exists.
//
// Gated on the contract, NOT on booking.createdByRole: a GENERAL customs
// instance is stamped CUSTOMER when the customer's shipment request opens
// it, yet it is GL who later completes it with cargo and a price. Keying on
// the role would silently skip exactly that case.
//
// Sent from here because this is the single funnel every contract booking
// passes through exactly once (create, complete, and the deferred
// consolidation-pairing replay), and it runs after invoicing so the message
// can quote the priced total.
if (contract.customsClearingEnabled) {
// Never let a notification failure read as a finalize failure — the
// booking is already committed by this point.
try {
const priced = await this.bookingsRepository.findByIdWithFiles(bookingId);
this.bookingNotifier.createdByGlForCustomer(priced ?? booking);
} catch (err) {
this.logger.warn(
`Could not notify the customer that GL created booking ${booking.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
}
/**
* A parked drawdown just paired — finalize whichever partner is a contract
* booking that was waiting (invoice + milestones deferred at creation). The
* pairing already resumed the booking's status from consolidationResumeStatus;
* this runs the create-time tail that was skipped. Non-contract partners have
* their own finalize path (staff accept) and are ignored here.
*/
@OnEvent('booking.consolidation.paired')
async onConsolidationPaired(payload: {
bookingIds: string[];
}): Promise<void> {
for (const id of payload.bookingIds ?? []) {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') {
continue;
}
const contract = await this.contractsRepository.findByIdWithRelations(
booking.contractId,
);
if (!contract) continue;
await this.finalizeContractBooking(id, contract).catch(
(err) =>
this.logger.error(
`Failed to finalize paired contract booking ${booking.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
}
}
/**
* Returns the role to stamp on the booking, or throws if the caller is not
* allowed to create one for this contract's execution path.
*/
private async assertGate(
contract: Contract,
isGlActor: boolean,
isInitiate = false,
): Promise<string> {
// Suspended contracts are frozen for everyone, GL included — say so instead
// of letting the executed-status check below give a misleading reason.
if (contract.status === 'SUSPENDED') {
throw new BadRequestException(
'This contract is suspended — no new shipments can be booked until EDR lifts the suspension.',
);
}
if (contract.customsClearingEnabled) {
// Path B — the customer OPENS the shipment instance on a ONE_TIME customs
// contract (one click, no cargo) and uploads the GL-input documents on it;
// GL still runs the phased ET/DJ clearance and completes the booking with
// cargo, day and price. A GENERAL customs instance is opened by a shipment
// request instead, and completing any customs booking stays GL-only.
const customerMayInitiate = isInitiate && contract.contractKind === 'ONE_TIME';
if (!isGlActor && !customerMayInitiate) {
throw new ForbiddenException(
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
);
}
// No contract clearance cycle exists on either kind now — clearance runs
// on the booking, so an executed/active contract is the only gate here.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);
}
return isGlActor ? 'GL_ET' : 'CUSTOMER';
}
// Path A — customer (or staff) once the contract is executed.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);
}
return isGlActor ? 'STAFF' : 'CUSTOMER';
}
/**
* GL fallback worklist: executed ONE_TIME customs contracts with no live
* shipment instance yet. The customer normally opens it himself from the
* portal; this list lets GL do it on his behalf, and shows the contracts that
* are on no other queue (clearance lives on the booking, which does not exist
* yet). GENERAL customs is excluded — opened by shipment requests.
*/
async awaitingShipmentContracts(): Promise<Contract[]> {
const { items } = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: ['FULLY_EXECUTED'],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: 'createdAt',
sortOrder: 'DESC',
} as never);
const out: Contract[] = [];
for (const contract of items) {
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
continue;
}
// A split chain frees the slot for the remainder, so those contracts stay
// on the list even while the paid partial booking still exists.
if (await this.hasSplitBooking(contract.id)) {
out.push(contract);
continue;
}
if ((await this.countActiveBookings(contract.id)) === 0) out.push(contract);
}
return out;
}
private async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES })
.getCount();
}
/**
* Whether the contract is in split-remainder mode: some booking on it was
* reduced by a paid partial batch offer and still holds capacity. A split
* booking that never shipped (CANCELLED / REJECTED / EXPIRED) releases its
* hold and the contract falls back to the plain single-slot rule — the
* customer can rebook the whole quantity again.
*/
private async hasSplitBooking(contractId: string): Promise<boolean> {
const count = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.is_split = true')
.andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
.getCount();
return count > 0;
}
/**
* Outstanding split remainder of a ONE_TIME contract: what the FIRST split
* booking carried before its reduction (its pre_split_quantities snapshot —
* one-time contracts have no quantity cap to derive this from) minus
* everything currently booked on the contract. Bookings that never shipped
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
* contract has no live split booking.
*/
/**
* Public: the remainder-placement engine reads this to size the auto-created
* remainder booking. Returns `null` when there is no live split chain.
*/
async splitOutstanding(contract: Contract): Promise<SplitOutstanding | null> {
const first = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId: contract.id })
.andWhere('b.is_split = true')
.andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
.orderBy('b.created_at', 'ASC')
.getOne();
if (!first?.preSplitQuantities) return null;
const booked = await this.bookedQuantities(contract);
if (contract.freightType === 'CONTAINER') {
const bySize = new Map<string, { total: number; outstanding: number }>();
for (const [size, total] of Object.entries(first.preSplitQuantities.bySize ?? {})) {
bySize.set(size, {
total: Number(total),
outstanding: Math.max(0, Number(total) - (booked.bySize.get(size) ?? 0)),
});
}
return { bySize, bulk: null };
}
const total = Number(first.preSplitQuantities.bulkTons ?? 0);
return {
bySize: new Map(),
bulk: { total, outstanding: Math.max(0, round3(total - booked.bulk)) },
};
}
/**
* EXPORT whole-booking single-train gate. Export bookings never split — the
* entire booking must ride ONE open train on the chosen day. When no train
* fits it whole (trying every fillable train on the corridor, earliest
* first), reject BEFORE anything is written, with the largest still-bookable
* space (tons for bulk via the cargo type's wagon type; wagons/containers
* for container freight) so the customer knows what he CAN book.
*/
private async assertExportTrainSpace(
contract: Contract,
route: ContractRoute | null,
dto: CreateBookingUnderContractDto,
yards?: { originYardId: string | null; destinationYardId: string | null },
): Promise<void> {
if (contract.tradeDirection !== 'EXPORT' || !dto.scheduledDate) return;
const probe = await this.buildExportProbe(contract, route, dto, yards);
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (report.scheduleId) return;
// With export split ON a booking no longer has to ride ONE train whole: the
// largest fitting part is offered and the leftover is rebooked on the next
// train. Rejecting on the single-train fit here would block exactly the
// bookings the split exists to serve — including the auto-created remainder,
// which by definition did not fit the train it was split off. Fall back to
// the day total: unbookable only when NO export train that day has room.
if (process.env.FREIGHT_EXPORT_SPLIT === 'true') {
const fitting = await this.bookingBatchService.fittingTrainsForDay(
probe,
eatDay(new Date(dto.scheduledDate)),
'EXPORT',
);
if (fitting.length > 0) return;
throw new BadRequestException(
'No export train on this day has space left — pick another shipment day.',
);
}
throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.',
);
}
/**
* Unsaved booking twin carrying exactly what the batch engine's capacity
* math reads: yards + day for the leg, container lines WITH their container
* type (wagon-type FK) for TEU/wagon sizing, or bulk tons + cargo type
* (wagon-type FK) for tons→wagons conversion.
*/
private async buildExportProbe(
contract: Contract,
route: ContractRoute | null,
dto: CreateBookingUnderContractDto,
yards?: { originYardId: string | null; destinationYardId: string | null },
): Promise<Booking> {
const probe = new Booking();
probe.freightType = contract.freightType;
probe.tradeDirection = contract.tradeDirection;
probe.scheduledDate = dto.scheduledDate ? new Date(dto.scheduledDate) : null;
// Entity types are non-nullable; a missing yard just makes legOf() match no
// train, which surfaces as "no export train for this day" — the right failure.
probe.originYardId = (yards?.originYardId ?? route?.originYardId) as string;
probe.destinationYardId = (yards?.destinationYardId ??
route?.destinationYardId) as string;
if (contract.freightType === 'CONTAINER') {
const lines = await Promise.all(
(dto.containers ?? []).map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const bc = new BookingContainer();
bc.containerSize = line.containerSize;
bc.quantity = line.quantity;
bc.containerTypeId = ct.id;
bc.containerType = ct;
bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt));
bc.totalVgmTons = (line.units ?? []).reduce(
(sum, u) => sum + Number(u.vgmTons ?? 0),
0,
);
return bc;
}),
);
probe.bookingContainers = lines;
probe.cargoTotalWeightVgm = lines.reduce(
(sum, l) => sum + Number(l.totalVgmTons ?? 0),
0,
);
return probe;
}
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
probe.cargoTypeId = cargoTypeId;
if (cargoTypeId) {
probe.cargoType =
(await this.dataSource
.getRepository(CargoType)
.findOne({ where: { id: cargoTypeId } })) ?? undefined;
}
return probe;
}
/**
* ONE_TIME split chain: the next booking must take the WHOLE outstanding
* remainder — a one-time contract is a single shipment, so the only way it
* fragments is the system splitting it on train capacity, never the customer
* choosing a partial amount.
*/
private async assertExactRemainder(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return; // no live split booking — nothing to pin the remainder to
if (contract.freightType === 'CONTAINER') {
const sizes = new Set<string>([
...outstanding.bySize.keys(),
...(dto.containers ?? []).map((l) => l.containerSize ?? ''),
]);
for (const size of sizes) {
const remaining = outstanding.bySize.get(size)?.outstanding ?? 0;
const requested = (dto.containers ?? [])
.filter((l) => (l.containerSize ?? '') === size)
.reduce((sum, l) => sum + Number(l.quantity ?? 0), 0);
if (requested !== remaining) {
throw new BadRequestException(
`This one-time contract was split — the next booking must take the whole remainder: ` +
`${remaining} × ${size || 'container'} container(s), got ${requested}.`,
);
}
}
return;
}
const requested = this.resolveBulkTons(dto);
const remaining = outstanding.bulk?.outstanding ?? 0;
// 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
if (Math.abs(requested - remaining) > 0.001) {
throw new BadRequestException(
`This one-time contract was split — the next booking must take the whole ` +
`remaining ${remaining} tons, got ${requested}.`,
);
}
}
// ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
/**
* Reject a GENERAL booking whose cargo would exceed the contract's quantity
* cap. Container caps are per size; bulk is a single tons/items cap. Bookings
* that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold.
*/
/**
* Capacity check for a SHIPMENT REQUEST (no per-unit data) — mirrors
* {@link assertWithinQuantityCap} but reads the request's quantity shape.
*/
async assertRequestWithinCapacity(
contract: Contract,
lines: {
containers?: Array<{ containerSize: string; quantity: number }>;
bulk?: { cargoWeightTons?: number; itemCount?: number };
},
): Promise<void> {
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped contract
if (contract.freightType === 'CONTAINER') {
for (const line of lines.containers ?? []) {
const cap = capacity.find((c) => c.containerSize === line.containerSize);
if (!cap || cap.remaining == null) continue;
if (line.quantity > cap.remaining) {
throw new BadRequestException(
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
);
}
}
} else {
// PER_ITEM contracts are capped in items, so the item count is the
// consumption figure — tonnage is only wagon-sizing data.
const requested =
Number(lines.bulk?.itemCount ?? lines.bulk?.cargoWeightTons ?? 0) || 0;
const cap = capacity.find((c) => c.cap != null);
if (cap && cap.remaining != null && requested > cap.remaining) {
throw new BadRequestException(
`Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
);
}
}
}
private async assertWithinQuantityCap(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped contract
if (contract.freightType === 'CONTAINER') {
for (const line of dto.containers ?? []) {
const cap = capacity.find((c) => c.containerSize === line.containerSize);
if (!cap || cap.remaining == null) continue; // size uncapped
if (line.quantity > cap.remaining) {
throw new BadRequestException(
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
);
}
}
} else {
const requested = this.resolveBulkTons(dto);
const cap = capacity.find((c) => c.cap != null);
if (cap && cap.remaining != null && requested > cap.remaining) {
throw new BadRequestException(
`Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
);
}
}
}
/**
* Remaining bookable quantity per cargo-scope line: cap minus what prior
* bookings already consumed. Returns [] when the contract has no caps.
*/
async computeCapacity(
contract: Contract,
): Promise<
Array<{
containerSize?: string | null;
cargoTypeId?: string | null;
cap: number | null;
booked: number;
remaining: number | null;
}>
> {
const scope = contract.cargoScope ?? [];
const capped = scope.filter((s) => s.quantityCap != null);
if (capped.length === 0) return [];
const booked = await this.bookedQuantities(contract);
return capped.map((s) => {
const cap = Number(s.quantityCap);
const used =
contract.freightType === 'CONTAINER'
? (booked.bySize.get(s.containerSize ?? '') ?? 0)
: booked.bulk;
return {
containerSize: s.containerSize,
cargoTypeId: s.cargoTypeId,
cap,
booked: used,
remaining: Math.max(0, cap - used),
};
});
}
/**
* Capacity as shown to bookers (the /:id/capacity endpoint): GENERAL cap
* lines as-is, or — for a ONE_TIME contract in split-remainder mode —
* synthesized lines whose cap is the first split booking's pre-split
* snapshot and whose remaining is the outstanding remainder, i.e. the exact
* quantity the next booking must take.
*/
async capacityView(
contract: Contract,
): Promise<
Array<{
containerSize?: string | null;
cargoTypeId?: string | null;
cap: number | null;
booked: number;
remaining: number | null;
}>
> {
const capacity = await this.computeCapacity(contract);
if (capacity.length > 0 || contract.contractKind === 'GENERAL') {
return capacity;
}
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return capacity;
if (contract.freightType === 'CONTAINER') {
return [...outstanding.bySize.entries()].map(([size, s]) => ({
containerSize: size,
cargoTypeId: null,
cap: s.total,
booked: s.total - s.outstanding,
remaining: s.outstanding,
}));
}
const bulk = outstanding.bulk;
if (!bulk) return [];
return [
{
containerSize: null,
cargoTypeId: null,
cap: bulk.total,
booked: round3(bulk.total - bulk.outstanding),
remaining: bulk.outstanding,
},
];
}
/**
* A ONE_TIME contract carries exactly one shipment: once that booking is
* delivered (COMPLETED) the contract is fulfilled and moves to
* CONTRACT_CLOSED — shown as "Completed" and greyed out in both portals, and
* blocking any further booking. A split ONE_TIME is the exception: its
* remainder chain must be rebooked and delivered first, so the contract stays
* open while the split remainder is outstanding.
*
* GENERAL contracts are untouched — they close on cap exhaustion or expiry.
* Best-effort: a status hiccup must never fail the booking that completed.
*/
@OnEvent('booking.completed')
async onBookingCompleted(payload: { bookingId: string }): Promise<void> {
try {
const booking = await this.bookingsRepository.findById(payload.bookingId);
if (!booking?.contractId) return;
const contract = await this.contractsRepository.findById(booking.contractId);
if (!contract || contract.contractKind === 'GENERAL') return;
// Already closed/expired/cancelled — nothing to do.
if (isEffectivelyExpired(contract)) return;
const outstanding = await this.splitOutstanding(contract);
if (outstanding) {
// 0.001 tolerance absorbs bulk-ton float rounding, same as the
// cap-exhaustion path below.
const exhausted =
contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
if (!exhausted) return;
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED',
} as never);
this.logger.log(
`Contract ${contract.reference} completed — its one-time booking ${booking.reference} was delivered.`,
);
} catch (err) {
this.logger.error(
`Could not close contract for completed booking ${payload.bookingId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract, and under a ONE_TIME
* contract in split-remainder mode (a split remainder being rebooked): when
* no capped scope line has capacity left, the contract moves to
* CONTRACT_CLOSED even though its validity window is still open — blocking
* further bookings and shipment requests, including inside an open booking
* window. Never throws: a status hiccup must not undo the booking that was
* just created.
*/
private async maybeCompleteContract(contract: Contract): Promise<void> {
try {
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
// ONE_TIME contracts are governed by the single-active-booking slot, so
// they normally complete by expiry — EXCEPT once a booking was split: the
// remainder chain draws down the split booking's pre-split snapshot, and
// the contract completes when the outstanding remainder hits zero.
// (An unsplit ONE_TIME never completes here, so re-booking after an
// expired unpaid booking keeps working.)
if (contract.contractKind !== 'GENERAL') {
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return;
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round
// to 3 decimals); container quantities are integers and unaffected.
const exhausted =
contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
if (!exhausted) return;
} else {
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped — completes only by expiry
const exhausted = capacity.every(
(c) => c.remaining != null && c.remaining <= 0.001,
);
if (!exhausted) return;
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED',
} as never);
this.logger.log(
`Contract ${contract.reference} quantity fully booked — completed; no further bookings within validity.`,
);
} catch (err) {
this.logger.error(
`Could not evaluate completion for contract ${contract.id}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* Quantities already booked under a contract that still hold capacity. Excludes
* bookings that never shipped (CANCELLED / REJECTED / EXPIRED).
*/
private async bookedQuantities(
contract: Contract,
): Promise<{ bySize: Map<string, number>; bulk: number }> {
const releasing = RELEASING_BOOKING_STATUSES;
if (contract.freightType === 'CONTAINER') {
const rows = await this.dataSource
.getRepository(BookingContainer)
.createQueryBuilder('bc')
.innerJoin(Booking, 'b', 'b.id = bc.booking_id')
.select('bc.container_size', 'size')
.addSelect('COALESCE(SUM(bc.quantity), 0)', 'qty')
.where('b.contract_id = :contractId', { contractId: contract.id })
.andWhere('b.status NOT IN (:...releasing)', { releasing })
.groupBy('bc.container_size')
.getRawMany<{ size: string | null; qty: string }>();
const bySize = new Map<string, number>();
for (const r of rows) bySize.set(r.size ?? '', Number(r.qty));
return { bySize, bulk: 0 };
}
const row = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'tons')
.where('b.contract_id = :contractId', { contractId: contract.id })
.andWhere('b.status NOT IN (:...releasing)', { releasing })
.getRawOne<{ tons: string }>();
return { bySize: new Map(), bulk: Number(row?.tons ?? 0) };
}
private async resolveRoute(
contract: Contract,
contractRouteId?: string,
): Promise<ContractRoute | null> {
const routes = contract.routes ?? [];
if (contractRouteId) {
const found = routes.find((r) => r.id === contractRouteId);
if (!found) {
throw new BadRequestException('Selected route is not part of this contract.');
}
return found;
}
// ONE_TIME (or single-route GENERAL): auto-select the only route.
if (routes.length === 1) return routes[0];
if (routes.length === 0) return null;
throw new BadRequestException(
'contractRouteId is required for multi-route general contracts.',
);
}
private resolveCargoTypeId(
contract: Contract,
dto: CreateBookingUnderContractDto,
): string | null {
if (contract.freightType === 'BULK') {
const bulk = dto.bulkLines?.[0];
if (bulk?.cargoTypeId) return bulk.cargoTypeId;
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
return scope?.cargoTypeId ?? null;
}
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
return scope?.cargoTypeId ?? null;
}
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
if (!dto.bulkLines?.length) return 0;
return dto.bulkLines.reduce(
(sum, l) => sum + Number(l.itemCount ?? l.cargoWeightTons ?? 0),
0,
);
}
/**
* Real tonnage of a PER_ITEM (break-bulk) booking, kept alongside the item
* count `cargoTotalWeightVgm` holds. Both are needed: the item count prices
* the booking, the tonnage sizes the wagons (`bulkItemWagonsRequired` derives
* per-item weight from tonnage ÷ items). Null for PER_TON bulk, where
* `cargoTotalWeightVgm` already IS the tonnage.
*/
private resolveBulkWeightTons(
dto: CreateBookingUnderContractDto,
): number | null {
const lines = dto.bulkLines ?? [];
if (!lines.some((l) => Number(l.itemCount) > 0)) return null;
const tons = lines.reduce((sum, l) => sum + Number(l.cargoWeightTons ?? 0), 0);
return tons > 0 ? tons : null;
}
/**
* Per-line handling counts. Each physical container carries its own hazardous
* / reefer / return switch (entered next to its VGM), so the count is however
* many units opted in. Forms that predate per-unit switches send line-level
* counts and no unit flags — those are honoured as-is.
*/
private handlingCounts(line: CreateBookingContainerLineDto): {
hazardousQuantity: number;
reeferQuantity: number;
returnQuantity: number;
} {
const units = line.units ?? [];
const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn);
if (!flagged) {
return {
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
reeferQuantity: Number(line.reeferQuantity ?? 0),
returnQuantity: Number(line.returnQuantity ?? 0),
};
}
return {
hazardousQuantity: units.filter((u) => u.isHazardous).length,
reeferQuantity: units.filter((u) => u.isReefer).length,
returnQuantity: units.filter((u) => u.isReturn).length,
};
}
/**
* Booking-level hazardous / reefer flags. The CONTRACT gates the service; the
* per-container opt-ins decide whether THIS shipment actually uses it. A
* container contract that allows hazardous but a booking where nobody ticked
* the switch is not a hazardous booking, and must not fire the surcharge.
* Bulk keeps the contract flag — it has its own bulk*Quantity fields.
*/
private resolveShipmentHandlingFlag(
contract: Contract,
dto: CreateBookingUnderContractDto,
field: 'hazardousQuantity' | 'reeferQuantity',
): boolean {
const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer;
if (!gated) return false;
if (contract.freightType !== 'CONTAINER') return true;
const lines = dto.containers ?? [];
if (!lines.length) return Boolean(gated);
return lines.some((l) => this.handlingCounts(l)[field] > 0);
}
/**
* Resolve the booking's equipment return from the per-line return quantities
* (container freight). The CONTRACT gates the service — like hazardous:
* - contract WITH_RETURN → per-line returnQuantity (≤ quantity) decides; any
* line > 0 makes the booking WITH_RETURN (fires the pricing surcharge).
* - contract WITHOUT_RETURN/unset → returnQuantity is rejected and the legacy
* booking-level override (dto.equipmentReturn ?? contract default) applies.
* Bulk freight keeps the legacy behaviour untouched.
*/
/**
* The billing currency for a shipment under this contract.
*
* A contract quotes in USD only — the currency is a per-shipment choice now.
* Precedence: intercity is always ETB (domestic transport is invoiced in
* birr), then the customer's explicit choice, then the contract's own
* currency, which is USD for contracts created under the current rule and the
* grandfathered value for older ones.
*/
private resolveShipmentCurrency(
contract: Contract,
requested?: string | null,
): string {
if (contract.tradeDirection === 'DOMESTIC') return 'ETB';
return requested?.trim() || contract.paymentCurrency || 'USD';
}
private resolveShipmentEquipmentReturn(
contract: Contract,
dto: CreateBookingUnderContractDto,
): string {
const legacy =
dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN';
if (contract.freightType !== 'CONTAINER') return legacy;
const lines = dto.containers ?? [];
for (const line of lines) {
const qty = this.handlingCounts(line).returnQuantity;
if (qty === 0) continue;
if (contract.equipmentReturn !== 'WITH_RETURN') {
throw new BadRequestException(
'This contract was not created with the empty-container return ' +
'service — return quantities are not allowed on its bookings.',
);
}
if (qty > line.quantity) {
throw new BadRequestException(
`Return quantity ${qty} exceeds the ${line.containerSize} line quantity ${line.quantity}.`,
);
}
}
if (contract.equipmentReturn === 'WITH_RETURN') {
const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0);
return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
}
return legacy;
}
/**
* Map each contract-scope container size to a concrete container type and
* persist the booking_container line + its per-unit container numbers. Weight
* rule results are filled in afterward by {@link applyWeightResults} once all
* lines exist (a single rule-engine pass over the booking).
*/
private async persistContainers(
bookingId: string,
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) {
throw new BadRequestException('At least one container line is required.');
}
// Size strings arrive in mixed formats ("20ft" from the contract scope,
// bare "20" from the rebook seed) — compare numerically so format never
// fails a size that IS in scope.
const allowedSizesFt = new Set(
(contract.cargoScope ?? [])
.map((c) => parseInt(c.containerSize ?? '', 10))
.filter((n) => Number.isFinite(n)),
);
const containerRepo = this.dataSource.getRepository(BookingContainer);
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
for (const line of lines) {
if (
allowedSizesFt.size &&
!allowedSizesFt.has(parseInt(line.containerSize, 10))
) {
throw new BadRequestException(
`Container size ${line.containerSize} is outside the contract scope.`,
);
}
const counts = this.handlingCounts(line);
const containerType = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || counts.reeferQuantity > 0,
);
const vgmPerUnit = line.units.length
? line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) / line.units.length
: 0;
const totalVgm = line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0);
const containerRow = await containerRepo.save(
containerRepo.create({
bookingId,
containerTypeId: containerType.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: counts.hazardousQuantity,
reeferQuantity: counts.reeferQuantity,
returnQuantity:
contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
isOverweight: false,
overweightExcessTons: null,
} as Partial<BookingContainer>),
);
let sortOrder = 0;
for (const unit of line.units) {
await unitRepo.save(
unitRepo.create({
bookingContainerId: containerRow.id,
containerNumber: unit.containerNumber,
sealNumber: unit.sealNumber ?? null,
vgmTons: unit.vgmTons,
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,
isReturn:
contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false),
sortOrder: sortOrder++,
}),
);
}
}
}
/**
* Run the rule engine once over the freshly-created booking and persist the
* overweight result per container line (same ordering the engine returns).
*/
private async applyWeightResults(booking: Booking): Promise<void> {
const evalInput = await this.bookingPricingService.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const containers = booking.bookingContainers ?? [];
const containerRepo = this.dataSource.getRepository(BookingContainer);
for (let i = 0; i < containers.length; i++) {
const wr = ruleResult.containerWeightResults[i];
if (!wr) continue;
await containerRepo.update(containers[i].id, {
weightLimitRuleId: wr.weightLimitRuleId,
isOverweight: wr.isOverweight,
overweightExcessTons: wr.overweightExcessTons,
});
}
}
/**
* Pre-create validation + authoritative price preview for the shipment form:
* build an UNSAVED booking shaped exactly like {@link createUnderContract}
* would persist it and run the same BookingPricingService compute over it —
* base rail freight, first/last-mile trucking, and every rule-engine surcharge
* (overweight, hazard, reefer, consolidation, …). The portal and the GL
* backoffice form call this from the price-confirm modal, so the breakdown the
* user confirms is line-for-line what the booking will be charged. Also runs
* the 20ft weight-pairing rule, which hard-blocks creation.
*/
async validateShipment(
contractId: string,
dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: the booking being completed must not clash
// with its own persisted containers.
excludeBookingId?: string,
): Promise<{
overweightLines: Array<{
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}>;
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
capacityErrors: string[];
containerClashErrors: string[];
spaceErrors: string[];
lineItems: PriceLineItemDto[];
totalAmount: number;
}> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const lines = dto.containers ?? [];
if (contract.freightType === 'CONTAINER' && !lines.length) {
return {
overweightLines: [],
overweightSurchargeAmount: 0,
currency: null,
pairingErrors: [],
capacityErrors: [],
containerClashErrors: [],
spaceErrors: [],
lineItems: [],
totalAmount: 0,
};
}
// Resolve each container line's type + total VGM (sum of unit weights) —
// mirrors persistContainers so the preview lines match the persisted ones.
const resolved = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const totalVgmTons = (line.units ?? []).reduce(
(s, u) => s + Number(u.vgmTons ?? 0),
0,
);
return { line, ct, totalVgmTons };
}),
);
// Same size-scope gate persistContainers enforces at create, surfaced as a
// blocking preview error so the form can't confirm a size the contract does
// not cover. Numeric compare — "20" and "20ft" are the same size.
const allowedSizesFt = new Set(
(contract.cargoScope ?? [])
.map((c) => parseInt(c.containerSize ?? '', 10))
.filter((n) => Number.isFinite(n)),
);
const scopeErrors = allowedSizesFt.size
? [
...new Set(
lines
.map((l) => l.containerSize)
.filter((s) => !allowedSizesFt.has(parseInt(s, 10))),
),
].map((s) => `Container size ${s} is outside the contract scope.`)
: [];
// The unsaved twin of the booking createUnderContract would write: same
// denormalized contract fields, same container-line math. No id → the
// pricing service derives wagon counts from the in-memory lines.
const route = await this.resolveRoute(contract, dto.contractRouteId);
const previewBooking = Object.assign(new Booking(), {
// contractId makes the preview price off the contract's frozen rate
// snapshots exactly like the persisted booking will — without it the
// preview total is 0 on a leg with no live rate and the form blocks.
contractId: contract.id,
freightType: contract.freightType,
tradeDirection: contract.tradeDirection,
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
serviceTypeId: contract.serviceTypeId,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
isGovernment: contract.isGovernment,
shippingLineId: null,
contractRouteId: route?.id ?? null,
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
Object.assign(new BookingContainer(), {
containerTypeId: ct.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: this.handlingCounts(line).hazardousQuantity,
reeferQuantity: this.handlingCounts(line).reeferQuantity,
returnQuantity:
contract.equipmentReturn === 'WITH_RETURN'
? this.handlingCounts(line).returnQuantity
: 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
}),
),
}) as Booking;
const computed = await this.bookingPricingService.computePriceForBooking(previewBooking);
// The overweight surcharge line is already currency-converted; surface its
// amount separately so the warning alert can reference the exact charge.
const overweightSurchargeAmount =
computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0;
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
const twentyFtUnits = resolved
.filter((r) => (r.line.containerSize ?? '').includes('20'))
.flatMap((r) =>
(r.line.units ?? []).map((u, idx) => ({
label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
})),
);
const maxDiff = await this.max20ftPairDiffTons();
const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map(
(v) => v.message,
);
// Hard capacity ceiling — a non-empty result means the create call will be
// rejected, so the form can block submit up front.
const capacityErrors = await this.ruleEngineService.capacityViolations(
resolved.map(({ line, ct, totalVgmTons }) => ({
containerTypeId: ct.id,
quantity: line.quantity,
totalVgmTons,
})),
contract.tradeDirection,
);
// A physical container rides one train only — surface a clash with another
// active booking on the same day + route in the preview, so the form can
// hard-block before the create call rejects with the same rule.
let containerClashErrors: string[] = [];
if (dto.scheduledDate) {
const numbers = lines.flatMap((line) =>
(line.units ?? [])
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
.filter((n) => n.length > 0),
);
const clashes = await this.findContainerClashesOnTrain(
[...new Set(numbers)],
dto.scheduledDate,
{
originYardId: route?.originYardId,
destinationYardId: route?.destinationYardId,
},
excludeBookingId,
);
containerClashErrors = clashes.map(
(c) =>
`${c.containerNumber} is already booked on ${c.reference} for this shipment day.`,
);
}
// EXPORT rides whole or not at all — surface the single-train space check
// in the preview so the form hard-blocks BEFORE the create call rejects
// with the same message (including how much space is still bookable).
let spaceErrors: string[] = [];
if (contract.tradeDirection === 'EXPORT' && dto.scheduledDate) {
const probe = await this.buildExportProbe(contract, route, dto);
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (!report.scheduleId) {
spaceErrors = [report.fullMessage ?? 'Not enough train space for this day.'];
}
}
return {
overweightLines: computed.overweightLines,
overweightSurchargeAmount,
currency: computed.currency,
pairingErrors,
// Pricing hard blocks (missing rate for a container size / requested
// service) ride the capacity-errors channel so the form hard-blocks in
// the preview instead of failing at the create call.
capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked],
containerClashErrors,
spaceErrors,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
};
}
/**
* Throws when any container line's total weight exceeds the hard capacity
* ceiling of its weight limit rule. Mirrors validateShipment's line
* resolution so the gate matches what the form preview reported.
*/
private async assertWithinMaxCapacity(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) return;
const containers = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const totalVgmTons = (line.units ?? []).reduce(
(s, u) => s + Number(u.vgmTons ?? 0),
0,
);
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
}),
);
const violations = await this.ruleEngineService.capacityViolations(
containers,
contract.tradeDirection,
);
if (violations.length) {
throw new BadRequestException(violations.join('; '));
}
}
/**
* Hard-block booking creation when the 20ft container weights cannot be
* balanced onto wagons (pair diff over the global cap). Same rule the
* shipment-form preview reports as `pairingErrors`, enforced server-side.
*/
/**
* A physical container rides one train only. Reject the submission when a
* container number is entered twice in the same booking (the portal checks
* this client-side, the API must not trust it) or already sits on another
* customer's active booking for the same train — same shipment day AND same
* route (origin/destination yards).
*/
private async assertContainerNumbersAvailable(
dto: CreateBookingUnderContractDto,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const numbers = (dto.containers ?? []).flatMap((line) =>
(line.units ?? [])
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
.filter((n) => n.length > 0),
);
if (!numbers.length) return;
const seen = new Set<string>();
const withinBooking = new Set<string>();
for (const n of numbers) {
if (seen.has(n)) withinBooking.add(n);
seen.add(n);
}
if (withinBooking.size) {
throw new BadRequestException(
`Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`,
);
}
// Intercity bookings have no shipment day yet — nothing to clash with.
if (!dto.scheduledDate) return;
await this.assertNumbersFreeOnTrain(
numbers,
dto.scheduledDate,
route,
excludeBookingId,
);
}
/**
* Same train guard for a booking whose containers are already persisted
* (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its
* stored numbers must be free on the newly chosen day for its route.
*/
private async assertPersistedContainersAvailable(
booking: Booking,
scheduledDate: string,
): Promise<void> {
const rows: Array<{ containerNumber: string }> = await this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
.select('unit.container_number', 'containerNumber')
.where('line.booking_id = :bookingId', { bookingId: booking.id })
.getRawMany();
const numbers = rows.map((r) => r.containerNumber).filter(Boolean);
if (!numbers.length) return;
await this.assertNumbersFreeOnTrain(
numbers,
scheduledDate,
{
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
},
booking.id,
);
}
/**
* Reject when any of `numbers` sits on another active booking of the same
* train — same day and same route. Bookings without route yards (legacy
* rows) are matched on the day alone rather than let through.
*/
private async assertNumbersFreeOnTrain(
numbers: string[],
scheduledDate: string,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const clashes = await this.findContainerClashesOnTrain(
numbers,
scheduledDate,
route,
excludeBookingId,
);
if (clashes.length) {
const detail = clashes
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
}
/**
* Container numbers among `numbers` that already sit on another active
* booking of the same train — same day and same route. One row per clashing
* number. Bookings without route yards (legacy rows) match on the day alone
* rather than let through.
*/
private async findContainerClashesOnTrain(
numbers: string[],
scheduledDate: string,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<Array<{ containerNumber: string; reference: string }>> {
if (!numbers.length) return [];
const qb = this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
.innerJoin(Booking, 'b', 'b.id = line.booking_id')
.select('unit.container_number', 'containerNumber')
.addSelect('b.reference', 'reference')
.where('unit.container_number IN (:...numbers)', { numbers })
.andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.andWhere('b.deleted_at IS NULL');
if (route.originYardId && route.destinationYardId) {
// Same train = same day + same corridor. A clashing booking whose yards
// were never denormalized still blocks (NULL yards match any route).
qb.andWhere(
'(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)',
{ originYardId: route.originYardId },
).andWhere(
'(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)',
{ destinationYardId: route.destinationYardId },
);
}
if (excludeBookingId) {
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
}
const clashes: Array<{ containerNumber: string; reference: string }> =
await qb.getRawMany();
return [...new Map(clashes.map((c) => [c.containerNumber, c])).values()];
}
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {
const twentyFtUnits = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.flatMap((line, lineIdx) =>
(line.units ?? []).map((u, idx) => ({
label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
})),
);
if (twentyFtUnits.length < 2) return;
const maxDiff = await this.max20ftPairDiffTons();
const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff);
if (violations.length) {
throw new BadRequestException(
`Cannot create booking — 20ft containers cannot be paired on wagons: ${violations
.map((v) => v.message)
.join(' ')}`,
);
}
}
private async max20ftPairDiffTons(): Promise<number> {
const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.find({ order: { createdAt: 'ASC' }, take: 1 })
.then((rows) => rows[0] ?? null)
.catch(() => null);
const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons);
return Number.isFinite(n) ? n : 10;
}
/** Pick the default container type for a size; prefer reefer when requested. */
private async resolveContainerTypeForSize(
size: string,
preferReefer: boolean,
): Promise<ContainerType> {
const sizeFt = parseInt(size, 10);
const { items } = await this.containerTypesService.findAll({ pageSize: 100 });
const types = items.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
if (!types.length) {
throw new BadRequestException(`No container type configured for size ${size}.`);
}
if (preferReefer) {
const reefer = types.find((t) => t.isReefer);
if (reefer) return reefer;
}
return types.find((t) => !t.isReefer) ?? types[0];
}
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const seq = await this.bookingsRepository.maxReferenceSequence(year);
return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
}
}