import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; // 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 { 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( 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, ) {} /** 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; 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, }; }), ); return { freightType: dto.freightType, cargoTypeId: dto.cargoTypeId ?? null, serviceTypeId: dto.serviceTypeId, paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, allowConsolidation: dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, shippingLineId: dto.shippingLineId, 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; // } let companyId = dto.companyId; 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; } const reference = dto.reference || (await this.generateReference()); const containers = dto.containers ?? []; assertFreightShape({ freightType: dto.freightType, cargoTypeId: dto.cargoTypeId, containers, }); 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: dto.tradeDirection, isHazardous: dto.isHazardous, 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, trainId: dto.trainId, 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: dto.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?.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 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: dto.tradeDirection ?? existing.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 updates: Record = { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, allowConsolidation, priorityScore: ruleResult.priorityScore, }; 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 (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 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); return this.bookingsRepository.findAllPaginated({ page, pageSize, ...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, 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); } 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, ), }; } }