import { BadRequestException, ConflictException, ForbiddenException, forwardRef, GoneException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { Freight, SchedulingStatus } from '@edr/types'; import { insertWithGeneratedReference, logCtx } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; import { InjectDataSource } from '@nestjs/typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants'; import { BookingContractService } from './booking-contract.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { assertFreightShape } from './booking-freight.util'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { mapStatusCountsToTabs } from './booking-list-tabs.config'; import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { BOOKING_STATUSES, CUSTOMER_EDITABLE_STATUSES, FreightType, } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; /** * The train as the backoffice booking detail page needs it: which train, its * window phase, and both the planned and actual clock. Attached by `findById` * for the allocated train (`train_schedule_id`) or, before the batch engine has * allocated one, the train the customer picked at day-commit * (`requested_train_schedule_id`) — see `isRequested`. */ export interface TrainScheduleSummary { id: string; reference: string | null; trainNumber: string | null; status: string | null; scheduledDepartureDate: string | null; scheduledArrivalDate: string | null; actualDepartureAt: string | null; actualArrivalAt: string | null; windowPhase: string | null; paymentPhaseEndsAt: string | null; /** * True when this is the customer's requested train rather than a confirmed * allocation — the state staff review at OPERATION_REQUEST_PENDING, before * accepting the operation puts the booking into the batch pool. */ isRequested: boolean; } /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { items: Booking[]; total: number; meta: { page: number; pageSize: number; total: number; totalPages: number; hasNextPage: boolean; hasPreviousPage: boolean; }; } /** One wagon line on the carriage acceptance sheet (raw SQL projection). */ interface CarriageAcceptanceWagonRow { sequenceNo: number; wagonType: string | null; wagonNumber: string | null; tareWeightTons: string | null; equatedLength: string | null; loadCapacityTons: string | null; allocatedWeightTons: string | null; trainNumber: string | null; departureAt: Date | null; marshalledAt: string | null; arrivalAt: string | null; containerNumbers: string | null; sealNumbers: string | null; } /** A received-but-not-yet-marshalled export line, standing in for a wagon row. */ interface CarriageAcceptanceReceivedRow { allocatedWeightTons: string | null; containerNumbers: string | null; sealNumbers?: string | null; } const URGENT_PRIORITY_THRESHOLD = 1000; const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', 'PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE', ] as const; /** * Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed * the total cargo it's a portion of, and is never negative. */ function clampToCargo(value: number | undefined, cargoAmount: number): number { const v = Number(value ?? 0); if (!Number.isFinite(v) || v <= 0) return 0; const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0; return Math.min(v, cap); } @Injectable() export class BookingsService { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, private readonly minioService: MinioService, // private readonly customersService: CustomersService, private readonly companiesService: CompaniesService, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, private readonly vehiclesService: VehiclesService, private readonly pdfRender: PdfRenderService, private readonly events: EventEmitter2, @Inject(forwardRef(() => BookingContractService)) private readonly bookingContractService: BookingContractService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, ) {} async assignCustomerTruck( bookingId: string, dto: CustomerTruckAssignmentDto, ): Promise { const booking = await this.findById(bookingId); const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim()); const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim()); const usesMileService = booking.tradeDirection === 'IMPORT' ? hasLastMile : booking.tradeDirection === 'EXPORT' ? hasFirstMile : hasFirstMile || hasLastMile; if (usesMileService) { throw new BadRequestException( 'Customer truck assignment is only allowed when first/last mile delivery is not selected', ); } if (booking.customerTruckAssignedAt) { throw new ConflictException('Customer truck assignment is already submitted and locked'); } if (booking.paymentStatus !== 'PAID') { throw new BadRequestException('Booking must be paid before assigning an external customer truck'); } await this.bookingsRepository.update(bookingId, { status: 'TRUCK_ASSIGNED', customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(), customerTruckDriverName: dto.driverName.trim(), customerTruckType: dto.truckType.trim(), customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(), customerTruckAssignedAt: new Date(), }); return this.findById(bookingId); } /** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */ static readonly FREIGHT_ORDER_EXTRA_COPIES = [ 'Original 1 (for Issuing Carrier)', 'Original 2 (for Consignee)', 'Original 3 (for Shipper)', 'Copy 4 (Delivery Receipt)', 'Copy 5 (Extra Copy)', 'Copy 6 (Extra Copy)', 'Copy 7 (Extra Copy)', 'Copy 8 (for Agent)', ] as const; async customerTruckFreightOrderCopies( bookingId: string, extraCopyIndexes: number[] = [], ): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); if (!booking.customerTruckAssignedAt) { throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated'); } const trucks: Array<{ plateNumber: string; driverName: string; truckType: string; arrivedAt: string | null; containers: string | null; }> = await this.dataSource.query( `SELECT a.plate_number AS "plateNumber", a.driver_name AS "driverName", a.truck_type AS "truckType", a.arrived_at AS "arrivedAt", string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers" FROM freight.customer_truck_assignments a LEFT JOIN freight.customer_truck_containers c ON c.assignment_id = a.id AND c.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at ORDER BY a.assigned_at`, [bookingId], ); // The 2 gate copies are ALWAYS printed; the waybill-style copies are // whatever the customer ticked (indexes into the fixed catalog). const extraCopies = [...new Set(extraCopyIndexes)] .map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1]) .filter(Boolean); const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies); // Chromium when available; otherwise the styled tabular fallback (never the // generic text dump — the freight order is an outward-facing gate document). const buffer = await this.pdfRender.htmlToPdfBuffer(html, { label: 'freight order', fallback: (prepared) => buildTabularFallbackPdf(prepared), }); return { filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, buffer, }; } /** * Carriage acceptance sheet — one per booking, listing every wagon the booking * occupies. Handed to the customer when EDR accepts the cargo (export) and when * the wagons are allocated before marshalling (import), so it is only available * once the booking has wagon allocations. */ async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); // The sheet attests that EDR has taken custody. For export that happens at // cargo receipt (GRN), so the GRN is required even when wagons are already // allocated — an allocation is a plan, not possession. await assertExportReceivedWithGrn(this.dataSource, booking); let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query( `SELECT tsw.sequence_no AS "sequenceNo", COALESCE(wt.code, wt.name) AS "wagonType", w.wagon_number AS "wagonNumber", wt.tare_weight_tons AS "tareWeightTons", tsw.length_meters AS "equatedLength", tsw.capacity_tons AS "loadCapacityTons", a.allocated_weight_tons AS "allocatedWeightTons", s.train_number AS "trainNumber", s.scheduled_departure_date AS "departureAt", so.label AS "marshalledAt", sd.label AS "arrivalAt", string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" FROM freight.wagon_booking_allocations a JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id LEFT JOIN freight.train_schedules s ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL LEFT JOIN freight.yards so ON so.id = s.origin_station_id LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, s.train_number, s.scheduled_departure_date, so.label, sd.label ORDER BY tsw.sequence_no`, [bookingId], ); // Export acceptance happens at the warehouse gate, not at marshalling: EDR // takes custody of the cargo when it receives it, and the customer is handed // this sheet then — before the booking is put on a train. So a received // export booking gets its sheet off the received cargo, wagon columns blank // until the consist exists. Import keeps the allocation gate: nothing is // accepted from the customer before the wagons carry it. // // Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork // and never appears on this sheet — it is only the signal that EDR has taken // the cargo, which is what the customer's sheet attests to. const isDirectExport = booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN; const pendingWagons = wagons.length === 0; if (pendingWagons) { // Direct truck-to-train cargo never enters the warehouse, so there is no // GRN'd inventory to build the sheet from. Choosing direct handover is // itself the acceptance, so the sheet issues off the containers the // customer declared on the booking — freight.containers only gains rows at // allocation, by which point the wagon query above already serves. const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport ? await this.dataSource.query( `SELECT NULL::numeric AS "allocatedWeightTons", unit.container_number AS "containerNumbers", unit.seal_number AS "sealNumbers" FROM freight.booking_container_units unit JOIN freight.booking_container line ON line.id = unit.booking_container_id AND line.deleted_at IS NULL WHERE line.booking_id = $1 AND unit.deleted_at IS NULL ORDER BY unit.container_number`, [bookingId], ) : booking.tradeDirection === 'EXPORT' ? await this.dataSource.query( `SELECT inv.weight AS "allocatedWeightTons", c.container_number AS "containerNumbers" FROM freight.warehouse_inventory inv LEFT JOIN freight.containers c ON c.id = inv.container_id AND c.deleted_at IS NULL WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL AND COALESCE( NULLIF(TRIM(inv.grn_number), ''), substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') ) IS NOT NULL ORDER BY inv.created_at`, [bookingId], ) : []; // Bulk direct cargo has no containers — one line carrying the booking's // declared weight still makes a valid sheet. bulkTotalWeightTons only // holds the real tonnage for PER_ITEM break-bulk; everywhere else (PER_TON // bulk and every container booking) the VGM column is the weight. if (isDirectExport && receivedLines.length === 0) { const totalWeight = booking.bulkTotalWeightTons ?? booking.cargoTotalWeightVgm; receivedLines.push({ allocatedWeightTons: totalWeight == null ? null : String(totalWeight), containerNumbers: null, }); } if (receivedLines.length === 0) { throw new BadRequestException( booking.tradeDirection === 'EXPORT' ? 'This export booking has no GRN yet — receive the cargo at the warehouse before issuing the carriage acceptance sheet' : 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation', ); } wagons = receivedLines.map((row, index) => ({ sequenceNo: index + 1, wagonType: null, wagonNumber: null, tareWeightTons: null, equatedLength: null, loadCapacityTons: null, allocatedWeightTons: row.allocatedWeightTons, trainNumber: null, departureAt: null, marshalledAt: null, arrivalAt: null, containerNumbers: row.containerNumbers, sealNumbers: row.sealNumbers ?? null, })); } const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons, { pendingWagons }); const buffer = await this.pdfRender.htmlToPdfBuffer(html, { label: 'carriage acceptance sheet', fallback: (prepared) => buildTabularFallbackPdf(prepared), }); return { filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, buffer, }; } /** * Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same * join chain as the carriage acceptance sheet, but structured (containers as * an array per wagon, bulk load description when the wagon carries bulk). * Empty array until the booking has been allocated onto a train. */ async wagonAllocations(bookingId: string): Promise { return this.dataSource.query( `SELECT a.id AS "allocationId", tsw.sequence_no AS "sequenceNo", w.wagon_number AS "wagonNumber", COALESCE(wt.name, wt.code) AS "wagonType", wt.code AS "wagonTypeCode", wt.tare_weight_tons AS "tareWeightTons", tsw.capacity_tons AS "capacityTons", tsw.length_meters AS "lengthMeters", a.allocated_weight_tons AS "allocatedWeightTons", a.load_type AS "loadType", a.status AS "status", s.train_number AS "trainNumber", s.scheduled_departure_date AS "departureAt", so.label AS "originStation", sd.label AS "destinationStation", bl.cargo_description AS "bulkCargoDescription", bl.quantity AS "bulkQuantity", COALESCE( json_agg( json_build_object( 'containerNumber', ci.container_number, 'sealNumber', ci.seal_number, 'positionOnWagon', ci.position_on_wagon, 'grossWeightTons', ci.gross_weight_tons, 'sizeFt', cit.size_ft ) ORDER BY ci.position_on_wagon, ci.container_number ) FILTER (WHERE ci.id IS NOT NULL), '[]' ) AS "containers" FROM freight.wagon_booking_allocations a JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id LEFT JOIN freight.train_schedules s ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL LEFT JOIN freight.yards so ON so.id = s.origin_station_id LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id LEFT JOIN freight.wagon_allocation_bulk_loads bl ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons, s.train_number, s.scheduled_departure_date, so.label, sd.label, bl.cargo_description, bl.quantity ORDER BY tsw.sequence_no`, [bookingId], ); } /** * Split the booking amount across its wagons, proportional to allocated weight * (equal shares when no weights are recorded). The last row absorbs the rounding * remainder so the Price column always sums to the Total Amount on the sheet. */ private splitAmountAcrossWagons(total: number, weights: number[]): number[] { const sum = weights.reduce((acc, w) => acc + w, 0); const shares = weights.map((w) => Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100, ); const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100; shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100; return shares; } private buildCarriageAcceptanceSheetHtml( booking: Booking, wagons: CarriageAcceptanceWagonRow[], { pendingWagons }: { pendingWagons: boolean }, ): string { const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits); const money = (v: number) => v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'; // Container bookings carry no cargo type or free text — name the freight type // rather than printing a dash in the Cargo Name column. const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? booking.freightType ?? '-'; const currency = booking.paymentCurrency ?? 'ETB'; const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; const prices = this.splitAmountAcrossWagons( totalAmount, wagons.map((w) => Number(w.allocatedWeightTons) || 0), ); const header = wagons[0]; const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); const totals = wagons.reduce( (acc, w) => ({ tare: acc.tare + (Number(w.tareWeightTons) || 0), capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), load: acc.load + (Number(w.allocatedWeightTons) || 0), length: acc.length + (Number(w.equatedLength) || 0), }), { tare: 0, capacity: 0, load: 0, length: 0 }, ); // A wagon carrying no weight and no container is running empty under this booking. const fullWagons = wagons.filter( (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers), ).length; const rows = wagons .map( (w, i) => ` ${i + 1} ${esc(w.wagonType)} ${esc(w.wagonNumber)} ${num(w.tareWeightTons, 2)} ${num(w.equatedLength)} ${num(w.loadCapacityTons)} ${esc(arrivalStation)} ${esc(cargoName)} ${esc(departureStation)} ${esc(w.containerNumbers)} ${esc(w.sealNumbers)} ${money(prices[i])} `, ) .join(''); // The totals belong in , not : the Chromium-less fallback // renderer only parses tbody rows, so a silently drops every footer // figure from the printed sheet. const totalsRow = ` TOT ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'} ${ pendingWagons ? 'pending marshalling' : `full ${fullWagons} / empty ${wagons.length - fullWagons}` } ${num(totals.tare, 2)} ${num(totals.length)} ${num(totals.capacity)} Gross ${num(totals.tare + totals.load)} T ${money(totalAmount)} `; return ` Carriage Acceptance Sheet
Ethio-Djibouti Railway S.C.

Carriage Acceptance Sheet

Booking ${esc(booking.reference)} — ${esc(booking.tradeDirection)}
Sheet No. CAS-${esc(booking.reference)} Generated: ${esc(new Date().toLocaleString('en-GB'))}
Marshalled at${esc(header.marshalledAt ?? departureStation)}
Arrival at${esc(header.arrivalAt ?? arrivalStation)}
Date and time${esc(sheetDate.toLocaleString('en-GB'))}
Train No.${esc(header.trainNumber)}
Customer${esc(booking.company?.name)}
Cargo${esc(cargoName)}
${rows} ${totalsRow}
SN Type of Wagon Wagon No. Tare Weight Equated Length Load Capacity Arrival Station Cargo Name Departure Station Container No. Seal No. Price (${esc(currency)})
${ pendingWagons ? `The cargo listed above is accepted for carriage under booking ${esc(booking.reference)}. Wagon identity and seal numbers are filled in when the booking is marshalled onto a train.` : `The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}. Wagon identity, container and seal numbers must be verified against the physical consist before the sheet is signed.` }
Signed by — EDR operations / date
Signed by — customer or agent / date
Signed by — marshalling yard / date
`; } /** Resolve trade direction from yard countries; reject client mismatch. */ /** * An intercity corridor is valid when both yards are Ethiopian and at least * one non-retired route passes the origin strictly before the destination in * its milestone order — that is the corridor an import/export train can * serve the booking on. */ private async assertIntercityCorridorExists( originYardId: string, destinationYardId: string, ): Promise { const yards = await this.dataSource.getRepository(Yard).find({ where: { id: In([originYardId, destinationYardId]) }, }); if (yards.some((y) => y.country !== 'Ethiopia')) { throw new BadRequestException( 'Intercity bookings only run between Ethiopian yards', ); } const rows: Array<{ id: string }> = await this.dataSource.query( `SELECT r.id FROM freight.routes r JOIN freight.route_milestones mo ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL JOIN freight.route_milestones md ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL WHERE mo.sequence_no < md.sequence_no AND r.status = 'AVAILABLE' AND r.deleted_at IS NULL LIMIT 1`, [originYardId, destinationYardId], ); if (rows.length === 0) { throw new BadRequestException( 'No route passes through this origin and destination in order — intercity service is not available on this corridor', ); } } private async resolveTradeDirectionForBooking( originYardId: string, destinationYardId: string, provided?: string, ): Promise { const yards = await this.dataSource.getRepository(Yard).find({ where: { id: In([originYardId, destinationYardId]) }, }); const origin = yards.find((y) => y.id === originYardId); const destination = yards.find((y) => y.id === destinationYardId); if (!origin) { throw new BadRequestException(`Origin yard ${originYardId} not found`); } if (!destination) { throw new BadRequestException(`Destination yard ${destinationYardId} not found`); } if (originYardId === destinationYardId) { throw new BadRequestException('Origin and destination yards must differ'); } const expected = deriveTradeDirection(origin, destination); if (provided && provided !== expected) { throw new BadRequestException( `tradeDirection must be ${expected} for the selected yard pair (got ${provided})`, ); } return expected; } /** Generate a unique booking reference number. */ private async generateReference(): Promise { const year = new Date().getFullYear(); const seq = await this.bookingsRepository.maxReferenceSequence(year); return `BK-${year}-${String(seq + 1).padStart(6, '0')}`; } private buildCustomerTruckFreightOrderHtml( booking: Booking, trucks: Array<{ plateNumber: string; driverName: string; truckType: string; arrivedAt: string | null; containers: string | null; }>, extraCopies: string[] = [], ): string { const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const assignedAt = booking.customerTruckAssignedAt ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') : '-'; // Fall back to the legacy single-truck booking columns when there are no // multi-truck rows (bookings assigned before the multi-truck feature). const truckList = trucks.length > 0 ? trucks : booking.customerTruckPlateNumber ? [ { plateNumber: booking.customerTruckPlateNumber, driverName: booking.customerTruckDriverName ?? '', truckType: booking.customerTruckType ?? '', arrivedAt: booking.customerTruckArrivedAt ? String(booking.customerTruckArrivedAt) : null, containers: booking.customerTruckContainerNumber ?? null, }, ] : []; const truckRows = truckList .map( (t, i) => ` ${i + 1} ${esc(t.plateNumber)} ${esc(t.driverName)} ${esc(t.truckType)} ${esc(t.containers)} ${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'} `, ) .join(''); const copy = (watermark: string) => `
${esc(watermark)}
Ethio-Djibouti Railway S.C.

Freight Order

Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}
Booking ${esc(booking.reference)} Generated: ${esc(new Date().toLocaleString('en-GB'))}
Client${esc(booking.company?.name)}
Client ID${esc(booking.companyId)}
Trade direction${esc(booking.tradeDirection)}
Freight type${esc(booking.freightType)}
Assigned at${esc(assignedAt)}
Booking status${esc(booking.status)}
${truckRows}
# Truck plate Driver Truck type Containers loaded Arrival
Present this freight order at the warehouse gate. Each truck may only collect the containers listed against it; the handover must be signed before any truck leaves.
Customer / Carrier signature — date
Port operations verification — date
Gate security verification — date
`; return ` Freight Order ${copy('Copy 1: Port Operations Copy')} ${copy('Copy 2: Gate Security & Carrier Copy')} ${extraCopies.map((label) => copy(label)).join('')} `; } private escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** Build evaluation input from booking freight shape. */ /** * Whether a service type bundles customs clearance. This is the single source * of truth for a booking's `customsClearingEnabled` — the customer cannot * diverge from it, and it decides who clears the documents (GL vs Marketing). */ private async resolveIncludesCustoms(serviceTypeId: string): Promise { const serviceType = await this.dataSource .getRepository(ServiceType) .findOne({ where: { id: serviceTypeId } }); return serviceType?.includesCustoms ?? false; } private async buildEvalInput(dto: { freightType: FreightType; cargoTypeId?: string | null; serviceTypeId: string; paymentCurrency: string; tradeDirection: string; isHazardous?: boolean; isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; originYardId?: string | null; destinationYardId?: string | null; bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { const containerLines = dto.freightType === 'CONTAINER' ? dto.containers : []; const containers = await Promise.all( containerLines.map(async (c) => { const ct = await this.containerTypesService.findById(c.containerTypeId); const totalVgmTons = c.quantity * c.vgmPerUnitTons; return { containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, totalVgmTons, isReefer: ct.isReefer, wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt), }; }), ); const totalWagons = Math.ceil( containers.reduce((sum, c) => sum + c.wagonsRequired, 0), ); // Consolidation is system-managed: the CONSOLIDATION_ENABLED rule trigger // fires whenever a container line leaves a wagon partially filled. There is // no customer opt-in — partial-wagon cargo always consolidates. const allowConsolidation = dto.freightType === 'CONTAINER' ? await this.needsConsolidation(dto.containers) : false; return { freightType: dto.freightType, cargoTypeId: dto.cargoTypeId ?? null, serviceTypeId: dto.serviceTypeId, paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, // Bulk reefer comes from the customer toggle; container reefer is derived // from the container type and ORed in by the engine. isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, isGovernment: dto.isGovernment ?? false, allowConsolidation, shippingLineId: dto.shippingLineId, originYardId: dto.originYardId ?? null, destinationYardId: dto.destinationYardId ?? null, totalWagons, bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0, containers, }; } /** * True when any container line leaves a wagon partially filled (e.g. 1×20ft on * a 2-slot wagon). Partial-wagon cargo must consolidate before it can finalize; * cargo that already fills whole wagons never does. This is computed from the * container quantities alone — there is no customer-facing opt-in flag. */ private async needsConsolidation( containers: CreateBookingContainerDto[], ): Promise { return this.consolidationService.needsConsolidation( containers.map((c) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, })), ); } /** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */ private async tryAutoConsolidate(booking: Booking): Promise<{ booking: Booking; messages: string[]; }> { const messages: string[] = []; if (booking.consolidationPartnerId) { return { booking, messages }; } // Only partial-wagon container lines produce slots; full-wagon (and bulk) // bookings return none and need no consolidation. const slots = await this.consolidationService.slotsFromBooking(booking); if (slots.length === 0) { return { booking, messages }; } // H9: find + pair must be atomic. Run both inside one transaction where the // finder holds a write lock on the candidate partner row and pairing // re-asserts both rows are still unpaired before writing — otherwise two // concurrent bookings can claim the same partner (or pair an // already-paired booking). `didPair` is false when a concurrent flow won // the partner, in which case we fall through to parking below. const partner = await this.dataSource.transaction(async (manager) => { const candidate = await this.bookingsRepository.findConsolidationPartner( booking, slots, manager, ); if (!candidate) return null; const didPair = await this.bookingsRepository.pairConsolidationIfUnpaired( booking.id, candidate.id, manager, ); return didPair ? candidate : null; }); if (partner) { const paired = await this.findById(booking.id); messages.push( this.consolidationService.describePaired(partner.reference, slots), ); // Let deferred owners (e.g. contract drawdowns whose invoice/milestones // were held while the booking waited) finalize now that a whole wagon // exists. Fire-and-forget: a listener failure must not undo the pairing. this.events .emitAsync('booking.consolidation.paired', { bookingIds: [booking.id, partner.id], }) .catch(() => undefined); return { booking: paired, messages }; } // No partner yet — park the booking so it waits. Applies both pre-submit // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never // reach this method. if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') { await this.bookingsRepository.parkForConsolidation(booking.id); } const pending = await this.findById(booking.id); messages.push(this.consolidationService.describePending(pending, slots)); return { booking: pending, messages }; } /** * Run consolidation right after a booking reaches SUBMITTED. If a complementary * partner already exists, both are paired and moved (back) to SUBMITTED so staff * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and * waits for a later complementary booking to complete the wagon. * * Returns the re-fetched booking, so callers can reflect the resulting status * (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting). */ async runConsolidationOnSubmit(bookingId: string): Promise { const booking = await this.findById(bookingId); // Already paired (e.g. a partner submitted first) — nothing to do. if (booking.consolidationPartnerId) { return booking; } const result = await this.tryAutoConsolidate(booking); return result.booking; } /** Create a new freight booking. */ async create( dto: CreateBookingDto, files: Express.Multer.File[], userId?: string, ): Promise<{ booking: Booking; warnings: string[] }> { const warnings: string[] = []; // Contract–booking separation: contracts are no longer created through the // booking endpoint. Legacy GENERAL_CONTRACT creation is deprecated — clients // must use POST /contracts (and create shipments via POST /contracts/:id/bookings). if (dto.bookingType === 'GENERAL_CONTRACT') { throw new GoneException( 'General contracts are no longer created here. Use POST /contracts instead.', ); } // let customerId = dto.customerId; // if (!customerId) { // if (!userId) { // throw new BadRequestException( // 'customerId is required or must be resolvable from auth token', // ); // } // const customer = await this.customersService.findByUserId(userId); // customerId = customer.id; // } const isGovernment = dto.isGovernment === true; let companyId: string | null | undefined = dto.companyId; if (isGovernment) { // Government bookings bill to a real seeded government company + an // explicitly-chosen importer/exporter profile (no more null company + // free-text institution). if (!dto.companyId) { throw new BadRequestException('A government company is required for government bookings'); } const govCompany = await this.companiesService.findCompanyById(dto.companyId); if (govCompany.kind !== CompanyKind.Government) { throw new BadRequestException('Selected company is not a government entity'); } if (govCompany.status !== CompanyStatus.Active) { throw new BadRequestException('Selected government company is not active'); } if (!dto.companyProfileId) { throw new BadRequestException('A government company profile is required for government bookings'); } companyId = govCompany.id; } else if (!companyId) { if (!userId) { throw new BadRequestException( 'companyId is required or must be resolvable from auth token', ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); // A customer can only book once their company has been approved; the // helper names the real status (suspended/blacklisted) when it isn't. this.companiesService.assertCompanyActiveFor(company, 'bookings'); companyId = company.id; } if (dto.trainScheduleId) { // Staff manual pin: the schedule must be OPEN and on the same route. const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: dto.trainScheduleId } }); if (!schedule) { throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`); } if (schedule.bookingWindowStatus !== 'OPEN') { throw new BadRequestException('Selected schedule is no longer accepting bookings'); } // Corridor-aware: the booking's leg must lie on the schedule's route in // stop order — sub-corridor pins (Dire→Djibouti on an Addis→Djibouti // train) are valid. const stops = await this.trainSchedulingService.stopYardsForSchedule( schedule, ); const fromIdx = stops.indexOf(dto.originYardId); const toIdx = stops.indexOf(dto.destinationYardId); if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) { throw new BadRequestException('Selected schedule is not on the booking route'); } } else if (dto.scheduledDate) { // A real (binding) scheduledDate was supplied (e.g. staff pinning a day // directly). Require that the route has at least one OPEN departure on // that EAT day AND that some departure that day can physically carry the // cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get // a partial split offer later). The booking wizard does NOT send // scheduledDate at creation — it captures a non-binding // estimatedShipmentDate instead, and the binding day is chosen later at // the operation-request step. General contracts also skip this (each // drawdown order validates its own day). const day = eatDay(new Date(dto.scheduledDate)); const { hasDeparture, hasCompatible } = await this.trainSchedulingService.checkDayCargoCompatibility( dto.originYardId, dto.destinationYardId, day, { freightType: dto.freightType as 'CONTAINER' | 'BULK', cargoTypeId: dto.cargoTypeId, containerTypeIds: (dto.containers ?? []) .map((c) => c.containerTypeId) .filter((id): id is string => Boolean(id)), }, ); if (!hasDeparture) { throw new BadRequestException( 'No departures available on the selected day for this route', ); } if (!hasCompatible) { throw new BadRequestException( 'No wagon on the selected day can carry this cargo type — please choose another day', ); } } const containers = dto.containers ?? []; assertFreightShape({ freightType: dto.freightType, cargoTypeId: dto.cargoTypeId, containers, }); const tradeDirection = await this.resolveTradeDirectionForBooking( dto.originYardId, dto.destinationYardId, dto.tradeDirection, ); // Intercity (DOMESTIC) bookings never get their own train — they ride on a // passing import/export train, so there is no booking window and no date to // pin. All we require at creation is that the corridor actually lies on a // route (origin before destination in some route's milestone order); staff // accept the booking onto a concrete train at finalize time. if (tradeDirection === 'DOMESTIC') { if (dto.scheduledDate || dto.trainScheduleId) { throw new BadRequestException( 'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later', ); } await this.assertIntercityCorridorExists( dto.originYardId, dto.destinationYardId, ); } // Stamp the operational profile this booking belongs to (importer/exporter) // so the customer portal can scope lists/KPIs to the active mode. Best-effort // for non-government bookings with a resolved company; never blocks creation. let companyProfileId: string | null = null; if (dto.companyProfileId && companyId) { // Explicit profile pin (government booking, or staff booking on behalf): // must belong to the chosen company and be active. const profile = await this.companiesService.getActiveCompanyProfileForBooking( companyId, dto.companyProfileId, ); companyProfileId = profile.id; } else if (companyId) { // No explicit profile pin: resolve from the booking's trade direction // (import→importer, export→exporter; otherwise the first profile). A // forwarder booking sends dto.companyProfileId and takes the branch above. companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( companyId, tradeDirection, ); // A customer booking under their own account may only do so once the // resolved operational profile has been approved by the backoffice. Staff- // and government-initiated bookings (companyId supplied explicitly) bypass // this gate. const customerSelfBooking = !dto.companyId && !!userId; if (customerSelfBooking && companyProfileId) { await this.companiesService.assertCompanyProfileApprovedForBooking( companyProfileId, ); } } // Every booking must link to a company and a company profile. if (!companyId) { throw new BadRequestException('A company is required to create a booking'); } if (!companyProfileId) { throw new BadRequestException( 'A company profile is required to create a booking — none could be resolved for this company', ); } const needsConsolidation = dto.freightType === 'CONTAINER' ? await this.needsConsolidation(containers) : false; const evalInput = await this.buildEvalInput({ freightType: dto.freightType as FreightType, cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null, serviceTypeId: dto.serviceTypeId, paymentCurrency: dto.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous, isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, originYardId: dto.originYardId, destinationYardId: dto.destinationYardId, bulkTons: dto.cargoTotalWeightVgm, containers, }); const ruleResult = await this.ruleEngineService.evaluate(evalInput); this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); // Customs clearing is owned by the service type, not the customer: when the // service includes customs, EDR/GL clears it (no external agent); otherwise // the customer clears it themselves and may name their broker. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); // Explicit reference is caller-chosen (a collision is a real conflict); // auto-generated references retry past a concurrent same-sequence insert. const insertBooking = (reference: string) => this.bookingsRepository.create({ reference, companyId, companyProfileId, isGovernment, governmentInstitution: dto.governmentInstitution?.trim() || null, trainId: dto.trainId, trainScheduleId: dto.trainScheduleId ?? null, contractType: dto.contractType, serviceTypeId: dto.serviceTypeId, firstMilePickupAddress: dto.firstMilePickupAddress, firstMilePickupLat: dto.firstMilePickupLat ?? null, firstMilePickupLng: dto.firstMilePickupLng ?? null, lastMileDeliveryAddress: dto.lastMileDeliveryAddress, lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, customsClearingEnabled: includesCustoms, customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), equipmentReturn: dto.equipmentReturn, originYardId: dto.originYardId, destinationYardId: dto.destinationYardId, tradeDirection, freightType: dto.freightType, cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null, cargoFreeText: dto.cargoFreeText, shippingLineId: dto.shippingLineId, cargoTotalWeightVgm: dto.cargoTotalWeightVgm, // Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK. bulkTotalWeightTons: dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null, isHazardous: dto.isHazardous ?? false, // Bulk reefer is the customer's toggle; container reefer is derived from // the container type at pricing time, so the booking-level flag stays off // for container freight to avoid double-counting. isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, // Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container // freight tracks this per line, so these are 0 for CONTAINER. bulkHazardousQuantity: dto.freightType === 'BULK' ? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm) : 0, bulkReeferQuantity: dto.freightType === 'BULK' ? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm) : 0, paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, estimatedShipmentDate: dto.estimatedShipmentDate ? new Date(dto.estimatedShipmentDate) : null, startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', priorityScore: ruleResult.priorityScore, totalAmount: 0, paymentStatus: 'PENDING', }); const booking = dto.reference ? await insertBooking(dto.reference) : await insertWithGeneratedReference( () => this.generateReference(), insertBooking, ); if (dto.freightType === 'CONTAINER') { await this.bookingsRepository.createContainers( booking.id, containers.map((c, i) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, hazardousQuantity: c.hazardousQuantity, reeferQuantity: c.reeferQuantity, containerNumbers: c.containerNumbers, weightResult: ruleResult.containerWeightResults[i], })), ); const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); warnings.push(`Estimated wagons required: ${wagonCount}`); } if (files.length > 0) { try { await this.filesService.uploadMany(booking.id, 'bookings', files); } catch { warnings.push('File upload failed — booking was created without attached files.'); } } // Reuse the booking profile's onboarding documents instead of asking the // customer to re-upload. Snapshot them onto the booking now (by reference), // so a later active-profile switch never changes this booking's documents. // // Skip this when the customer uploaded documents for this booking — those // per-booking files take precedence, so auto-attaching the profile snapshots // would create duplicates. if (companyProfileId && files.length === 0) { try { const onboardingFiles = await this.companiesService.getProfileOnboardingFiles(companyProfileId); if (onboardingFiles.length > 0) { await this.filesService.attachExistingFiles( booking.id, 'bookings', onboardingFiles.map((f, i) => ({ code: `onboarding_document_${i + 1}`, name: f.name, url: f.url, size: f.size, mimeType: f.mimeType, })), ); } } catch { warnings.push( 'Could not attach onboarding documents — they can be added from the booking page.', ); } } let full = await this.findById(booking.id); if (needsConsolidation) { const consolidation = await this.tryAutoConsolidate(full); full = consolidation.booking; warnings.push(...consolidation.messages); } // Government bookings pass every customer step at creation: the server // expedites them to PAID/Eligible, generates the contract (signable at any // time) and queues priority placement. Best-effort — the booking row is // already inserted, so a late failure must not 500 the whole create; the // idempotent expedite endpoint remains the retry path. if (isGovernment) { try { full = await this.governmentExpedite(booking.id, userId ?? 'system'); } catch (err) { warnings.push( `Government expedite incomplete — retry via the expedite action: ${(err as Error).message}`, ); } } return { booking: full, warnings }; } /** Update a draft booking. */ async update( id: string, dto: UpdateBookingDto, files: Express.Multer.File[], ): Promise<{ booking: Booking; warnings: string[] }> { const existing = await this.findById(id); if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { throw new BadRequestException( 'Only DRAFT or CHANGES_REQUESTED bookings can be updated', ); } const warnings: string[] = []; const freightType = (dto.freightType ?? existing.freightType) as FreightType; let containers = dto.containers ?? (existing.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) .map((bc) => ({ containerTypeId: bc.containerTypeId, quantity: bc.quantity, vgmPerUnitTons: Number(bc.vgmPerUnitTons), })); let cargoTypeId = dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; if (freightType === 'BULK') { containers = []; if (dto.containers !== undefined) { await this.bookingsRepository.deleteContainers(id); } } else { cargoTypeId = null; if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) { throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight'); } } assertFreightShape({ freightType, cargoTypeId, containers }); const originYardId = dto.originYardId ?? existing.originYardId; const destinationYardId = dto.destinationYardId ?? existing.destinationYardId; const tradeDirection = await this.resolveTradeDirectionForBooking( originYardId, destinationYardId, dto.tradeDirection, ); const needsConsolidation = freightType === 'CONTAINER' ? await this.needsConsolidation(containers) : false; const evalInput = await this.buildEvalInput({ freightType, cargoTypeId, serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, originYardId: dto.originYardId ?? existing.originYardId, destinationYardId: dto.destinationYardId ?? existing.destinationYardId, bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0), containers, }); const ruleResult = await this.ruleEngineService.evaluate(evalInput); this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); const pricingFieldsChanged = await this.pricingRelevantFieldsChanged( existing, dto, freightType, cargoTypeId, containers, ); const cargoAmount = dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0); const updates: Record = { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, // Break-bulk actual tonnage; cleared when the booking leaves BULK. bulkTotalWeightTons: freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? existing.bulkTotalWeightTons ?? null) : null, // Booking-level reefer is only meaningful for bulk; container reefer is // derived from the container type at pricing time. isReefer: freightType === 'BULK' ? (dto.isReefer ?? existing.isReefer ?? false) : false, // Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for // container freight (per-line on the containers instead). bulkHazardousQuantity: freightType === 'BULK' ? clampToCargo( dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0), cargoAmount, ) : 0, bulkReeferQuantity: freightType === 'BULK' ? clampToCargo( dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0), cargoAmount, ) : 0, priorityScore: ruleResult.priorityScore, tradeDirection, }; // If the route (hence trade direction) changed, re-stamp the operational // profile so an edited draft doesn't get stranded under the wrong profile. if ( tradeDirection !== existing.tradeDirection && !existing.isGovernment && existing.companyId ) { updates.companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( existing.companyId, tradeDirection, ); } // Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED // booking) must obey the same gate as creation: the route needs an OPEN // departure on that EAT day that can carry the cargo. Skipped when the day // didn't change, for general contracts (period-based, no pinned day) and // for intercity (staff assign a passing train later). if (dto.scheduledDate) { const day = eatDay(new Date(dto.scheduledDate)); const dayChanged = !existing.scheduledDate || eatDay(existing.scheduledDate) !== day; if ( dayChanged && existing.bookingType !== 'GENERAL_CONTRACT' && tradeDirection !== 'DOMESTIC' ) { const { hasDeparture, hasCompatible } = await this.trainSchedulingService.checkDayCargoCompatibility( originYardId, destinationYardId, day, { freightType: freightType as 'CONTAINER' | 'BULK', cargoTypeId, containerTypeIds: containers .map((c) => c.containerTypeId) .filter((cid): cid is string => Boolean(cid)), }, ); if (!hasDeparture) { throw new BadRequestException( 'No departures available on the selected day for this route', ); } if (!hasCompatible) { throw new BadRequestException( 'No wagon on the selected day can carry this cargo type — please choose another day', ); } } updates.scheduledDate = new Date(dto.scheduledDate); } if (dto.estimatedShipmentDate) updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.endDate) updates.endDate = new Date(dto.endDate); delete updates.containers; // Customs clearing always mirrors the (possibly changed) service type — never // the client payload — so it can't diverge from the service's customs scope. const includesCustoms = await this.resolveIncludesCustoms( dto.serviceTypeId ?? existing.serviceTypeId, ); updates.customsClearingEnabled = includesCustoms; updates.customsClearingAgent = includesCustoms ? null : (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null); await this.bookingsRepository.update(id, updates); if (freightType === 'CONTAINER' && dto.containers) { await this.bookingsRepository.deleteContainers(id); await this.bookingsRepository.createContainers( id, dto.containers.map((c, i) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, hazardousQuantity: c.hazardousQuantity, reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); } if (pricingFieldsChanged) { await this.bookingsRepository.invalidatePricingPreview(id); } if (files.length > 0) { await this.filesService.uploadMany(id, 'bookings', files); } let booking = await this.findById(id); if (needsConsolidation && !booking.consolidationPartnerId) { const consolidation = await this.tryAutoConsolidate(booking); booking = consolidation.booking; warnings.push(...consolidation.messages); } return { booking, warnings }; } /** Parse comma-separated scheduling status query values. */ private parseSchedulingStatusFilter(filter: FilterBookingDto): { schedulingStatuses?: string[]; } { const raw = filter.schedulingStatuses; if (!raw) return {}; const schedulingStatuses = raw .split(',') .map((s) => s.trim()) .filter(Boolean); return schedulingStatuses.length ? { schedulingStatuses } : {}; } /** Parse comma-separated or repeated status query values. */ private parseStatusFilter(filter: FilterBookingDto): { statuses?: string[]; status?: string; } { const allowed = new Set(BOOKING_STATUSES); const raw = filter.statuses; const statusList = raw ? raw .split(',') .map((s) => s.trim()) .filter((s) => allowed.has(s)) : []; if (statusList.length > 0) { return { statuses: statusList }; } if (filter.status && allowed.has(filter.status)) { return { status: filter.status }; } return {}; } /** Return a paginated list of bookings matching the filter. */ /** * Whether a route has at least one OPEN train departure on the given EAT day. * Used to validate the binding shipment day chosen at the operation-request * step (only days with a schedule are selectable). */ async hasOpenDepartureOnDay( originYardId: string, destinationYardId: string, day: string, ): Promise { return this.trainSchedulingService.existsOpenScheduleOnRouteDay( originYardId, destinationYardId, day, ); } /** Cargo identity of a booking for the wagon-TYPE compatibility gate. */ private cargoIdentityOf(booking: Booking): { freightType: 'CONTAINER' | 'BULK'; cargoTypeId?: string | null; containerTypeIds?: string[]; } { return { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId ?? null, containerTypeIds: (booking.bookingContainers ?? []) .map((line) => line.containerTypeId) .filter((id): id is string => Boolean(id)), }; } /** * Day gate for a specific booking: OPEN departure exists AND some departure * that day can physically carry the booking's cargo/container type. * Quantity never blocks — oversized bookings get a partial split offer. */ async checkDayCompatibilityForBooking( booking: Booking, day: string, ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> { return this.trainSchedulingService.checkDayCargoCompatibility( booking.originYardId, booking.destinationYardId, day, this.cargoIdentityOf(booking), ); } /** * Days the customer may pick for THIS booking (operation-request step): * cargo-aware — only days whose departures can carry the booking's cargo * type. Returns days only, no capacity counts. */ async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> { const booking = await this.findById(bookingId); return this.trainSchedulingService.getAvailableDaysForCargo({ originYardId: booking.originYardId, destinationYardId: booking.destinationYardId, ...this.cargoIdentityOf(booking), }); } /** * Batched version of the findById flag: marks each page item whose booking * has a generated-but-unsigned handover (self-haul or EDR last-mile), so list * rows (portal dashboard) can show "Approve delivery" for exactly the * generated→signed window. One query for the whole page. */ private async attachHandoverFlags(bookings: Booking[]): Promise { const ids = bookings.map((b) => b.id); if (!ids.length) return; const rows: Array<{ bookingId: string }> = await this.dataSource.query( `SELECT DISTINCT booking_id AS "bookingId" FROM freight.booking_handovers WHERE booking_id = ANY($1::uuid[]) AND signed_at IS NULL AND deleted_at IS NULL`, [ids], ); const pending = new Set(rows.map((r) => r.bookingId)); for (const b of bookings) { (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = pending.has(b.id); } this.attachPaymentDrainEnds(bookings); await this.attachShippingLineCompanies(bookings); } /** * Batched name lookup for shipping-line-owned bookings (`companyId` null, * `shippingLineCompanyId` set). No relation on the entity — the shipping-line * module sits above bookings — so a raw query keyed off the loaded ids fills * `shippingLineCompany` the way `company` is filled for customers. */ private async attachShippingLineCompanies(bookings: Booking[]): Promise { const ids = [ ...new Set( bookings .map((b) => b.shippingLineCompanyId) .filter((id): id is string => id != null), ), ]; if (!ids.length) return; const rows: Array<{ id: string; name: string; email: string | null; phoneNumber: string | null }> = await this.dataSource.query( `SELECT id, name, email, phone_number AS "phoneNumber" FROM freight.shipping_line_companies WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, [ids], ); const byId = new Map(rows.map((r) => [r.id, r])); for (const b of bookings) { const line = b.shippingLineCompanyId ? byId.get(b.shippingLineCompanyId) : undefined; if (line) { (b as Booking & { shippingLineCompany?: typeof line }).shippingLineCompany = line; } } } /** * Derived, no query: end of the settlement drain tail after `paymentDeadline`. * The portal hides "Pay now" between the deadline and this instant — a payment * started just before the buzzer is still settling, so offering to pay again * would invite a double payment. */ private attachPaymentDrainEnds(bookings: Booking[]): void { for (const b of bookings) { (b as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = paymentDrainEndsAtIso(b.paymentDeadline); } } async findAll( filter: FilterBookingDto, forceCompanyId?: string, forceCompanyProfileId?: string, tradeDirections?: string[], ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const statusFilter = this.parseStatusFilter(filter); const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter); const result = await this.bookingsRepository.findAllPaginated({ page, pageSize, ...statusFilter, ...schedulingStatusFilter, assignedToSchedule: filter.assignedToSchedule, // A forced company scope (portal/customer) overrides any caller-provided // companyId so a customer can only ever see their own company's bookings. // The company guard always applies; the optional companyProfileId filter // (from the per-page service filter) narrows WITHIN the company — the repo // ANDs both, so cross-company access is impossible. companyId: forceCompanyId ?? filter.companyId, companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, contractId: filter.contractId, tradeDirections, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, scheduledFrom: filter.scheduledFrom, scheduledTo: filter.scheduledTo, originYardId: filter.originYardId, destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, customerKind: filter.customerKind, consolidationPaired: filter.consolidationPaired, // DTO carries 'true'/'false' strings (query params); the repo option is a // real boolean — convert, preserving "not filtered" when absent. customsClearingEnabled: filter.customsClearingEnabled === undefined ? undefined : filter.customsClearingEnabled === 'true', search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); await this.attachHandoverFlags(result.items ?? []); return result; } /** Booking statuses at which a customer can pay (mirrors booking-payment.service). */ private static readonly PAYABLE_STATUSES = [ 'FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', ]; /** * Booking statuses that belong to the customs document-clearance queue. The * Global Logistics role is scoped to ONLY these — it never sees the general * booking-request list. */ private static readonly CLEARANCE_STATUSES = [ 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY', ]; /** * List bookings in the customs document-clearance queue. Used by Global * Logistics (clearance:view) which has no general bookings:view — so the * status set is force-scoped to clearance statuses and can't be widened to * arbitrary bookings by a caller-supplied status filter. */ async findClearanceQueue( filter: FilterBookingDto, ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 100; // Honour a caller status filter only if it's within the clearance set; // otherwise fall back to the full clearance status list. const requested = filter.status; const statuses = requested && BookingsService.CLEARANCE_STATUSES.includes(requested) ? [requested] : BookingsService.CLEARANCE_STATUSES; return this.bookingsRepository.findAllPaginated({ page, pageSize, statuses, // Global Logistics only clears customs bookings; non-customs clearance is // reviewed by Marketing from the booking detail, not this queue. customsClearingEnabled: true, search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** * List the current customer's bookings that are ready for payment: * payable status AND not yet PAID. Company scope is derived from the * authenticated user and cannot be widened by the caller. */ async findMyPayable( userId: string, filter: FilterBookingDto, ): Promise { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); return this.bookingsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 20, statuses: BookingsService.PAYABLE_STATUSES, excludePaymentStatus: 'PAID', // Company-wide: payables span all of the customer's services. companyId: company.id, companyProfileId: filter.companyProfileId, search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** * Resolve the company a customer user belongs to, for scoping their own * bookings. Returns null when no profile/company is linked yet. */ async resolveCustomerCompanyId(userId: string): Promise { try { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); return company?.id ?? null; } catch { return null; } } /** * Authorize a customer's access to a single booking. Staff are scoped at the * controller (they pass `isStaff`); for a customer, the booking must belong * to the company the authenticated user is linked to — otherwise it is hidden * behind a NotFound so booking IDs can't be probed. */ async assertCustomerCanAccessBooking( userId: string | undefined, booking: Booking, ): Promise { if (!userId) { throw new ForbiddenException('Authentication required'); } const companyId = await this.resolveCustomerCompanyId(userId); if (!companyId || booking.companyId !== companyId) { // Don't reveal that the booking exists for another company. throw new NotFoundException(`Booking ${booking.id} not found`); } } /** * Build the customer-facing shipment tracking payload for a booking from the * train schedule it is assigned to and the live checkpoint log. The caller is * responsible for authorizing access to the booking first. * * When the booking has not been assigned to a train yet, returns a valid * "no schedule" payload so the UI can show a pre-dispatch state. */ async getBookingTracking( bookingId: string, ): Promise { const booking = await this.findById(bookingId); const journey = { bookingStatus: booking.status ?? null, bookingOriginYardId: booking.originYardId ?? null, bookingDestinationYardId: booking.destinationYardId ?? null, loadedAt: booking.loadedAt ? new Date(booking.loadedAt).toISOString() : null, arrivedAt: booking.arrivedAt ? new Date(booking.arrivedAt).toISOString() : null, }; const empty: Freight.IBookingTracking = { bookingId: booking.id, bookingReference: booking.reference, hasSchedule: false, scheduleId: null, trainNumber: null, scheduleStatus: null, direction: null, origin: null, destination: null, stations: [], checkpoints: [], currentSequenceNo: -1, actualDepartureAt: null, actualArrivalAt: null, scheduledDepartureAt: null, scheduledArrivalAt: null, ...journey, }; if (!booking.trainScheduleId) { return empty; } // Pull the live corridor + checkpoints for the assigned schedule. If the // schedule was removed, fall back to the pre-dispatch state rather than 500. let track: Awaited< ReturnType >; try { track = await this.trainSchedulingService.getScheduleCheckpoints( booking.trainScheduleId, ); } catch { return empty; } return { bookingId: booking.id, bookingReference: booking.reference, hasSchedule: true, scheduleId: track.scheduleId, trainNumber: track.trainNumber, scheduleStatus: track.status as Freight.TrainScheduleStatus, direction: track.direction, origin: track.origin, destination: track.destination, stations: track.stations, checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[], currentSequenceNo: track.currentSequenceNo, actualDepartureAt: track.actualDepartureAt, actualArrivalAt: track.actualArrivalAt, scheduledDepartureAt: track.scheduledDepartureAt, scheduledArrivalAt: track.scheduledArrivalAt, ...journey, }; } /** Aggregate metrics and tab counts for the backoffice booking list. */ async getListSummary(filter: FilterBookingDto): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const statusFilter = this.parseStatusFilter(filter); const listFilter = { ...statusFilter, companyId: filter.companyId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, scheduledFrom: filter.scheduledFrom, scheduledTo: filter.scheduledTo, originYardId: filter.originYardId, destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, customerKind: filter.customerKind, consolidationPaired: filter.consolidationPaired, }; const [statusCounts, facets, metrics] = await Promise.all([ this.bookingsRepository.getStatusCounts(), this.bookingsRepository.getFacets(listFilter), this.bookingsRepository.getListSummaryMetrics({ ...listFilter, page, pageSize, needsActionStatuses: NEEDS_ACTION_STATUSES, urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD, }), ]); return { metrics, // Tabs stay unfiltered (whole-set) on purpose — see the DTO comment. tabs: mapStatusCountsToTabs(statusCounts), facets, }; } /** Get a single booking by ID with files. */ /** * EXPORT only. Choose how the cargo reaches the train. DIRECT_TO_TRAIN takes * the booking out of the warehouse flow entirely — no receipt, no GRN, and the * carriage acceptance sheet becomes issuable straight away. * * Switching to direct is refused once the goods are already in the shed: * inventory exists, so the cargo demonstrably went the warehouse route and its * GRN paperwork must stand. */ async setExportHandoverMode( bookingId: string, mode: string, ): Promise<{ bookingId: string; exportHandoverMode: string }> { const booking = await this.bookingsRepository.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking ${bookingId} not found`); } if ((booking.tradeDirection ?? '').toUpperCase() !== 'EXPORT') { throw new BadRequestException('Handover mode applies to export bookings only'); } if (mode === DIRECT_TO_TRAIN) { const [stored]: Array<{ one: number }> = await this.dataSource.query( `SELECT 1 AS one FROM freight.warehouse_inventory WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`, [bookingId], ); if (stored) { throw new BadRequestException( 'This booking already has cargo in the warehouse, so it cannot be switched to direct truck-to-train', ); } } await this.dataSource.query( `UPDATE freight.bookings SET export_handover_mode = $2, updated_at = NOW() WHERE id = $1`, [bookingId, mode], ); return { bookingId, exportHandoverMode: mode }; } async findById(id: string): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { throw new NotFoundException(`Booking ${id} not found`); } // Nearly every booking flow loads the booking through here, so this one // call puts the human-searchable reference + entry state on the request log // line for all of them. Entry state only — the write trail (statusChanges) // shows where it ended up. logCtx( { id: booking.id, reference: booking.reference, statusAtEntry: booking.status, companyId: booking.companyId, contractId: booking.contractId ?? undefined, }, { path: "booking" }, ); await this.attachShippingLineCompanies([booking]); if (booking.files && booking.files.length > 0) { booking.files = await Promise.all( booking.files.map(async (file: FileRecord) => { const objectName = this.minioService.getObjectNameFromUrl(file.url); const signedUrl = await this.minioService.getSignedUrl(objectName, 300); return { ...file, signedUrl }; }), ); } // Surface the parent contract's reference for drawdown bookings — the // portal detail header shows it (the entity has no contract relation, so // the list attaches it via a raw join and the detail attaches it here). if (booking.contractId) { const contract = await this.dataSource.getRepository(Contract).findOne({ where: { id: booking.contractId }, select: { reference: true }, }); (booking as Booking & { contractReference?: string | null }).contractReference = contract?.reference ?? null; } // Surface the assigned train's operational status so the portal stepper // can show the Arrival stage: the booking status stays IN_TRANSIT from // dispatch until delivery, so arrival is only knowable from the schedule. // The allocated train, or — before the batch engine has allocated one — the // train the customer picked at day-commit. Staff reviewing an operation // request (OPERATION_REQUEST_PENDING) must see which train they are // accepting onto before they approve, and at that point only the requested // id is set. const summarySourceId = booking.trainScheduleId ?? booking.requestedTrainScheduleId; if (summarySourceId) { const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: summarySourceId } }); // trainScheduleStatus drives the portal's Arrival stage, so it stays tied // to a real allocation — a merely requested train has not departed. (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = booking.trainScheduleId ? (schedule?.status ?? null) : null; // Backoffice staff view: the train's identity and clock, so the detail // page can state which train the booking rides and when it runs without a // second round-trip to the schedules API. ( booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null } ).trainScheduleSummary = schedule ? { id: schedule.id, reference: schedule.reference ?? null, trainNumber: schedule.trainNumber ?? null, status: schedule.status ?? null, scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null, actualDepartureAt: schedule.actualDepartureAt?.toISOString() ?? null, actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null, windowPhase: schedule.windowPhase ?? null, paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, isRequested: !booking.trainScheduleId, } : null; } // End of this booking's own pay window including the settlement drain tail — // the deadline staff should quote, since a payment landing inside the drain // still counts (see paymentDrainEndsAtIso). (booking as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = paymentDrainEndsAtIso(booking.paymentDeadline); // A generated-but-unsigned handover means the customer must approve delivery // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: // per delivering truck (generated on truck exit), signed one by one. const [pendingHandover] = await this.dataSource.query( `SELECT 1 FROM freight.booking_handovers WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL LIMIT 1`, [id], ); (booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = Boolean(pendingHandover); return booking; } async findByReference(reference: string): Promise { const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); if (!booking) { throw new NotFoundException(`Booking with reference "${reference}" not found`); } return this.findById(booking.id); } /** * Upload documents for a DRAFT booking — or for a booking created by * rebooking a wagon-cancellation credit, whose paperwork may have changed * with the new containers (old documents stay; new ones ride alongside). */ async uploadDocuments( id: string, files: Express.Multer.File[], ): Promise { const booking = await this.findById(id); if (booking.status !== 'DRAFT') { const rebooked = await this.dataSource .getRepository(BookingWagonCancellation) .findOne({ where: { rebookedBookingId: id } }); if (!rebooked) { throw new BadRequestException( 'Documents can only be uploaded for DRAFT bookings', ); } } await this.filesService.uploadMany(id, 'bookings', files); return this.findById(id); } async remove(id: string): Promise { const booking = await this.findById(id); if (booking.status !== 'DRAFT') { throw new BadRequestException('Only DRAFT bookings can be deleted'); } await this.bookingsRepository.softDelete(id); } async findQueue( queue: string, filter: FilterBookingDto, options?: { excludeBulk?: boolean }, ): Promise<{ items: Booking[]; total: number }> { const statusMap: Record = { intake: 'SUBMITTED', approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'], contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], marketing: 'SIGNED_CUSTOMER', finance: 'FULLY_EXECUTED', }; const status = statusMap[queue]; if (!status) { throw new BadRequestException(`Unknown queue: ${queue}`); } return this.bookingsRepository.findQueue({ status, page: filter.page, pageSize: filter.pageSize, excludeBulk: options?.excludeBulk ?? queue === 'approval', sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } async requestConsolidation(id: string): Promise<{ booking: Booking; partner: Booking | null; paired: boolean; message: string; }> { const booking = await this.findById(id); const needs = await this.consolidationService.needsConsolidationFromBooking( booking, ); if (!needs) { throw new BadRequestException( 'Booking already fills whole wagon(s) for all container lines; consolidation is not required', ); } if (booking.consolidationPartnerId) { throw new ConflictException('Booking is already paired for consolidation'); } const result = await this.tryAutoConsolidate(booking); const partner = result.booking.consolidationPartnerId ? await this.findById(result.booking.consolidationPartnerId) : null; return { booking: result.booking, partner, paired: partner !== null, message: result.messages[0] ?? '', }; } async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> { const booking = await this.findById(id); if (!booking.consolidationPartnerId) { throw new BadRequestException('Booking has no consolidation partner'); } const partnerId = booking.consolidationPartnerId; await this.bookingsRepository.unpairConsolidation(id, partnerId); return { booking: await this.findById(id), partner: await this.findById(partnerId), }; } async getConsolidationDetails(id: string): Promise<{ booking: Booking; partner: Booking | null; splitBilling: { bookingShare: number; partnerShare: number } | null; wagonSlots: Awaited>; statusMessage: string; }> { const booking = await this.findById(id); const wagonSlots = await this.consolidationService.slotsFromBooking(booking); if (!booking.consolidationPartnerId) { const statusMessage = booking.status === 'PENDING_CONSOLIDATION' ? this.consolidationService.describePending(booking, wagonSlots) : wagonSlots.length > 0 ? 'Consolidation may be required; no partner paired yet.' : 'No wagon consolidation needed.'; return { booking, partner: null, splitBilling: null, wagonSlots, statusMessage, }; } const partner = await this.findById(booking.consolidationPartnerId); return { booking, partner, splitBilling: { bookingShare: Number(booking.totalAmount), partnerShare: Number(partner.totalAmount), }, wagonSlots, statusMessage: this.consolidationService.describePaired( partner.reference, wagonSlots, ), }; } private async pricingRelevantFieldsChanged( existing: Booking, dto: UpdateBookingDto, freightType: FreightType, cargoTypeId: string | null | undefined, containers: CreateBookingContainerDto[], ): Promise { if (dto.freightType !== undefined && dto.freightType !== existing.freightType) { return true; } if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) { return true; } if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) { return true; } if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) { return true; } if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) { return true; } if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) { return true; } // Container lines drive both the base price and the consolidation surcharge // (CONSOLIDATION_ENABLED fires on partial wagons), so any line change re-prices. if (dto.containers !== undefined) { const existingContainers = (existing.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) .map((bc) => ({ containerTypeId: bc.containerTypeId, quantity: bc.quantity, vgmPerUnitTons: Number(bc.vgmPerUnitTons), })); if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) { return true; } } if ( freightType !== existing.freightType || (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ) { return true; } return false; } /** * Expedite a government booking past every customer step: PAID + Eligible * (no commercial hold, no payment), contract generated server-side (signable * at any time), and the (route, day) fill kicked immediately so it grabs a * seat on any open train — government-first, preempting commercial cargo if * the day is full. Runs automatically at creation; the endpoint remains as a * no-op-safe retry for older bookings. */ async governmentExpedite(id: string, staffUserId: string): Promise { const booking = await this.findById(id); if (!booking.isGovernment) { throw new BadRequestException('Only government bookings can be expedited'); } // Idempotent: create() already expedites — a repeat call changes nothing. if (booking.status === 'PAID') return booking; const blocked = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED']; if (blocked.includes(booking.status)) { throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`); } await this.bookingsRepository.update(id, { status: 'PAID', paymentStatus: 'PAID', schedulingStatus: SchedulingStatus.Eligible, holdStartedAt: null, holdExpiresAt: null, }); await this.bookingContractService.generateContractForGovernment(id); await this.bookingsRepository.createReviewNote( id, `Government booking expedited to PAID by staff (${staffUserId})`, 'STAFF_NOTE', staffUserId, ); // Priority placement: run the day-level fill now instead of waiting for a // batch tick — the pool sorts government first and preempts if needed. if (booking.scheduledDate) { this.bookingBatchService.enqueueRouteDayProcessing( booking.originYardId, booking.destinationYardId, eatDay(booking.scheduledDate), ); } return this.findById(id); } async findCustomerBookings(companyId: string): Promise<{ id: string; reference: string; status: string; tradeDirection: string; freightType: string; originLabel: string; destinationLabel: string; totalAmount: number; currency: string; scheduledDate: Date | null; createdAt: Date; }[]> { const { items } = await this.bookingsRepository.findAllPaginated({ page: 1, pageSize: 500, companyId, }); return items.map((b) => ({ id: b.id, reference: b.reference, status: b.status, tradeDirection: b.tradeDirection, freightType: b.freightType, originLabel: b.originYard?.label ?? '', destinationLabel: b.destinationYard?.label ?? '', totalAmount: Number(b.totalAmount), currency: b.paymentCurrency, scheduledDate: b.scheduledDate ?? null, createdAt: b.createdAt, })); } async allocateContainers( bookingId: string, allocations: Array<{ containerId: string; vehicleId: string }>, ) { const booking = await this.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking ${bookingId} not found`); } const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, { where: { bookingId, containerId: In(allocations.map((a) => a.containerId)), }, }); const previousVehicleIds = previousAllocations .map((a) => a.vehicleId) .filter((id): id is string => Boolean(id)); await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(BookingContainerAllocation, { bookingId, containerId: allocation.containerId, }); await manager.insert(BookingContainerAllocation, { bookingId, containerId: allocation.containerId, vehicleId: allocation.vehicleId, containerType: 'CONTAINER', quantity: 1, }); } }); const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); await Promise.all( [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY), ), ); await this.vehiclesService.releaseIfUnused( previousVehicleIds.filter((id) => !vehicleIds.has(id)), ); return { success: true, allocated: allocations.length, }; } }