import { BadRequestException, ConflictException, Injectable, NotFoundException, } from "@nestjs/common"; import { IsNull, Not } from "typeorm"; import { FilesService } from "../files/files.service"; import { MinioService } from "../minio/minio.service"; import { BookingsRepository } from "./bookings.repository"; import { CreateBookingDto } from "./dto/create-booking.dto"; import { FilterBookingDto } from "./dto/filter-booking.dto"; import { UpdateBookingDto } from "./dto/update-booking.dto"; import { UpdateStatusDto } from "./dto/update-status.dto"; import { Booking } from "./entities/booking.entity"; import { FileRecord } from "../files/entities/file.entity"; /** Weight thresholds (tons) that trigger overweight surcharge alerts. */ const WEIGHT_LIMITS = { IMPORT_20FT: 20, EXPORT_20FT: 25, ANY_40FT: 32.5, } as const; /** Bookings above this total VGM are considered high-volume. */ const HIGH_VOLUME_THRESHOLD_TONS = 500; @Injectable() export class BookingsService { constructor( private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, private readonly minioService: MinioService, ) {} // ── helpers ────────────────────────────────────────────────────────── /** Resolve auto-consolidation flag. */ private resolveConsolidation( containers: Array<{ type: string; qty: number }> | undefined | null, explicit?: boolean, ): boolean { if (explicit === false) return false; if (!containers || containers.length === 0) return explicit ?? false; // Auto-enable if any 20FT container has odd quantity const needsConsolidation = containers.some( (c) => c.type === "20FT" && c.qty % 2 !== 0 ); if (needsConsolidation) return true; return explicit ?? false; } /** Calculate priority score based on currency and service type. */ private calculatePriorityScore(currency: string, serviceType: string): number { let score = 0; if (currency === "USD") score += 100; if (serviceType === "RAIL_AND_FORWARDING") score += 50; else if (serviceType === "RAIL_ONLY") score += 25; return score; } /** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */ private calculateWagonCount( containers: Array<{ type: string; qty: number }>, ): number { return containers.reduce((total, container) => { if (container.type === "40FT") { return total + container.qty; } // 20FT: 1 wagon per 2 containers (rounded up) return total + Math.ceil(container.qty / 2); }, 0); } /** Check per-container weight limits and return warnings if exceeded. */ private checkOverweight( containers: Array<{ type: string; vgm: number }>, tradeDirection: string, ): string[] { const warnings: string[] = []; for (const container of containers) { if (container.type === "40FT" && container.vgm > WEIGHT_LIMITS.ANY_40FT) { warnings.push( `40FT container VGM ${container.vgm}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t` ); } if (container.type === "20FT") { const limit = tradeDirection === "IMPORT" ? WEIGHT_LIMITS.IMPORT_20FT : WEIGHT_LIMITS.EXPORT_20FT; if (container.vgm > limit) { warnings.push( `20FT ${tradeDirection} container VGM ${container.vgm}t exceeds limit of ${limit}t` ); } } } return warnings; } // ── CRUD ───────────────────────────────────────────────────────────── /** Create a new freight booking. */ async create( dto: CreateBookingDto, files: Express.Multer.File[], ): Promise<{ booking: Booking; warnings: string[] }> { const warnings: string[] = []; const allowConsolidation = this.resolveConsolidation( dto.containers, dto.allowConsolidation, ); const priorityScore = this.calculatePriorityScore( dto.paymentCurrency, dto.serviceType, ); const overweightWarnings = this.checkOverweight( dto.containers, dto.tradeDirection, ); warnings.push(...overweightWarnings); const wagonCount = this.calculateWagonCount(dto.containers); warnings.push(`Estimated wagons required: ${wagonCount}`); const booking = await this.bookingsRepository.create({ ...dto, 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, }); if (files.length > 0) { try { await this.filesService.uploadMany(booking.id, "bookings", files); } catch (err) { console.error('[BookingsService] File upload failed, booking still created:', err); warnings.push('File upload failed — booking was created without attached files.'); } } return { booking, 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 (existing.status !== "DRAFT") { throw new BadRequestException("Only DRAFT bookings can be updated"); } const warnings: string[] = []; const updates: Record = { ...dto }; 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); // Re-evaluate consolidation if containers changed const containers = dto.containers ?? existing.containers ?? []; updates.allowConsolidation = this.resolveConsolidation( containers, dto.allowConsolidation, ); // Recalculate priority const currency = dto.paymentCurrency ?? existing.paymentCurrency; const serviceType = dto.serviceType ?? existing.serviceType; updates.priorityScore = this.calculatePriorityScore(currency, serviceType); // Overweight check const direction = dto.tradeDirection ?? existing.tradeDirection; const overweightWarnings = this.checkOverweight(containers, direction); warnings.push(...overweightWarnings); if (files.length > 0) { await this.filesService.uploadMany(id, "bookings", files); } const booking = await this.bookingsRepository.update(id, updates); if (!booking) throw new NotFoundException(`Booking ${id} not found`); return { booking, warnings }; } /** 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 where: Record = {}; if (filter.status) where.status = filter.status; if (filter.customerId) where.customerId = filter.customerId; if (filter.contractType) where.contractType = filter.contractType; if (filter.serviceType) where.serviceType = filter.serviceType; if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency; if (filter.freightType) where.freightType = filter.freightType; if (filter.allowConsolidation !== undefined) where.allowConsolidation = filter.allowConsolidation; if (filter.consolidationPaired === "true") where.consolidationPartnerId = Not(IsNull()); else if (filter.consolidationPaired === "false") where.consolidationPartnerId = IsNull(); const sortField = filter.sortBy ?? "createdAt"; const sortDir = filter.sortOrder ?? "DESC"; const [items, total] = await this.bookingsRepository.findAndCount({ where, skip: (page - 1) * pageSize, take: pageSize, order: { [sortField]: sortDir }, }); return { items, total }; } /** Get a single booking by ID with files, throwing if not found. */ async findById(id: string): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { throw new NotFoundException(`Booking ${id} not found`); } // Add signed URLs for files (5-minute expiration) if (booking.files && booking.files.length > 0) { booking.files = await Promise.all( booking.files.map(async (file: FileRecord) => { const objectName = this.extractObjectName(file.url); const signedUrl = await this.minioService.getSignedUrl(objectName, 300); return { ...file, signedUrl }; }) ); } return booking; } /** Extract object name from Minio URL. */ private extractObjectName(url: string): string { const parts = url.split("/"); return parts.slice(4).join("/"); } /** Find booking by reference with files. */ async findByReference(reference: string): Promise { const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); if (!booking) { throw new NotFoundException(`Booking with reference "${reference}" not found`); } // Add signed URLs for files (5-minute expiration) if (booking.files && booking.files.length > 0) { booking.files = await Promise.all( booking.files.map(async (file: FileRecord) => { const objectName = this.extractObjectName(file.url); const signedUrl = await this.minioService.getSignedUrl(objectName, 300); return { ...file, signedUrl }; }) ); } return booking; } /** Soft-delete a booking (DRAFT only). */ 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); } // ── status workflow ────────────────────────────────────────────────── /** Unified status transition handler. */ async updateStatus(id: string, dto: UpdateStatusDto): Promise { const booking = await this.findById(id); const { action, actorId, reason } = dto; switch (action) { case "SUBMIT": return this.handleSubmit(booking); case "APPROVE_STAFF": return this.handleApproveStaff(booking, actorId); case "APPROVE_DIRECTOR": return this.handleApproveDirector(booking, actorId); case "APPROVE_CEO": return this.handleApproveCeo(booking, actorId); case "REJECT": return this.handleReject(booking, actorId, reason); case "CANCEL": return this.handleCancel(booking, actorId, reason); case "ACTIVATE": return this.handleActivate(booking); case "EXPIRE": return this.handleExpire(booking); default: throw new BadRequestException(`Unknown action: ${action}`); } } /** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (bulk). */ private async handleSubmit(booking: Booking): Promise { this.assertStatus(booking, ["DRAFT"]); const isBulk = booking.freightType === "BULK" || booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS; const nextStatus = isBulk ? "PENDING_DIRECTOR" : "PENDING_LINE_STAFF"; const updated = await this.bookingsRepository.update(booking.id, { status: nextStatus, } as never); return updated!; } /** APPROVE_STAFF: PENDING_LINE_STAFF → APPROVED_PENDING_SIGNATURE. */ private async handleApproveStaff( booking: Booking, actorId?: string, ): Promise { this.assertStatus(booking, ["PENDING_LINE_STAFF"]); if (!actorId) throw new BadRequestException("actorId is required for APPROVE_STAFF"); // Line staff cannot approve bulk if ( booking.freightType === "BULK" || booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS ) { throw new BadRequestException( "Line staff cannot approve bulk or high-volume bookings", ); } const updated = await this.bookingsRepository.update(booking.id, { status: "APPROVED_PENDING_SIGNATURE", approvedByStaffId: actorId, approvedByStaffAt: new Date(), } as never); return updated!; } /** APPROVE_DIRECTOR: APPROVED_PENDING_SIGNATURE|PENDING_DIRECTOR → SIGNED or PENDING_CEO. */ private async handleApproveDirector( booking: Booking, actorId?: string, ): Promise { this.assertStatus(booking, [ "APPROVED_PENDING_SIGNATURE", "PENDING_DIRECTOR", ]); if (!actorId) throw new BadRequestException("actorId is required for APPROVE_DIRECTOR"); const isBulk = booking.freightType === "BULK" || booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS; const nextStatus = isBulk ? "PENDING_CEO" : "SIGNED"; const updated = await this.bookingsRepository.update(booking.id, { status: nextStatus, signedByDirectorId: actorId, signedByDirectorAt: new Date(), } as never); return updated!; } /** APPROVE_CEO: PENDING_CEO → SIGNED. */ private async handleApproveCeo( booking: Booking, actorId?: string, ): Promise { this.assertStatus(booking, ["PENDING_CEO"]); if (!actorId) throw new BadRequestException("actorId is required for APPROVE_CEO"); const updated = await this.bookingsRepository.update(booking.id, { status: "SIGNED", signedByCeoId: actorId, signedByCeoAt: new Date(), } as never); return updated!; } /** REJECT: PENDING_* → CANCELLED. */ private async handleReject( booking: Booking, actorId?: string, reason?: string, ): Promise { this.assertStatus(booking, [ "PENDING_LINE_STAFF", "PENDING_DIRECTOR", "PENDING_CEO", "APPROVED_PENDING_SIGNATURE", ]); if (!actorId || !reason) throw new BadRequestException("actorId and reason are required for REJECT"); const updated = await this.bookingsRepository.update(booking.id, { status: "CANCELLED", } as never); return updated!; } /** CANCEL: DRAFT|PENDING_* → CANCELLED. */ private async handleCancel( booking: Booking, _actorId?: string, reason?: string, ): Promise { this.assertStatus(booking, [ "DRAFT", "PENDING_LINE_STAFF", "PENDING_DIRECTOR", "PENDING_CEO", "APPROVED_PENDING_SIGNATURE", ]); if (!reason) throw new BadRequestException("reason is required for CANCEL"); const updated = await this.bookingsRepository.update(booking.id, { status: "CANCELLED", } as never); return updated!; } /** ACTIVATE: SIGNED → ACTIVE. */ private async handleActivate(booking: Booking): Promise { this.assertStatus(booking, ["SIGNED"]); const updated = await this.bookingsRepository.update(booking.id, { status: "ACTIVE", startDate: booking.startDate ?? new Date(), } as never); return updated!; } /** EXPIRE: ACTIVE → EXPIRED. */ private async handleExpire(booking: Booking): Promise { this.assertStatus(booking, ["ACTIVE"]); const updated = await this.bookingsRepository.update(booking.id, { status: "EXPIRED", endDate: new Date(), } as never); return updated!; } /** Guard: ensure current status is one of the allowed values. */ private assertStatus(booking: Booking, allowed: string[]): void { if (!allowed.includes(booking.status)) { throw new ConflictException( `Cannot perform this action on a booking with status "${booking.status}". Allowed: ${allowed.join(", ")}`, ); } } // ── consolidation ──────────────────────────────────────────────────── /** Request consolidation — auto-pair if a partner exists, else queue. */ async requestConsolidation(id: string): Promise<{ booking: Booking; partner: Booking | null; paired: boolean; }> { const booking = await this.findById(id); if (!booking.allowConsolidation) { throw new BadRequestException("Booking is not eligible for consolidation"); } // Check if any 20FT container has odd quantity const hasOdd20FT = booking.containers?.some( (c) => c.type === "20FT" && c.qty % 2 !== 0 ) ?? false; if (!hasOdd20FT) { throw new BadRequestException( "Only bookings with odd-quantity 20FT containers need consolidation", ); } if (booking.consolidationPartnerId) { throw new ConflictException("Booking is already paired for consolidation"); } const partner = await this.bookingsRepository.findConsolidationPartner(booking); if (partner) { await this.bookingsRepository.pairConsolidation(booking.id, partner.id); const updated = await this.findById(id); const updatedPartner = await this.findById(partner.id); return { booking: updated, partner: updatedPartner, paired: true }; } // No partner found — enter queue await this.bookingsRepository.update(booking.id, { status: "PENDING_CONSOLIDATION", } as never); const updated = await this.findById(id); return { booking: updated, partner: null, paired: false }; } /** Remove consolidation pairing. */ 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); const updated = await this.findById(id); const updatedPartner = await this.findById(partnerId); return { booking: updated, partner: updatedPartner }; } /** Get consolidation details for a booking. */ async getConsolidationDetails(id: string): Promise<{ booking: Booking; partner: Booking | null; splitBilling: { bookingShare: number; partnerShare: number } | null; }> { const booking = await this.findById(id); if (!booking.consolidationPartnerId) { return { booking, partner: null, splitBilling: null }; } const partner = await this.findById(booking.consolidationPartnerId); const splitBilling = { bookingShare: booking.totalAmount, partnerShare: partner.totalAmount, }; return { booking, partner, splitBilling }; } }