import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; 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 { 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 { FileRecord } from '../files/entities/file.entity'; 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, 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. */ private async buildEvalInput(dto: { freightType: FreightType; cargoTypeId?: string | null; serviceTypeId: string; paymentCurrency: string; tradeDirection: string; isHazardous?: boolean; isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; 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), ); return { freightType: dto.freightType, cargoTypeId: dto.cargoTypeId ?? null, serviceTypeId: dto.serviceTypeId, paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, isGovernment: dto.isGovernment ?? false, allowConsolidation: dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, shippingLineId: dto.shippingLineId, totalWagons, containers, }; } /** * Enable consolidation when any container line leaves a wagon partially filled * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out. */ private async resolveConsolidation( containers: CreateBookingContainerDto[], explicit?: boolean, ): Promise { if (explicit === false) return false; const needs = await this.consolidationService.needsConsolidation( containers.map((c) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, })), ); if (needs) return true; return explicit ?? false; } /** 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.allowConsolidation || booking.consolidationPartnerId) { return { booking, messages }; } 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 }; } if (booking.status === 'DRAFT') { await this.bookingsRepository.update(booking.id, { status: 'PENDING_CONSOLIDATION', } as never); } const pending = await this.findById(booking.id); messages.push(this.consolidationService.describePending(pending, slots)); return { booking: pending, messages }; } /** Create a new freight booking. */ async create( dto: CreateBookingDto, files: Express.Multer.File[], userId?: string, ): Promise<{ booking: Booking; warnings: string[] }> { const warnings: string[] = []; // 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) { if (!dto.governmentInstitution?.trim()) { throw new BadRequestException('governmentInstitution is required for government bookings'); } companyId = dto.companyId ?? null; } 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); companyId = company.id; } // Schedule targeting: when provided, the schedule must be OPEN and on the same route. if (dto.trainScheduleId) { 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'); } } 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, ); const allowConsolidation = dto.freightType === 'CONTAINER' ? await this.resolveConsolidation(containers, dto.allowConsolidation) : 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, isGovernment, allowConsolidation, shippingLineId: dto.shippingLineId, containers, }); const ruleResult = await this.ruleEngineService.evaluate(evalInput); this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); const booking = await this.bookingsRepository.create({ reference, companyId: companyId ?? null, isGovernment, governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, trainId: dto.trainId, trainScheduleId: dto.trainScheduleId ?? null, contractType: dto.contractType, previousContractId: dto.previousContractId, serviceTypeId: dto.serviceTypeId, firstMilePickupAddress: dto.firstMilePickupAddress, lastMileDeliveryAddress: dto.lastMileDeliveryAddress, 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, paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, scheduledDate: new Date(dto.scheduledDate), startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', allowConsolidation, 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.'); } } let full = await this.findById(booking.id); if (allowConsolidation) { 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 allowConsolidation = freightType === 'CONTAINER' ? await this.resolveConsolidation( containers, dto.allowConsolidation ?? existing.allowConsolidation, ) : false; const evalInput = await this.buildEvalInput({ freightType, cargoTypeId, serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous ?? existing.isHazardous, allowConsolidation, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, containers, }); const ruleResult = await this.ruleEngineService.evaluate(evalInput); this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); const pricingFieldsChanged = this.pricingRelevantFieldsChanged( existing, dto, freightType, cargoTypeId, allowConsolidation, containers, ); const updates: Record = { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, allowConsolidation, priorityScore: ruleResult.priorityScore, tradeDirection, }; if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.endDate) updates.endDate = new Date(dto.endDate); delete updates.containers; 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 (allowConsolidation && !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. */ async findAll( filter: FilterBookingDto, ): Promise<{ items: Booking[]; total: number }> { 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, companyId: filter.companyId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, allowConsolidation: filter.allowConsolidation, consolidationPaired: filter.consolidationPaired, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** 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, allowConsolidation: filter.allowConsolidation, 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); if (!booking.allowConsolidation) { throw new BadRequestException('Booking is not eligible for consolidation'); } 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 pricingRelevantFieldsChanged( existing: Booking, dto: UpdateBookingDto, freightType: FreightType, cargoTypeId: string | null | undefined, allowConsolidation: boolean, containers: CreateBookingContainerDto[], ): boolean { if (dto.freightType !== undefined && dto.freightType !== existing.freightType) { return true; } if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) { return true; } if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) { return true; } if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) { return true; } if ( dto.allowConsolidation !== undefined && dto.allowConsolidation !== existing.allowConsolidation ) { return true; } if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) { return true; } if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) { return true; } 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) || allowConsolidation !== existing.allowConsolidation ) { 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); } }