Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts

1232 lines
43 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,
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 } 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, FREIGHT_PERMS.contracts.staffAccept);
}
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)) {
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)
) {
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')
@BookingStaff(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: AuthUserPayload,
) {
return this.transitionService.staffAccept(
id,
resolveAuthUserId(user),
dto.validityDays,
dto.documentSnapshot,
);
}
@Get(':id/document/draft')
@BookingStaff(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(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(FREIGHT_PERMS.contracts.requestChanges)
@ApiOperation({ summary: 'Staff return contract for customer updates' })
requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.transitionService.requestChanges(
id,
dto.note,
resolveAuthUserId(user),
);
}
@Post(':id/staff/reject')
@BookingStaff(FREIGHT_PERMS.contracts.reject)
@ApiOperation({ summary: 'Staff reject contract' })
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectContractDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.transitionService.reject(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')
@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)) {
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<void> {
const contract = await this.contractsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.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<string, string> = {
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 bypass, mirroring getContractView/downloadContractDocument.
const contract = await this.contractsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.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 bypass, mirroring the other contract handlers.
const contract = await this.contractsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.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),
);
}
}