mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user