mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
Implement contract document download functionality and enhance clearance review status checks
- Added methods to assert clearance reviewable, finalizable, and uploadable statuses in the ContractClearanceService. - Introduced a new endpoint in ContractsController for downloading contract PDFs. - Implemented download functionality in the contracts service for both backoffice and portal applications. - Updated UI components to include download buttons for contract PDFs in relevant pages. - Enhanced contract request and view pages to support contract document downloads.
This commit is contained in:
@@ -154,6 +154,59 @@ export class ContractClearanceService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */
|
||||
private assertClearanceReviewableStatus(contract: Contract): void {
|
||||
const allowed = [
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
];
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot review clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Finalize when docs are under review or all approved after a partial query cycle. */
|
||||
private assertClearanceFinalizableStatus(contract: Contract): void {
|
||||
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertClearanceOutputUploadableStatus(contract: Contract): void {
|
||||
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot upload output documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise<void> {
|
||||
const refreshed = await this.contractsService.findById(contractId);
|
||||
const allApproved = await this.isClearanceFullyApproved(refreshed);
|
||||
if (
|
||||
!allApproved ||
|
||||
(refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||
refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer uploads clearance documents on the contract. When every required
|
||||
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
|
||||
@@ -294,19 +347,7 @@ export class ContractClearanceService {
|
||||
note?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has
|
||||
// dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips
|
||||
// the contract to "awaiting" (the customer must re-upload), but the reviewer
|
||||
// may still be working through the rest of the batch. Restricting to
|
||||
// UNDER_REVIEW only would 409 every review after the first query.
|
||||
if (
|
||||
contract.status !== 'CLEARANCE_UNDER_REVIEW' &&
|
||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS'
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Cannot review clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceReviewableStatus(contract);
|
||||
if (status === 'QUERIED' && !note?.trim()) {
|
||||
throw new BadRequestException('A note is required when querying a document');
|
||||
}
|
||||
@@ -348,6 +389,8 @@ export class ContractClearanceService {
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||
}
|
||||
} else if (status === 'APPROVED') {
|
||||
await this.bumpToUnderReviewWhenFullyApproved(contractId);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
@@ -359,11 +402,7 @@ export class ContractClearanceService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot upload output documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceOutputUploadableStatus(contract);
|
||||
const { outputCode } = contractClearanceCodes(contract);
|
||||
if (!outputCode) {
|
||||
throw new BadRequestException('This contract has no customs output documents');
|
||||
@@ -394,11 +433,7 @@ export class ContractClearanceService {
|
||||
'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
|
||||
);
|
||||
}
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceFinalizableStatus(contract);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(contract);
|
||||
if (!approved) {
|
||||
@@ -452,11 +487,7 @@ export class ContractClearanceService {
|
||||
'Operations finalize applies only to self-clearance (non-customs) contracts.',
|
||||
);
|
||||
}
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceFinalizableStatus(contract);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(contract);
|
||||
if (!approved) {
|
||||
|
||||
@@ -345,6 +345,14 @@ export class ContractTransitionService {
|
||||
return { view, html, signatures: view.signatures };
|
||||
}
|
||||
|
||||
/** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */
|
||||
async streamContractPdf(contractId: string) {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||
const record = await this.upsertContractPdf(contractId, contract.reference, view);
|
||||
return this.filesService.streamById(record.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the stored `contract` PDF from the current aggregate (now including
|
||||
* the latest signatures) so the downloaded/viewed file matches the live HTML
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
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 type { Response } from 'express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -417,6 +419,26 @@ export class ContractsController {
|
||||
};
|
||||
}
|
||||
|
||||
@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(
|
||||
|
||||
Reference in New Issue
Block a user