mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
2597 lines
102 KiB
TypeScript
2597 lines
102 KiB
TypeScript
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<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);
|
||
}
|
||
|
||
/** 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<unknown[]> {
|
||
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) => `<tr>
|
||
<td class="num">${i + 1}</td>
|
||
<td>${esc(w.wagonType)}</td>
|
||
<td>${esc(w.wagonNumber)}</td>
|
||
<td class="num">${num(w.tareWeightTons, 2)}</td>
|
||
<td class="num">${num(w.equatedLength)}</td>
|
||
<td class="num">${num(w.loadCapacityTons)}</td>
|
||
<td>${esc(arrivalStation)}</td>
|
||
<td>${esc(cargoName)}</td>
|
||
<td>${esc(departureStation)}</td>
|
||
<td>${esc(w.containerNumbers)}</td>
|
||
<td>${esc(w.sealNumbers)}</td>
|
||
<td class="num">${money(prices[i])}</td>
|
||
</tr>`,
|
||
)
|
||
.join('');
|
||
|
||
// The totals belong in <tbody>, not <tfoot>: the Chromium-less fallback
|
||
// renderer only parses tbody rows, so a <tfoot> silently drops every footer
|
||
// figure from the printed sheet.
|
||
const totalsRow = `<tr class="totals">
|
||
<td>TOT</td>
|
||
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
||
<td>${
|
||
pendingWagons
|
||
? 'pending marshalling'
|
||
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
||
}</td>
|
||
<td class="num">${num(totals.tare, 2)}</td>
|
||
<td class="num">${num(totals.length)}</td>
|
||
<td class="num">${num(totals.capacity)}</td>
|
||
<td></td>
|
||
<td>Gross ${num(totals.tare + totals.load)} T</td>
|
||
<td></td>
|
||
<td></td>
|
||
<td></td>
|
||
<td class="num">${money(totalAmount)}</td>
|
||
</tr>`;
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Carriage Acceptance Sheet</title>
|
||
<style>
|
||
@page { size: A4 landscape; margin: 10mm; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||
.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 { font-size: 11px; color: #475569; margin-top: 4px; }
|
||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||
.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; }
|
||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||
.num { text-align: right; }
|
||
tr.totals td { background: #f8fafc; font-weight: 700; }
|
||
.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: 34px; }
|
||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="top">
|
||
<div>
|
||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||
<h1>Carriage Acceptance Sheet</h1>
|
||
<div class="subtitle">Booking ${esc(booking.reference)} — ${esc(booking.tradeDirection)}</div>
|
||
</div>
|
||
<div class="meta">
|
||
Sheet No.
|
||
<strong>CAS-${esc(booking.reference)}</strong>
|
||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="summary">
|
||
<div class="tile"><span>Marshalled at</span><strong>${esc(header.marshalledAt ?? departureStation)}</strong></div>
|
||
<div class="tile"><span>Arrival at</span><strong>${esc(header.arrivalAt ?? arrivalStation)}</strong></div>
|
||
<div class="tile"><span>Date and time</span><strong>${esc(sheetDate.toLocaleString('en-GB'))}</strong></div>
|
||
<div class="tile"><span>Train No.</span><strong>${esc(header.trainNumber)}</strong></div>
|
||
<div class="tile"><span>Customer</span><strong>${esc(booking.company?.name)}</strong></div>
|
||
<div class="tile"><span>Cargo</span><strong>${esc(cargoName)}</strong></div>
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th class="num">SN</th>
|
||
<th>Type of Wagon</th>
|
||
<th>Wagon No.</th>
|
||
<th class="num">Tare Weight</th>
|
||
<th class="num">Equated Length</th>
|
||
<th class="num">Load Capacity</th>
|
||
<th>Arrival Station</th>
|
||
<th>Cargo Name</th>
|
||
<th>Departure Station</th>
|
||
<th>Container No.</th>
|
||
<th>Seal No.</th>
|
||
<th class="num">Price (${esc(currency)})</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows}
|
||
${totalsRow}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="notice">
|
||
${
|
||
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.`
|
||
}
|
||
</div>
|
||
|
||
<div class="signatures">
|
||
<div class="line">Signed by — EDR operations / date</div>
|
||
<div class="line">Signed by — customer or agent / date</div>
|
||
<div class="line">Signed by — marshalling yard / date</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
/** 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;
|
||
}>,
|
||
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) => `<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')}
|
||
${extraCopies.map((label) => copy(label)).join('')}
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
private escapeHtml(value: string): string {
|
||
return value
|
||
.replace(/&/g, '&')
|
||
.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<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;
|
||
originYardId?: string | null;
|
||
destinationYardId?: 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,
|
||
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<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[] = [];
|
||
|
||
// 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<string, unknown> = {
|
||
...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<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 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<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`,
|
||
[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<void> {
|
||
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<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,
|
||
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<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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
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<Booking> {
|
||
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<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 — 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<Booking> {
|
||
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<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;
|
||
}
|
||
|
||
/**
|
||
* 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<Booking> {
|
||
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,
|
||
};
|
||
}
|
||
}
|