import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Put, Query, Res, UnauthorizedException, UploadedFiles, UploadedFile, 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 { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOkResponse, ApiOperation, ApiTags, } from '@nestjs/swagger'; import { actorLabel } from '../warehouses/current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; import { ContractDocumentHistoryService } from './contract-document-history.service'; import { FREIGHT_PERMS, bothFreightTypes, forFreightType, } from '../../seed/freight-permissions.registry'; import { assertFreightPermission, hasFreightPermission, } from '../../common/freight-permission.util'; import { type AuthUserPayload, resolveAuthUserId, } from '../../common/resolve-auth-user-id'; import { ContractsService } from './contracts.service'; import { ContractPricingService } from './contract-pricing.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; import { BookingClearanceService } from './booking-clearance.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { BookingRequestService } from './booking-request.service'; import { SignaturesService } from '../signatures/signatures.service'; import { BookingsService } from '../bookings/bookings.service'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { AcceptContractDto } from './dto/accept-contract.dto'; import { UpdateContractDocumentDto } from './dto/contract-document.dto'; import { RejectContractDto, RejectStepDto, RequestChangesDto, } from './dto/approve-step.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; import { RenewContractDto } from './dto/renew-contract.dto'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; import { CreateBookingRequestDto, ReviewBookingRequestDto, } from './dto/create-booking-request.dto'; import { AdviseDutyDto, AssignRiskDto, AssignStationDto, CompleteMilestoneDto, ReportIncidentDto, } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @ApiTags('contracts') @Controller('contracts') @ApiBearerAuth() export class ContractsController { constructor( private readonly documentHistory: ContractDocumentHistoryService, private readonly contractsService: ContractsService, private readonly pricingService: ContractPricingService, private readonly transitionService: ContractTransitionService, private readonly clearanceService: ContractClearanceService, private readonly contractBookingService: ContractBookingService, private readonly milestoneService: ClearanceMilestoneService, private readonly glOperationsService: GlOperationsService, private readonly bookingRequestService: BookingRequestService, private readonly signaturesService: SignaturesService, private readonly bookingClearanceService: BookingClearanceService, private readonly bookingsService: BookingsService, ) {} // ── Shipment / booking requests (GENERAL + customs, Path B) ─────────────── // STATIC routes declared before any `:id`-param route so Nest matches them // (mirrors the clearance/queue ordering below). @Get('booking-requests/queue') @BookingStaff(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' }) bookingRequestQueue() { return this.bookingRequestService.queue(); } @Get('booking-requests/:reqId') @ApiOperation({ summary: 'A single shipment request' }) getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) { return this.bookingRequestService.findOne(reqId); } @Post('booking-requests/:reqId/accept') @BookingStaff(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'GL marks a shipment request accepted + links the created booking' }) acceptBookingRequest( @Param('reqId', ParseUUIDPipe) reqId: string, @Body() body: { bookingId: string }, @CurrentUser() user: AuthUserPayload, ) { return this.bookingRequestService.accept( reqId, body.bookingId, resolveAuthUserId(user), ); } @Post('booking-requests/:reqId/reject') @BookingStaff(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'GL rejects a shipment request' }) rejectBookingRequest( @Param('reqId', ParseUUIDPipe) reqId: string, @Body() dto: ReviewBookingRequestDto, @CurrentUser() user: AuthUserPayload, ) { return this.bookingRequestService.reject(reqId, dto.note, resolveAuthUserId(user)); } @Post('booking-requests/:reqId/cancel') @ApiOperation({ summary: 'Customer cancels their own pending shipment request' }) cancelBookingRequest( @Param('reqId', ParseUUIDPipe) reqId: string, @CurrentUser() user: AuthUserPayload, ) { return this.bookingRequestService.cancel(reqId, resolveAuthUserId(user)); } @Post(':id/booking-requests') @ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' }) submitBookingRequest( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingRequestDto, @CurrentUser() user: AuthUserPayload, ) { return this.bookingRequestService.submit(id, dto, resolveAuthUserId(user)); } @Get(':id/booking-requests') @ApiOperation({ summary: 'List the shipment requests on a contract' }) listBookingRequests(@Param('id', ParseUUIDPipe) id: string) { return this.bookingRequestService.listForContract(id); } @Post() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' }) @ApiBody({ type: CreateContractDto }) async create( @Body() dto: CreateContractDto, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { if (dto.isGovernment) { assertFreightPermission( user, forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType), ); } return this.contractsService.create(dto, files ?? [], user?.id); } @Get() @ApiOperation({ summary: 'List contracts (paginated)' }) async findAll( @Query() filter: FilterContractDto, @CurrentUser() user: TCurrentUser, ) { // Staff see every contract; customers are force-scoped to their own company. if ( hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { return this.contractsService.findAll(filter); } const userId = user?.id; if (!userId) throw new UnauthorizedException('Authentication required'); const companyId = await this.contractsService.resolveCustomerCompanyId(userId); 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, }, }; } return this.contractsService.findAll(filter, companyId); } @Get('my') @ApiOperation({ summary: "List the current customer's contracts" }) async findMy( @CurrentUser() user: AuthUserPayload, @Query() filter: FilterContractDto, ) { const userId = resolveAuthUserId(user); const companyId = await this.contractsService.resolveCustomerCompanyId(userId); 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, }, }; } return this.contractsService.findAll(filter, companyId); } @Get('list-summary') @BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view]) @ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' }) @ApiOkResponse({ type: ContractListSummaryDto }) findListSummary(@Query() filter: FilterContractDto) { return this.contractsService.getListSummary(filter); } @Get('clearance/queue') @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) @ApiOperation({ summary: 'GL ET queue: contracts awaiting pre-booking document review' }) clearanceQueue(@Query() filter: FilterContractDto) { return this.clearanceService.queue(filter); } @Get(':id') @ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' }) async findOne( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const contract = await this.contractsService.findById(id); if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) && !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return contract; } @Patch(':id') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Update contract', description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', }) @ApiBody({ type: UpdateContractDto }) update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContractDto, @UploadedFiles() files: Express.Multer.File[], ) { return this.contractsService.update(id, dto, files ?? []); } @Delete(':id') @HttpCode(204) @ApiOperation({ summary: 'Soft-delete DRAFT contract' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.contractsService.remove(id); } @Post(':id/documents') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' }) uploadDocuments( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { return this.contractsService.uploadDocuments(id, files ?? []); } @Post(':id/generate-price') @ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' }) generatePrice(@Param('id', ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } @Post(':id/submit') @ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' }) submit(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } @Post(':id/confirm-submit') @ApiOperation({ summary: 'Confirm submit after a price change' }) confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); } @Post(':id/staff/accept') // One-of guard; the service then requires the arm matching the contract's freight type. @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' }) staffAccept( @Param('id', ParseUUIDPipe) id: string, @Body() dto: AcceptContractDto, @CurrentUser() user: TCurrentUser, ) { return this.transitionService.staffAccept( id, resolveAuthUserId(user), dto.validityDays, dto.documentSnapshot, user, ); } @Get(':id/document/draft') @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog', }) getContractDocumentDraft( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { // Editability depends on WHO is asking — only the approver whose turn it is // may edit — so the caller is part of the draft lookup. return this.transitionService.getContractDocumentDraft(id, user); } @Get(':id/document/revisions') @BookingStaff(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Audit trail of edits to this contract\'s document (newest first)', }) getContractDocumentRevisions(@Param('id', ParseUUIDPipe) id: string) { return this.documentHistory.list(id); } @Put(':id/document/articles') @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', }) updateContractDocument( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContractDocumentDto, @CurrentUser() user: TCurrentUser, ) { return this.transitionService.updateContractDocument( id, dto, user, resolveAuthUserId(user), ); } @Post(':id/staff/request-changes') @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges)) @ApiOperation({ summary: 'Staff return contract for customer updates' }) requestChanges( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, @CurrentUser() user: TCurrentUser, ) { return this.transitionService.requestChanges( id, dto.note, resolveAuthUserId(user), user, ); } @Post(':id/staff/reject') @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject)) @ApiOperation({ summary: 'Staff reject contract' }) reject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RejectContractDto, @CurrentUser() user: TCurrentUser, ) { return this.transitionService.reject( id, dto.reason, resolveAuthUserId(user), user, ); } @Post(':id/approval-steps/:stepId/approve') @BookingStaff(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Approve one approval step in sequence' }) approveStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @CurrentUser() user: TCurrentUser, ) { // Whether this caller may approve depends on the step's own required role // (an IAM position type), so the service resolves the step and authorizes // against it — the client never declares its own role. return this.transitionService.approveStep( id, stepId, resolveAuthUserId(user), user, ); } @Post(':id/approval-steps/:stepId/reject') @BookingStaff([ FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveCeo, ]) @ApiOperation({ summary: 'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)', }) rejectStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @Body() dto: RejectStepDto, @CurrentUser() user: AuthUserPayload, ) { return this.transitionService.rejectStep( id, stepId, resolveAuthUserId(user), dto.reason, dto.returnToStepId, ); } @Post(':id/contract/generate') @BookingStaff(FREIGHT_PERMS.contracts.generateContract) @ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' }) generateContract(@Param('id', ParseUUIDPipe) id: string) { return this.transitionService.generateContract(id); } @Get(':id/contract/view') @ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' }) async getContractView( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const contract = await this.contractsService.findById(id); if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { view, html, signatures } = await this.transitionService.getContractDocumentView(id); // The signer's reusable saved signature (if any) so the sign UI can offer // "Approve & sign" with the stored image instead of forcing a fresh draw. const signerId = resolveAuthUserId(user); const savedSignature = signerId ? await this.signaturesService.getForUser(signerId) : null; return { contractId: view.bookingId, reference: view.reference, status: view.status, templateKey: view.templateKey, title: view.template.title, html, view, canSignCustomer: view.canSignCustomer, canSignStaff: view.canSignStaff, hasContractDocument: view.hasContractDocument, signatures, savedSignature, }; } @Get(':id/contract/document') @ApiOperation({ summary: 'Download contract PDF' }) async downloadContractDocument( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, ): Promise { const contract = await this.contractsService.findById(id); if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { stream, record } = await this.transitionService.streamContractPdf(id); res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); res.setHeader( 'Content-Disposition', `attachment; filename="${record.name}"`, ); stream.pipe(res); } @Post(':id/contract/send-signing-otp') @UseGuards(JwtGuard) @ApiOperation({ summary: "Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", }) sendSigningOtp( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { return this.transitionService.sendSigningOtp(id, { signerUserId: user?.id }); } @Post(':id/contract/sign') @UseGuards(JwtGuard) @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) signContract( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: TCurrentUser, ) { // Each staff signing role maps to the permission that step already requires; // customers sign their own contract with no permission key. const signRolePermission: Record = { STAFF: FREIGHT_PERMS.contracts.signStaff, DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, CEO: FREIGHT_PERMS.contracts.approveCeo, }; if (dto.role !== 'CUSTOMER') { assertFreightPermission(user, signRolePermission[dto.role]); } return this.transitionService.sign(id, dto, { signerUserId: user?.id, }); } @Post(':id/renew') @ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' }) async renew( @Param('id', ParseUUIDPipe) id: string, @Body() _dto: RenewContractDto, @CurrentUser() user: TCurrentUser, ) { // H12(c): a customer may only renew a contract their company owns. Staff // with bookings.view/contracts.view bypass, mirroring getContractView/downloadContractDocument. const contract = await this.contractsService.findById(id); if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return this.transitionService.renew(id, resolveAuthUserId(user)); } // ── Pre-booking clearance (Path B, doc §15.2.1) ──────────────────────────── @Get(':id/clearance') @ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' }) getClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.getClearanceView(id); } @Post(':id/clearance/documents') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' }) async uploadClearanceDocuments( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @UploadedFiles() files: Express.Multer.File[], ) { // H12(c): only the owning company's customer may upload clearance docs. // Staff with bookings.view/contracts.view bypass, mirroring the other contract handlers. const contract = await this.contractsService.findById(id); if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return this.clearanceService.uploadDocuments(id, files ?? []); } @Post(':id/clearance/review') @BookingStaff([ FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.clearanceEtActions, ]) @ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' }) reviewClearanceDocument( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ReviewClearanceDocumentDto, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.reviewDocument( id, dto.fileKey, dto.status, resolveAuthUserId(user), dto.note, ); } @Post(':id/clearance/output-documents') @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…) pre-booking' }) uploadOutputDocuments( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { return this.clearanceService.uploadOutputDocuments(id, files ?? []); } @Post(':id/clearance/finalize') @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)' }) finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.finalize(id); } @Post(':id/clearance/declaration') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL ET uploads customs declaration documents (multi-file)' }) uploadDeclaration( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.uploadDeclaration(id, files ?? [], resolveAuthUserId(user)); } @Post(':id/clearance/duty') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) @UseInterceptors(FileInterceptor('attachment')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount with notice attachment' }) adviseContractDuty( @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: AuthUserPayload, ) { const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; const dto: AdviseContractDutyDto = { dutyRequired, amount: amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, currency: currency ?? 'ETB', declarationSerial, }; return this.clearanceService.adviseDuty( id, dto, resolveAuthUserId(user), attachment, ); } @Post(':id/clearance/finalize-pre-clearance') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET finalizes import pre-clearance — unlocks Djibouti DO upload' }) finalizePreClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.finalizePreClearance(id); } @Post(':id/clearance/duty-slip') @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' }) uploadContractDutySlip( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, ) { return this.clearanceService.uploadDutySlip(id, file); } @Post(':id/clearance/transit-permit') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' }) uploadTransitPermit( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.uploadTransitPermit(id, files ?? [], resolveAuthUserId(user)); } @Post(':id/clearance/delivery-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' }) uploadDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.uploadDeliveryOrder( id, file, resolveAuthUserId(user), vesselDepartureDate, ); } @Post(':id/clearance/release-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' }) uploadReleaseOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, @Body('vesselDepartureDate') vesselDepartureDate: string, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.uploadReleaseOrder( id, file, vesselDepartureDate, resolveAuthUserId(user), ); } @Post(':id/clearance/ro-amendment') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: 'GL DJ requests port amendment when RO vessel window is too short' }) requestRoAmendment( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RoAmendmentDto, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.requestRoAmendment(id, dto.note, resolveAuthUserId(user)); } @Post(':id/clearance/export-release') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET confirms export release after declaration' }) confirmExportRelease( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user)); } @Post(':id/clearance/finalize-export-clearance') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET finalizes export clearance after post-booking transit permit upload', }) finalizeExportClearance( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user)); } @Get('clearance/et-queue') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' }) etClearanceQueue(@Query() filter: FilterContractDto) { return this.clearanceService.etQueue(filter); } @Get('clearance/dj-queue') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' }) djClearanceQueue(@Query() filter: FilterContractDto) { return this.clearanceService.djQueue(filter); } // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review', }) opsClearanceQueue(@Query() filter: FilterContractDto) { return this.clearanceService.opsQueue(filter); } @Post(':id/clearance/ops-review') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ summary: 'Operations reviews a customer self-clearance document (Approve | Query)', }) opsReviewClearanceDocument( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ReviewClearanceDocumentDto, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.opsReviewDocument( id, dto.fileKey, dto.status, resolveAuthUserId(user), dto.note, ); } @Post(':id/clearance/ops-finalize') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ summary: 'Operations finalizes self-clearance → customer may create the booking', }) opsFinalizeClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.opsFinalize(id); } @Get('clearance/history') @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) @ApiOperation({ summary: 'GL ET clearance history: contracts that completed Path B clearance' }) clearanceHistory(@Query() filter: FilterContractDto) { return this.clearanceService.history(filter); } @Get('clearance/ops-history') @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) @ApiOperation({ summary: 'Operations clearance history: contracts that completed Path A self-clearance' }) opsClearanceHistory(@Query() filter: FilterContractDto) { return this.clearanceService.opsHistory(filter); } // ── Booking under contract (Path A customer / Path B GL ET) ──────────────── @Post(':id/bookings') @ApiOperation({ summary: 'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).', }) createBooking( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, @CurrentUser() user: AuthUserPayload, ) { // The service decides the execution path from the contract: // Path A (customs disabled) → customer/staff create; status checks apply. // Path B (customs enabled) → GL Ethiopia only, once clearance is ready. return this.contractBookingService.createUnderContract( id, dto, { id: user?.id ?? user?.sub }, user, ); } @Post(':id/bookings/initiate') @ApiOperation({ summary: 'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).', }) initiateBooking( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, @CurrentUser() user: AuthUserPayload, ) { return this.contractBookingService.initiateUnderContract( id, { contractRouteId: dto?.contractRouteId }, { id: user?.id ?? user?.sub }, user, ); } @Post(':id/bookings/:bookingId/complete') @ApiOperation({ summary: 'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.', }) completeBooking( @Param('id', ParseUUIDPipe) id: string, @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: CreateBookingUnderContractDto, @CurrentUser() user: AuthUserPayload, ) { // Customs (Path B) instances may only be completed by GL Ethiopia — the // service checks the actor's contracts:create_booking permission. return this.contractBookingService.completeUnderContract( id, bookingId, dto, user, ); } @Post(':id/validate-shipment') @ApiOperation({ summary: 'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).', }) validateShipment( @Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateBookingUnderContractDto, ) { return this.contractBookingService.validateShipment(id, dto); } @Get(':id/capacity') @ApiOperation({ summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)', }) async capacity(@Param('id', ParseUUIDPipe) id: string) { const contract = await this.contractsService.findById(id); return this.contractBookingService.capacityView(contract); } // ── Clearance milestones (doc §11.3, §12.2) ──────────────────────────────── @Get(':id/milestones') @ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' }) listContractMilestones(@Param('id', ParseUUIDPipe) id: string) { return this.milestoneService.listForContract(id); } @Get('bookings/:bookingId/milestones') @ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' }) listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.milestoneService.listForBooking(bookingId); } @Post('bookings/:bookingId/milestones/:code/complete') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'GL / Ops / Terminal marks a post-booking milestone complete' }) completeBookingMilestone( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('code') code: string, @Body() body: { note?: string }, @CurrentUser() user: AuthUserPayload, ) { return this.milestoneService.completeForBooking( bookingId, code, user?.id ?? user?.sub, body?.note, ); } @Post(':id/milestones/:code/complete') @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) @ApiOperation({ summary: 'GL marks a pre-booking (contract) milestone complete' }) completeContractMilestone( @Param('id', ParseUUIDPipe) id: string, @Param('code') code: string, @Body() body: CompleteMilestoneDto, @CurrentUser() user: AuthUserPayload, ) { return this.milestoneService.completeForContract( id, code, resolveAuthUserId(user), body?.note, ); } // ── GL operational actions on a booking (doc §11–§13) ────────────────────── @Post('bookings/:bookingId/risk') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'GL ET assigns a customs risk level (GREEN/YELLOW/RED)' }) assignRisk( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignRiskDto, @CurrentUser() user: TCurrentUser, ) { return this.milestoneService.assignRisk( bookingId, dto.riskLevel, resolveAuthUserId(user), dto.note, // Risk history is read by people, so resolve the name now — the id alone // would render as a UUID in the trail. actorLabel(user), ); } @Post('bookings/:bookingId/duty') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'GL ET advises duty & tax amount + declaration serial' }) adviseDuty( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AdviseDutyDto, @CurrentUser() user: AuthUserPayload, ) { return this.milestoneService.adviseDuty( bookingId, { amount: dto.amount, currency: dto.currency, declarationSerial: dto.declarationSerial }, resolveAuthUserId(user), dto.note, ); } @Post('bookings/:bookingId/station-assign') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'GL station manager routes the shipment + binds staff' }) assignStation( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignStationDto, ) { return this.glOperationsService.assignStation(bookingId, { stationYardId: dto.stationYardId, staffId: dto.staffId, }); } @Post('bookings/:bookingId/transport-document') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' }) uploadTransportDocument( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], ) { return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); } @Post('bookings/:bookingId/t1-documents') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs', }) uploadT1Documents( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], ) { return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); } @Post('bookings/:bookingId/t1-close') @BookingStaff([ FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDjActions, ]) @ApiOperation({ summary: 'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)', }) closeT1( @Param('bookingId', ParseUUIDPipe) bookingId: string, @CurrentUser() user: AuthUserPayload, ) { return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); } @Post('bookings/:bookingId/final-invoice') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)', }) createFinalInvoice( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body('amount') amountRaw: string, @Body('currency') currency: string | undefined, @Body('description') description: string | undefined, @UploadedFile() file: Express.Multer.File, @CurrentUser() user: AuthUserPayload, ) { return this.glOperationsService.createFinalInvoice( bookingId, { amount: Number(amountRaw), currency: currency?.trim() || 'ETB', description, }, file, resolveAuthUserId(user), ); } @Post('bookings/:bookingId/final-invoice-slip') @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' }) uploadFinalInvoiceSlip( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFile() file: Express.Multer.File, ) { return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file); } @Post('bookings/:bookingId/final-invoice/confirm') @BookingStaff([ FREIGHT_PERMS.contracts.clearanceDjActions, FREIGHT_PERMS.contracts.clearanceEtActions, ]) @ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' }) confirmFinalInvoicePaid( @Param('bookingId', ParseUUIDPipe) bookingId: string, @CurrentUser() user: AuthUserPayload, ) { return this.glOperationsService.confirmFinalInvoicePaid( bookingId, resolveAuthUserId(user), ); } @Post('bookings/:bookingId/second-duty') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(FileInterceptor('attachment')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)', }) adviseSecondDuty( @Param('bookingId', ParseUUIDPipe) bookingId: 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: AuthUserPayload, ) { return this.glOperationsService.adviseSecondDuty( bookingId, { dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1', amount: amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, currency: currency ?? 'ETB', declarationSerial, }, attachment, resolveAuthUserId(user), ); } @Post('bookings/:bookingId/second-duty-slip') @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' }) uploadSecondDutySlip( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFile() file: Express.Multer.File, ) { return this.glOperationsService.uploadSecondDutySlip(bookingId, file); } @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL uploads post-booking operational documents (DO/RO/T1/…)', }) uploadGlDocuments( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], ) { return this.glOperationsService.uploadDocuments(bookingId, files ?? []); } @Post('bookings/:bookingId/duty-slip') @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' }) async uploadDutySlip( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], ) { const file = (files ?? [])[0]; const booking = await this.bookingsService.findById(bookingId); if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) { return this.bookingClearanceService.uploadDutySlip(bookingId, file); } return this.glOperationsService.uploadDutySlip(bookingId, file); } @Get('bookings/:bookingId/incidents') @ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' }) listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.glOperationsService.listIncidents(bookingId); } @Post('bookings/:bookingId/incidents') @BookingStaff(FREIGHT_PERMS.bookings.operations) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL DJ logs a cargo exception with photo evidence' }) reportIncident( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: ReportIncidentDto, @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: AuthUserPayload, ) { return this.glOperationsService.reportIncident( bookingId, { incidentType: dto.incidentType, description: dto.description, files: files ?? [], }, resolveAuthUserId(user), ); } }