contrat,booking,global logestic

This commit is contained in:
Marshal
2026-06-26 23:24:48 +00:00
parent f931342f31
commit 01d53c218c
105 changed files with 19573 additions and 909 deletions

View File

@@ -0,0 +1,461 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UnauthorizedException,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { AnyFilesInterceptor } from '@nestjs/platform-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 { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.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';
@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,
) {}
@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,
@Query('region') region?: string,
) {
return this.clearanceService.queue(filter, region);
}
@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);
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,
};
}
@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)
@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' })
finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.finalize(id);
}
// ── 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,
);
}
// ── 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,
);
}
}