import { BadRequestException, ConflictException, ForbiddenException, forwardRef, GoneException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { Freight, SchedulingStatus } from '@edr/types'; import { insertWithGeneratedReference } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; import { InjectDataSource } from '@nestjs/typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { assertFreightShape } from './booking-freight.util'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { mapStatusCountsToTabs } from './booking-list-tabs.config'; import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { BOOKING_STATUSES, CUSTOMER_EDITABLE_STATUSES, FreightType, } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { items: Booking[]; total: number; meta: { page: number; pageSize: number; total: number; totalPages: number; hasNextPage: boolean; hasPreviousPage: boolean; }; } const URGENT_PRIORITY_THRESHOLD = 1000; const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', 'PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE', ] as const; /** * Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed * the total cargo it's a portion of, and is never negative. */ function clampToCargo(value: number | undefined, cargoAmount: number): number { const v = Number(value ?? 0); if (!Number.isFinite(v) || v <= 0) return 0; const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0; return Math.min(v, cap); } @Injectable() export class BookingsService { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, private readonly minioService: MinioService, // private readonly customersService: CustomersService, private readonly companiesService: CompaniesService, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, private readonly vehiclesService: VehiclesService, private readonly contractPdfService: ContractPdfService, private readonly events: EventEmitter2, ) {} async assignCustomerTruck( bookingId: string, dto: CustomerTruckAssignmentDto, ): Promise { const booking = await this.findById(bookingId); const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim()); const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim()); const usesMileService = booking.tradeDirection === 'IMPORT' ? hasLastMile : booking.tradeDirection === 'EXPORT' ? hasFirstMile : hasFirstMile || hasLastMile; if (usesMileService) { throw new BadRequestException( 'Customer truck assignment is only allowed when first/last mile delivery is not selected', ); } if (booking.customerTruckAssignedAt) { throw new ConflictException('Customer truck assignment is already submitted and locked'); } if (booking.paymentStatus !== 'PAID') { throw new BadRequestException('Booking must be paid before assigning an external customer truck'); } await this.bookingsRepository.update(bookingId, { status: 'TRUCK_ASSIGNED', customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(), customerTruckDriverName: dto.driverName.trim(), customerTruckType: dto.truckType.trim(), customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(), customerTruckAssignedAt: new Date(), }); return this.findById(bookingId); } async customerTruckFreightOrderCopies( bookingId: string, ): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); if (!booking.customerTruckAssignedAt) { throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated'); } const trucks: Array<{ plateNumber: string; driverName: string; truckType: string; arrivedAt: string | null; containers: string | null; }> = await this.dataSource.query( `SELECT a.plate_number AS "plateNumber", a.driver_name AS "driverName", a.truck_type AS "truckType", a.arrived_at AS "arrivedAt", string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers" FROM freight.customer_truck_assignments a LEFT JOIN freight.customer_truck_containers c ON c.assignment_id = a.id AND c.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at ORDER BY a.assigned_at`, [bookingId], ); const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); const buffer = await this.contractPdfService.htmlToPdfBuffer(html); return { filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, buffer, }; } /** Resolve trade direction from yard countries; reject client mismatch. */ /** * An intercity corridor is valid when both yards are Ethiopian and at least * one non-retired route passes the origin strictly before the destination in * its milestone order — that is the corridor an import/export train can * serve the booking on. */ private async assertIntercityCorridorExists( originYardId: string, destinationYardId: string, ): Promise { const yards = await this.dataSource.getRepository(Yard).find({ where: { id: In([originYardId, destinationYardId]) }, }); if (yards.some((y) => y.country !== 'Ethiopia')) { throw new BadRequestException( 'Intercity bookings only run between Ethiopian yards', ); } const rows: Array<{ id: string }> = await this.dataSource.query( `SELECT r.id FROM freight.routes r JOIN freight.route_milestones mo ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL JOIN freight.route_milestones md ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL WHERE mo.sequence_no < md.sequence_no AND r.status = 'AVAILABLE' AND r.deleted_at IS NULL LIMIT 1`, [originYardId, destinationYardId], ); if (rows.length === 0) { throw new BadRequestException( 'No route passes through this origin and destination in order — intercity service is not available on this corridor', ); } } private async resolveTradeDirectionForBooking( originYardId: string, destinationYardId: string, provided?: string, ): Promise { const yards = await this.dataSource.getRepository(Yard).find({ where: { id: In([originYardId, destinationYardId]) }, }); const origin = yards.find((y) => y.id === originYardId); const destination = yards.find((y) => y.id === destinationYardId); if (!origin) { throw new BadRequestException(`Origin yard ${originYardId} not found`); } if (!destination) { throw new BadRequestException(`Destination yard ${destinationYardId} not found`); } if (originYardId === destinationYardId) { throw new BadRequestException('Origin and destination yards must differ'); } const expected = deriveTradeDirection(origin, destination); if (provided && provided !== expected) { throw new BadRequestException( `tradeDirection must be ${expected} for the selected yard pair (got ${provided})`, ); } return expected; } /** Generate a unique booking reference number. */ private async generateReference(): Promise { const year = new Date().getFullYear(); const seq = await this.bookingsRepository.maxReferenceSequence(year); return `BK-${year}-${String(seq + 1).padStart(6, '0')}`; } private buildCustomerTruckFreightOrderHtml( booking: Booking, trucks: Array<{ plateNumber: string; driverName: string; truckType: string; arrivedAt: string | null; containers: string | null; }>, ): string { const assignedAt = booking.customerTruckAssignedAt ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') : '-'; const bookingRows: Array<[string, string | null | undefined]> = [ ['Booking Reference', booking.reference], ['Client Name', booking.company?.name], ['Client ID', booking.companyId], ['Trade Direction', booking.tradeDirection], ['Freight Type', booking.freightType], ['Assigned At', assignedAt], ['Booking Status', booking.status], ]; const bookingRowHtml = bookingRows .map(([label, value]) => `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`) .join(''); // 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 truckBlocks = truckList .map((t, i) => { const rows: Array<[string, string | null | undefined]> = [ ['Truck Plate Number', t.plateNumber], ['Driver Name', t.driverName], ['Truck Type', t.truckType], ['Containers Loaded', t.containers], [ 'Arrival', t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival', ], ]; const html = rows .map( ([label, value]) => `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`, ) .join(''); return `

Truck ${i + 1}

${html}
`; }) .join(''); const copy = (watermark: string) => `
${this.escapeHtml(watermark)}

Freight Order

Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}

${this.escapeHtml(booking.reference)}
${bookingRowHtml}
${truckBlocks}
Customer / Carrier Signature
Port Operations Verification
Gate Security Verification
`; return ` ${copy('Copy 1: Port Operations Copy')} ${copy('Copy 2: Gate Security & Carrier Copy')} `; } private escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** Build evaluation input from booking freight shape. */ /** * Whether a service type bundles customs clearance. This is the single source * of truth for a booking's `customsClearingEnabled` — the customer cannot * diverge from it, and it decides who clears the documents (GL vs Marketing). */ private async resolveIncludesCustoms(serviceTypeId: string): Promise { const serviceType = await this.dataSource .getRepository(ServiceType) .findOne({ where: { id: serviceTypeId } }); return serviceType?.includesCustoms ?? false; } private async buildEvalInput(dto: { freightType: FreightType; cargoTypeId?: string | null; serviceTypeId: string; paymentCurrency: string; tradeDirection: string; isHazardous?: boolean; isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { const containerLines = dto.freightType === 'CONTAINER' ? dto.containers : []; const containers = await Promise.all( containerLines.map(async (c) => { const ct = await this.containerTypesService.findById(c.containerTypeId); const totalVgmTons = c.quantity * c.vgmPerUnitTons; return { containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, totalVgmTons, isReefer: ct.isReefer, wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1), }; }), ); const totalWagons = Math.ceil( containers.reduce((sum, c) => sum + c.wagonsRequired, 0), ); // Consolidation is system-managed: the CONSOLIDATION_ENABLED rule trigger // fires whenever a container line leaves a wagon partially filled. There is // no customer opt-in — partial-wagon cargo always consolidates. const allowConsolidation = dto.freightType === 'CONTAINER' ? await this.needsConsolidation(dto.containers) : false; return { freightType: dto.freightType, cargoTypeId: dto.cargoTypeId ?? null, serviceTypeId: dto.serviceTypeId, paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, // Bulk reefer comes from the customer toggle; container reefer is derived // from the container type and ORed in by the engine. isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, isGovernment: dto.isGovernment ?? false, allowConsolidation, shippingLineId: dto.shippingLineId, totalWagons, bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0, containers, }; } /** * True when any container line leaves a wagon partially filled (e.g. 1×20ft on * a 2-slot wagon). Partial-wagon cargo must consolidate before it can finalize; * cargo that already fills whole wagons never does. This is computed from the * container quantities alone — there is no customer-facing opt-in flag. */ private async needsConsolidation( containers: CreateBookingContainerDto[], ): Promise { 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 }; } const partner = await this.bookingsRepository.findConsolidationPartner( booking, slots, ); if (partner) { await this.bookingsRepository.pairConsolidation(booking.id, partner.id); const paired = await this.findById(booking.id); messages.push( this.consolidationService.describePaired(partner.reference, slots), ); // Let deferred owners (e.g. contract drawdowns whose invoice/milestones // were held while the booking waited) finalize now that a whole wagon // exists. Fire-and-forget: a listener failure must not undo the pairing. this.events .emitAsync('booking.consolidation.paired', { bookingIds: [booking.id, partner.id], }) .catch(() => undefined); return { booking: paired, messages }; } // No partner yet — park the booking so it waits. Applies both pre-submit // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never // reach this method. if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') { await this.bookingsRepository.parkForConsolidation(booking.id); } const pending = await this.findById(booking.id); messages.push(this.consolidationService.describePending(pending, slots)); return { booking: pending, messages }; } /** * Run consolidation right after a booking reaches SUBMITTED. If a complementary * partner already exists, both are paired and moved (back) to SUBMITTED so staff * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and * waits for a later complementary booking to complete the wagon. * * Returns the re-fetched booking, so callers can reflect the resulting status * (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting). */ async runConsolidationOnSubmit(bookingId: string): Promise { const booking = await this.findById(bookingId); // Already paired (e.g. a partner submitted first) — nothing to do. if (booking.consolidationPartnerId) { return booking; } const result = await this.tryAutoConsolidate(booking); return result.booking; } /** Create a new freight booking. */ async create( dto: CreateBookingDto, files: Express.Multer.File[], userId?: string, ): Promise<{ booking: Booking; warnings: string[] }> { const warnings: string[] = []; // Contract–booking separation: contracts are no longer created through the // booking endpoint. Legacy GENERAL_CONTRACT creation is deprecated — clients // must use POST /contracts (and create shipments via POST /contracts/:id/bookings). if (dto.bookingType === 'GENERAL_CONTRACT') { throw new GoneException( 'General contracts are no longer created here. Use POST /contracts instead.', ); } // let customerId = dto.customerId; // if (!customerId) { // if (!userId) { // throw new BadRequestException( // 'customerId is required or must be resolvable from auth token', // ); // } // const customer = await this.customersService.findByUserId(userId); // customerId = customer.id; // } const isGovernment = dto.isGovernment === true; let companyId: string | null | undefined = dto.companyId; if (isGovernment) { // Government bookings bill to a real seeded government company + an // explicitly-chosen importer/exporter profile (no more null company + // free-text institution). if (!dto.companyId) { throw new BadRequestException('A government company is required for government bookings'); } const govCompany = await this.companiesService.findCompanyById(dto.companyId); if (govCompany.kind !== CompanyKind.Government) { throw new BadRequestException('Selected company is not a government entity'); } if (govCompany.status !== CompanyStatus.Active) { throw new BadRequestException('Selected government company is not active'); } if (!dto.companyProfileId) { throw new BadRequestException('A government company profile is required for government bookings'); } companyId = govCompany.id; } else if (!companyId) { if (!userId) { throw new BadRequestException( 'companyId is required or must be resolvable from auth token', ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); // A customer can only book once their company has been approved. if (company.status !== CompanyStatus.Active) { throw new ForbiddenException( "Your company is awaiting approval — you can't create bookings yet.", ); } companyId = company.id; } if (dto.trainScheduleId) { // Staff manual pin: the schedule must be OPEN and on the same route. const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: dto.trainScheduleId } }); if (!schedule) { throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`); } if (schedule.bookingWindowStatus !== 'OPEN') { throw new BadRequestException('Selected schedule is no longer accepting bookings'); } // Corridor-aware: the booking's leg must lie on the schedule's route in // stop order — sub-corridor pins (Dire→Djibouti on an Addis→Djibouti // train) are valid. const stops = await this.trainSchedulingService.stopYardsForSchedule( schedule, ); const fromIdx = stops.indexOf(dto.originYardId); const toIdx = stops.indexOf(dto.destinationYardId); if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) { throw new BadRequestException('Selected schedule is not on the booking route'); } } else if (dto.scheduledDate) { // A real (binding) scheduledDate was supplied (e.g. staff pinning a day // directly). Require that the route has at least one OPEN departure on // that EAT day. 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 = await this.trainSchedulingService.existsOpenScheduleOnRouteDay( dto.originYardId, dto.destinationYardId, day, ); if (!hasDeparture) { throw new BadRequestException( 'No departures available on the selected day for this route', ); } } const containers = dto.containers ?? []; assertFreightShape({ freightType: dto.freightType, cargoTypeId: dto.cargoTypeId, containers, }); const tradeDirection = await this.resolveTradeDirectionForBooking( dto.originYardId, dto.destinationYardId, dto.tradeDirection, ); // Intercity (DOMESTIC) bookings never get their own train — they ride on a // passing import/export train, so there is no booking window and no date to // pin. All we require at creation is that the corridor actually lies on a // route (origin before destination in some route's milestone order); staff // accept the booking onto a concrete train at finalize time. if (tradeDirection === 'DOMESTIC') { if (dto.scheduledDate || dto.trainScheduleId) { throw new BadRequestException( 'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later', ); } await this.assertIntercityCorridorExists( dto.originYardId, dto.destinationYardId, ); } // Stamp the operational profile this booking belongs to (importer/exporter) // so the customer portal can scope lists/KPIs to the active mode. Best-effort // for non-government bookings with a resolved company; never blocks creation. let companyProfileId: string | null = null; if (dto.companyProfileId && companyId) { // Explicit profile pin (government booking, or staff booking on behalf): // must belong to the chosen company and be active. const profile = await this.companiesService.getActiveCompanyProfileForBooking( companyId, dto.companyProfileId, ); companyProfileId = profile.id; } else if (companyId) { let fallbackType: ProfileType | null = null; if (userId) { try { const { profile } = await this.companiesService.getCompanyInfoByUserId(userId); fallbackType = profile.activeProfileType ?? null; } catch { // No profile (e.g. staff creating on behalf) — fall back to mapping. } } companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( companyId, tradeDirection, fallbackType, ); // A customer booking under their own account may only do so once the // resolved operational profile has been approved by the backoffice. Staff- // and government-initiated bookings (companyId supplied explicitly) bypass // this gate. const customerSelfBooking = !dto.companyId && !!userId; if (customerSelfBooking && companyProfileId) { await this.companiesService.assertCompanyProfileApprovedForBooking( companyProfileId, ); } } // Every booking must link to a company and a company profile. if (!companyId) { throw new BadRequestException('A company is required to create a booking'); } if (!companyProfileId) { throw new BadRequestException( 'A company profile is required to create a booking — none could be resolved for this company', ); } const needsConsolidation = dto.freightType === 'CONTAINER' ? await this.needsConsolidation(containers) : false; const evalInput = await this.buildEvalInput({ freightType: dto.freightType as FreightType, cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null, serviceTypeId: dto.serviceTypeId, paymentCurrency: dto.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous, isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, bulkTons: dto.cargoTotalWeightVgm, containers, }); const ruleResult = await this.ruleEngineService.evaluate(evalInput); this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); // Customs clearing is owned by the service type, not the customer: when the // service includes customs, EDR/GL clears it (no external agent); otherwise // the customer clears it themselves and may name their broker. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); // Explicit reference is caller-chosen (a collision is a real conflict); // auto-generated references retry past a concurrent same-sequence insert. const insertBooking = (reference: string) => this.bookingsRepository.create({ reference, companyId, companyProfileId, isGovernment, governmentInstitution: dto.governmentInstitution?.trim() || null, trainId: dto.trainId, trainScheduleId: dto.trainScheduleId ?? null, contractType: dto.contractType, serviceTypeId: dto.serviceTypeId, firstMilePickupAddress: dto.firstMilePickupAddress, firstMilePickupLat: dto.firstMilePickupLat ?? null, firstMilePickupLng: dto.firstMilePickupLng ?? null, lastMileDeliveryAddress: dto.lastMileDeliveryAddress, lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, customsClearingEnabled: includesCustoms, customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), equipmentReturn: dto.equipmentReturn, originYardId: dto.originYardId, destinationYardId: dto.destinationYardId, tradeDirection, freightType: dto.freightType, cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null, cargoFreeText: dto.cargoFreeText, shippingLineId: dto.shippingLineId, cargoTotalWeightVgm: dto.cargoTotalWeightVgm, isHazardous: dto.isHazardous ?? false, // Bulk reefer is the customer's toggle; container reefer is derived from // the container type at pricing time, so the booking-level flag stays off // for container freight to avoid double-counting. isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, // Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container // freight tracks this per line, so these are 0 for CONTAINER. bulkHazardousQuantity: dto.freightType === 'BULK' ? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm) : 0, bulkReeferQuantity: dto.freightType === 'BULK' ? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm) : 0, paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, estimatedShipmentDate: dto.estimatedShipmentDate ? new Date(dto.estimatedShipmentDate) : null, startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', priorityScore: ruleResult.priorityScore, totalAmount: 0, paymentStatus: 'PENDING', }); const booking = dto.reference ? await insertBooking(dto.reference) : await insertWithGeneratedReference( () => this.generateReference(), insertBooking, ); if (dto.freightType === 'CONTAINER') { await this.bookingsRepository.createContainers( booking.id, containers.map((c, i) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, hazardousQuantity: c.hazardousQuantity, reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); warnings.push(`Estimated wagons required: ${wagonCount}`); } if (files.length > 0) { try { await this.filesService.uploadMany(booking.id, 'bookings', files); } catch { warnings.push('File upload failed — booking was created without attached files.'); } } // Reuse the booking profile's onboarding documents instead of asking the // customer to re-upload. Snapshot them onto the booking now (by reference), // so a later active-profile switch never changes this booking's documents. // // Skip this when the customer uploaded documents for this booking — those // per-booking files take precedence, so auto-attaching the profile snapshots // would create duplicates. if (companyProfileId && files.length === 0) { try { const onboardingFiles = await this.companiesService.getProfileOnboardingFiles(companyProfileId); if (onboardingFiles.length > 0) { await this.filesService.attachExistingFiles( booking.id, 'bookings', onboardingFiles.map((f, i) => ({ code: `onboarding_document_${i + 1}`, name: f.name, url: f.url, size: f.size, mimeType: f.mimeType, })), ); } } catch { warnings.push( 'Could not attach onboarding documents — they can be added from the booking page.', ); } } let full = await this.findById(booking.id); if (needsConsolidation) { const consolidation = await this.tryAutoConsolidate(full); full = consolidation.booking; warnings.push(...consolidation.messages); } return { booking: full, warnings }; } /** Update a draft booking. */ async update( id: string, dto: UpdateBookingDto, files: Express.Multer.File[], ): Promise<{ booking: Booking; warnings: string[] }> { const existing = await this.findById(id); if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { throw new BadRequestException( 'Only DRAFT or CHANGES_REQUESTED bookings can be updated', ); } const warnings: string[] = []; const freightType = (dto.freightType ?? existing.freightType) as FreightType; let containers = dto.containers ?? (existing.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) .map((bc) => ({ containerTypeId: bc.containerTypeId, quantity: bc.quantity, vgmPerUnitTons: Number(bc.vgmPerUnitTons), })); let cargoTypeId = dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; if (freightType === 'BULK') { containers = []; if (dto.containers !== undefined) { await this.bookingsRepository.deleteContainers(id); } } else { cargoTypeId = null; if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) { throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight'); } } assertFreightShape({ freightType, cargoTypeId, containers }); const originYardId = dto.originYardId ?? existing.originYardId; const destinationYardId = dto.destinationYardId ?? existing.destinationYardId; const tradeDirection = await this.resolveTradeDirectionForBooking( originYardId, destinationYardId, dto.tradeDirection, ); const needsConsolidation = freightType === 'CONTAINER' ? await this.needsConsolidation(containers) : false; const evalInput = await this.buildEvalInput({ freightType, cargoTypeId, serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0), containers, }); const ruleResult = await this.ruleEngineService.evaluate(evalInput); this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); const pricingFieldsChanged = await this.pricingRelevantFieldsChanged( existing, dto, freightType, cargoTypeId, containers, ); const cargoAmount = dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0); const updates: Record = { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, // Booking-level reefer is only meaningful for bulk; container reefer is // derived from the container type at pricing time. isReefer: freightType === 'BULK' ? (dto.isReefer ?? existing.isReefer ?? false) : false, // Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for // container freight (per-line on the containers instead). bulkHazardousQuantity: freightType === 'BULK' ? clampToCargo( dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0), cargoAmount, ) : 0, bulkReeferQuantity: freightType === 'BULK' ? clampToCargo( dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0), cargoAmount, ) : 0, priorityScore: ruleResult.priorityScore, tradeDirection, }; // If the route (hence trade direction) changed, re-stamp the operational // profile so an edited draft doesn't get stranded under the wrong profile. if ( tradeDirection !== existing.tradeDirection && !existing.isGovernment && existing.companyId ) { updates.companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( existing.companyId, tradeDirection, existing.companyProfileId ? undefined : (existing.companyProfile?.type as ProfileType | undefined), ); } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); if (dto.estimatedShipmentDate) updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.endDate) updates.endDate = new Date(dto.endDate); delete updates.containers; // Customs clearing always mirrors the (possibly changed) service type — never // the client payload — so it can't diverge from the service's customs scope. const includesCustoms = await this.resolveIncludesCustoms( dto.serviceTypeId ?? existing.serviceTypeId, ); updates.customsClearingEnabled = includesCustoms; updates.customsClearingAgent = includesCustoms ? null : (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null); await this.bookingsRepository.update(id, updates); if (freightType === 'CONTAINER' && dto.containers) { await this.bookingsRepository.deleteContainers(id); await this.bookingsRepository.createContainers( id, dto.containers.map((c, i) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, hazardousQuantity: c.hazardousQuantity, reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); } if (pricingFieldsChanged) { await this.bookingsRepository.invalidatePricingPreview(id); } if (files.length > 0) { await this.filesService.uploadMany(id, 'bookings', files); } let booking = await this.findById(id); if (needsConsolidation && !booking.consolidationPartnerId) { const consolidation = await this.tryAutoConsolidate(booking); booking = consolidation.booking; warnings.push(...consolidation.messages); } return { booking, warnings }; } /** Parse comma-separated scheduling status query values. */ private parseSchedulingStatusFilter(filter: FilterBookingDto): { schedulingStatuses?: string[]; } { const raw = filter.schedulingStatuses; if (!raw) return {}; const schedulingStatuses = raw .split(',') .map((s) => s.trim()) .filter(Boolean); return schedulingStatuses.length ? { schedulingStatuses } : {}; } /** Parse comma-separated or repeated status query values. */ private parseStatusFilter(filter: FilterBookingDto): { statuses?: string[]; status?: string; } { const allowed = new Set(BOOKING_STATUSES); const raw = filter.statuses; const statusList = raw ? raw .split(',') .map((s) => s.trim()) .filter((s) => allowed.has(s)) : []; if (statusList.length > 0) { return { statuses: statusList }; } if (filter.status && allowed.has(filter.status)) { return { status: filter.status }; } return {}; } /** Return a paginated list of bookings matching the filter. */ /** * Whether a route has at least one OPEN train departure on the given EAT day. * Used to validate the binding shipment day chosen at the operation-request * step (only days with a schedule are selectable). */ async hasOpenDepartureOnDay( originYardId: string, destinationYardId: string, day: string, ): Promise { return this.trainSchedulingService.existsOpenScheduleOnRouteDay( originYardId, destinationYardId, day, ); } /** * Batched version of the findById flag: marks each page item whose booking * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal * dashboard) can show "Approve delivery" for exactly the generated→signed * window. One query for the whole page. */ private async attachHandoverFlags(bookings: Booking[]): Promise { const ids = bookings.map((b) => b.id); if (!ids.length) return; const rows: Array<{ bookingId: string }> = await this.dataSource.query( `SELECT DISTINCT booking_id AS "bookingId" FROM freight.booking_handovers WHERE booking_id = ANY($1::uuid[]) AND signed_at IS NULL AND deleted_at IS NULL AND mile_type = 'SELF_HAUL'`, [ids], ); const pending = new Set(rows.map((r) => r.bookingId)); for (const b of bookings) { (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = pending.has(b.id); } } async findAll( filter: FilterBookingDto, forceCompanyId?: string, forceCompanyProfileId?: string, ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const statusFilter = this.parseStatusFilter(filter); const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter); const result = await this.bookingsRepository.findAllPaginated({ page, pageSize, ...statusFilter, ...schedulingStatusFilter, assignedToSchedule: filter.assignedToSchedule, // A forced company scope (portal/customer) overrides any caller-provided // companyId so a customer can only ever see their own company's bookings. // The company guard always applies; the optional companyProfileId filter // (from the per-page service filter) narrows WITHIN the company — the repo // ANDs both, so cross-company access is impossible. companyId: forceCompanyId ?? filter.companyId, companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, scheduledFrom: filter.scheduledFrom, scheduledTo: filter.scheduledTo, originYardId: filter.originYardId, destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, consolidationPaired: filter.consolidationPaired, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); await this.attachHandoverFlags(result.items ?? []); return result; } /** Booking statuses at which a customer can pay (mirrors booking-payment.service). */ private static readonly PAYABLE_STATUSES = [ 'FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', ]; /** * Booking statuses that belong to the customs document-clearance queue. The * Global Logistics role is scoped to ONLY these — it never sees the general * booking-request list. */ private static readonly CLEARANCE_STATUSES = [ 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY', ]; /** * List bookings in the customs document-clearance queue. Used by Global * Logistics (clearance:view) which has no general bookings:view — so the * status set is force-scoped to clearance statuses and can't be widened to * arbitrary bookings by a caller-supplied status filter. */ async findClearanceQueue( filter: FilterBookingDto, ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 100; // Honour a caller status filter only if it's within the clearance set; // otherwise fall back to the full clearance status list. const requested = filter.status; const statuses = requested && BookingsService.CLEARANCE_STATUSES.includes(requested) ? [requested] : BookingsService.CLEARANCE_STATUSES; return this.bookingsRepository.findAllPaginated({ page, pageSize, statuses, // Global Logistics only clears customs bookings; non-customs clearance is // reviewed by Marketing from the booking detail, not this queue. customsClearingEnabled: true, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** * List the current customer's bookings that are ready for payment: * payable status AND not yet PAID. Company scope is derived from the * authenticated user and cannot be widened by the caller. */ async findMyPayable( userId: string, filter: FilterBookingDto, ): Promise { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); return this.bookingsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 20, statuses: BookingsService.PAYABLE_STATUSES, excludePaymentStatus: 'PAID', // Company-wide: payables span all of the customer's services. companyId: company.id, companyProfileId: filter.companyProfileId, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** * Resolve the company a customer user belongs to, for scoping their own * bookings. Returns null when no profile/company is linked yet. */ async resolveCustomerCompanyId(userId: string): Promise { try { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); return company?.id ?? null; } catch { return null; } } /** * Resolve the active company_profile id a customer's bookings should be * scoped to (importer/exporter mode). Null when not onboarded — callers fall * back to company-level scoping. */ async resolveActiveCompanyProfileId(userId: string): Promise { return this.companiesService.resolveActiveCompanyProfileId(userId); } /** * Authorize a customer's access to a single booking. Staff are scoped at the * controller (they pass `isStaff`); for a customer, the booking must belong * to the company the authenticated user is linked to — otherwise it is hidden * behind a NotFound so booking IDs can't be probed. */ async assertCustomerCanAccessBooking( userId: string | undefined, booking: Booking, ): Promise { if (!userId) { throw new ForbiddenException('Authentication required'); } const companyId = await this.resolveCustomerCompanyId(userId); if (!companyId || booking.companyId !== companyId) { // Don't reveal that the booking exists for another company. throw new NotFoundException(`Booking ${booking.id} not found`); } } /** * Build the customer-facing shipment tracking payload for a booking from the * train schedule it is assigned to and the live checkpoint log. The caller is * responsible for authorizing access to the booking first. * * When the booking has not been assigned to a train yet, returns a valid * "no schedule" payload so the UI can show a pre-dispatch state. */ async getBookingTracking( bookingId: string, ): Promise { const booking = await this.findById(bookingId); const journey = { bookingStatus: booking.status ?? null, bookingOriginYardId: booking.originYardId ?? null, bookingDestinationYardId: booking.destinationYardId ?? null, loadedAt: booking.loadedAt ? new Date(booking.loadedAt).toISOString() : null, arrivedAt: booking.arrivedAt ? new Date(booking.arrivedAt).toISOString() : null, }; const empty: Freight.IBookingTracking = { bookingId: booking.id, bookingReference: booking.reference, hasSchedule: false, scheduleId: null, trainNumber: null, scheduleStatus: null, direction: null, origin: null, destination: null, stations: [], checkpoints: [], currentSequenceNo: -1, actualDepartureAt: null, actualArrivalAt: null, scheduledDepartureAt: null, scheduledArrivalAt: null, ...journey, }; if (!booking.trainScheduleId) { return empty; } // Pull the live corridor + checkpoints for the assigned schedule. If the // schedule was removed, fall back to the pre-dispatch state rather than 500. let track: Awaited< ReturnType >; try { track = await this.trainSchedulingService.getScheduleCheckpoints( booking.trainScheduleId, ); } catch { return empty; } return { bookingId: booking.id, bookingReference: booking.reference, hasSchedule: true, scheduleId: track.scheduleId, trainNumber: track.trainNumber, scheduleStatus: track.status as Freight.TrainScheduleStatus, direction: track.direction, origin: track.origin, destination: track.destination, stations: track.stations, checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[], currentSequenceNo: track.currentSequenceNo, actualDepartureAt: track.actualDepartureAt, actualArrivalAt: track.actualArrivalAt, scheduledDepartureAt: track.scheduledDepartureAt, scheduledArrivalAt: track.scheduledArrivalAt, ...journey, }; } /** Aggregate metrics and tab counts for the backoffice booking list. */ async getListSummary(filter: FilterBookingDto): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const statusFilter = this.parseStatusFilter(filter); const listFilter = { ...statusFilter, companyId: filter.companyId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, scheduledFrom: filter.scheduledFrom, scheduledTo: filter.scheduledTo, originYardId: filter.originYardId, destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, consolidationPaired: filter.consolidationPaired, }; const [statusCounts, metrics] = await Promise.all([ this.bookingsRepository.getStatusCounts(), this.bookingsRepository.getListSummaryMetrics({ ...listFilter, page, pageSize, needsActionStatuses: NEEDS_ACTION_STATUSES, urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD, }), ]); return { metrics, tabs: mapStatusCountsToTabs(statusCounts), }; } /** Get a single booking by ID with files. */ async findById(id: string): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { throw new NotFoundException(`Booking ${id} not found`); } if (booking.files && booking.files.length > 0) { booking.files = await Promise.all( booking.files.map(async (file: FileRecord) => { const objectName = this.minioService.getObjectNameFromUrl(file.url); const signedUrl = await this.minioService.getSignedUrl(objectName, 300); return { ...file, signedUrl }; }), ); } // Surface the assigned train's operational status so the portal stepper // can show the Arrival stage: the booking status stays IN_TRANSIT from // dispatch until delivery, so arrival is only knowable from the schedule. if (booking.trainScheduleId) { const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: booking.trainScheduleId } }); (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = schedule?.status ?? null; } // A generated-but-unsigned SELF_HAUL handover means the customer must approve // delivery from the portal (booking-based, one per booking). EDR last-mile // handovers are per delivering truck and signed by the receiver at the door, // so they never surface the portal "Approve delivery" action. const [pendingHandover] = await this.dataSource.query( `SELECT 1 FROM freight.booking_handovers WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL AND mile_type = 'SELF_HAUL' LIMIT 1`, [id], ); (booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = Boolean(pendingHandover); return booking; } async findByReference(reference: string): Promise { const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); if (!booking) { throw new NotFoundException(`Booking with reference "${reference}" not found`); } return this.findById(booking.id); } /** Upload documents for a DRAFT booking. */ async uploadDocuments( id: string, files: Express.Multer.File[], ): Promise { const booking = await this.findById(id); if (booking.status !== 'DRAFT') { throw new BadRequestException( 'Documents can only be uploaded for DRAFT bookings', ); } await this.filesService.uploadMany(id, 'bookings', files); return this.findById(id); } async remove(id: string): Promise { const booking = await this.findById(id); if (booking.status !== 'DRAFT') { throw new BadRequestException('Only DRAFT bookings can be deleted'); } await this.bookingsRepository.softDelete(id); } async findQueue( queue: string, filter: FilterBookingDto, options?: { excludeBulk?: boolean }, ): Promise<{ items: Booking[]; total: number }> { const statusMap: Record = { intake: 'SUBMITTED', approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'], contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], marketing: 'SIGNED_CUSTOMER', finance: 'FULLY_EXECUTED', }; const status = statusMap[queue]; if (!status) { throw new BadRequestException(`Unknown queue: ${queue}`); } return this.bookingsRepository.findQueue({ status, page: filter.page, pageSize: filter.pageSize, excludeBulk: options?.excludeBulk ?? queue === 'approval', sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } async requestConsolidation(id: string): Promise<{ booking: Booking; partner: Booking | null; paired: boolean; message: string; }> { const booking = await this.findById(id); const needs = await this.consolidationService.needsConsolidationFromBooking( booking, ); if (!needs) { throw new BadRequestException( 'Booking already fills whole wagon(s) for all container lines; consolidation is not required', ); } if (booking.consolidationPartnerId) { throw new ConflictException('Booking is already paired for consolidation'); } const result = await this.tryAutoConsolidate(booking); const partner = result.booking.consolidationPartnerId ? await this.findById(result.booking.consolidationPartnerId) : null; return { booking: result.booking, partner, paired: partner !== null, message: result.messages[0] ?? '', }; } async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> { const booking = await this.findById(id); if (!booking.consolidationPartnerId) { throw new BadRequestException('Booking has no consolidation partner'); } const partnerId = booking.consolidationPartnerId; await this.bookingsRepository.unpairConsolidation(id, partnerId); return { booking: await this.findById(id), partner: await this.findById(partnerId), }; } async getConsolidationDetails(id: string): Promise<{ booking: Booking; partner: Booking | null; splitBilling: { bookingShare: number; partnerShare: number } | null; wagonSlots: Awaited>; statusMessage: string; }> { const booking = await this.findById(id); const wagonSlots = await this.consolidationService.slotsFromBooking(booking); if (!booking.consolidationPartnerId) { const statusMessage = booking.status === 'PENDING_CONSOLIDATION' ? this.consolidationService.describePending(booking, wagonSlots) : wagonSlots.length > 0 ? 'Consolidation may be required; no partner paired yet.' : 'No wagon consolidation needed.'; return { booking, partner: null, splitBilling: null, wagonSlots, statusMessage, }; } const partner = await this.findById(booking.consolidationPartnerId); return { booking, partner, splitBilling: { bookingShare: Number(booking.totalAmount), partnerShare: Number(partner.totalAmount), }, wagonSlots, statusMessage: this.consolidationService.describePaired( partner.reference, wagonSlots, ), }; } private async pricingRelevantFieldsChanged( existing: Booking, dto: UpdateBookingDto, freightType: FreightType, cargoTypeId: string | null | undefined, containers: CreateBookingContainerDto[], ): Promise { if (dto.freightType !== undefined && dto.freightType !== existing.freightType) { return true; } if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) { return true; } if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) { return true; } if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) { return true; } if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) { return true; } if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) { return true; } // Container lines drive both the base price and the consolidation surcharge // (CONSOLIDATION_ENABLED fires on partial wagons), so any line change re-prices. if (dto.containers !== undefined) { const existingContainers = (existing.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) .map((bc) => ({ containerTypeId: bc.containerTypeId, quantity: bc.quantity, vgmPerUnitTons: Number(bc.vgmPerUnitTons), })); if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) { return true; } } if ( freightType !== existing.freightType || (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ) { return true; } return false; } /** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */ async governmentExpedite(id: string, staffUserId: string): Promise { const booking = await this.findById(id); if (!booking.isGovernment) { throw new BadRequestException('Only government bookings can be expedited'); } const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED']; if (blocked.includes(booking.status)) { throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`); } await this.bookingsRepository.update(id, { status: 'PAID', paymentStatus: 'PAID', schedulingStatus: SchedulingStatus.Eligible, holdStartedAt: null, holdExpiresAt: null, }); await this.bookingsRepository.createReviewNote( id, `Government booking expedited to PAID by staff (${staffUserId})`, 'STAFF_NOTE', staffUserId, ); return this.findById(id); } async findCustomerBookings(companyId: string): Promise<{ id: string; reference: string; status: string; tradeDirection: string; freightType: string; originLabel: string; destinationLabel: string; totalAmount: number; currency: string; scheduledDate: Date | null; createdAt: Date; }[]> { const { items } = await this.bookingsRepository.findAllPaginated({ page: 1, pageSize: 500, companyId, }); return items.map((b) => ({ id: b.id, reference: b.reference, status: b.status, tradeDirection: b.tradeDirection, freightType: b.freightType, originLabel: b.originYard?.label ?? '', destinationLabel: b.destinationYard?.label ?? '', totalAmount: Number(b.totalAmount), currency: b.paymentCurrency, scheduledDate: b.scheduledDate ?? null, createdAt: b.createdAt, })); } async allocateContainers( bookingId: string, allocations: Array<{ containerId: string; vehicleId: string }>, ) { const booking = await this.findById(bookingId); if (!booking) { throw new NotFoundException(`Booking ${bookingId} not found`); } const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, { where: { bookingId, containerId: In(allocations.map((a) => a.containerId)), }, }); const previousVehicleIds = previousAllocations .map((a) => a.vehicleId) .filter((id): id is string => Boolean(id)); await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(BookingContainerAllocation, { bookingId, containerId: allocation.containerId, }); await manager.insert(BookingContainerAllocation, { bookingId, containerId: allocation.containerId, vehicleId: allocation.vehicleId, containerType: 'CONTAINER', quantity: 1, }); } }); const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); await Promise.all( [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY), ), ); await this.vehiclesService.releaseIfUnused( previousVehicleIds.filter((id) => !vehicleIds.has(id)), ); return { success: true, allocated: allocations.length, }; } }