import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res, UnauthorizedException, UploadedFile, UploadedFiles, 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 { BookingStaff, BookingView, MixedAudience, PortalCustomer, WagonCancellationView, } 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 { BookingClearanceChargeService } from './booking-clearance-charge.service'; import { BookingPayablesService } from './booking-payables.service'; import { ClearanceEventService } from './clearance-event.service'; import { BillClearanceChargeDto, RejectClearanceChargeDto, } from './dto/clearance-charge.dto'; 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 { scopedDirections } from '../user-trade-access/trade-scope.util'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { BookingsService } from './bookings.service'; import { ConsolidationApprovalService } from './consolidation-approval.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, ApproveConsolidationDto, CancelBookingDto, PairedDecisionDto, RejectConsolidationDto, 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 { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; import { FilterWagonCancellationsDto, RebookCancelledWagonsDto, RequestWagonCancellationDto, } from './dto/wagon-cancellation.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, private readonly userTradeAccessService: UserTradeAccessService, private readonly wagonCancellationService: BookingWagonCancellationService, private readonly consolidationApprovalService: ConsolidationApprovalService, private readonly clearanceChargeService: BookingClearanceChargeService, private readonly bookingPayablesService: BookingPayablesService, private readonly clearanceEventService: ClearanceEventService, ) {} @Post() @MixedAudience([]) @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") @MixedAudience([]) @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() @MixedAudience(FREIGHT_PERMS.bookings.view) @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)) { // Per-user trade-direction scope (import/export/intercity checkboxes). const allowed = await this.userTradeAccessService.resolveAllowedDirections(user); const dirs = scopedDirections(allowed, filter.tradeDirection); return this.bookingsService.findAll( filter, undefined, undefined, dirs ?? undefined, ); } // 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); } // Powers the customer-detail bookings tab, so `customers:view` reaches it too // — otherwise a staffer granted only the customer permission gets a page whose // tabs 403 individually. @Get("by-company/:companyId/customer-view") @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view]) @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-payables") @PortalCustomer() @ApiOperation({ summary: "Outstanding customer payments per booking — invoices to pay, prices to accept, duty slips to upload", }) async findMyPayables(@CurrentUser() user: AuthUserPayload) { const companyId = await this.bookingsService.resolveCustomerCompanyId( resolveAuthUserId(user), ); return companyId ? this.bookingPayablesService.summarizeForCompany(companyId) : []; } @Get("my") @PortalCustomer() @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") @MixedAudience([]) @ApiOperation({ summary: "Booking form catalog" }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } @Get("by-reference/:reference") @MixedAudience(FREIGHT_PERMS.bookings.view) @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") @MixedAudience(FREIGHT_PERMS.bookings.view) @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') @MixedAudience([]) @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') @MixedAudience([]) @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') @MixedAudience(FREIGHT_PERMS.bookings.view) @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') @PortalCustomer() @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') @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', }) async customerTruckFreightOrder( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, @Query('copies') copies?: string, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } const extraCopyIndexes = (copies ?? '') .split(',') .map((n) => Number(n.trim())) .filter((n) => Number.isInteger(n) && n >= 1 && n <= 8); const { filename, buffer } = await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(buffer); } @Get(':id/carriage-acceptance-sheet') @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: 'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)', }) async carriageAcceptanceSheet( @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.carriageAcceptanceSheet(id); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(buffer); } @Get(':id/wagons') @ApiOperation({ summary: 'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train', }) async wagonAllocations( @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.bookingsService.wagonAllocations(id); } // ── Partial wagon cancellation (paid bookings) ──────────────────────────── // Customer endpoints are ownership-scoped (no portal permission keys); the // staff history/void/rebook variants are permission-gated below. @Post(':id/wagon-cancellations/preview') @ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' }) async previewWagonCancellation( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestWagonCancellationDto, @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.wagonCancellationService.previewCancellation(id, dto); } @Post(':id/wagon-cancellations') @ApiOperation({ summary: 'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles', }) async requestWagonCancellation( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestWagonCancellationDto, @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.wagonCancellationService.requestCancellation(id, dto, user?.id); } @Get(':id/wagon-cancellations') @ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' }) async listBookingWagonCancellations( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); const staff = hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView); if (!staff) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 }); } @Get('wagon-cancellations/my') @ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' }) async listMyWagonCancellations( @Query() filter: FilterWagonCancellationsDto, @CurrentUser() user: TCurrentUser, ) { const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? ''); if (!companyId) throw new ForbiddenException('No customer company for this user.'); return this.wagonCancellationService.list({ companyId, status: filter.statuses, search: filter.search, from: filter.from ? new Date(filter.from) : undefined, to: filter.to ? new Date(filter.to) : undefined, page: filter.page, pageSize: filter.pageSize, }); } @Get('wagon-cancellations/history') @WagonCancellationView() @ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' }) async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) { return this.wagonCancellationService.list({ status: filter.statuses, search: filter.search, from: filter.from ? new Date(filter.from) : undefined, to: filter.to ? new Date(filter.to) : undefined, page: filter.page, pageSize: filter.pageSize, }); } @Post('wagon-cancellations/:cancellationId/withdraw') @ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' }) async withdrawWagonCancellation( @Param('cancellationId', ParseUUIDPipe) cancellationId: string, @CurrentUser() user: TCurrentUser, ) { await this.assertWagonCancellationActor( cancellationId, user, FREIGHT_PERMS.bookings.wagonCancellationVoid, ); return this.wagonCancellationService.withdraw(cancellationId); } @Post('wagon-cancellations/:cancellationId/rebook') @ApiOperation({ summary: 'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)', }) async rebookWagonCancellation( @Param('cancellationId', ParseUUIDPipe) cancellationId: string, @Body() dto: RebookCancelledWagonsDto, @CurrentUser() user: TCurrentUser, ) { await this.assertWagonCancellationActor( cancellationId, user, FREIGHT_PERMS.bookings.wagonCancellationRebook, ); return this.wagonCancellationService.rebook(cancellationId, dto, user?.id); } /** Owner-or-staff gate shared by the per-cancellation actions. */ private async assertWagonCancellationActor( cancellationId: string, user: TCurrentUser, staffPermission: string, ): Promise { if (hasFreightPermission(user, staffPermission)) return; const row = await this.wagonCancellationService.findById(cancellationId); const booking = await this.bookingsService.findById(row.bookingId); await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } @Get(':id/customer-trucks') @MixedAudience([ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.operations, FREIGHT_PERMS.warehouseInventory.view, ]) @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') @PortalCustomer() @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') @PortalCustomer() @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') @PortalCustomer() @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') @PortalCustomer() @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') @MixedAudience(FREIGHT_PERMS.bookings.view) @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); } @Patch(':id/export-handover-mode') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first', }) setExportHandoverMode( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SetExportHandoverModeDto, ) { return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode); } @Post(':id/customer-trucks/:assignmentId/load') @BookingStaff(FREIGHT_PERMS.bookings.operations) @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') @BookingStaff(FREIGHT_PERMS.bookings.operations) @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') @MixedAudience(FREIGHT_PERMS.bookings.view) @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') @BookingStaff(FREIGHT_PERMS.bookings.operations) @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') @MixedAudience(FREIGHT_PERMS.bookings.view) @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") @MixedAudience([]) @HttpCode(204) @ApiOperation({ summary: "Soft-delete DRAFT booking" }) remove(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } @Post(":id/documents") @MixedAudience([]) @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") @MixedAudience([]) @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") @MixedAudience([]) @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") @MixedAudience([]) @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") @PortalCustomer() @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(@CurrentUser() user: unknown) { return this.bookingClearanceService.etQueue(user); } @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') @MixedAudience([FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments]) @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") @PortalCustomer() @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[], @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.submitClearanceDocuments( id, files ?? [], resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } // Customer requests the operation; GL ET also resubmits here on the // customer's behalf after operations requests changes (BookingChangesRequestedAlert). @Post(":id/clearance/proceed") @MixedAudience(FREIGHT_PERMS.contracts.createBooking) @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, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.requestOperation( id, dto.scheduledDate, dto.trainScheduleId ?? null, { userId: resolveAuthUserId(user) }, ); return this.transitionService.enrichBookingResponse(booking); } @Get(":id/export-trains") @MixedAudience([]) @ApiOperation({ summary: "Export train picker: the day's export trains on the booking's corridor " + "with per-wagon-type free space (export rail bookings only)", }) async exportTrainsForBooking( @Param("id", ParseUUIDPipe) id: string, @Query("date") date: string, // Bare contract instances carry no cargo yet — the completion form sends // what the customer is entering so per-type space reflects THEIR cargo. @Query("containerTypeIds") containerTypeIds?: string, @Query("containerSizes") containerSizes?: string, @Query("cargoTypeId") cargoTypeId?: string, @Query("cargoTypeCode") cargoTypeCode?: string, @Query("wagons") wagons?: string, ) { const parsedWagons = Number(wagons); return this.transitionService.exportTrainsForBooking(id, date, { containerTypeIds: containerTypeIds ? containerTypeIds.split(",").filter(Boolean) : undefined, containerSizes: containerSizes ? containerSizes.split(",").filter(Boolean) : undefined, cargoTypeId: cargoTypeId || undefined, cargoTypeCode: cargoTypeCode || undefined, wagons: Number.isFinite(parsedWagons) && parsedWagons > 0 ? parsedWagons : undefined, }); } @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/doc-requests") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: "GL asks the customer for additional clearance document(s) — shown on the portal with author and time", }) async requestAdditionalDocuments( @Param("id", ParseUUIDPipe) id: string, @Body("note") note: string, @CurrentUser() user: AuthUserPayload, ) { await this.transitionService.requestAdditionalDocuments( id, note, resolveAuthUserId(user), ); return { success: true }; } @Get(":id/clearance/history") @BookingStaff([ FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDjActions, ]) @ApiOperation({ summary: "Clearance action history for the booking — reviews, workflow steps, charges (newest first)", }) getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) { return this.clearanceEventService.list(id); } // ── Clearance charges (post-finalization customer billing) ──────────────── @Get(":id/clearance/charges") @MixedAudience([ FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDjActions, ]) @ApiOperation({ summary: "Clearance charges billed to the customer (port + miscellaneous); customers see only the charges sent to them", }) async getClearanceCharges( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const isStaff = hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions); if (isStaff) return this.clearanceChargeService.list(id); const booking = await this.bookingsService.findById(id); await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); return this.clearanceChargeService.listForCustomer(id); } @Post(":id/clearance/charges/:chargeId/accept") @PortalCustomer() @ApiOperation({ summary: "Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge", }) acceptClearanceCharge( @Param("id", ParseUUIDPipe) id: string, @Param("chargeId", ParseUUIDPipe) chargeId: string, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceChargeService.customerAccept( id, chargeId, resolveAuthUserId(user), ); } @Post(":id/clearance/charges/:chargeId/reject") @PortalCustomer() @ApiOperation({ summary: "Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends", }) rejectClearanceCharge( @Param("id", ParseUUIDPipe) id: string, @Param("chargeId", ParseUUIDPipe) chargeId: string, @Body() dto: RejectClearanceChargeDto, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceChargeService.customerReject( id, chargeId, dto.note, resolveAuthUserId(user), ); } @Post(":id/clearance/charges/port-document") @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor("file")) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "GL Djibouti uploads (or replaces, until billed) the port-charges document", }) uploadPortChargeDocument( @Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, @CurrentUser() user: AuthUserPayload, ) { if (!file) throw new BadRequestException("A document file is required"); return this.clearanceChargeService.uploadPortDocument( id, file, resolveAuthUserId(user), ); } @Patch(":id/clearance/charges/:chargeId/bill") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: "GL Ethiopia sets or revises the charge's amount, currency and description (locked once the customer accepts)", }) billClearanceCharge( @Param("id", ParseUUIDPipe) id: string, @Param("chargeId", ParseUUIDPipe) chargeId: string, @Body() dto: BillClearanceChargeDto, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceChargeService.billCharge( id, chargeId, dto, resolveAuthUserId(user), ); } @Post(":id/clearance/charges/:chargeId/send") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: "GL Ethiopia sends the priced charge to the customer for approval (the invoice is issued when they accept)", }) sendClearanceCharge( @Param("id", ParseUUIDPipe) id: string, @Param("chargeId", ParseUUIDPipe) chargeId: string, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceChargeService.sendCharge( id, chargeId, resolveAuthUserId(user), ); } @Post(":id/clearance/charges/miscellaneous") @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(FileInterceptor("file")) @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "GL Ethiopia creates a miscellaneous charge (document + amount + currency + description) as a draft to send", }) createMiscellaneousCharge( @Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, @Body() dto: BillClearanceChargeDto, @CurrentUser() user: AuthUserPayload, ) { if (!file) throw new BadRequestException("A document file is required"); return this.clearanceChargeService.createMiscellaneous( id, file, dto, resolveAuthUserId(user), ); } @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[], @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.uploadClearanceOutputDocuments( id, files ?? [], resolveAuthUserId(user), ); 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, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.finalizeClearance( id, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/transit-assignee/request') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration', }) async requestBookingTransitAssignee( @Param('id', ParseUUIDPipe) id: string, @Body('note') note: string | undefined, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.requestTransitAssignee( id, note, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/transit-assignee/assign') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: 'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns', }) async assignBookingTransitAssignee( @Param('id', ParseUUIDPipe) id: string, @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.assignTransitAssignee( id, transitAgentId, resolveAuthUserId(user), ); 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/draft-declaration') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review', }) async uploadBookingDraftDeclaration( @Param('id', ParseUUIDPipe) id: string, @Body('price') priceRaw: string, @Body('currency') currency: string | undefined, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDraftDeclaration( id, files ?? [], Number(priceRaw), currency ?? 'ETB', resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/draft-declaration/accept') @PortalCustomer() @ApiOperation({ summary: 'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia', }) async acceptBookingDraftDeclaration( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.acceptDraftDeclaration( id, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/draft-declaration/change') @PortalCustomer() @ApiOperation({ summary: 'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)', }) async requestBookingDraftDeclarationChange( @Param('id', ParseUUIDPipe) id: string, @Body('note') note: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.requestDraftDeclarationChange( id, note, resolveAuthUserId(user), ); 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, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.finalizePreClearance( id, resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/duty-slip') @PortalCustomer() @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, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingClearanceService.uploadDutySlip( id, file, resolveAuthUserId(user), ); 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(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, files ?? [], resolveAuthUserId(user), { vesselArrivalDate, doCollectedDate }, ); return this.transitionService.enrichBookingResponse(booking); } @Post(':id/clearance/release-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') async uploadBookingReleaseOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @Body('vesselDepartureDate') vesselDepartureDate: string, @CurrentUser() user: TCurrentUser, ) { const result = await this.bookingClearanceService.uploadReleaseOrder( id, files ?? [], 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.governmentExpedite) @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") @MixedAudience(FREIGHT_PERMS.bookings.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") @MixedAudience(FREIGHT_PERMS.bookings.view) @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") @MixedAudience(FREIGHT_PERMS.bookings.view) @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") @MixedAudience(FREIGHT_PERMS.bookings.signStaff) @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") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "List contract signatures" }) getContractSignatures(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSignatures(id); } @Get(":id/summary") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Contract summary string for dashboard" }) getSummary(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSummary(id); } @Post(":id/customer/sign") @PortalCustomer() @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); } // ── Shared-wagon (consolidation) approval gate ──────────────────────────── // A consolidated pair is held here, not in the operations queue: two // customers' cargo on one wagon is a commercial call, so a person signs off // on the pairing before Operations sees either half. @Get("consolidation-approvals/queue") @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) @ApiOperation({ summary: "Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.", }) consolidationApprovalQueue() { return this.consolidationApprovalService.queue(); } @Get(":id/consolidation-approvals") @BookingStaff(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Approval history for this booking's shared wagon — who decided what, when, and why.", }) consolidationApprovalHistory(@Param("id", ParseUUIDPipe) id: string) { return this.consolidationApprovalService.historyForBooking(id); } @Post("consolidation-approvals/:approvalId/approve") @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) @ApiOperation({ summary: "Approve a shared wagon: both bookings leave the gate and continue to Operations together.", }) approveConsolidation( @Param("approvalId", ParseUUIDPipe) approvalId: string, @Body() dto: ApproveConsolidationDto, @CurrentUser() user: AuthUserPayload, ) { return this.consolidationApprovalService.approve( approvalId, resolveAuthUserId(user) ?? "", dto.note, ); } @Post("consolidation-approvals/:approvalId/reject") @BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation) @ApiOperation({ summary: "Reject a shared wagon: both bookings go back to GL for changes with the reason.", }) rejectConsolidation( @Param("approvalId", ParseUUIDPipe) approvalId: string, @Body() dto: RejectConsolidationDto, @CurrentUser() user: AuthUserPayload, ) { return this.consolidationApprovalService.reject( approvalId, resolveAuthUserId(user) ?? "", dto.reason, ); } @Post(":id/paired-decision") @BookingStaff(FREIGHT_PERMS.bookings.cancel) @ApiOperation({ summary: "Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.", }) async pairedDecision( @Param("id", ParseUUIDPipe) id: string, @Body() dto: PairedDecisionDto, @CurrentUser() user: AuthUserPayload, ) { const { booking, partner } = await this.transitionService.applyPairedDecision( id, dto.decision, resolveAuthUserId(user), { reason: dto.reason, note: dto.note, validityDays: dto.validityDays }, ); // Sequential enrichment: both go back so the UI can refresh either tab. const enrichedBooking = await this.transitionService.enrichBookingResponse(booking); const enrichedPartner = await this.transitionService.enrichBookingResponse(partner); return { booking: enrichedBooking, partner: enrichedPartner }; } @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/customer-cancel") @ApiOperation({ summary: "Customer cancels their own booking before payment — no cancellation fee", }) async customerCancel( @Param("id", ParseUUIDPipe) id: string, @Body() dto: RejectBookingDto, ) { const booking = await this.transitionService.customerCancel(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/cancel-hold") @PortalCustomer() @ApiOperation({ summary: "Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " + "reserved wagons release immediately", }) async cancelHold( @Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelBookingDto, ) { const booking = await this.transitionService.cancelHold(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } @Post(":id/consolidation") @PortalCustomer() @ApiOperation({ summary: "Request freight consolidation" }) requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } @Delete(":id/consolidation") @PortalCustomer() @ApiOperation({ summary: "Remove consolidation pairing" }) removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } @Get(":id/consolidation") @MixedAudience(FREIGHT_PERMS.bookings.view) @ApiOperation({ summary: "Get consolidation details" }) getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); } }