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

@@ -8,6 +8,10 @@ import { splitMilestones } from './clearance-milestone.catalog';
import { Contract } from './entities/contract.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import type { ClearanceMetaState } from './clearance-workflow.types';
import { metaFromBooking } from './clearance-workflow.types';
export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS';
@@ -29,19 +33,45 @@ export class ClearanceWorkflowService {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly milestoneService: ClearanceMilestoneService,
private readonly bookingsRepository: BookingsRepository,
) {}
boundaryMilestone(tradeDirection: string): string {
return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY;
}
// ── Contract scope (ONE_TIME) ─────────────────────────────────────────────
async listMilestones(contractId: string): Promise<ClearanceMilestone[]> {
return this.milestoneService.listForContract(contractId);
}
async listMilestonesForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
return this.milestoneService.listForBooking(bookingId);
}
async isBoundaryComplete(contractId: string, tradeDirection: string): Promise<boolean> {
return this.isBoundaryCompleteForMilestones(
await this.listMilestones(contractId),
tradeDirection,
);
}
async isBoundaryCompleteForBooking(
bookingId: string,
tradeDirection: string,
): Promise<boolean> {
return this.isBoundaryCompleteForMilestones(
await this.listMilestonesForBooking(bookingId),
tradeDirection,
);
}
private isBoundaryCompleteForMilestones(
milestones: ClearanceMilestone[],
tradeDirection: string,
): boolean {
const code = this.boundaryMilestone(tradeDirection);
const milestones = await this.listMilestones(contractId);
const m = milestones.find((x) => x.milestoneCode === code);
return m?.status === 'COMPLETED';
}
@@ -59,14 +89,38 @@ export class ClearanceWorkflowService {
contractId: string,
tradeDirection: string,
targetCode: string,
): Promise<void> {
await this.assertPriorCompleteOnMilestones(
await this.listMilestones(contractId),
tradeDirection,
targetCode,
);
}
async assertPriorCompleteForBooking(
bookingId: string,
tradeDirection: string,
targetCode: string,
): Promise<void> {
await this.assertPriorCompleteOnMilestones(
await this.listMilestonesForBooking(bookingId),
tradeDirection,
targetCode,
);
}
private async assertPriorCompleteOnMilestones(
milestones: ClearanceMilestone[],
tradeDirection: string,
targetCode: string,
): Promise<void> {
const { preBooking } = splitMilestones(tradeDirection);
const milestones = await this.listMilestones(contractId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
const targetIdx = preBooking.findIndex((d) => d.code === targetCode);
if (targetIdx < 0) return;
for (let i = 0; i < targetIdx; i++) {
const code = preBooking[i]!.code;
const m = byCode.get(code);
if (!m) continue;
@@ -85,6 +139,12 @@ export class ClearanceWorkflowService {
}
}
async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise<void> {
for (const code of codes) {
await this.milestoneService.skipForBooking(bookingId, code);
}
}
async completeMilestone(
contractId: string,
code: string,
@@ -102,6 +162,23 @@ export class ClearanceWorkflowService {
return this.milestoneService.completeForContract(contractId, code, userId);
}
async completeMilestoneForBooking(
bookingId: string,
code: string,
userId?: string,
metadata?: MilestoneMetadata,
): Promise<ClearanceMilestone> {
if (metadata && Object.keys(metadata).length > 0) {
return this.milestoneService.completeWithMetadataForBooking(
bookingId,
code,
metadata,
userId,
);
}
return this.milestoneService.completeForBooking(bookingId, code, userId);
}
async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise<void> {
const uploaded =
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
@@ -109,24 +186,52 @@ export class ClearanceWorkflowService {
await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW');
}
async onCustomerDocsUploadedForBooking(
bookingId: string,
tradeDirection: string,
): Promise<void> {
const uploaded =
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
await this.completeMilestoneForBooking(bookingId, uploaded);
await this.completeMilestoneForBooking(bookingId, 'PENDING_DOCUMENT_REVIEW');
}
async onAllDocsApproved(contractId: string): Promise<void> {
await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED');
}
async onAllDocsApprovedForBooking(bookingId: string): Promise<void> {
await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED');
}
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
await this.completeMilestone(contractId, 'DECLARED', userId);
}
async onDeclarationUploadedForBooking(bookingId: string, userId?: string): Promise<void> {
await this.completeMilestoneForBooking(bookingId, 'UNDER_CUSTOMS_CLEARANCE');
await this.completeMilestoneForBooking(bookingId, 'DECLARED', userId);
}
async onDutySkipped(contractId: string): Promise<void> {
await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
}
async onDutySkippedForBooking(bookingId: string): Promise<void> {
await this.skipMilestonesForBooking(bookingId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
}
async onExportReleased(contractId: string, userId?: string): Promise<void> {
await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId);
await this.markReadyForBooking(contractId);
}
async onExportReleasedForBooking(bookingId: string, userId?: string): Promise<void> {
await this.completeMilestoneForBooking(bookingId, 'EXPORT_RELEASED', userId);
await this.markReadyForOperation(bookingId);
}
async markReadyForBooking(contractId: string): Promise<void> {
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
@@ -144,37 +249,92 @@ export class ClearanceWorkflowService {
}
}
/** GENERAL per-booking: boundary complete → customer may proceed to operations. */
async markReadyForOperation(bookingId: string): Promise<void> {
await this.bookingsRepository.update(bookingId, {
status: 'CLEARANCE_READY',
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
}
resolvePhase(
contract: Contract,
cycle: ContractClearanceCycle | null,
milestones: ClearanceMilestone[],
): ContractDocPhase {
if (cycle?.currentPhase) {
return cycle.currentPhase as ContractDocPhase;
const meta: ClearanceMetaState = {
dutyRequired: cycle?.dutyRequired ?? null,
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
roHoldReason: cycle?.roHoldReason ?? null,
currentPhase: cycle?.currentPhase ?? null,
};
return this.resolvePhaseFromMeta(contract.tradeDirection, meta, milestones);
}
resolvePhaseForBooking(
booking: Booking,
milestones: ClearanceMilestone[],
): ContractDocPhase {
return this.resolvePhaseFromMeta(
booking.tradeDirection ?? 'IMPORT',
metaFromBooking(booking),
milestones,
);
}
private resolvePhaseFromMeta(
tradeDirection: string,
meta: ClearanceMetaState,
milestones: ClearanceMilestone[],
): ContractDocPhase {
if (meta.currentPhase) {
return meta.currentPhase as ContractDocPhase;
}
return this.inferPhase(contract, cycle, milestones);
return this.inferPhaseFromMeta(tradeDirection, meta, milestones);
}
inferPhase(
contract: Contract,
cycle: ContractClearanceCycle | null,
milestones: ClearanceMilestone[],
): ContractDocPhase {
return this.inferPhaseFromMeta(
contract.tradeDirection,
{
dutyRequired: cycle?.dutyRequired ?? null,
roHoldReason: cycle?.roHoldReason ?? null,
},
milestones,
);
}
inferPhaseForBooking(booking: Booking, milestones: ClearanceMilestone[]): ContractDocPhase {
return this.inferPhaseFromMeta(
booking.tradeDirection ?? 'IMPORT',
metaFromBooking(booking),
milestones,
);
}
private inferPhaseFromMeta(
tradeDirection: string,
meta: ClearanceMetaState,
milestones: ClearanceMilestone[],
): ContractDocPhase {
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
const isDone = (code: string) =>
byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED';
const docUploaded =
contract.tradeDirection === 'IMPORT'
tradeDirection === 'IMPORT'
? isDone(IMPORT_DOC_UPLOADED)
: isDone(EXPORT_DOC_UPLOADED);
if (!docUploaded) return ContractDocPhase.CustomerIntake;
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
if (contract.tradeDirection === 'EXPORT') {
if (tradeDirection === 'EXPORT') {
if (!isDone('RELEASE_ORDER_SECURED')) {
if (cycle?.roHoldReason) return ContractDocPhase.GlDjCollection;
return ContractDocPhase.GlDjCollection;
}
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
@@ -182,12 +342,12 @@ export class ClearanceWorkflowService {
return ContractDocPhase.GlEtPostClearance;
}
// Import
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (cycle?.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
return ContractDocPhase.CustomerDuty;
}
if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance;
if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance;
if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection;
return ContractDocPhase.GlEtPostClearance;
}
@@ -197,12 +357,42 @@ export class ClearanceWorkflowService {
cycle: ContractClearanceCycle | null,
milestones: ClearanceMilestone[],
): ClearanceNextAction | null {
if (cycle?.roHoldReason) {
return this.computeNextActionFromMeta(
contract.tradeDirection,
{
dutyRequired: cycle?.dutyRequired ?? null,
roHoldReason: cycle?.roHoldReason ?? null,
preClearanceFinalizedAt: cycle?.preClearanceFinalizedAt ?? null,
},
milestones,
'contract',
);
}
computeNextActionForBooking(
booking: Booking,
milestones: ClearanceMilestone[],
): ClearanceNextAction | null {
return this.computeNextActionFromMeta(
booking.tradeDirection ?? 'IMPORT',
metaFromBooking(booking),
milestones,
'booking',
);
}
private computeNextActionFromMeta(
tradeDirection: string,
meta: ClearanceMetaState,
milestones: ClearanceMilestone[],
terminalScope: 'contract' | 'booking',
): ClearanceNextAction | null {
if (meta.roHoldReason) {
return {
actor: 'GL_DJ',
action: 'Re-upload Release Order or request port amendment',
milestoneCode: 'RELEASE_ORDER_SECURED',
blockedReason: cycle.roHoldReason,
blockedReason: meta.roHoldReason,
};
}
@@ -217,7 +407,7 @@ export class ClearanceWorkflowService {
};
const docCode =
contract.tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
if (pending(docCode) || !isDone(docCode)) {
return {
@@ -235,7 +425,12 @@ export class ClearanceWorkflowService {
};
}
if (contract.tradeDirection === 'EXPORT') {
const terminalAction =
terminalScope === 'contract'
? 'Create shipment booking'
: 'Proceed to request operation';
if (tradeDirection === 'EXPORT') {
if (!isDone('RELEASE_ORDER_SECURED')) {
return {
actor: 'GL_DJ',
@@ -258,13 +453,12 @@ export class ClearanceWorkflowService {
};
}
return {
actor: 'GL_ET',
action: 'Create shipment booking',
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
action: terminalAction,
milestoneCode: EXPORT_BOUNDARY,
};
}
// Import
if (!isDone('DECLARED')) {
return {
actor: 'GL_ET',
@@ -273,7 +467,7 @@ export class ClearanceWorkflowService {
};
}
if (cycle?.dutyRequired === null || cycle?.dutyRequired === undefined) {
if (meta.dutyRequired === null || meta.dutyRequired === undefined) {
return {
actor: 'GL_ET',
action: 'Set whether duty/tax applies',
@@ -281,7 +475,7 @@ export class ClearanceWorkflowService {
};
}
if (cycle.dutyRequired && !isDone('DUTY_TAX_PAID')) {
if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) {
if (!isDone('DUTY_TAXES_ADVISED')) {
return {
actor: 'GL_ET',
@@ -304,6 +498,14 @@ export class ClearanceWorkflowService {
};
}
if (!meta.preClearanceFinalizedAt) {
return {
actor: 'GL_ET',
action: 'Finalize pre-clearance',
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
};
}
if (!isDone(IMPORT_BOUNDARY)) {
return {
actor: 'GL_DJ',
@@ -313,19 +515,17 @@ export class ClearanceWorkflowService {
}
return {
actor: 'GL_ET',
action: 'Create shipment booking',
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
action: terminalAction,
milestoneCode: IMPORT_BOUNDARY,
};
}
/** Contracts where the next pending milestone is owned by ET. */
etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET');
return pending?.milestoneCode ?? null;
}
/** Contracts where the next pending milestone is owned by DJ. */
djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ');
return pending?.milestoneCode ?? null;