import { BadRequestException, ConflictException, ForbiddenException, forwardRef, GoneException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { Freight, SchedulingStatus } from '@edr/types'; // 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 { 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 { 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'; /** 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; @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, ) {} /** Resolve trade direction from yard countries; reject client mismatch. */ 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 count = await this.bookingsRepository.countByYear(year); return `BK-${year}-${String(count + 1).padStart(6, '0')}`; } /** 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), ); 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'); } if ( schedule.originStationId !== dto.originYardId || schedule.destinationStationId !== dto.destinationYardId ) { 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 reference = dto.reference || (await this.generateReference()); const containers = dto.containers ?? []; assertFreightShape({ freightType: dto.freightType, cargoTypeId: dto.cargoTypeId, containers, }); const tradeDirection = await this.resolveTradeDirectionForBooking( dto.originYardId, dto.destinationYardId, dto.tradeDirection, ); // 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); const booking = await 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, 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', }); if (dto.freightType === 'CONTAINER') { await this.bookingsRepository.createContainers( booking.id, containers.map((c, i) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, 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 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, 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, 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, ); } 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); return 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, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, consolidationPaired: filter.consolidationPaired, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** 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 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, }; 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, }; } /** 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, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, 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 }; }), ); } 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', '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`); } 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, }); } }); return { success: true, allocated: allocations.length, }; } }