Files
edr-platform/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
2026-07-17 09:16:35 +00:00

1914 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import {
BadRequestException,
ConflictException,
ForbiddenException,
forwardRef,
GoneException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/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 { 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 { 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 { 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';
/** 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;
};
}
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,
) {}
async assignCustomerTruck(
bookingId: string,
dto: CustomerTruckAssignmentDto,
): Promise<Booking> {
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);
}
async customerTruckFreightOrderCopies(
bookingId: string,
): 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],
);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
// 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,
};
}
/** 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<void> {
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<string> {
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<string> {
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;
}>,
): 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) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(t.plateNumber)}</td>
<td>${esc(t.driverName)}</td>
<td>${esc(t.truckType)}</td>
<td>${esc(t.containers)}</td>
<td>${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'}</td>
</tr>`,
)
.join('');
const copy = (watermark: string) => `
<section class="copy">
<div class="watermark">${esc(watermark)}</div>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Freight Order</h1>
<div class="subtitle">Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</div>
</div>
<div class="meta">
Booking
<strong>${esc(booking.reference)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Client</span><strong>${esc(booking.company?.name)}</strong></div>
<div class="tile"><span>Client ID</span><strong>${esc(booking.companyId)}</strong></div>
<div class="tile"><span>Trade direction</span><strong>${esc(booking.tradeDirection)}</strong></div>
<div class="tile"><span>Freight type</span><strong>${esc(booking.freightType)}</strong></div>
<div class="tile"><span>Assigned at</span><strong>${esc(assignedAt)}</strong></div>
<div class="tile"><span>Booking status</span><strong>${esc(booking.status)}</strong></div>
</div>
<table>
<thead>
<tr>
<th class="num">#</th>
<th>Truck plate</th>
<th>Driver</th>
<th>Truck type</th>
<th>Containers loaded</th>
<th>Arrival</th>
</tr>
</thead>
<tbody>${truckRows}</tbody>
</table>
<div class="notice">
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.
</div>
<div class="signatures">
<div class="line">Customer / Carrier signature — date</div>
<div class="line">Port operations verification — date</div>
<div class="line">Gate security verification — date</div>
</div>
</section>`;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Freight Order</title>
<style>
@page { size: A4 portrait; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.copy { position: relative; padding: 24px 28px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; color: rgba(15, 23, 42, 0.07); transform: rotate(-18deg); pointer-events: none; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.subtitle { margin-top: 4px; color: #64748b; font-size: 12px; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin: 4px 0; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 14px 0; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 48px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 6px 7px; font-size: 10.5px; vertical-align: top; }
.num { text-align: right; width: 26px; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 30px; position: relative; z-index: 1; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 30px; }
</style>
</head>
<body>
${copy('Copy 1: Port Operations Copy')}
${copy('Copy 2: Gate Security & Carrier Copy')}
</body>
</html>`;
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** 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<boolean> {
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;
bulkTons?: number;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
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,
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<boolean> {
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<Booking> {
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[] = [];
// Contractbooking 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.
if (company.status !== CompanyStatus.Active) {
throw new ForbiddenException(
"Your company is awaiting approval — you can't create bookings yet.",
);
}
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) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
const { profile } =
await this.companiesService.getCompanyInfoByUserId(userId);
fallbackType = profile.activeProfileType ?? null;
} catch {
// No profile (e.g. staff creating on behalf) — fall back to mapping.
}
}
companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
companyId,
tradeDirection,
fallbackType,
);
// 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,
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,
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,
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);
}
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,
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<string, unknown> = {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : 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,
existing.companyProfileId
? undefined
: (existing.companyProfile?.type as ProfileType | undefined),
);
}
if (dto.scheduledDate) 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<string>(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<boolean> {
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 SELF_HAUL handover, 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<void> {
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
AND mile_type = 'SELF_HAUL'`,
[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);
}
}
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
forceCompanyProfileId?: string,
): Promise<PaginatedBookings> {
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,
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,
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<PaginatedBookings> {
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<PaginatedBookings> {
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<string | null> {
try {
const { company } =
await this.companiesService.getCompanyInfoByUserId(userId);
return company?.id ?? null;
} catch {
return null;
}
}
/**
* Resolve the active company_profile id a customer's bookings should be
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
* back to company-level scoping.
*/
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
return this.companiesService.resolveActiveCompanyProfileId(userId);
}
/**
* 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<void> {
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<Freight.IBookingTracking> {
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<TrainSchedulingService['getScheduleCheckpoints']>
>;
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<BookingListSummaryDto> {
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,
consolidationPaired: filter.consolidationPaired,
};
const [statusCounts, metrics] = await Promise.all([
this.bookingsRepository.getStatusCounts(),
this.bookingsRepository.getListSummaryMetrics({
...listFilter,
page,
pageSize,
needsActionStatuses: NEEDS_ACTION_STATUSES,
urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD,
}),
]);
return {
metrics,
tabs: mapStatusCountsToTabs(statusCounts),
};
}
/** Get a single booking by ID with files. */
async findById(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) {
throw new NotFoundException(`Booking ${id} not found`);
}
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.
if (booking.trainScheduleId) {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: booking.trainScheduleId } });
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
schedule?.status ?? null;
}
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
// delivery from the portal (booking-based, one per booking). EDR last-mile
// handovers are per delivering truck and signed by the receiver at the door,
// so they never surface the portal "Approve delivery" action.
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
AND mile_type = 'SELF_HAUL'
LIMIT 1`,
[id],
);
(booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
Boolean(pendingHandover);
return booking;
}
async findByReference(reference: string): Promise<Booking> {
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. */
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
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<void> {
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<string, string | string[]> = {
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<ReturnType<ConsolidationService['slotsFromBooking']>>;
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<boolean> {
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;
}
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
const booking = await this.findById(id);
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
const blocked = ['PAID', '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.bookingsRepository.createReviewNote(
id,
`Government booking expedited to PAID by staff (${staffUserId})`,
'STAFF_NOTE',
staffUserId,
);
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,
};
}
}