import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res, UnauthorizedException, UploadedFile, UploadedFiles, UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { BookingStaff, BookingView } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOkResponse, ApiOperation, ApiTags, } from "@nestjs/swagger"; import type { Response } from "express"; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingTransitionService } from './booking-transition.service'; import { BookingClearanceService } from '../contracts/booking-clearance.service'; import { AdviseContractDutyDto, RoAmendmentDto, } from '../contracts/dto/phased-clearance.dto'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingsService } from './bookings.service'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; import { CreateBookingDto } from './dto/create-booking.dto'; import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, CancelBookingDto, RejectBookingDto, RequestChangesDto, ReviewDocumentDto, RequestOperationDto, OperationReviewDto, StaffRejectDto, } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; import { FirstMileService } from '../first-mile/first-mile.service'; import { LastMileService } from '../last-mile/last-mile.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { type AuthUserPayload, resolveAuthUserId, } from "../../common/resolve-auth-user-id"; import { assertFreightPermission, hasFreightPermission, } from "../../common/freight-permission.util"; interface MileVehicleSummary { plate: string | null; code: string | null; driverName: string | null; containerNumber: string | null; distanceKm: number | null; } interface MileLegSummary { status: string; exactKm: number | null; remainingPayment: number | null; currency: string; invoiced: boolean; vehicles: MileVehicleSummary[]; } /** Trim a first/last-mile record down to a customer-safe operational summary. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function summarizeMileLeg(rec?: Record): MileLegSummary | null { if (!rec) return null; const num = (v: unknown) => (v == null ? null : Number(v)); const assignments: Array> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any const currency = rec.vehicle?.currency ?? assignments[0]?.vehicle?.currency ?? rec.booking?.paymentCurrency ?? 'ETB'; const vehicles: MileVehicleSummary[] = assignments.map((a) => ({ plate: a.vehicle?.plateNumber ?? null, code: a.vehicle?.code ?? null, driverName: a.vehicle?.assignedDriverName ?? null, containerNumber: a.containerNumber ?? null, distanceKm: num(a.distanceKm), })); if (!vehicles.length && rec.vehicle) { vehicles.push({ plate: rec.vehicle.plateNumber ?? null, code: rec.vehicle.code ?? null, driverName: rec.vehicle.assignedDriverName ?? null, containerNumber: null, distanceKm: num(rec.exactKm), }); } return { status: rec.status ?? '', exactKm: num(rec.exactKm), remainingPayment: num(rec.remainingPayment), currency, invoiced: Boolean(rec.invoice), vehicles, }; } @ApiTags("bookings") @Controller("bookings") @ApiBearerAuth() export class BookingsController { constructor( private readonly bookingsService: BookingsService, private readonly bookingReferenceDataService: BookingReferenceDataService, private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, private readonly bookingClearanceService: BookingClearanceService, private readonly customerTruckService: CustomerTruckService, private readonly containerReceiptService: ContainerReceiptService, private readonly firstMileService: FirstMileService, private readonly lastMileService: LastMileService, ) {} @Post() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Create a new freight booking (DRAFT)" }) @ApiBody({ type: CreateBookingDto }) async create( @Body() dto: CreateBookingDto, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { if (dto.isGovernment) { assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); } const result = await this.bookingsService.create( dto, files ?? [], user?.id, ); // Staff-created commercial bookings skip the draft stage: auto generate-price + submit. const isStaff = hasFreightPermission( user, FREIGHT_PERMS.bookings.staffAccept, ); if (isStaff && !dto.isGovernment) { try { await this.pricingService.generatePrice(result.booking.id); await this.transitionService.submit(result.booking.id); const submitted = await this.bookingsService.findById( result.booking.id, ); return { booking: submitted, warnings: result.warnings }; } catch { // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. return result; } } return result; } @Patch(":id") @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Update booking", description: "Allowed when status is DRAFT or CHANGES_REQUESTED.", }) @ApiBody({ type: UpdateBookingDto }) update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto, @UploadedFiles() files: Express.Multer.File[], ) { return this.bookingsService.update(id, dto, files ?? []); } @Get() @ApiOperation({ summary: "List freight bookings (paginated)" }) async findAll( @Query() filter: FilterBookingDto, @CurrentUser() user: TCurrentUser, ) { // Staff (backoffice) see every booking. Customers (portal) are always // force-scoped to their own company, regardless of any companyId they pass. if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { return this.bookingsService.findAll(filter); } // Global Logistics has clearance:view but NOT bookings:view — it is scoped // to the customs document-clearance queue only and never sees the general // booking-request list. if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) { return this.bookingsService.findClearanceQueue(filter); } const userId = user?.id; if (!userId) throw new UnauthorizedException("Authentication required"); const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet → no bookings to show (avoids leaking all bookings). if (!companyId) { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; return { items: [], total: 0, meta: { page, pageSize, total: 0, totalPages: 0, hasNextPage: false, hasPreviousPage: false, }, }; } // Company-wide by default; the optional filter.companyProfileId (per-page // service filter) narrows within the company. The company guard always // applies, so a customer can only ever see their own company's bookings. return this.bookingsService.findAll(filter, companyId); } @Get("by-company/:companyId/customer-view") @BookingView() @ApiOperation({ summary: "List bookings for a company (customer-view shape, backoffice)", }) findByCompanyCustomerView( @Param("companyId", ParseUUIDPipe) companyId: string, ) { return this.bookingsService.findCustomerBookings(companyId); } @Get("list-summary") @BookingView() @ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" }) @ApiOkResponse({ type: BookingListSummaryDto }) findListSummary(@Query() filter: FilterBookingDto) { return this.bookingsService.getListSummary(filter); } @Get("my") @ApiOperation({ summary: "List the current customer's bookings ready for payment", description: "Bookings owned by the authenticated user's company that are payable " + "(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.", }) findMyPayable( @CurrentUser() user: AuthUserPayload, @Query() filter: FilterBookingDto, ) { return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter); } @Get("queues/:queue") @BookingView() @ApiOperation({ summary: "List bookings for a dashboard queue", description: "Queues: intake, approval, signatures, marketing, finance", }) findQueue( @Param("queue") queue: string, @Query() filter: FilterBookingDto, @Query("excludeBulk") excludeBulk?: string, ) { return this.bookingsService.findQueue(queue, filter, { excludeBulk: excludeBulk === "true", }); } @Get("reference-data") @ApiOperation({ summary: "Booking form catalog" }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } @Get("by-reference/:reference") @ApiOperation({ summary: "Get booking by reference" }) async findByReference( @Param("reference") reference: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findByReference(reference); // Staff see any booking; customers only their own company's. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, booking, ); } return this.transitionService.enrichBookingResponse(booking); } @Get(":id") @ApiOperation({ summary: "Get booking by ID" }) async findOne( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); // Staff see any booking; Global Logistics (clearance:view) may inspect any // booking for the clearance gate; customers only their own company's. if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) ) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, booking, ); } return this.transitionService.enrichBookingResponse(booking); } @Get(':id/available-days') @ApiOperation({ summary: 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)', }) async availableDays( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) ) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, booking, ); } return this.bookingsService.availableDaysForBooking(id); } @Get(':id/day-availability') @ApiOperation({ summary: 'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' + 'Export: whole-booking fit + largest single-train leftover. ' + 'Import/domestic: total room across the day for the booking\'s wagon type.', }) async dayAvailability( @Param('id', ParseUUIDPipe) id: string, @Query('date') date: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) ) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, booking, ); } return this.transitionService.dayAvailabilityForBooking(id, date); } @Get(':id/mile-summary') @ApiOperation({ summary: 'First/last-mile operational summary for a booking (customer-safe)', }) async mileSummary( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { // Customers may only see their own booking's mile summary. const booking = await this.bookingsService.findById(id); if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) ) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } const [first, last] = await Promise.all([ this.firstMileService.findAll({ bookingId: id, pageSize: 1 }), this.lastMileService.findAll({ bookingId: id, pageSize: 1 }), ]); return { firstMile: summarizeMileLeg(first.data[0]), lastMile: summarizeMileLeg(last.data[0]), }; } @Post(':id/customer-truck-assignment') @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) async assignCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CustomerTruckAssignmentDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } const assigned = await this.bookingsService.assignCustomerTruck(id, dto); return this.transitionService.enrichBookingResponse(assigned); } @Get(':id/customer-truck-assignment/freight-order') @ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' }) async customerTruckFreightOrder( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } const { filename, buffer } = await this.bookingsService.customerTruckFreightOrderCopies(id); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(buffer); } @Get(':id/customer-trucks') @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) async listCustomerTrucks( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } return this.customerTruckService.listTrucks(id); } @Post(':id/customer-trucks') @ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' }) async addCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @Body() dto: AddCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } return this.customerTruckService.addTruck(id, dto); } @Post(':id/customer-trucks/bulk') @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) async bulkAddCustomerTrucks( @Param('id', ParseUUIDPipe) id: string, @Body() payload: { trucks: AddCustomerTruckDto[] }, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } return this.customerTruckService.addBulkTrucks(id, payload.trucks); } @Patch(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @Param('assignmentId', ParseUUIDPipe) assignmentId: string, @Body() dto: AddCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } return this.customerTruckService.updateTruck(id, assignmentId, dto); } @Delete(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) async removeCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @Param('assignmentId', ParseUUIDPipe) assignmentId: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } return this.customerTruckService.removeTruck(id, assignmentId); } @Get(':id/customer-trucks/loadable-containers') @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' }) async loadableContainers( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } return this.customerTruckService.getLoadableContainers(id); } @Post(':id/customer-trucks/:assignmentId/load') @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) async loadCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @Param('assignmentId', ParseUUIDPipe) assignmentId: string, @Body() dto: LoadCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { throw new ForbiddenException('Only warehouse staff can load a truck'); } return this.customerTruckService.loadTruck(id, assignmentId, dto); } @Post(':id/customer-trucks/:assignmentId/depart') @ApiOperation({ summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', }) async departCustomerTruck( @Param('id', ParseUUIDPipe) id: string, @Param('assignmentId', ParseUUIDPipe) assignmentId: string, @Body() dto: DepartCustomerTruckDto, @CurrentUser() user: TCurrentUser, ) { // Weighing + registering the load on exit is a warehouse/gate staff action. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { throw new ForbiddenException('Only warehouse staff can register a truck departure'); } return this.customerTruckService.departTruck(id, assignmentId, dto); } @Get(':id/received-pending-grn') @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) async receivedPendingGrn( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { // GRN is a warehouse-staff action — no customer access. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); } return this.containerReceiptService.listReceivedPendingGrn(id); } @Post(':id/generate-grn') @ApiOperation({ summary: 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', }) async generateGrn( @Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateGrnDto, @CurrentUser() user: TCurrentUser, ) { // GRN is a warehouse-staff action — no customer access. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); } return this.containerReceiptService.generateGrn(id, dto.containerNumbers); } @Get(':id/tracking') @ApiOperation({ summary: "Shipment tracking timeline for a booking", description: "Returns the booking's consignment (once dispatched) and its ordered " + "tracking events. Scoped to the customer's own company.", }) async findTracking( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); // Staff see any booking; customers only their own company's. if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, booking, ); } return this.bookingsService.getBookingTracking(id); } @Delete(":id") @HttpCode(204) @ApiOperation({ summary: "Soft-delete DRAFT booking" }) remove(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } @Post(":id/documents") @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" }) async uploadDocuments( @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.bookingsService.uploadDocuments(id, files ?? []); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/generate-price") @ApiOperation({ summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)", description: "Computes and stores a price preview on the booking. Does not create rate snapshots.", }) @ApiOkResponse({ type: GeneratePriceResponseDto }) generatePrice(@Param("id", ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } @Post(":id/submit") @ApiOperation({ summary: "Customer submit booking", description: "Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) submit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } @Post(":id/confirm-submit") @ApiOperation({ summary: "Confirm submit after price change", description: "Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) confirmSubmit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); } @Post(":id/reject") @ApiOperation({ summary: "Customer reject price estimate", description: "Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.", }) async reject( @Param("id", ParseUUIDPipe) id: string, @Body() dto: RejectBookingDto, ) { const booking = await this.transitionService.reject(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } // ── Document clearance (post counter-sign) ──────────────────────────────── @Get('clearance/et-queue') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' }) getBookingEtClearanceQueue() { return this.bookingClearanceService.etQueue(); } @Get('clearance/dj-queue') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' }) getBookingDjClearanceQueue() { return this.bookingClearanceService.djQueue(); } @Get(':id/clearance') @ApiOperation({ summary: "Document-clearance grid (required docs + upload + GL review status)", }) getClearance(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.getClearanceView(id); } @Post(":id/clearance/documents") @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Customer uploads clearance documents (fieldname = document key)", }) async submitClearanceDocuments( @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.submitClearanceDocuments( id, files ?? [], ); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/clearance/proceed") @ApiOperation({ summary: "Customer requests operation with a schedule day " + "(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)", }) async proceedToOperation( @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestOperationDto, ) { const booking = await this.transitionService.requestOperation( id, dto.scheduledDate, ); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/operation/review") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: "Operations reviews an operation request: ACCEPT (→ batch pool), " + "REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)", }) async reviewOperationRequest( @Param("id", ParseUUIDPipe) id: string, @Body() dto: OperationReviewDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.reviewOperationRequest( id, dto.decision, resolveAuthUserId(user), { note: dto.note }, ); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/clearance/review") @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) @ApiOperation({ summary: "GL reviews a clearance document (Approve | Query)", }) async reviewClearanceDocument( @Param("id", ParseUUIDPipe) id: string, @Body() dto: ReviewDocumentDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.reviewDocument( id, dto.fileKey, dto.status, resolveAuthUserId(user), dto.note, ); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/clearance/output-documents") @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" }) async uploadClearanceOutput( @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.uploadClearanceOutputDocuments( id, files ?? [], ); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/clearance/finalize") @BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance) @ApiOperation({ summary: "GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", }) async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.finalizeClearance(id); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/declaration') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' }) async uploadBookingDeclaration( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeclaration( id, files ?? [], resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/duty') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) @UseInterceptors(FileInterceptor('attachment')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' }) async adviseBookingDuty( @Param('id', ParseUUIDPipe) id: string, @Body('dutyRequired') dutyRequiredRaw: string, @Body('amount') amountRaw: string | undefined, @Body('currency') currency: string | undefined, @Body('declarationSerial') declarationSerial: string | undefined, @UploadedFile() attachment: Express.Multer.File | undefined, @CurrentUser() user: TCurrentUser, ) { const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; const dto: AdviseContractDutyDto = { dutyRequired, amount: amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, currency: currency ?? 'ETB', declarationSerial, }; const booking = await this.bookingClearanceService.adviseDuty( id, dto, resolveAuthUserId(user), attachment, ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/finalize-pre-clearance') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) { const booking = await this.bookingClearanceService.finalizePreClearance(id); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/duty-slip') @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' }) async uploadBookingDutySlip( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, ) { const booking = await this.bookingClearanceService.uploadDutySlip(id, file); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/transit-permit') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') async uploadBookingTransitPermit( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadTransitPermit( id, files ?? [], resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/delivery-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, file, resolveAuthUserId(user), vesselDepartureDate, ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/release-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') async uploadBookingReleaseOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, @Body('vesselDepartureDate') vesselDepartureDate: string, @CurrentUser() user: TCurrentUser, ) { const result = await this.bookingClearanceService.uploadReleaseOrder( id, file, vesselDepartureDate, resolveAuthUserId(user), ); return { ...this.transitionService.enrichBookingResponse(result.booking), hold: result.hold, holdReason: result.holdReason, }; } @Post(':id/clearance/ro-amendment') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) async requestBookingRoAmendment( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RoAmendmentDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.requestRoAmendment( id, dto.note, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/export-release') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) async confirmBookingExportRelease( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.confirmExportRelease( id, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/staff/request-changes') @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.requestChanges( id, dto.note, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/staff/accept") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: "Staff accept intake → set contract validity window + start approval chain", }) async acceptIntake( @Param("id", ParseUUIDPipe) id: string, @Body() dto: AcceptIntakeDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.acceptIntake( id, resolveAuthUserId(user), dto.validityDays, ); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/staff/reject") @BookingStaff(FREIGHT_PERMS.bookings.reject) @ApiOperation({ summary: "Staff final reject" }) async staffReject( @Param("id", ParseUUIDPipe) id: string, @Body() dto: StaffRejectDto, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.staffReject( id, dto.reason, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/government-expedite") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: "Expedite government booking to PAID / ELIGIBLE for scheduling", }) async governmentExpedite( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingsService.governmentExpedite( id, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/contract/generate") @BookingStaff(FREIGHT_PERMS.bookings.generateContract) @ApiOperation({ summary: "Generate contract PDF from template" }) async generateContract(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.contractService.generateContract(id); return this.transitionService.enrichBookingResponse(booking); } @Get(":id/contract/view") @ApiOkResponse({ type: ContractViewDto }) @ApiOperation({ summary: "Contract HTML view for portal and backoffice" }) getContractView( @Param("id", ParseUUIDPipe) id: string, @Request() req: { user?: { id?: string; sub?: string } }, ) { const userId = req.user?.id ?? req.user?.sub; return this.contractService.getContractView(id, userId); } @Get(":id/contract/document") @ApiOperation({ summary: "Download contract PDF" }) async downloadContractDocument( @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { const { stream, record } = await this.contractService.streamContract(id); res.setHeader("Content-Type", record.mimeType ?? "application/pdf"); res.setHeader( "Content-Disposition", `attachment; filename="${record.name}"`, ); stream.pipe(res); } @Get(":id/contract") @ApiOperation({ summary: "Download contract file (alias)" }) async downloadContract( @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { return this.downloadContractDocument(id, res); } @Post(":id/contract/sign") @UseGuards(JwtGuard) @ApiOperation({ summary: "Apply digital signature (customer or staff)" }) async signContract( @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: TCurrentUser, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { // Staff signature needs the sign permission; customer signs their own booking. if (dto.role !== "CUSTOMER") { assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff); } const userId = req.user?.id ?? req.user?.sub; const booking = await this.contractService.signContract(id, dto, { signerUserId: userId, ipAddress: req.ip, }); return this.transitionService.enrichBookingResponse(booking); } @Get(":id/contract/signatures") @ApiOperation({ summary: "List contract signatures" }) getContractSignatures(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSignatures(id); } @Get(":id/summary") @ApiOperation({ summary: "Contract summary string for dashboard" }) getSummary(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSummary(id); } @Post(":id/customer/sign") @ApiOperation({ summary: "Customer digital signature (deprecated — use POST contract/sign)", }) async customerSign( @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { const payload: SignContractDto = { ...dto, role: "CUSTOMER" }; const booking = await this.contractService.signContract(id, payload, { signerUserId: req.user?.id ?? req.user?.sub, ipAddress: req.ip, }); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/marketing/approve") @BookingStaff(FREIGHT_PERMS.bookings.signStaff) @ApiOperation({ summary: "Staff contract signature and fully execute (use contract/sign STAFF preferred)", }) async marketingApprove( @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: AuthUserPayload, @Request() req: { ip?: string }, ) { const payload: SignContractDto = { ...dto, role: "STAFF", }; const booking = await this.contractService.signContract(id, payload, { signerUserId: resolveAuthUserId(user), ipAddress: req.ip, }); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/operations/start-transit") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: "Mark in transit" }) async startTransit(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.startTransit(id); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/operations/complete") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: "Mark completed" }) async complete(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.complete(id); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/cancel") @BookingStaff(FREIGHT_PERMS.bookings.cancel) @ApiOperation({ summary: "Cancel booking" }) async cancel( @Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelBookingDto, ) { const booking = await this.transitionService.cancel(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/consolidation") @ApiOperation({ summary: "Request freight consolidation" }) requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } @Delete(":id/consolidation") @ApiOperation({ summary: "Remove consolidation pairing" }) removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } @Get(":id/consolidation") @ApiOperation({ summary: "Get consolidation details" }) getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); } }