mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage. - Implemented API endpoints for recording station work and managing wagon detach requests. - Updated contract templates to include Ethiopian customs handling options. - Enhanced shipment forms to collect customs clearing agent details for without-customs bookings. - Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts. - Improved validation for customs clearing agent information in shipment forms. - Updated various components and services to accommodate new features and ensure data integrity.
2765 lines
114 KiB
TypeScript
2765 lines
114 KiB
TypeScript
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 { CargoUnitOfMeasure } from '@edr/types';
|
||
|
||
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 { ConsolidationApprovalService } from '../bookings/consolidation-approval.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/services/train-scheduling.service';
|
||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.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 { BookingRequest } from './entities/booking-request.entity';
|
||
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 {
|
||
CompleteConsolidatedPairDto,
|
||
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[];
|
||
}
|
||
|
||
/**
|
||
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
|
||
* booking. `hasCargo` is false for a bare instance whose containers GL still has
|
||
* to enter on the split completion form.
|
||
*/
|
||
export interface ConsolidationCandidate {
|
||
id: string;
|
||
reference: string;
|
||
contractId: string | null;
|
||
companyName: string | null;
|
||
status: string;
|
||
tradeDirection: string | null;
|
||
originYardId: string | null;
|
||
destinationYardId: string | null;
|
||
scheduledDate: string | null;
|
||
ft20Quantity: number;
|
||
hasCargo: boolean;
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
// forwardRef: part of the booking-invoice ⇄ wagon-cancellation ⇄ contracts
|
||
// require cycle (see BookingTransitionService).
|
||
@Inject(forwardRef(() => BookingInvoiceService))
|
||
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,
|
||
@Inject(forwardRef(() => ConsolidationApprovalService))
|
||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||
) {}
|
||
|
||
async createUnderContract(
|
||
contractId: string,
|
||
dto: CreateBookingUnderContractDto,
|
||
user?: { id?: string } | null,
|
||
actorPermissions?: unknown,
|
||
opts?: {
|
||
/**
|
||
* Wagon-cancellation credit rebook only: the freight was paid while the
|
||
* contract was live, so redeeming the credit is allowed even after the
|
||
* contract's validity lapsed. Never set for a genuinely new booking.
|
||
*/
|
||
allowExpiredContract?: boolean;
|
||
},
|
||
): 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);
|
||
|
||
if (!opts?.allowExpiredContract) await this.assertNotExpired(contract);
|
||
const createdByRole = await this.assertGate(
|
||
contract,
|
||
isGlActor,
|
||
false,
|
||
opts?.allowExpiredContract,
|
||
);
|
||
|
||
// 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,
|
||
});
|
||
}
|
||
|
||
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
|
||
|
||
// 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'),
|
||
...bulkFields,
|
||
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' &&
|
||
// A rebooked cancellation credit carries `skipAutoConsolidation`: its
|
||
// shared-wagon partner is picked by GL in the rebook flow, so nothing may
|
||
// auto-claim (or park) it here behind GL's back.
|
||
!dto.skipAutoConsolidation &&
|
||
(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.
|
||
await this.assertNotExpired(contract);
|
||
const createdByRole = await this.assertGate(contract, isGlActor, true);
|
||
|
||
// 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.',
|
||
);
|
||
}
|
||
await this.assertNotExpired(contract);
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Candidate partners a GL operator may link to an odd-20ft customs booking.
|
||
* Manual counterpart to the automatic pairing in {@link consolidateDrawdown} —
|
||
* a customs instance is completed by GL, so GL also chooses who shares its
|
||
* wagon rather than waiting for the auto-matcher to find an exact complement.
|
||
*/
|
||
async listConsolidationCandidates(
|
||
contractId: string,
|
||
bookingId: string,
|
||
): Promise<ConsolidationCandidate[]> {
|
||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||
if (!booking || booking.contractId !== contractId) {
|
||
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
|
||
}
|
||
|
||
const rows = await this.bookingsRepository.findManualConsolidationCandidates(
|
||
booking,
|
||
);
|
||
return rows.map((row) => {
|
||
const lines = row.bookingContainers ?? [];
|
||
return {
|
||
id: row.id,
|
||
reference: row.reference,
|
||
contractId: row.contractId ?? null,
|
||
companyName: row.company?.name ?? null,
|
||
status: row.status,
|
||
tradeDirection: row.tradeDirection ?? null,
|
||
originYardId: row.originYardId ?? null,
|
||
destinationYardId: row.destinationYardId ?? null,
|
||
scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null,
|
||
ft20Quantity: lines
|
||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||
hasCargo: lines.length > 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Complete an odd-20ft customs booking together with the partner booking GL
|
||
* picked for its shared wagon. Both halves run the ordinary
|
||
* {@link completeUnderContract} machine — same gates, same pricing, same
|
||
* per-booking invoice, so each customer still pays only its own shipment — and
|
||
* are linked as consolidation partners at the end.
|
||
*
|
||
* All-or-nothing: the two completions plus the pairing run inside one
|
||
* transaction, so a failure on either half leaves neither booking completed
|
||
* and no half-linked wagon behind. `runInTransaction` is used rather than a
|
||
* manual QueryRunner so the nested services join the same transactional
|
||
* context through the shared DataSource.
|
||
*/
|
||
async completeConsolidatedPair(
|
||
contractId: string,
|
||
bookingId: string,
|
||
dto: CompleteConsolidatedPairDto,
|
||
actorPermissions?: unknown,
|
||
/** IAM id of the GL user creating the pairing — recorded on the approval. */
|
||
actorUserId?: string | null,
|
||
): Promise<{
|
||
booking: Booking;
|
||
partner: Booking;
|
||
warnings: string[];
|
||
}> {
|
||
if (dto.partnerBookingId === bookingId) {
|
||
throw new BadRequestException(
|
||
'A booking cannot be consolidated with itself.',
|
||
);
|
||
}
|
||
|
||
const partner = await this.bookingsRepository.findByIdWithFiles(
|
||
dto.partnerBookingId,
|
||
);
|
||
if (!partner) {
|
||
throw new NotFoundException(
|
||
`Partner booking ${dto.partnerBookingId} not found`,
|
||
);
|
||
}
|
||
if (partner.consolidationPartnerId) {
|
||
throw new ConflictException(
|
||
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||
);
|
||
}
|
||
if (!partner.contractId) {
|
||
throw new BadRequestException(
|
||
`Booking ${partner.reference} is not a contract booking and cannot be completed here.`,
|
||
);
|
||
}
|
||
|
||
const warnings: string[] = [];
|
||
|
||
const { ownId, partnerId } = await this.dataSource.transaction(async () => {
|
||
const own = await this.completeUnderContract(
|
||
contractId,
|
||
bookingId,
|
||
{ ...dto.booking, skipAutoConsolidation: true },
|
||
// Both halves are completed by the same GL actor that reached this
|
||
// endpoint — the customs gate in completeUnderContract re-checks it.
|
||
actorPermissions,
|
||
);
|
||
warnings.push(...own.warnings);
|
||
|
||
const other = await this.completeUnderContract(
|
||
partner.contractId as string,
|
||
partner.id,
|
||
{ ...dto.partner, skipAutoConsolidation: true },
|
||
actorPermissions,
|
||
);
|
||
warnings.push(...other.warnings);
|
||
|
||
// Link the two halves. Written directly (not via pairConsolidation) because
|
||
// both bookings have just been completed into their live status here —
|
||
// pairConsolidation exists to RESUME bookings parked in
|
||
// PENDING_CONSOLIDATION and would overwrite that status.
|
||
await this.bookingsRepository.linkConsolidationPartners(
|
||
own.booking.id,
|
||
other.booking.id,
|
||
);
|
||
return { ownId: own.booking.id, partnerId: other.booking.id };
|
||
});
|
||
|
||
// Both halves have just been completed into the operations queue by the
|
||
// ordinary completion machine. A shared wagon does not go there unreviewed:
|
||
// pull the pair back into the approval gate, which releases them to
|
||
// Operations only once a person signs off on the pairing.
|
||
await this.consolidationApprovalService.requestApproval(
|
||
ownId,
|
||
partnerId,
|
||
actorUserId ?? null,
|
||
);
|
||
|
||
// Sequential reads: one connection per transaction context.
|
||
const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId);
|
||
const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId);
|
||
return {
|
||
booking: finalBooking!,
|
||
partner: finalPartner ?? partner,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
}
|
||
// Without-customs import/export: the customer's own clearing agent (name,
|
||
// email, phone) is captured per booking at completion. A resubmit may omit
|
||
// the fields and keep what the booking already stored. Customs contracts
|
||
// (GL clears) and intercity (no border) never collect an agent.
|
||
if (
|
||
!contract.customsClearingEnabled &&
|
||
contract.tradeDirection !== 'DOMESTIC'
|
||
) {
|
||
const agentName =
|
||
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
|
||
const agentEmail =
|
||
dto.customsClearingAgentEmail?.trim() ||
|
||
booking.customsClearingAgentEmail ||
|
||
null;
|
||
const agentPhone =
|
||
dto.customsClearingAgentPhone?.trim() ||
|
||
booking.customsClearingAgentPhone ||
|
||
null;
|
||
if (!agentName || !agentEmail || !agentPhone) {
|
||
throw new BadRequestException(
|
||
'Customs clearing agent name, email and phone are required to complete this booking.',
|
||
);
|
||
}
|
||
await this.bookingsRepository.update(booking.id, {
|
||
customsClearingAgent: agentName,
|
||
customsClearingAgentEmail: agentEmail,
|
||
customsClearingAgentPhone: agentPhone,
|
||
} as never);
|
||
}
|
||
// No expiry gate here on purpose: this booking was already initiated
|
||
// before the contract lapsed (createUnderContract/initiateUnderContract
|
||
// already checked expiry at start). Finishing an in-flight booking must
|
||
// proceed even if the contract expires meanwhile — only starting a NEW
|
||
// booking is blocked (see assertNotExpired).
|
||
|
||
// 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,
|
||
});
|
||
|
||
// The customer's shipment request is the order: sizes, quantities and
|
||
// billing currency are theirs — GL enters everything else. Both halves of a
|
||
// consolidated pair pass through here, so each is checked against its OWN
|
||
// request.
|
||
await this.assertMatchesShipmentRequest(booking.id, dto);
|
||
|
||
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,
|
||
...(await this.resolveBulkCargoFields(contract, 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.
|
||
// Customs (Path B) instances are exempt from the AUTO-matcher: GL links
|
||
// their shared wagon by hand through completeConsolidatedPair, so nothing
|
||
// may claim a partner for them behind GL's back. A customs half completed
|
||
// as part of a manual pair carries `skipAutoConsolidation`; one completed
|
||
// alone still falls through to the automatic gate below, so an odd 20ft
|
||
// booking can never proceed on a partial wagon. Non-customs drawdowns are
|
||
// unaffected.
|
||
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||
if (
|
||
withContainers &&
|
||
freightType === 'CONTAINER' &&
|
||
!dto.skipAutoConsolidation &&
|
||
(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 };
|
||
}
|
||
|
||
/**
|
||
* The linked shipment request (customs Path B) is the customer's order:
|
||
* container sizes + quantities and the billing currency are the customer's
|
||
* choices, and GL may not change them at completion — only per-unit details
|
||
* (numbers, seals, VGM, handling) are GL's to enter. No linked request, or a
|
||
* legacy request without lines/currency ⇒ nothing to enforce. Container lines
|
||
* are checked only when the payload restates cargo (a day-only resubmit keeps
|
||
* the already-validated persisted cargo).
|
||
*/
|
||
private async assertMatchesShipmentRequest(
|
||
bookingId: string,
|
||
dto: CreateBookingUnderContractDto,
|
||
): Promise<void> {
|
||
const request = await this.dataSource.getRepository(BookingRequest).findOne({
|
||
where: { createdBookingId: bookingId },
|
||
});
|
||
if (!request) return;
|
||
const lines = request.requestedLines ?? {};
|
||
|
||
if (request.paymentCurrency) {
|
||
if (dto.paymentCurrency && dto.paymentCurrency !== request.paymentCurrency) {
|
||
throw new BadRequestException(
|
||
`The customer chose ${request.paymentCurrency} on the shipment request — the billing currency cannot be changed.`,
|
||
);
|
||
}
|
||
dto.paymentCurrency = request.paymentCurrency;
|
||
}
|
||
|
||
if (dto.containers?.length && lines.containers?.length) {
|
||
// Compare per size in ft ("20ft" vs "20FT"/"20" spellings must not differ).
|
||
const byFt = (rows: Array<{ containerSize: string; quantity: number }>) => {
|
||
const map = new Map<number, number>();
|
||
for (const row of rows) {
|
||
const ft = parseInt(String(row.containerSize), 10);
|
||
map.set(ft, (map.get(ft) ?? 0) + Number(row.quantity || 0));
|
||
}
|
||
return map;
|
||
};
|
||
const requested = byFt(lines.containers);
|
||
const given = byFt(dto.containers);
|
||
const same =
|
||
requested.size === given.size &&
|
||
[...requested].every(([ft, qty]) => given.get(ft) === qty);
|
||
if (!same) {
|
||
const summary = [...requested]
|
||
.map(([ft, qty]) => `${qty} × ${ft}ft`)
|
||
.join(', ');
|
||
throw new BadRequestException(
|
||
`The customer requested exactly ${summary} — container sizes and quantities cannot be changed at completion.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (dto.bulkLines?.length && lines.bulk?.cargoWeightTons != null) {
|
||
const givenTons = dto.bulkLines.reduce(
|
||
(sum, l) => sum + Number(l.cargoWeightTons || 0),
|
||
0,
|
||
);
|
||
if (givenTons !== Number(lines.bulk.cargoWeightTons)) {
|
||
throw new BadRequestException(
|
||
`The customer requested ${lines.bulk.cargoWeightTons} tons on the shipment request — the bulk quantity cannot be changed at completion.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
/**
|
||
* Blocks starting a NEW booking (create/initiate) once the contract has
|
||
* lapsed, and lazily flips the stored status to EXPIRED so it doesn't wait
|
||
* for the nightly sweep. Only for the "start something new" entry points —
|
||
* a booking already underway (completeUnderContract) must be allowed to
|
||
* finish even if the contract expires mid-flight.
|
||
*/
|
||
private async assertNotExpired(contract: Contract): Promise<void> {
|
||
if (!isEffectivelyExpired(contract)) return;
|
||
if (contract.status !== 'EXPIRED') {
|
||
const flipped = await this.contractsRepository.expireIfLapsed(contract.id);
|
||
if (flipped) contract.status = 'EXPIRED';
|
||
}
|
||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||
}
|
||
|
||
private async assertGate(
|
||
contract: Contract,
|
||
isGlActor: boolean,
|
||
isInitiate = false,
|
||
allowExpired = 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) &&
|
||
!(allowExpired && contract.status === 'EXPIRED')
|
||
) {
|
||
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) &&
|
||
!(allowExpired && contract.status === 'EXPIRED')
|
||
) {
|
||
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;
|
||
}
|
||
|
||
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
|
||
probe.cargoTotalWeightVgm = bulkFields.cargoTotalWeightVgm;
|
||
probe.bulkTotalWeightTons = bulkFields.bulkTotalWeightTons;
|
||
probe.bulkRequestedWagons = bulkFields.bulkRequestedWagons;
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Bulk cargo columns for the booking row, resolved against the commodity's
|
||
* unit of measure:
|
||
*
|
||
* - PER_TON: `cargoTotalWeightVgm` = tons (legacy behaviour).
|
||
* - PER_ITEM: `cargoTotalWeightVgm` = item count, real tonnage in
|
||
* `bulkTotalWeightTons` (legacy behaviour).
|
||
* - NUMBER_OF_WAGONS: `cargoTotalWeightVgm` = tons, and the payload must fix
|
||
* the wagon count (customer on the portal, GL in the backoffice). The
|
||
* count is validated so each wagon's even share (tons ÷ wagons) fits what
|
||
* one wagon of this cargo may carry; the optional item count is stored as
|
||
* information only and never prices or sizes anything.
|
||
*
|
||
* Container contracts (and payloads without bulk lines) pass through with
|
||
* the legacy zero/null values.
|
||
*/
|
||
private async resolveBulkCargoFields(
|
||
contract: Contract,
|
||
dto: CreateBookingUnderContractDto,
|
||
): Promise<{
|
||
cargoTotalWeightVgm: number;
|
||
bulkTotalWeightTons: number | null;
|
||
bulkRequestedWagons: number | null;
|
||
bulkItemCount: number | null;
|
||
}> {
|
||
const legacy = {
|
||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||
bulkRequestedWagons: null as number | null,
|
||
bulkItemCount: null as number | null,
|
||
};
|
||
if (contract.freightType === 'CONTAINER' || !dto.bulkLines?.length) {
|
||
return legacy;
|
||
}
|
||
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||
if (!cargoTypeId) return legacy;
|
||
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
||
where: { id: cargoTypeId },
|
||
relations: { wagonTypes: true },
|
||
});
|
||
if (cargoType?.unitOfMeasure !== CargoUnitOfMeasure.NumberOfWagons) {
|
||
return legacy;
|
||
}
|
||
|
||
const tons = dto.bulkLines.reduce(
|
||
(sum, l) => sum + Number(l.cargoWeightTons ?? 0),
|
||
0,
|
||
);
|
||
const items = dto.bulkLines.reduce(
|
||
(sum, l) => sum + Number(l.itemCount ?? 0),
|
||
0,
|
||
);
|
||
const wagons = Math.floor(Number(dto.requestedWagons ?? 0));
|
||
if (!(wagons >= 1)) {
|
||
throw new BadRequestException(
|
||
`${cargoType.cargoTypeName} is booked by wagons — enter the number of wagons needed.`,
|
||
);
|
||
}
|
||
if (!(tons > 0)) {
|
||
throw new BadRequestException('Cargo weight in tons is required.');
|
||
}
|
||
this.assertWagonShareFits(cargoType, tons, wagons);
|
||
return {
|
||
cargoTotalWeightVgm: tons,
|
||
bulkTotalWeightTons: null,
|
||
bulkRequestedWagons: wagons,
|
||
bulkItemCount: items > 0 ? Math.floor(items) : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* NUMBER_OF_WAGONS: block the booking outright when the even per-wagon share
|
||
* (tons ÷ requested wagons) is heavier than what ANY of the cargo's allowed
|
||
* wagon types may carry — 100T on 2 wagons is 50T each and fine on a 60T
|
||
* wagon, but 100T on 1 wagon can never ride. Cargo types with no wagon types
|
||
* configured skip the check (allocation falls back to the default rating).
|
||
*/
|
||
private assertWagonShareFits(
|
||
cargoType: CargoType,
|
||
tons: number,
|
||
wagons: number,
|
||
): void {
|
||
const allowed = (cargoType.wagonTypes ?? []).filter(
|
||
(wt) => Number(wt.capacityTons) > 0,
|
||
);
|
||
if (!allowed.length) return;
|
||
const maxPerWagon = Math.max(
|
||
...allowed.map((wt) =>
|
||
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
|
||
),
|
||
);
|
||
const share = tons / wagons;
|
||
if (share > maxPerWagon) {
|
||
throw new BadRequestException(
|
||
`${tons} tons across ${wagons} wagon(s) loads ${round3(share)}T per wagon, ` +
|
||
`but a wagon of this cargo carries at most ${round3(maxPerWagon)}T — ` +
|
||
`request at least ${Math.ceil(tons / maxPerWagon)} wagons.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
containerLabel: 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,
|
||
// The clearance fee is gated on this flag in BookingPricingService, and
|
||
// createUnderContract copies it off the contract. Omitting it here priced
|
||
// the preview WITHOUT the customs line the created booking is then billed
|
||
// — the customer confirmed one total and got invoiced a larger one.
|
||
customsClearingEnabled: contract.customsClearingEnabled,
|
||
shippingLineId: null,
|
||
contractRouteId: route?.id ?? null,
|
||
originYardId: route?.originYardId ?? null,
|
||
destinationYardId: route?.destinationYardId ?? null,
|
||
...(await this.resolveBulkCargoFields(contract, 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,
|
||
// Per-box weights drive the overweight check — the limit is per
|
||
// container, so a heavy box is billed even when the line total fits.
|
||
units: (line.units ?? []).map((u, idx) => ({
|
||
vgmTons: Number(u.vgmTons ?? 0),
|
||
sortOrder: idx,
|
||
})) as BookingContainer['units'],
|
||
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,
|
||
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
|
||
})),
|
||
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,
|
||
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
|
||
};
|
||
}),
|
||
);
|
||
|
||
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> {
|
||
// Odd 20ft totals are no longer rejected here: the wagon consolidation gate
|
||
// that runs right after (consolidateDrawdown / needsConsolidationFromBooking,
|
||
// same machinery the plain booking flow already uses live) auto-pairs an odd
|
||
// total with another customer's odd booking or parks it as
|
||
// PENDING_CONSOLIDATION until one appears. This assert now only checks that
|
||
// any 20ft containers actually present can be weight-paired on a wagon.
|
||
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')}`;
|
||
}
|
||
}
|