mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +00:00
Enhance clearance milestone management and introduce new contract actions
- Added new milestones for 'Transit Permit Uploaded' and 'Export Transport Document Issued' in the clearance milestone catalog. - Implemented methods in ClearanceMilestoneService to skip milestones and complete them with metadata. - Updated ContractBookingService to check boundary conditions before booking creation. - Introduced new endpoints in ContractsController for uploading customs declarations, advising duty, and handling various document uploads. - Enhanced the UI to support new clearance actions and display relevant components based on milestone statuses.
This commit is contained in:
@@ -22,6 +22,11 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
TRANSIT_PERMIT_UPLOADED: {
|
||||
label: 'Transit Permit Uploaded',
|
||||
ownerRegion: 'ET',
|
||||
triggeredByDoc: true,
|
||||
},
|
||||
DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||
WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
@@ -52,6 +57,11 @@ const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
EXPORT_TRANSPORT_ISSUED: {
|
||||
label: 'Export Transport Document Issued',
|
||||
ownerRegion: 'ET',
|
||||
triggeredByDoc: true,
|
||||
},
|
||||
CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
|
||||
@@ -187,6 +187,58 @@ export class ClearanceMilestoneService {
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
/** Skip optional milestones (e.g. duty when not required). */
|
||||
async skipForContract(contractId: string, code: string): Promise<ClearanceMilestone> {
|
||||
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||
if (!milestone) {
|
||||
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') return milestone;
|
||||
milestone.status = 'SKIPPED';
|
||||
milestone.triggeredAt = new Date();
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
/** Complete a contract milestone with structured metadata (duty advice, etc.). */
|
||||
async completeWithMetadataForContract(
|
||||
contractId: string,
|
||||
code: string,
|
||||
metadata: MilestoneMetadata,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||
if (!milestone) {
|
||||
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
milestone.triggeredByUserId = userId ?? null;
|
||||
milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata };
|
||||
if (note) milestone.note = note;
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
async adviseDutyForContract(
|
||||
contractId: string,
|
||||
input: { amount: number; currency: string; declarationSerial?: string },
|
||||
userId?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
return this.completeWithMetadataForContract(
|
||||
contractId,
|
||||
'DUTY_TAXES_ADVISED',
|
||||
{
|
||||
dutyAmount: input.amount,
|
||||
dutyCurrency: input.currency,
|
||||
declarationSerial: input.declarationSerial,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
|
||||
async completeByDocTrigger(
|
||||
scope: { bookingId?: string; contractId?: string },
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
|
||||
function ms(
|
||||
code: string,
|
||||
status: 'PENDING' | 'COMPLETED' | 'SKIPPED',
|
||||
ownerRegion: 'ET' | 'DJ' | 'CUST' | 'OPS' = 'ET',
|
||||
): ClearanceMilestone {
|
||||
return { milestoneCode: code, status, ownerRegion } as ClearanceMilestone;
|
||||
}
|
||||
|
||||
function importThroughDeclaration(): ClearanceMilestone[] {
|
||||
return [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('UNDER_CUSTOMS_CLEARANCE', 'PENDING', 'ET'),
|
||||
ms('DECLARED', 'PENDING', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
];
|
||||
}
|
||||
|
||||
function makeService(milestones: ClearanceMilestone[]) {
|
||||
const contractsRepository = {
|
||||
currentCycle: jest.fn(),
|
||||
update: jest.fn(),
|
||||
setCycleStatus: jest.fn(),
|
||||
updateCycle: jest.fn(),
|
||||
};
|
||||
const milestoneService = {
|
||||
listForContract: jest.fn().mockResolvedValue(milestones),
|
||||
skipForContract: jest.fn(),
|
||||
completeForContract: jest.fn(),
|
||||
completeWithMetadataForContract: jest.fn(),
|
||||
};
|
||||
const service = new ClearanceWorkflowService(
|
||||
contractsRepository as never,
|
||||
milestoneService as never,
|
||||
);
|
||||
return { service, milestoneService, contractsRepository };
|
||||
}
|
||||
|
||||
const importContract = {
|
||||
id: 'c-import',
|
||||
tradeDirection: 'IMPORT',
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
} as Contract;
|
||||
|
||||
const exportContract = {
|
||||
id: 'c-export',
|
||||
tradeDirection: 'EXPORT',
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
} as Contract;
|
||||
|
||||
describe('ClearanceWorkflowService', () => {
|
||||
describe('boundaryMilestone', () => {
|
||||
it('uses DO_COLLECTED for import and EXPORT_RELEASED for export', () => {
|
||||
const { service } = makeService([]);
|
||||
expect(service.boundaryMilestone('IMPORT')).toBe('DO_COLLECTED');
|
||||
expect(service.boundaryMilestone('EXPORT')).toBe('EXPORT_RELEASED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertPriorComplete', () => {
|
||||
it('rejects when a prior milestone is still pending', async () => {
|
||||
const milestones = importThroughDeclaration().map((m) =>
|
||||
m.milestoneCode === 'DOCUMENTS_APPROVED'
|
||||
? ms('DOCUMENTS_APPROVED', 'PENDING', 'ET')
|
||||
: m,
|
||||
);
|
||||
const { service } = makeService(milestones);
|
||||
await expect(
|
||||
service.assertPriorComplete('c-import', 'IMPORT', 'DECLARED'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('allows proceeding when prior milestones are completed or skipped', async () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('UNDER_CUSTOMS_CLEARANCE', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
];
|
||||
const { service } = makeService(milestones);
|
||||
await expect(
|
||||
service.assertPriorComplete('c-import', 'IMPORT', 'TRANSIT_PERMIT_UPLOADED'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBoundaryComplete', () => {
|
||||
it('returns true only when boundary milestone is completed', async () => {
|
||||
const done = [
|
||||
...importThroughDeclaration().slice(0, -1),
|
||||
ms('DO_COLLECTED', 'COMPLETED', 'DJ'),
|
||||
];
|
||||
const { service: doneSvc } = makeService(done);
|
||||
await expect(doneSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(true);
|
||||
|
||||
const pending = importThroughDeclaration();
|
||||
const { service: pendingSvc } = makeService(pending);
|
||||
await expect(pendingSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onDutySkipped', () => {
|
||||
it('skips duty milestones on the contract', async () => {
|
||||
const { service, milestoneService } = makeService([]);
|
||||
await service.onDutySkipped('c-import');
|
||||
expect(milestoneService.skipForContract).toHaveBeenCalledWith(
|
||||
'c-import',
|
||||
'DUTY_TAXES_ADVISED',
|
||||
);
|
||||
expect(milestoneService.skipForContract).toHaveBeenCalledWith(
|
||||
'c-import',
|
||||
'DUTY_TAX_PAID',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextAction — import happy path', () => {
|
||||
it('prompts customer to upload docs first', () => {
|
||||
const { service } = makeService([ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST')]);
|
||||
const next = service.computeNextAction(importContract, null, [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST'),
|
||||
]);
|
||||
expect(next?.actor).toBe('CUSTOMER');
|
||||
expect(next?.milestoneCode).toBe('IMPORT_DOCS_UPLOADED');
|
||||
});
|
||||
|
||||
it('prompts ET review after customer docs', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
|
||||
];
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(importContract, null, milestones);
|
||||
expect(next?.actor).toBe('GL_ET');
|
||||
expect(next?.action).toMatch(/Review/i);
|
||||
});
|
||||
|
||||
it('prompts duty toggle when declaration done and duty unset', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'),
|
||||
];
|
||||
const cycle = { dutyRequired: null } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('GL_ET');
|
||||
expect(next?.action).toMatch(/duty/i);
|
||||
});
|
||||
|
||||
it('prompts customer duty slip when duty required and advised', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
|
||||
];
|
||||
const cycle = { dutyRequired: true } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('CUSTOMER');
|
||||
expect(next?.milestoneCode).toBe('DUTY_TAX_PAID');
|
||||
});
|
||||
|
||||
it('skips duty path when duty not required', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
|
||||
];
|
||||
const cycle = { dutyRequired: false } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('GL_ET');
|
||||
expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED');
|
||||
});
|
||||
|
||||
it('prompts DJ for DO then ET booking when pre-booking complete', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
];
|
||||
const cycle = { dutyRequired: false } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const djNext = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(djNext?.actor).toBe('GL_DJ');
|
||||
|
||||
const booked = milestones.map((m) =>
|
||||
m.milestoneCode === 'DO_COLLECTED' ? ms('DO_COLLECTED', 'COMPLETED', 'DJ') : m,
|
||||
);
|
||||
const etNext = service.computeNextAction(importContract, cycle, booked);
|
||||
expect(etNext?.actor).toBe('GL_ET');
|
||||
expect(etNext?.action).toMatch(/booking/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextAction — export RO hold', () => {
|
||||
it('surfaces DJ action when RO is on hold', () => {
|
||||
const milestones = [
|
||||
ms('EXPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('RELEASE_ORDER_SECURED', 'PENDING', 'DJ'),
|
||||
];
|
||||
const cycle = {
|
||||
roHoldReason: 'Vessel departs in 1 day(s) — minimum lead time is 2 day(s).',
|
||||
} as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextAction(exportContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('GL_DJ');
|
||||
expect(next?.blockedReason).toMatch(/minimum lead time/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferPhase', () => {
|
||||
it('places import contract in customer duty phase when duty outstanding', () => {
|
||||
const milestones = [
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
|
||||
];
|
||||
const cycle = { dutyRequired: true } as ContractClearanceCycle;
|
||||
const { service } = makeService(milestones);
|
||||
const phase = service.inferPhase(importContract, cycle, milestones);
|
||||
expect(phase).toBe(ContractDocPhase.CustomerDuty);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue helpers', () => {
|
||||
it('returns first pending ET-owned milestone code', () => {
|
||||
const { service } = makeService([
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
]);
|
||||
expect(service.etPendingMilestoneCodes([])).toBeNull();
|
||||
expect(
|
||||
service.etPendingMilestoneCodes([
|
||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
|
||||
]),
|
||||
).toBe('DOCUMENTS_APPROVED');
|
||||
});
|
||||
|
||||
it('returns first pending DJ-owned milestone code', () => {
|
||||
const { service } = makeService([]);
|
||||
expect(
|
||||
service.djPendingMilestoneCodes([
|
||||
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
|
||||
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||
]),
|
||||
).toBe('DO_COLLECTED');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { MilestoneMetadata } from './entities/clearance-milestone.entity';
|
||||
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';
|
||||
|
||||
export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS';
|
||||
|
||||
export interface ClearanceNextAction {
|
||||
actor: ClearanceActorRole;
|
||||
action: string;
|
||||
milestoneCode?: string | null;
|
||||
blockedReason?: string | null;
|
||||
}
|
||||
|
||||
const IMPORT_BOUNDARY = 'DO_COLLECTED';
|
||||
const EXPORT_BOUNDARY = 'EXPORT_RELEASED';
|
||||
|
||||
const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED';
|
||||
const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED';
|
||||
|
||||
@Injectable()
|
||||
export class ClearanceWorkflowService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
) {}
|
||||
|
||||
boundaryMilestone(tradeDirection: string): string {
|
||||
return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY;
|
||||
}
|
||||
|
||||
async listMilestones(contractId: string): Promise<ClearanceMilestone[]> {
|
||||
return this.milestoneService.listForContract(contractId);
|
||||
}
|
||||
|
||||
async isBoundaryComplete(contractId: string, tradeDirection: string): Promise<boolean> {
|
||||
const code = this.boundaryMilestone(tradeDirection);
|
||||
const milestones = await this.listMilestones(contractId);
|
||||
const m = milestones.find((x) => x.milestoneCode === code);
|
||||
return m?.status === 'COMPLETED';
|
||||
}
|
||||
|
||||
async assertBoundaryComplete(contract: Contract): Promise<void> {
|
||||
const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection);
|
||||
if (!ok) {
|
||||
throw new BadRequestException(
|
||||
`Pre-booking clearance is not complete — ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async assertPriorComplete(
|
||||
contractId: string,
|
||||
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;
|
||||
if (m.status === 'SKIPPED') continue;
|
||||
if (m.status !== 'COMPLETED') {
|
||||
throw new BadRequestException(
|
||||
`Complete "${preBooking[i]!.label}" before proceeding.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async skipMilestones(contractId: string, codes: string[]): Promise<void> {
|
||||
for (const code of codes) {
|
||||
await this.milestoneService.skipForContract(contractId, code);
|
||||
}
|
||||
}
|
||||
|
||||
async completeMilestone(
|
||||
contractId: string,
|
||||
code: string,
|
||||
userId?: string,
|
||||
metadata?: MilestoneMetadata,
|
||||
): Promise<ClearanceMilestone> {
|
||||
if (metadata && Object.keys(metadata).length > 0) {
|
||||
return this.milestoneService.completeWithMetadataForContract(
|
||||
contractId,
|
||||
code,
|
||||
metadata,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
return this.milestoneService.completeForContract(contractId, code, userId);
|
||||
}
|
||||
|
||||
async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise<void> {
|
||||
const uploaded =
|
||||
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||
await this.completeMilestone(contractId, uploaded);
|
||||
await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW');
|
||||
}
|
||||
|
||||
async onAllDocsApproved(contractId: string): Promise<void> {
|
||||
await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED');
|
||||
}
|
||||
|
||||
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
|
||||
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
|
||||
await this.completeMilestone(contractId, 'DECLARED', userId);
|
||||
}
|
||||
|
||||
async onDutySkipped(contractId: string): Promise<void> {
|
||||
await this.skipMilestones(contractId, ['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 markReadyForBooking(contractId: string): Promise<void> {
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_READY_FOR_BOOKING',
|
||||
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
|
||||
clearanceReadyAt: new Date(),
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resolvePhase(
|
||||
contract: Contract,
|
||||
cycle: ContractClearanceCycle | null,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
if (cycle?.currentPhase) {
|
||||
return cycle.currentPhase as ContractDocPhase;
|
||||
}
|
||||
return this.inferPhase(contract, cycle, milestones);
|
||||
}
|
||||
|
||||
inferPhase(
|
||||
contract: Contract,
|
||||
cycle: ContractClearanceCycle | null,
|
||||
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'
|
||||
? 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 (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
if (cycle?.roHoldReason) return ContractDocPhase.GlDjCollection;
|
||||
return ContractDocPhase.GlDjCollection;
|
||||
}
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
|
||||
return ContractDocPhase.GlEtPostClearance;
|
||||
}
|
||||
|
||||
// Import
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (cycle?.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
|
||||
return ContractDocPhase.CustomerDuty;
|
||||
}
|
||||
if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance;
|
||||
if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection;
|
||||
return ContractDocPhase.GlEtPostClearance;
|
||||
}
|
||||
|
||||
computeNextAction(
|
||||
contract: Contract,
|
||||
cycle: ContractClearanceCycle | null,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ClearanceNextAction | null {
|
||||
if (cycle?.roHoldReason) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Re-upload Release Order or request port amendment',
|
||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||
blockedReason: cycle.roHoldReason,
|
||||
};
|
||||
}
|
||||
|
||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||
const pending = (code: string) => {
|
||||
const m = byCode.get(code);
|
||||
return m && m.status === 'PENDING';
|
||||
};
|
||||
const isDone = (code: string) => {
|
||||
const m = byCode.get(code);
|
||||
return m?.status === 'COMPLETED' || m?.status === 'SKIPPED';
|
||||
};
|
||||
|
||||
const docCode =
|
||||
contract.tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||
|
||||
if (pending(docCode) || !isDone(docCode)) {
|
||||
return {
|
||||
actor: 'CUSTOMER',
|
||||
action: 'Upload clearance documents',
|
||||
milestoneCode: docCode,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDone('DOCUMENTS_APPROVED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Review and approve customer documents',
|
||||
milestoneCode: 'DOCUMENTS_APPROVED',
|
||||
};
|
||||
}
|
||||
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Upload Release Order and vessel departure date',
|
||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||
};
|
||||
}
|
||||
if (!isDone('DECLARED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Upload customs declaration (EX3/EX8)',
|
||||
milestoneCode: 'DECLARED',
|
||||
};
|
||||
}
|
||||
if (!isDone(EXPORT_BOUNDARY)) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Confirm export release',
|
||||
milestoneCode: EXPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Create shipment booking',
|
||||
milestoneCode: EXPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
|
||||
// Import
|
||||
if (!isDone('DECLARED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Upload customs declaration (IM4/IM5)',
|
||||
milestoneCode: 'DECLARED',
|
||||
};
|
||||
}
|
||||
|
||||
if (cycle?.dutyRequired === null || cycle?.dutyRequired === undefined) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Set whether duty/tax applies',
|
||||
milestoneCode: 'DUTY_TAXES_ADVISED',
|
||||
};
|
||||
}
|
||||
|
||||
if (cycle.dutyRequired && !isDone('DUTY_TAX_PAID')) {
|
||||
if (!isDone('DUTY_TAXES_ADVISED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Advise duty and tax amount',
|
||||
milestoneCode: 'DUTY_TAXES_ADVISED',
|
||||
};
|
||||
}
|
||||
return {
|
||||
actor: 'CUSTOMER',
|
||||
action: 'Upload duty/tax payment slip',
|
||||
milestoneCode: 'DUTY_TAX_PAID',
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDone('TRANSIT_PERMIT_UPLOADED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Upload transit permit screenshot',
|
||||
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDone(IMPORT_BOUNDARY)) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Upload Delivery Order',
|
||||
milestoneCode: IMPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Create shipment booking',
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||
@@ -56,6 +57,7 @@ export class ContractBookingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
@@ -246,10 +248,14 @@ export class ContractBookingService {
|
||||
}
|
||||
return 'GL_ET';
|
||||
}
|
||||
// ONE_TIME customs — UNCHANGED: requires the finalized contract cycle.
|
||||
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
// ONE_TIME customs — pre-booking boundary milestone must be complete.
|
||||
const boundaryOk = await this.workflowService.isBoundaryComplete(
|
||||
contract.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
if (!boundaryOk) {
|
||||
throw new BadRequestException(
|
||||
'Contract clearance is not ready for booking yet.',
|
||||
'Pre-booking clearance is not complete — booking cannot be created yet.',
|
||||
);
|
||||
}
|
||||
return 'GL_ET';
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
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 { 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';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
export interface ContractClearanceDocument {
|
||||
fileKey: string;
|
||||
@@ -34,6 +41,28 @@ export interface ContractClearanceView {
|
||||
outputCode: string | null;
|
||||
documents: ContractClearanceDocument[];
|
||||
allApproved: boolean;
|
||||
phase?: string | null;
|
||||
milestones?: Array<{
|
||||
id: string;
|
||||
milestoneCode: string;
|
||||
milestoneLabel: string;
|
||||
status: string;
|
||||
ownerRegion?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
nextAction?: {
|
||||
actor: string;
|
||||
action: string;
|
||||
milestoneCode?: string | null;
|
||||
blockedReason?: string | null;
|
||||
} | null;
|
||||
dutyRequired?: boolean | null;
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
bookingReady?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -43,8 +72,20 @@ export class ContractClearanceService {
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
) {}
|
||||
|
||||
private assertPhasedCustoms(contract: Contract): void {
|
||||
if (!contract.customsClearingEnabled) {
|
||||
throw new BadRequestException('Phased clearance applies only to customs contracts.');
|
||||
}
|
||||
if (contract.contractKind !== 'ONE_TIME') {
|
||||
throw new BadRequestException('Phased clearance (Phase 1) applies to one-time contracts.');
|
||||
}
|
||||
}
|
||||
|
||||
/** The pre-booking clearance document grid for a contract (Path B). */
|
||||
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
@@ -109,6 +150,13 @@ 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(
|
||||
contractId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
|
||||
return {
|
||||
contractId,
|
||||
@@ -120,6 +168,25 @@ export class ContractClearanceService {
|
||||
outputCode,
|
||||
documents,
|
||||
allApproved,
|
||||
phase,
|
||||
milestones: milestones.map((m) => ({
|
||||
id: m.id,
|
||||
milestoneCode: m.milestoneCode,
|
||||
milestoneLabel: m.milestoneLabel,
|
||||
status: m.status,
|
||||
ownerRegion: m.ownerRegion,
|
||||
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
|
||||
sortOrder: m.sortOrder,
|
||||
})),
|
||||
nextAction,
|
||||
dutyRequired: cycle?.dutyRequired ?? null,
|
||||
roHold: Boolean(cycle?.roHoldReason),
|
||||
roHoldReason: cycle?.roHoldReason ?? null,
|
||||
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
||||
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
|
||||
? cycle.roAmendmentRequestedAt.toISOString()
|
||||
: null,
|
||||
bookingReady: boundary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,8 +329,15 @@ export class ContractClearanceService {
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', {
|
||||
currentPhase: ContractDocPhase.GlEtReview,
|
||||
});
|
||||
}
|
||||
|
||||
if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') {
|
||||
await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -391,6 +465,23 @@ export class ContractClearanceService {
|
||||
}
|
||||
} else if (status === 'APPROVED') {
|
||||
await this.bumpToUnderReviewWhenFullyApproved(contractId);
|
||||
const refreshed = await this.contractsService.findById(contractId);
|
||||
if (
|
||||
refreshed.customsClearingEnabled &&
|
||||
refreshed.contractKind === 'ONE_TIME' &&
|
||||
(await this.isClearanceFullyApproved(refreshed))
|
||||
) {
|
||||
await this.workflowService.onAllDocsApproved(contractId);
|
||||
const c = await this.contractsRepository.currentCycle(contractId);
|
||||
if (c) {
|
||||
await this.contractsRepository.updateCycle(c.id, {
|
||||
currentPhase:
|
||||
refreshed.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlDjCollection
|
||||
: ContractDocPhase.GlEtOutput,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
@@ -567,4 +658,395 @@ export class ContractClearanceService {
|
||||
sortOrder: filter.sortOrder ?? 'DESC',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
||||
|
||||
async uploadDeclaration(
|
||||
contractId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
contract.tradeDirection,
|
||||
'DECLARED',
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No declaration documents uploaded');
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
await this.workflowService.onDeclarationUploaded(contractId, userId);
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase:
|
||||
contract.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlEtPostClearance
|
||||
: ContractDocPhase.CustomerDuty,
|
||||
});
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async adviseDuty(
|
||||
contractId: string,
|
||||
dto: AdviseContractDutyDto,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty advice applies only to import contracts.');
|
||||
}
|
||||
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED');
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
dutyRequired: dto.dutyRequired,
|
||||
currentPhase: dto.dutyRequired
|
||||
? ContractDocPhase.CustomerDuty
|
||||
: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
|
||||
if (!dto.dutyRequired) {
|
||||
await this.workflowService.onDutySkipped(contractId);
|
||||
} else {
|
||||
if (dto.amount == null || dto.amount < 0) {
|
||||
throw new BadRequestException('Duty amount is required when duty applies.');
|
||||
}
|
||||
await this.milestoneService.adviseDutyForContract(
|
||||
contractId,
|
||||
{
|
||||
amount: dto.amount,
|
||||
currency: dto.currency ?? 'ETB',
|
||||
declarationSerial: dto.declarationSerial,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async uploadDutySlip(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty slip upload applies only to import contracts.');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle?.dutyRequired) {
|
||||
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||
}
|
||||
|
||||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'duty_tax_receipt',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID');
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async uploadTransitPermit(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Transit permit applies only to import contracts.');
|
||||
}
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
'IMPORT',
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
);
|
||||
|
||||
if (!file) throw new BadRequestException('No transit permit uploaded');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'transit_permitted',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId);
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase: ContractDocPhase.GlDjCollection,
|
||||
});
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async uploadDeliveryOrder(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Delivery Order applies only to import contracts.');
|
||||
}
|
||||
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED');
|
||||
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'delivery_order',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
|
||||
await this.workflowService.markReadyForBooking(contractId);
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
private async resolveRoMinDays(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE);
|
||||
const first = setting.children?.[0];
|
||||
const n = Number(first?.value);
|
||||
return Number.isFinite(n) && n > 0 ? n : 2;
|
||||
} catch {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private daysUntil(dateStr: string): number {
|
||||
const target = new Date(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
async uploadReleaseOrder(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
vesselDepartureDate: string,
|
||||
userId?: string,
|
||||
): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Release Order applies only to export contracts.');
|
||||
}
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
'EXPORT',
|
||||
'RELEASE_ORDER_SECURED',
|
||||
);
|
||||
|
||||
if (!file) throw new BadRequestException('No Release Order uploaded');
|
||||
if (!vesselDepartureDate?.trim()) {
|
||||
throw new BadRequestException('Vessel departure date is required');
|
||||
}
|
||||
|
||||
const minDays = await this.resolveRoMinDays();
|
||||
const leadDays = this.daysUntil(vesselDepartureDate);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'release_order',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
vesselDepartureDate,
|
||||
roAmendmentRequestedAt: null,
|
||||
});
|
||||
|
||||
if (leadDays < minDays) {
|
||||
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
roHoldReason: reason,
|
||||
currentPhase: ContractDocPhase.GlDjCollection,
|
||||
});
|
||||
return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason };
|
||||
}
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
roHoldReason: null,
|
||||
currentPhase: ContractDocPhase.GlEtOutput,
|
||||
});
|
||||
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
|
||||
|
||||
return { contract: await this.contractsService.findById(contractId), hold: false };
|
||||
}
|
||||
|
||||
async requestRoAmendment(
|
||||
contractId: string,
|
||||
note?: string,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('RO amendment applies only to export contracts.');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
|
||||
const reason =
|
||||
note?.trim() ||
|
||||
'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
roAmendmentRequestedAt: new Date(),
|
||||
roHoldReason: reason,
|
||||
currentPhase: ContractDocPhase.GlDjCollection,
|
||||
});
|
||||
|
||||
if (userId) {
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
reason,
|
||||
'CHANGES_REQUESTED',
|
||||
userId,
|
||||
'GL_DJ',
|
||||
);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async confirmExportRelease(contractId: string, userId?: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Export release applies only to export contracts.');
|
||||
}
|
||||
await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED');
|
||||
|
||||
await this.workflowService.onExportReleased(contractId, userId);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** GL ET queue: customs ONE_TIME contracts with a pending ET-owned milestone. */
|
||||
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
const base = await this.contractsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'],
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
const filtered: typeof base.items = [];
|
||||
for (const c of base.items) {
|
||||
const milestones = await this.workflowService.listMilestones(c.id);
|
||||
const pending = this.workflowService.etPendingMilestoneCodes(milestones);
|
||||
const next = this.workflowService.computeNextAction(
|
||||
c,
|
||||
await this.contractsRepository.currentCycle(c.id),
|
||||
milestones,
|
||||
);
|
||||
if (pending || next?.actor === 'GL_ET') filtered.push(c);
|
||||
}
|
||||
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: filtered.length,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: filtered.length,
|
||||
totalPages: Math.ceil(filtered.length / pageSize) || 1,
|
||||
hasNextPage: start + pageSize < filtered.length,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** GL DJ queue: customs ONE_TIME contracts with a pending DJ-owned milestone or RO hold. */
|
||||
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
const base = await this.contractsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'],
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
const filtered: typeof base.items = [];
|
||||
for (const c of base.items) {
|
||||
const cycle = await this.contractsRepository.currentCycle(c.id);
|
||||
const milestones = await this.workflowService.listMilestones(c.id);
|
||||
const pending = this.workflowService.djPendingMilestoneCodes(milestones);
|
||||
const next = this.workflowService.computeNextAction(c, cycle, milestones);
|
||||
if (cycle?.roHoldReason || pending || next?.actor === 'GL_DJ') filtered.push(c);
|
||||
}
|
||||
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: filtered.length,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: filtered.length,
|
||||
totalPages: Math.ceil(filtered.length / pageSize) || 1,
|
||||
hasNextPage: start + pageSize < filtered.length,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
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 { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
@@ -72,6 +73,10 @@ import {
|
||||
CompleteMilestoneDto,
|
||||
ReportIncidentDto,
|
||||
} from './dto/gl-operations.dto';
|
||||
import {
|
||||
AdviseContractDutyDto,
|
||||
RoAmendmentDto,
|
||||
} from './dto/phased-clearance.dto';
|
||||
|
||||
@ApiTags('contracts')
|
||||
@Controller('contracts')
|
||||
@@ -511,11 +516,126 @@ export class ContractsController {
|
||||
|
||||
@Post(':id/clearance/finalize')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
|
||||
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' })
|
||||
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)' })
|
||||
finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clearanceService.finalize(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads customs declaration (IM4/IM5 or EX3/EX8)' })
|
||||
uploadDeclaration(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadDeclaration(id, files ?? [], resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||||
@ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount' })
|
||||
adviseContractDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AdviseContractDutyDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.adviseDuty(id, dto, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' })
|
||||
uploadContractDutySlip(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.clearanceService.uploadDutySlip(id, file);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-permit')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads transit permit screenshot (import)' })
|
||||
uploadTransitPermit(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadTransitPermit(id, file, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/delivery-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' })
|
||||
uploadDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' })
|
||||
uploadReleaseOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
file,
|
||||
vesselDepartureDate,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/ro-amendment')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL DJ requests port amendment when RO vessel window is too short' })
|
||||
requestRoAmendment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.requestRoAmendment(id, dto.note, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/export-release')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET confirms export release after declaration' })
|
||||
confirmExportRelease(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL Ethiopia phased clearance queue' })
|
||||
etClearanceQueue(@Query() filter: FilterContractDto) {
|
||||
return this.clearanceService.etQueue(filter);
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL Djibouti phased clearance queue' })
|
||||
djClearanceQueue(@Query() filter: FilterContractDto) {
|
||||
return this.clearanceService.djQueue(filter);
|
||||
}
|
||||
|
||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||
|
||||
@Get('clearance/ops-queue')
|
||||
@@ -696,6 +816,18 @@ export class ContractsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/transport-document')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads export transport document after wagon allocation' })
|
||||
uploadTransportDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.glOperationsService.uploadTransportDocument(bookingId, file);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/documents')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
@@ -18,6 +18,7 @@ import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
@@ -71,7 +72,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
CompaniesModule,
|
||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||
BookingsModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
@@ -85,6 +86,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractPricingService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ClearanceWorkflowService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
GlOperationsService,
|
||||
@@ -103,6 +105,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractPricingService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ClearanceWorkflowService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
],
|
||||
|
||||
@@ -518,7 +518,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
cycleId: string,
|
||||
status: string,
|
||||
fields: Partial<
|
||||
Pick<ContractClearanceCycle, 'bookingId' | 'clearanceReadyAt' | 'completedAt'>
|
||||
Pick<
|
||||
ContractClearanceCycle,
|
||||
| 'bookingId'
|
||||
| 'clearanceReadyAt'
|
||||
| 'completedAt'
|
||||
| 'dutyRequired'
|
||||
| 'vesselDepartureDate'
|
||||
| 'roAmendmentRequestedAt'
|
||||
| 'roHoldReason'
|
||||
| 'currentPhase'
|
||||
>
|
||||
> = {},
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
@@ -526,6 +536,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.update(cycleId, { status, ...fields } as never);
|
||||
}
|
||||
|
||||
async updateCycle(
|
||||
cycleId: string,
|
||||
fields: Partial<
|
||||
Pick<
|
||||
ContractClearanceCycle,
|
||||
| 'dutyRequired'
|
||||
| 'vesselDepartureDate'
|
||||
| 'roAmendmentRequestedAt'
|
||||
| 'roHoldReason'
|
||||
| 'currentPhase'
|
||||
| 'status'
|
||||
>
|
||||
>,
|
||||
): Promise<void> {
|
||||
await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never);
|
||||
}
|
||||
|
||||
/** Link the GL-created booking to a clearance cycle. */
|
||||
async linkBooking(cycleId: string, bookingId: string): Promise<void> {
|
||||
await this.dataSource
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
export class AdviseContractDutyDto {
|
||||
@ApiProperty({ description: 'Whether the customer must pay duty/tax' })
|
||||
@IsBoolean()
|
||||
dutyRequired!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Duty amount (required when dutyRequired is true)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 'ETB' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Declaration / payment reference code' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
declarationSerial?: string;
|
||||
}
|
||||
|
||||
export class ReleaseOrderDto {
|
||||
@ApiProperty({ description: 'Vessel departure date (ISO date YYYY-MM-DD)' })
|
||||
@IsString()
|
||||
vesselDepartureDate!: string;
|
||||
}
|
||||
|
||||
export class RoAmendmentDto {
|
||||
@ApiPropertyOptional({ description: 'Note to customer / ET GL about the amendment request' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -34,4 +34,21 @@ export class ContractClearanceCycle extends BaseEntity {
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt?: Date | null;
|
||||
|
||||
/** ET GL toggle: whether customer must pay duty/tax before DO collection (import). */
|
||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||
dutyRequired?: boolean | null;
|
||||
|
||||
/** Export RO vessel departure date (Path B export). */
|
||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||
vesselDepartureDate?: string | null;
|
||||
|
||||
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ro_hold_reason', type: 'text', nullable: true })
|
||||
roHoldReason?: string | null;
|
||||
|
||||
@Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
currentPhase?: string | null;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const DOC_CODE_TO_MILESTONE: Record<string, string> = {
|
||||
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
|
||||
full_in_interchange: 'OFFLOADED', // export — GL DJ
|
||||
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
|
||||
export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -158,4 +159,47 @@ export class GlOperationsService {
|
||||
}
|
||||
return { uploaded: files.length, completedMilestones };
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
|
||||
*/
|
||||
async uploadTransportDocument(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Transport document upload applies to export shipments only.');
|
||||
}
|
||||
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
|
||||
const wagonDone =
|
||||
wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED';
|
||||
if (!wagonDone) {
|
||||
throw new BadRequestException(
|
||||
'Wagon must be allocated before the transport document can be uploaded.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!file) throw new BadRequestException('No transport document uploaded');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'export_transport_document',
|
||||
file,
|
||||
});
|
||||
|
||||
if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') {
|
||||
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
|
||||
}
|
||||
|
||||
await this.milestoneService.completeByDocTrigger(
|
||||
{ bookingId },
|
||||
'EXPORT_TRANSPORT_ISSUED',
|
||||
);
|
||||
|
||||
return { uploaded: true, milestoneCompleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
interface Capacity {
|
||||
@@ -179,6 +181,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly notifier: BookingNotifierService,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
) {}
|
||||
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
@@ -1002,6 +1005,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
});
|
||||
this.notifier.secured(booking, reason);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
void this.markWagonAllocatedMilestone(booking.id);
|
||||
}
|
||||
|
||||
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
|
||||
if (!this.milestoneService) return;
|
||||
try {
|
||||
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
|
||||
} catch {
|
||||
// Booking may have no milestone rows (non-contract path).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,7 @@ import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -49,6 +50,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
TrainSchedulesModule,
|
||||
forwardRef(() => WarehousesModule),
|
||||
RuleEngineModule,
|
||||
forwardRef(() => ContractsModule),
|
||||
],
|
||||
controllers: [TrainSchedulingController],
|
||||
providers: [
|
||||
|
||||
Reference in New Issue
Block a user