import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import type { Freight } from '@edr/types'; import { BookingRequestRepository } from './booking-request.repository'; import { ContractsService } from './contracts.service'; import { ContractBookingService } from './contract-booking.service'; import { BookingRequest } from './entities/booking-request.entity'; import { Contract } from './entities/contract.entity'; import { CreateBookingRequestDto } from './dto/create-booking-request.dto'; /** * Customer shipment requests on GENERAL customs (Path B) contracts. The customer * submits a request (date + quantities); GL reviews the queue and, on accept, * creates the booking — after which per-booking clearance begins. ONE_TIME and * Path A do not use this flow. */ @Injectable() export class BookingRequestService { constructor( private readonly repo: BookingRequestRepository, private readonly contractsService: ContractsService, private readonly contractBookingService: ContractBookingService, ) {} /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ private assertGeneralCustoms(contract: Contract): void { if ( contract.contractKind !== 'GENERAL' || !contract.customsClearingEnabled ) { throw new BadRequestException( 'Shipment requests apply only to general customs-clearance contracts.', ); } } /** Customer submits a shipment request. */ async submit( contractId: string, dto: CreateBookingRequestDto, userId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); await this.contractsService.assertCustomerCanAccessContract(userId, contract); this.assertGeneralCustoms(contract); if (contract.status !== 'CONTRACT_ACTIVE') { throw new ConflictException( 'The contract must be active before requesting a shipment.', ); } const isContainer = contract.freightType === 'CONTAINER'; const hasLines = isContainer ? (dto.containers?.length ?? 0) > 0 : Boolean(dto.bulk); if (!hasLines) { throw new BadRequestException( isContainer ? 'Add at least one container line.' : 'Enter the bulk cargo amount.', ); } // Validate requested container sizes against the contract cargo scope and // remaining draw-down capacity (reuses the booking quantity-cap check). if (isContainer) { const allowed = new Set( (contract.cargoScope ?? []) .map((s) => s.containerSize) .filter((s): s is string => !!s), ); for (const line of dto.containers ?? []) { if (allowed.size && !allowed.has(line.containerSize)) { throw new BadRequestException( `Container size ${line.containerSize} is not in this contract's scope.`, ); } } } await this.contractBookingService.assertRequestWithinCapacity(contract, { containers: dto.containers, bulk: dto.bulk, }); const requestedLines: Freight.RequestedShipmentLines = isContainer ? { containers: (dto.containers ?? []).map((l) => ({ containerSize: l.containerSize, quantity: l.quantity, hazardousQuantity: l.hazardousQuantity, reeferQuantity: l.reeferQuantity, })), } : { bulk: { cargoTypeId: dto.bulk?.cargoTypeId ?? null, cargoWeightTons: dto.bulk?.cargoWeightTons, itemCount: dto.bulk?.itemCount, hazardousQuantity: dto.bulk?.hazardousQuantity, }, }; const reference = await this.generateReference(); return this.repo.create({ reference, contractId, requestedByUserId: userId ?? null, contractRouteId: dto.contractRouteId ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, status: 'PENDING', requestedLines, notes: dto.notes ?? null, } as never); } listForContract(contractId: string): Promise { return this.repo.findForContract(contractId); } async findOne(requestId: string): Promise { const request = await this.repo.findById(requestId); if (!request) throw new NotFoundException(`Booking request ${requestId} not found`); return request; } queue(): Promise { return this.repo.findPending(); } private async findPending(requestId: string): Promise { const request = await this.repo.findById(requestId); if (!request) throw new NotFoundException(`Booking request ${requestId} not found`); if (request.status !== 'PENDING') { throw new ConflictException( `This request is already ${request.status.toLowerCase()}.`, ); } return request; } /** * GL accepts a request. The booking itself is created via the GL booking form * (POST /contracts/:id/bookings) which carries the per-unit container data the * request omits; this endpoint records the acceptance + links the created * booking. `bookingId` is supplied by the GL form on success. */ async accept( requestId: string, bookingId: string, staffId?: string, ): Promise { const request = await this.findPending(requestId); await this.repo.update(requestId, { status: 'ACCEPTED', createdBookingId: bookingId, reviewedByStaffId: staffId ?? null, reviewedAt: new Date(), } as never); return (await this.repo.findById(requestId)) ?? request; } /** GL rejects a request with a note. */ async reject( requestId: string, note?: string, staffId?: string, ): Promise { const request = await this.findPending(requestId); await this.repo.update(requestId, { status: 'REJECTED', reviewNote: note ?? null, reviewedByStaffId: staffId ?? null, reviewedAt: new Date(), } as never); return (await this.repo.findById(requestId)) ?? request; } /** Customer cancels their own pending request. */ async cancel(requestId: string, userId?: string): Promise { const request = await this.findPending(requestId); if (request.requestedByUserId && request.requestedByUserId !== userId) { throw new ForbiddenException('You can only cancel your own requests.'); } await this.repo.update(requestId, { status: 'CANCELLED' } as never); return (await this.repo.findById(requestId)) ?? request; } private async generateReference(): Promise { const count = await this.repo.count(); const seq = String(count + 1).padStart(6, '0'); return `SR-${seq}`; } }