Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
Marshal 1abef3ce34 enhance shipment requests page with filtering and sorting options
- Added status and cargo filters to the ShipmentRequestsPage.
- Implemented date range filtering for preferred dates.
- Introduced sorting options for shipment requests based on submission date and reference.
- Enhanced the display of shipment request details, including status badges and customer information.
- Updated the UI to include a search input with clear functionality and improved layout for filters.

feat: add equipment return option in new shipment form

- Introduced a toggle for equipment return in the NewShipmentPage.
- Updated form schema to include  field for container contracts.
- Enhanced user experience with visual feedback on the equipment return selection.

fix: update booking DTO to include equipment return option

- Added  field to CreateBookingUnderContractDto for per-shipment override.
- Updated related types and schemas to accommodate the new field for better contract handling.
2026-07-10 10:32:41 +00:00

1073 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { insertWithGeneratedReference } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { 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 { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
export interface CreateBookingUnderContractResult {
booking: Booking;
warnings: string[];
}
/**
* The single create path for shipment bookings under a contract.
*
* - Path A (transport only): the customer creates the booking once the contract
* is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled.
* - Path B (customs clearance): only GL Ethiopia creates the booking, once the
* contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters
* shipment data.
*
* From booking creation onward the existing batch/payment/allocation pipeline
* runs unchanged. See docs/new-doc.md §8, §13.
*/
@Injectable()
export class ContractBookingService {
private readonly logger = new Logger(ContractBookingService.name);
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly bookingPricingService: BookingPricingService,
private readonly consolidationService: ConsolidationService,
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
async createUnderContract(
contractId: string,
dto: CreateBookingUnderContractDto,
user?: { id?: string } | null,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
// A contract whose quantity cap was fully booked is completed — no further
// bookings, even while contract validity and a booking window are still
// open. Capacity released after closure (a cancelled/expired booking)
// reopens the contract on the next booking attempt.
if (contract.status === 'CONTRACT_CLOSED') {
const capacity = await this.computeCapacity(contract);
const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
if (!hasRoom) {
throw new BadRequestException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_ACTIVE',
} as never);
contract.status = 'CONTRACT_ACTIVE';
}
// GL Ethiopia is identified by the dedicated contract create-booking permission
// (granted to the edr_gl_ethiopia preset).
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
const createdByRole = await this.assertGate(contract, isGlActor);
// Validity window must still be open.
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
// letting the customer re-book within contract validity (doc §10.4).
if (contract.contractKind === 'ONE_TIME') {
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;
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
// operations, and there is NO contract-level clearance cycle to link.
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// GENERAL without customs (Path A) ALSO clears per booking: the customer
// uploads his own clearance proof on each booking and Operations reviews it
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation machine). DOMESTIC has no border, so no gate.
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
// 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');
}
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Bookings that enter the
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
// are scheduled later, so they are not gated here.
if (!generalCustoms && !generalSelfClear && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null,
});
}
// 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);
}
// 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:
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING',
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: contract.paymentCurrency,
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
freightType,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never),
);
// 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);
// 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. Roll back the just-inserted row + its lines so it
// does NOT occupy the one-time contract's single active-booking slot — else
// the customer's retry hits "already has an active booking" against a broken
// draft. The customer must fix the contract's rates, then rebook.
if (!(computed.totalAmount > 0)) {
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.hardDelete(booking.id);
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);
}
// 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,
);
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
if (
withContainers &&
freightType === 'CONTAINER' &&
(await this.consolidationService.needsConsolidationFromBooking(
withContainers,
))
) {
const parked = await this.consolidateDrawdown(
withContainers,
intendedStatus,
);
warnings.push(parked.message);
if (!parked.paired) {
// Waiting for a partner — stop here. The booking sits in
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
// A parked booking still holds contract capacity, so the cap may
// already be exhausted by it.
await this.maybeCompleteContract(contract);
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
return { booking: pendingResult ?? booking, warnings };
}
}
await this.finalizeContractBooking(
booking.id,
contract,
generalCustoms,
);
await this.maybeCompleteContract(contract);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: result ?? booking, warnings };
}
/**
* Search for a complementary partner for a parked-eligible drawdown, pair it or
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
* Pairing (via BookingsRepository.pairConsolidation) resumes both partners and
* emits booking.consolidation.paired, which finalizes any deferred contract
* booking. Returns whether a partner was found plus a customer-facing message.
*/
private async consolidateDrawdown(
booking: Booking,
resumeStatus: string,
): Promise<{ paired: boolean; message: string }> {
const slots = await this.consolidationService.slotsFromBooking(booking);
if (!slots.length) {
return { paired: false, message: '' };
}
const partner = await this.bookingsRepository.findConsolidationPartner(
booking,
slots,
);
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
return {
paired: true,
message: this.consolidationService.describePaired(
partner.reference,
slots,
),
};
}
await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus);
return {
paired: false,
message: this.consolidationService.describePending(booking, slots),
};
}
/**
* Finalize a contract booking once it is cleared to proceed (needed no
* consolidation, or has just paired): seed clearance milestones / link the
* contract cycle, then generate the invoice. Idempotent — safe to call again
* for a booking that pairs after having waited. Skips a booking that is still
* PENDING_CONSOLIDATION (guards the pairing event against a stray partner).
*/
private async finalizeContractBooking(
bookingId: string,
contract: Contract,
generalCustoms: boolean,
): Promise<void> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
// cycle to this booking, seed post-booking milestones, and lock the contract
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
if (contract.customsClearingEnabled && !generalCustoms) {
const cycle = await this.contractsRepository.currentCycle(contract.id);
if (cycle) {
await this.contractsRepository.linkBooking(cycle.id, bookingId);
}
await this.milestoneService.seedPostBookingMilestones(
bookingId,
contract.tradeDirection,
);
await this.contractsRepository.update(contract.id, {
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
} as never);
} else if (generalCustoms) {
// Per-booking clearance: seed full milestone timeline on the booking.
await this.milestoneService.seedPreBookingMilestonesOnBooking(
bookingId,
contract.tradeDirection,
);
await this.milestoneService.seedPostBookingMilestones(
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)
}`,
),
);
}
/**
* 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;
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
(err) =>
this.logger.error(
`Failed to finalize paired contract booking ${booking.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
}
}
/**
* Returns the role to stamp on the booking, or throws if the caller is not
* allowed to create one for this contract's execution path.
*/
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
if (contract.customsClearingEnabled) {
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
// The customer never books a customs contract himself.
if (!isGlActor) {
throw new ForbiddenException(
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
);
}
if (contract.contractKind === 'GENERAL') {
// GENERAL customs has NO contract clearance cycle — GL books per accepted
// shipment request while the contract is active; clearance is per booking.
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
'Contract must be active to book a shipment.',
);
}
return 'GL_ET';
}
// ONE_TIME customs — pre-booking boundary milestone must be complete.
const boundaryOk = await this.workflowService.isBoundaryComplete(
contract.id,
contract.tradeDirection,
);
if (!boundaryOk) {
throw new BadRequestException(
'Pre-booking clearance is not complete — booking cannot be created yet.',
);
}
return 'GL_ET';
}
// Path A — customer (or staff) once the contract is executed.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);
}
return isGlActor ? 'STAFF' : 'CUSTOMER';
}
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();
}
// ── 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 {
const requested =
(lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 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 =
(dto.bulkLines ?? []).reduce(
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
0,
) || this.resolveBulkTons(dto) || 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.`,
);
}
}
}
/**
* 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),
};
});
}
/**
* Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract (including 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 {
// ONE_TIME contracts are governed by the single-active-booking slot (and
// are promoted to GENERAL on split), so only GENERAL completes by cap.
if (contract.contractKind !== 'GENERAL') return;
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped — completes only by expiry
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round to
// 3 decimals); container caps are integers and unaffected.
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 cap 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 = ['CANCELLED', 'REJECTED', 'EXPIRED'];
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.cargoWeightTons ?? l.itemCount ?? 0),
0,
);
}
/**
* 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.');
}
const allowedSizes = new Set(
(contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s),
);
const containerRepo = this.dataSource.getRepository(BookingContainer);
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
for (const line of lines) {
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
throw new BadRequestException(
`Container size ${line.containerSize} is outside the contract scope.`,
);
}
const containerType = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 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: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
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,
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,
): Promise<{
overweightLines: Array<{
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}>;
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
capacityErrors: 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: [],
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 };
}),
);
// 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(), {
freightType: contract.freightType,
tradeDirection: contract.tradeDirection,
paymentCurrency: contract.paymentCurrency,
serviceTypeId: contract.serviceTypeId,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
isGovernment: contract.isGovernment,
shippingLineId: null,
contractRouteId: route?.id ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(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: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
}),
),
}) as Booking;
const computed = await this.bookingPricingService.computePriceForBooking(previewBooking);
// The overweight surcharge line is already currency-converted; surface its
// amount separately so the warning alert can reference the exact charge.
const overweightSurchargeAmount =
computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0;
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
const twentyFtUnits = resolved
.filter((r) => (r.line.containerSize ?? '').includes('20'))
.flatMap((r) =>
(r.line.units ?? []).map((u, idx) => ({
label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
})),
);
const maxDiff = await this.max20ftPairDiffTons();
const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map(
(v) => v.message,
);
// Hard capacity ceiling — a non-empty result means the create call will be
// rejected, so the form can block submit up front.
const capacityErrors = await this.ruleEngineService.capacityViolations(
resolved.map(({ line, ct, totalVgmTons }) => ({
containerTypeId: ct.id,
quantity: line.quantity,
totalVgmTons,
})),
contract.tradeDirection,
);
return {
overweightLines: computed.overweightLines,
overweightSurchargeAmount,
currency: computed.currency,
pairingErrors,
capacityErrors,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
};
}
/**
* Throws when any container line's total weight exceeds the hard capacity
* ceiling of its weight limit rule. Mirrors validateShipment's line
* resolution so the gate matches what the form preview reported.
*/
private async assertWithinMaxCapacity(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) return;
const containers = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const totalVgmTons = (line.units ?? []).reduce(
(s, u) => s + Number(u.vgmTons ?? 0),
0,
);
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
}),
);
const violations = await this.ruleEngineService.capacityViolations(
containers,
contract.tradeDirection,
);
if (violations.length) {
throw new BadRequestException(violations.join('; '));
}
}
/**
* Hard-block booking creation when the 20ft container weights cannot be
* balanced onto wagons (pair diff over the global cap). Same rule the
* shipment-form preview reports as `pairingErrors`, enforced server-side.
*/
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {
const twentyFtUnits = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.flatMap((line, lineIdx) =>
(line.units ?? []).map((u, idx) => ({
label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
})),
);
if (twentyFtUnits.length < 2) return;
const maxDiff = await this.max20ftPairDiffTons();
const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff);
if (violations.length) {
throw new BadRequestException(
`Cannot create booking — 20ft containers cannot be paired on wagons: ${violations
.map((v) => v.message)
.join(' ')}`,
);
}
}
private async max20ftPairDiffTons(): Promise<number> {
const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.find({ order: { createdAt: 'ASC' }, take: 1 })
.then((rows) => rows[0] ?? null)
.catch(() => null);
const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons);
return Number.isFinite(n) ? n : 10;
}
/** Pick the default container type for a size; prefer reefer when requested. */
private async resolveContainerTypeForSize(
size: string,
preferReefer: boolean,
): Promise<ContainerType> {
const sizeFt = parseInt(size, 10);
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
const types = data.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')}`;
}
}