mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
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';
|
|
import type { Booking } from '../bookings/entities/booking.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),
|
|
listForBooking: jest.fn().mockResolvedValue(milestones),
|
|
skipForContract: jest.fn(),
|
|
completeForContract: jest.fn(),
|
|
completeWithMetadataForContract: jest.fn(),
|
|
};
|
|
const bookingsRepository = { update: jest.fn() };
|
|
const service = new ClearanceWorkflowService(
|
|
contractsRepository as never,
|
|
milestoneService as never,
|
|
bookingsRepository as never,
|
|
);
|
|
return { service, milestoneService, contractsRepository, bookingsRepository };
|
|
}
|
|
|
|
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 ET to finalize pre-clearance after transit permit', () => {
|
|
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 next = service.computeNextAction(importContract, cycle, milestones);
|
|
expect(next?.actor).toBe('GL_ET');
|
|
expect(next?.action).toMatch(/finalize pre-clearance/i);
|
|
});
|
|
|
|
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,
|
|
preClearanceFinalizedAt: new Date(),
|
|
} 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');
|
|
});
|
|
});
|
|
|
|
describe('computeNextActionForBooking', () => {
|
|
it('prompts customer to proceed after import boundary on booking', () => {
|
|
const booking = {
|
|
tradeDirection: 'IMPORT',
|
|
dutyRequired: false,
|
|
preClearanceFinalizedAt: new Date(),
|
|
} as Booking;
|
|
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', 'COMPLETED', 'DJ'),
|
|
];
|
|
const { service } = makeService(milestones);
|
|
const next = service.computeNextActionForBooking(booking, milestones);
|
|
expect(next?.actor).toBe('CUSTOMER');
|
|
expect(next?.action).toMatch(/operation/i);
|
|
});
|
|
});
|
|
});
|