This commit is contained in:
marshal
2026-07-01 20:55:17 +03:00
parent 612df8daff
commit 9c18d086d7
112 changed files with 5654 additions and 1370 deletions

View File

@@ -9,10 +9,12 @@ import { ContractsService, PaginatedContracts } from './contracts.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { assertDeclarationFiles, buildWorkflowFiles } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -63,6 +65,14 @@ export interface ContractClearanceView {
vesselDepartureDate?: string | null;
roAmendmentRequestedAt?: string | null;
bookingReady?: boolean;
preClearanceFinalized?: boolean;
dutyAdvice?: {
amount: number;
currency: string;
declarationSerial?: string | null;
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
}
@Injectable()
@@ -77,18 +87,49 @@ export class ContractClearanceService {
private readonly dropdownSettingsService: DropdownSettingsService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME';
}
private assertPhasedCustoms(contract: Contract): void {
if (!contract.customsClearingEnabled) {
throw new BadRequestException('Phased clearance applies only to customs contracts.');
if (!this.isPhasedCustoms(contract)) {
throw new BadRequestException(
'Phased clearance (Phase 1) applies to one-time customs contracts.',
);
}
if (contract.contractKind !== 'ONE_TIME') {
throw new BadRequestException('Phased clearance (Phase 1) applies to one-time contracts.');
}
/**
* Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing
* phased milestones. Revert that state so declaration / DO steps can proceed.
*/
private async reconcilePrematureBookingReady(
contractId: string,
contract: Contract,
bookingReady: boolean,
): Promise<Contract> {
if (
!this.isPhasedCustoms(contract) ||
contract.status !== 'CLEARANCE_READY_FOR_BOOKING' ||
bookingReady
) {
return contract;
}
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') {
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
}
return this.contractsService.findById(contractId);
}
/** The pre-booking clearance document grid for a contract (Path B). */
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
const contract = await this.contractsService.findById(contractId);
let contract = await this.contractsService.findById(contractId);
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
@@ -151,12 +192,20 @@ export class ContractClearanceService {
const allApproved = await this.isClearanceFullyApproved(contract);
const milestones = await this.workflowService.listMilestones(contractId);
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
const nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
const boundary = await this.workflowService.isBoundaryComplete(
let boundary = await this.workflowService.isBoundaryComplete(
contractId,
contract.tradeDirection,
);
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
const nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
const documentFileKeys = new Set(documents.map((d) => d.fileKey));
const workflowFiles = buildWorkflowFiles(
files,
contract.tradeDirection ?? 'IMPORT',
documentFileKeys,
);
return {
contractId,
@@ -187,6 +236,34 @@ export class ContractClearanceService {
? cycle.roAmendmentRequestedAt.toISOString()
: null,
bookingReady: boundary,
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
dutyAdvice,
workflowFiles,
};
}
private buildDutyAdvice(
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
milestones: ClearanceMilestone[],
): ContractClearanceView['dutyAdvice'] {
const advised = milestones.find(
(m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED',
);
if (!advised?.metadata) return null;
const amount = advised.metadata.dutyAmount;
const currency = advised.metadata.dutyCurrency;
if (typeof amount !== 'number' || typeof currency !== 'string') return null;
const notice = files.find((f) => f.code === 'duty_tax_notice');
return {
amount,
currency,
declarationSerial:
typeof advised.metadata.declarationSerial === 'string'
? advised.metadata.declarationSerial
: null,
noticeFile: notice
? { id: notice.id, name: notice.name, url: notice.url }
: null,
};
}
@@ -462,6 +539,12 @@ export class ContractClearanceService {
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
if (this.isPhasedCustoms(contract) && cycle.preClearanceFinalizedAt) {
await this.contractsRepository.updateCycle(cycle.id, {
preClearanceFinalizedAt: null,
currentPhase: ContractDocPhase.GlEtPostClearance,
});
}
}
} else if (status === 'APPROVED') {
await this.bumpToUnderReviewWhenFullyApproved(contractId);
@@ -514,8 +597,10 @@ export class ContractClearanceService {
/**
* GL ET finalizes Path B pre-booking clearance: requires every customer
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
* document APPROVED. For phased customs (ONE_TIME), document review completes
* here — booking readiness is set only after delivery order (import) or export
* release via the milestone workflow. Non-phased customs still jump straight to
* CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts.
*/
async finalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
@@ -533,6 +618,24 @@ export class ContractClearanceService {
);
}
if (this.isPhasedCustoms(contract)) {
await this.workflowService.onAllDocsApproved(contractId);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase:
contract.tradeDirection === 'EXPORT'
? ContractDocPhase.GlDjCollection
: ContractDocPhase.GlEtOutput,
});
}
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
return this.contractsService.findById(contractId);
}
const { outputCode } = contractClearanceCodes(contract);
if (outputCode) {
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
@@ -661,6 +764,24 @@ export class ContractClearanceService {
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
private async ensureDeclarationPrerequisites(
contractId: string,
contract: Contract,
): Promise<void> {
const allApproved = await this.isClearanceFullyApproved(contract);
if (!allApproved) {
throw new BadRequestException(
'All required customer documents must be approved before uploading a declaration.',
);
}
const milestones = await this.workflowService.listMilestones(contractId);
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
await this.workflowService.onAllDocsApproved(contractId);
}
}
async uploadDeclaration(
contractId: string,
files: Express.Multer.File[],
@@ -668,15 +789,17 @@ export class ContractClearanceService {
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
await this.ensureDeclarationPrerequisites(contractId, contract);
await this.workflowService.assertPriorComplete(
contractId,
contract.tradeDirection,
'DECLARED',
'UNDER_CUSTOMS_CLEARANCE',
);
if (files.length === 0) {
throw new BadRequestException('No declaration documents uploaded');
}
assertDeclarationFiles(files, contract.tradeDirection);
for (const file of files) {
await this.filesService.upsertByCode({
@@ -706,6 +829,7 @@ export class ContractClearanceService {
contractId: string,
dto: AdviseContractDutyDto,
userId?: string,
attachment?: Express.Multer.File,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
@@ -730,6 +854,15 @@ export class ContractClearanceService {
if (dto.amount == null || dto.amount < 0) {
throw new BadRequestException('Duty amount is required when duty applies.');
}
if (!attachment) {
throw new BadRequestException('Duty notice attachment is required when duty applies.');
}
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: 'duty_tax_notice',
file: attachment,
});
await this.milestoneService.adviseDutyForContract(
contractId,
{
@@ -808,13 +941,40 @@ export class ContractClearanceService {
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase: ContractDocPhase.GlDjCollection,
currentPhase: ContractDocPhase.GlEtPostClearance,
});
}
return this.contractsService.findById(contractId);
}
async finalizePreClearance(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Pre-clearance finalize applies only to import contracts.');
}
await this.workflowService.assertPriorComplete(
contractId,
'IMPORT',
'TRANSIT_PERMIT_UPLOADED',
);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
if (cycle.preClearanceFinalizedAt) {
return this.contractsService.findById(contractId);
}
await this.contractsRepository.updateCycle(cycle.id, {
preClearanceFinalizedAt: new Date(),
currentPhase: ContractDocPhase.GlDjCollection,
});
return this.contractsService.findById(contractId);
}
async uploadDeliveryOrder(
contractId: string,
file: Express.Multer.File,
@@ -825,6 +985,14 @@ export class ContractClearanceService {
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Delivery Order applies only to import contracts.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.preClearanceFinalizedAt) {
throw new BadRequestException(
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
);
}
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED');
if (!file) throw new BadRequestException('No Delivery Order uploaded');