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 { ContractNotifierService } from './contract-notifier.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, private readonly notifier: ContractNotifierService, ) {} /** * Shipment requests exist because on a CUSTOMS contract the customer never * books directly — GL Ethiopia does it for them. The request is how the * customer states what to ship and, now, which currency to be invoiced in. * * GENERAL: each request opens its own per-booking clearance instance. * ONE_TIME: clearance already ran at the contract level, so the request only * records the customer's intent; GL creates the single booking from it. */ private assertCustomsContract(contract: Contract): void { if (!contract.customsClearingEnabled) { throw new BadRequestException( 'Shipment requests apply only to customs-clearance contracts.', ); } } /** * Statuses in which a ONE_TIME customs contract may take a shipment request: * both signatures are in and the contract is at (or past) its clearance * phase, but GL has not booked yet. */ private static readonly ONE_TIME_REQUESTABLE_STATUSES = [ 'FULLY_EXECUTED', 'AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', ]; /** 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.assertCustomsContract(contract); const isOneTime = contract.contractKind === 'ONE_TIME'; if (contract.status === 'CONTRACT_CLOSED') { throw new ConflictException( 'This contract is completed — the full contracted quantity has been booked.', ); } if (isOneTime) { if ( !BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.includes( contract.status, ) ) { throw new ConflictException( 'The contract must be fully executed before requesting its shipment.', ); } // A one-time contract carries exactly one shipment, so it carries at most // one open request — otherwise GL sees two conflicting currencies. const open = (await this.repo.findForContract(contractId)).find( (r) => r.status === 'PENDING', ); if (open) { throw new ConflictException( `Shipment request ${open.reference} is already open on this contract.`, ); } } else 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.`, ); } } } // Draw-down capacity is a GENERAL concept — a ONE_TIME contract's single // shipment is bounded by the contract scope itself, checked when GL books. if (!isOneTime) { 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, }, }; // Clearance-first flow: the request immediately initiates a BARE booking // instance (no cargo, no date, no price) that enters per-booking phased // customs clearance. GL no longer screens the request up front — it // reviews the documents in the clearance queue and completes the booking // (container numbers, VGM, shipment day) once clearance is ready. The // instance is created first so a failure leaves no half-linked request. // GENERAL: the request immediately opens a BARE booking instance that runs // per-booking phased customs clearance. ONE_TIME: clearance already ran on // the contract, so there is nothing to open — the request stays PENDING // until GL creates the contract's single booking from it. const booking = isOneTime ? null : await this.contractBookingService.initiateForShipmentRequest(contract, { contractRouteId: dto.contractRouteId, userId, paymentCurrency: dto.paymentCurrency, }); const reference = await this.generateReference(); const request = await this.repo.create({ reference, contractId, requestedByUserId: userId ?? null, contractRouteId: dto.contractRouteId ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, status: booking ? 'ACCEPTED' : 'PENDING', createdBookingId: booking?.id ?? null, requestedLines, // Intercity is invoiced in birr whatever the customer picked. paymentCurrency: contract.tradeDirection === 'DOMESTIC' ? 'ETB' : (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'), notes: dto.notes ?? null, } as never); this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference); return request; } 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.findQueue(); } 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); const contract = await this.contractsService.findById(request.contractId); this.notifier.shipmentRequestRejected(contract, request.reference, note); 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 seq = await this.repo.maxReferenceSequence(); return `SR-${String(seq + 1).padStart(6, '0')}`; } }