mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
934 lines
32 KiB
TypeScript
934 lines
32 KiB
TypeScript
import {
|
||
Body,
|
||
Controller,
|
||
Delete,
|
||
Get,
|
||
HttpCode,
|
||
Param,
|
||
ParseUUIDPipe,
|
||
Patch,
|
||
Post,
|
||
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 { BookingStaff } from '../../common/booking-guards';
|
||
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 {
|
||
ApproveStepDto,
|
||
RejectContractDto,
|
||
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 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: pending shipment requests across contracts' })
|
||
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')
|
||
@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,
|
||
);
|
||
}
|
||
|
||
@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.approveLineStaff,
|
||
FREIGHT_PERMS.contracts.approveDirector,
|
||
FREIGHT_PERMS.contracts.approveCeo,
|
||
])
|
||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||
approveStep(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||
@Body() dto: ApproveStepDto,
|
||
@CurrentUser() user: TCurrentUser,
|
||
) {
|
||
return this.transitionService.approveStep(
|
||
id,
|
||
stepId,
|
||
resolveAuthUserId(user),
|
||
dto.requiredRole,
|
||
user,
|
||
);
|
||
}
|
||
|
||
@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/sign')
|
||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||
signContract(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Body() dto: SignContractDto,
|
||
@CurrentUser() user: AuthUserPayload,
|
||
) {
|
||
return this.transitionService.sign(id, dto, {
|
||
signerUserId: user?.id ?? user?.sub,
|
||
});
|
||
}
|
||
|
||
@Post(':id/renew')
|
||
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
|
||
renew(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@Body() _dto: RenewContractDto,
|
||
@CurrentUser() user: AuthUserPayload,
|
||
) {
|
||
return this.transitionService.renew(id, user?.id ?? user?.sub);
|
||
}
|
||
|
||
// ── 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)' })
|
||
uploadClearanceDocuments(
|
||
@Param('id', ParseUUIDPipe) id: string,
|
||
@UploadedFiles() files: Express.Multer.File[],
|
||
) {
|
||
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,
|
||
@CurrentUser() user: AuthUserPayload,
|
||
) {
|
||
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user));
|
||
}
|
||
|
||
@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,
|
||
);
|
||
}
|
||
|
||
@Get(':id/capacity')
|
||
@ApiOperation({
|
||
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
||
})
|
||
async capacity(@Param('id', ParseUUIDPipe) id: string) {
|
||
const contract = await this.contractsService.findById(id);
|
||
return this.contractBookingService.computeCapacity(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: AuthUserPayload,
|
||
) {
|
||
return this.milestoneService.assignRisk(
|
||
bookingId,
|
||
dto.riskLevel,
|
||
resolveAuthUserId(user),
|
||
dto.note,
|
||
);
|
||
}
|
||
|
||
@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/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),
|
||
);
|
||
}
|
||
}
|