mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
changes
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user