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:
marshal
2026-07-01 07:27:53 +03:00
parent 7654b18385
commit ccd5d6de31
24 changed files with 785 additions and 133 deletions

View File

@@ -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) {