Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
Marshal 40904049cf feat: implement consolidation approval process for shared-wagon bookings
- Add migration for consolidation approvals table and status enum
- Create ConsolidationApprovalService to handle approval logic
- Implement repository for managing consolidation approvals
- Add entity for consolidation approval with necessary fields
- Develop frontend components for displaying and managing consolidation approvals
- Create tests for consolidation approval service to ensure correct behavior
2026-08-18 13:17:55 +00:00

1542 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
Res,
UnauthorizedException,
UploadedFiles,
UploadedFile,
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 { 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, MixedAudience, PortalCustomer } 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 { scopedDirections } from '../user-trade-access/trade-scope.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.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 {
CancelContractDto,
RejectContractDto,
RejectStepDto,
RequestChangesDto,
ResumeContractDto,
SuspendContractDto,
} 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 {
CompleteConsolidatedPairDto,
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 userTradeAccessService: UserTradeAccessService,
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')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@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')
@PortalCustomer()
@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')
@PortalCustomer()
@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')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'List the shipment requests on a contract' })
listBookingRequests(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingRequestService.listForContract(id);
}
@Post()
@MixedAudience([])
@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()
@MixedAudience([])
@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)
) {
// Per-user trade-direction scope (import/export/intercity checkboxes).
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
const dirs = scopedDirections(allowed, filter.tradeDirection);
return this.contractsService.findAll(
filter,
undefined,
undefined,
dirs ?? undefined,
);
}
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')
@PortalCustomer()
@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);
}
// Must stay ABOVE @Get(':id') — declared after it, Nest matched the literal
// path as an id and ParseUUIDPipe answered 400 "uuid is expected".
@Get('awaiting-shipment')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'GL worklist: executed one-time customs contracts with no shipment instance yet — GL initiates the booking the customer then uploads documents on.',
})
awaitingShipmentContracts() {
return this.contractBookingService.awaitingShipmentContracts();
}
@Get(':id')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@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')
@MixedAudience([])
@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[],
// Recorded on the edit's audit revision — who changed the contract.
@CurrentUser() user?: TCurrentUser,
) {
return this.contractsService.update(id, dto, files ?? [], user?.id);
}
@Delete(':id')
@MixedAudience([])
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT contract' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.contractsService.remove(id);
}
@Post(':id/documents')
@MixedAudience([])
@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')
@MixedAudience([])
@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')
@MixedAudience([])
@ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@MixedAudience([])
@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,
{ validFrom: dto.validFrom, validUntil: dto.validUntil },
);
}
// Readable by anyone who may view the contract: the draft carries
// `editableByMe`, and the approval chain's approvers (identified by position
// type, not by staff_accept) must be able to fetch it to learn it is their
// turn. Gating this on staff_accept hid the edit dialog from every approver.
@Get(':id/document/draft')
@BookingStaff([
FREIGHT_PERMS.contracts.view,
...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);
}
// Coarse gate only. WHO may actually edit is turn-based, not a static
// permission, so `updateContractDocument` -> `assertDocumentEditable` is the
// real boundary: it admits only the approver whose step is currently pending
// (edit rights hand off down the chain on each approval).
@Put(':id/document/articles')
@BookingStaff(FREIGHT_PERMS.contracts.editDocument)
@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/suspend')
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
@ApiOperation({
summary: 'Staff freeze a signed contract (reversible, any post-signature step)',
})
suspend(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SuspendContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.suspend(
id,
dto.reason,
resolveAuthUserId(user),
user,
);
}
@Post(':id/resume')
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
@ApiOperation({ summary: 'Staff lift a suspension — contract returns to its prior status' })
resume(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ResumeContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.resume(
id,
dto.note,
resolveAuthUserId(user),
user,
);
}
@Post(':id/cancel')
@PortalCustomer()
@ApiOperation({
summary: 'Customer cancels their own contract (blocked while a booking is live)',
})
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CancelContractDto,
@CurrentUser() user: TCurrentUser,
) {
// Same ownership rule as renew: staff with bookings.view/contracts.view pass
// through, everyone else must own the contract's company.
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.cancelByCustomer(
id,
dto.reason,
resolveAuthUserId(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')
@MixedAudience(FREIGHT_PERMS.contracts.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,
// Drives the per-freight-type sign permission on the client.
freightType: contract.freightType,
templateKey: view.templateKey,
title: view.template.title,
html,
view,
canSignCustomer: view.canSignCustomer,
canSignStaff: view.canSignStaff,
hasContractDocument: view.hasContractDocument,
signatures,
savedSignature,
};
}
@Get(':id/contract/document')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
): Promise<void> {
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')
@MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff))
@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')
@MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff))
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
async signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: TCurrentUser,
) {
if (dto.role !== 'CUSTOMER') {
// Each staff signing role maps to the permission that step already
// requires; the STAFF counter-signature is split per freight type, so a
// bulk signer cannot counter-sign a container contract (and vice versa).
const contract = await this.contractsService.findById(id);
const signRolePermission: Record<string, string> = {
STAFF: forFreightType(FREIGHT_PERMS.contracts.signStaff, contract.freightType),
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
assertFreightPermission(user, signRolePermission[dto.role]);
}
return this.transitionService.sign(id, dto, {
signerUserId: user?.id,
});
}
@Post(':id/renew')
@PortalCustomer()
@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')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@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')
@PortalCustomer()
@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/transit-assignee/request')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
'GL ET asks GL Djibouti to name the transit officer — required before the customs declaration',
})
requestTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.requestTransitAssignee(
id,
note,
resolveAuthUserId(user),
);
}
@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',
})
assignTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.assignTransitAssignee(
id,
transitAgentId,
resolveAuthUserId(user),
);
}
@Get(':id/clearance/documents/:fileKey/versions')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@ApiOperation({
summary:
'Version history of one clearance document — the customer original plus every staff replacement',
})
documentVersions(
@Param('id', ParseUUIDPipe) id: string,
@Param('fileKey') fileKey: string,
) {
return this.clearanceService.documentVersions(id, fileKey);
}
@Post(':id/clearance/documents/:fileKey/replace')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving',
})
replaceClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Param('fileKey') fileKey: string,
@UploadedFile() file: Express.Multer.File,
@Body('reason') reason: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.replaceDocument(
id,
fileKey,
file,
resolveAuthUserId(user),
reason,
);
}
@Post(':id/clearance/duty/dispute')
@PortalCustomer()
@ApiOperation({
summary:
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
})
disputeContractDuty(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.disputeDuty(id, note, resolveAuthUserId(user));
}
@Post(':id/clearance/duty-slip')
@PortalCustomer()
@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(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL DJ uploads Delivery Order files (import) with vessel arrival + DO collected dates',
})
uploadDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
@Body('doCollectedDate') doCollectedDate: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadDeliveryOrder(id, files ?? [], resolveAuthUserId(user), {
vesselArrivalDate,
doCollectedDate,
});
}
@Post(':id/clearance/release-order')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL DJ uploads Release Order files + vessel departure date (export)' })
uploadReleaseOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@Body('vesselDepartureDate') vesselDepartureDate: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadReleaseOrder(
id,
files ?? [],
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));
}
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
@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')
// Path A is a customer flow — both audiences must reach the service, whose
// assertGate decides per role. Staff still need contracts:create_booking.
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
})
async createBooking(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: TCurrentUser & { sub?: string },
) {
// Customer callers may only book on their own contract.
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
const contract = await this.contractsService.findById(id);
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
// 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')
// Customer initiates their own ONE_TIME instance; GL initiates on customs
// contracts — the service's assertGate decides per role, so both audiences
// must reach it. Staff still need contracts:create_booking.
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
})
async initiateBooking(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: TCurrentUser & { sub?: string },
) {
// Customer callers may only initiate on their own contract; the service's
// assertGate then decides what a customer may do on it.
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
const contract = await this.contractsService.findById(id);
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.contractBookingService.initiateUnderContract(
id,
{ contractRouteId: dto?.contractRouteId },
{ id: user?.id ?? user?.sub },
user,
);
}
@Post(':id/bookings/:bookingId/complete')
// Customers complete their own initiated (non-customs) instances; the
// service keeps customs completion GL-only via the actor's permissions.
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
})
async completeBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: TCurrentUser & { sub?: string },
) {
// Customer callers may only complete bookings on their own contract.
if (!hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)) {
const contract = await this.contractsService.findById(id);
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
// 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,
// skipAutoConsolidation is internal to the manual pair-completion path; a
// client must never suppress the wagon gate on a lone booking.
{ ...dto, skipAutoConsolidation: false },
user,
);
}
@Get(':id/bookings/:bookingId/consolidation-candidates')
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Bookings GL may link to this odd-20ft customs booking as its shared-wagon partner (same route and direction, customs, odd 20ft, unpaired).',
})
listConsolidationCandidates(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
) {
return this.contractBookingService.listConsolidationCandidates(id, bookingId);
}
@Post(':id/bookings/:bookingId/complete-consolidated')
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.',
})
completeConsolidatedPair(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CompleteConsolidatedPairDto,
@CurrentUser() user: TCurrentUser & { sub?: string },
) {
return this.contractBookingService.completeConsolidatedPair(
id,
bookingId,
dto,
user,
// Recorded as the requester on the approval: the person who created the
// pairing may not be the one who approves it.
user?.id ?? user?.sub ?? null,
);
}
@Post(':id/validate-shipment')
@MixedAudience([FREIGHT_PERMS.contracts.createBooking, FREIGHT_PERMS.contracts.view])
@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,
// Completion/resubmit preview: exclude this booking's own persisted
// containers from the same-train clash check.
@Query('bookingId') bookingId?: string,
) {
return this.contractBookingService.validateShipment(id, dto, bookingId);
}
@Get(':id/capacity')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@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')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@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')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@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.finalInvoiceRaise)
@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/approve')
@PortalCustomer()
@ApiOperation({
summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
})
approveFinalInvoice(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.approveFinalInvoice(
bookingId,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/final-invoice-slip')
@PortalCustomer()
@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.finalInvoiceConfirm)
@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')
@PortalCustomer()
@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')
@PortalCustomer()
@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.isPhasedCustomsBooking(booking)) {
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
}
return this.glOperationsService.uploadDutySlip(bookingId, file);
}
@Get('bookings/:bookingId/incidents')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@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),
);
}
}