mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import { ContractDocPhase } from '@edr/types';
|
|
|
|
import { BookingClearanceService } from './booking-clearance.service';
|
|
import type { Booking } from '../bookings/entities/booking.entity';
|
|
|
|
const generalImportBooking = {
|
|
id: 'b-general',
|
|
status: 'DOCUMENTS_UNDER_REVIEW',
|
|
tradeDirection: 'IMPORT',
|
|
freightType: 'CONTAINER',
|
|
customsClearingEnabled: true,
|
|
contractKind: 'GENERAL',
|
|
contractId: 'c-1',
|
|
dutyRequired: true,
|
|
roHoldReason: null,
|
|
vesselDepartureDate: null,
|
|
// Djibouti already named the transit officer — the declaration gate is open.
|
|
transitAssigneeRequestedAt: new Date('2026-01-01T00:00:00Z'),
|
|
transitAssigneeName: 'Ahmed Bourhan',
|
|
} as Booking;
|
|
|
|
const generalExportBooking = {
|
|
...generalImportBooking,
|
|
id: 'b-export',
|
|
tradeDirection: 'EXPORT',
|
|
dutyRequired: null,
|
|
} as Booking;
|
|
|
|
function makeService(overrides?: {
|
|
booking?: Booking;
|
|
workflowThrows?: boolean;
|
|
/** Resolve the input doc set with no required fields → every doc counts approved. */
|
|
docsApproved?: boolean;
|
|
/** Yard ids the caller is scoped to; `null` (default) = unrestricted. */
|
|
yardScope?: string[] | null;
|
|
}) {
|
|
const booking = overrides?.booking ?? generalImportBooking;
|
|
const bookingsRepository = {
|
|
findDocumentReviews: jest.fn().mockResolvedValue([]),
|
|
update: jest.fn().mockResolvedValue(booking),
|
|
findByStatuses: jest.fn().mockResolvedValue([]),
|
|
findBookingsWithUnreviewedDocuments: jest
|
|
.fn()
|
|
.mockResolvedValue(new Set<string>()),
|
|
};
|
|
const bookingsService = {
|
|
findById: jest.fn().mockResolvedValue(booking),
|
|
};
|
|
const filesService = {
|
|
upsertByCode: jest.fn().mockResolvedValue({}),
|
|
upload: jest.fn().mockResolvedValue({}),
|
|
deleteByCode: jest.fn().mockResolvedValue(undefined),
|
|
findByResource: jest.fn().mockResolvedValue([]),
|
|
};
|
|
const fileUploadSettingsService = {
|
|
getByCode: overrides?.docsApproved
|
|
? jest.fn().mockResolvedValue({ fields: [] })
|
|
: jest.fn().mockRejectedValue(new Error('no setting')),
|
|
};
|
|
const workflowService = {
|
|
assertPriorCompleteForBooking: overrides?.workflowThrows
|
|
? jest.fn().mockRejectedValue(new BadRequestException('Prior milestone incomplete'))
|
|
: jest.fn().mockResolvedValue(undefined),
|
|
completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined),
|
|
onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined),
|
|
onAllDocsApprovedForBooking: jest.fn().mockResolvedValue(undefined),
|
|
onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined),
|
|
listMilestonesForBooking: jest.fn().mockResolvedValue([]),
|
|
resolvePhaseForBooking: jest.fn().mockReturnValue(null),
|
|
computeNextActionForBooking: jest.fn().mockReturnValue(null),
|
|
isBoundaryCompleteForBooking: jest.fn().mockResolvedValue(false),
|
|
markReadyForOperation: jest.fn().mockResolvedValue(undefined),
|
|
onExportReleasedForBooking: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const milestoneService = {
|
|
adviseDuty: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const dropdownSettingsService = {
|
|
getByCode: jest.fn().mockResolvedValue({
|
|
children: [{ value: '2' }],
|
|
}),
|
|
};
|
|
const glOperationsService = {
|
|
t1State: jest.fn().mockResolvedValue({
|
|
bookingId: 'b-general',
|
|
wagonAllocated: false,
|
|
trainDepartedAt: null,
|
|
trainArrivedAt: null,
|
|
closed: false,
|
|
closedAt: null,
|
|
}),
|
|
};
|
|
|
|
const service = new BookingClearanceService(
|
|
bookingsRepository as never,
|
|
bookingsService as never,
|
|
filesService as never,
|
|
fileUploadSettingsService as never,
|
|
workflowService as never,
|
|
milestoneService as never,
|
|
dropdownSettingsService as never,
|
|
glOperationsService as never,
|
|
{
|
|
dutyAdvised: jest.fn(),
|
|
clearanceReady: jest.fn(),
|
|
documentQueried: jest.fn(),
|
|
dutySlipUploadedToStaff: jest.fn(),
|
|
clearanceDocsUploadedToStaff: jest.fn(),
|
|
transitAssigneeRequested: jest.fn(),
|
|
transitAssigneeAssigned: jest.fn(),
|
|
} as never, // notifier
|
|
{ listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange
|
|
{
|
|
getAssignable: jest
|
|
.fn()
|
|
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
|
} as never, // transit agents
|
|
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
|
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
|
{ record: jest.fn() } as never, // clearanceEvents
|
|
);
|
|
|
|
return {
|
|
service,
|
|
bookingsRepository,
|
|
bookingsService,
|
|
filesService,
|
|
workflowService,
|
|
milestoneService,
|
|
};
|
|
}
|
|
|
|
describe('BookingClearanceService', () => {
|
|
describe('etQueue yard scope', () => {
|
|
const queueBookings = [
|
|
{ ...generalImportBooking, id: 'b-mojo-out', originYardId: 'mojo', destinationYardId: 'dire' },
|
|
{ ...generalImportBooking, id: 'b-mojo-in', originYardId: 'addis', destinationYardId: 'mojo' },
|
|
{ ...generalImportBooking, id: 'b-elsewhere', originYardId: 'addis', destinationYardId: 'dire' },
|
|
] as unknown as Booking[];
|
|
|
|
it('keeps only bookings whose origin or destination is in scope', async () => {
|
|
const { service, bookingsRepository, workflowService } = makeService({ yardScope: ['mojo'] });
|
|
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
|
|
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
|
|
const rows = await service.etQueue({});
|
|
expect(rows.map((b) => b.id)).toEqual(['b-mojo-out', 'b-mojo-in']);
|
|
});
|
|
|
|
it('shows everything when the position has no yard mapping', async () => {
|
|
const { service, bookingsRepository, workflowService } = makeService({ yardScope: null });
|
|
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
|
|
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
|
|
const rows = await service.etQueue({});
|
|
expect(rows).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe('adviseDuty', () => {
|
|
it('skips duty milestones when duty is not required', async () => {
|
|
const { service, workflowService, bookingsRepository } = makeService();
|
|
await service.adviseDuty('b-general', { dutyRequired: false });
|
|
|
|
expect(workflowService.onDutySkippedForBooking).toHaveBeenCalledWith('b-general');
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-general',
|
|
expect.objectContaining({
|
|
dutyRequired: false,
|
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('records duty advice when duty applies', async () => {
|
|
const { service, milestoneService } = makeService();
|
|
await service.adviseDuty(
|
|
'b-general',
|
|
{
|
|
dutyRequired: true,
|
|
amount: 1500,
|
|
currency: 'ETB',
|
|
declarationSerial: 'DS-1',
|
|
},
|
|
undefined,
|
|
// The duty notice attachment is now mandatory when duty applies.
|
|
{ fieldname: 'duty_tax_notice' } as Express.Multer.File,
|
|
);
|
|
|
|
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
|
|
'b-general',
|
|
expect.objectContaining({ amount: 1500, currency: 'ETB', declarationSerial: 'DS-1' }),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('uploadDutySlip', () => {
|
|
it('rejects when duty is not required', async () => {
|
|
const { service } = makeService({
|
|
booking: { ...generalImportBooking, dutyRequired: false } as Booking,
|
|
});
|
|
await expect(
|
|
service.uploadDutySlip('b-general', { fieldname: 'file' } as Express.Multer.File),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('uploads slip and completes DUTY_TAX_PAID on happy path', async () => {
|
|
const { service, filesService, workflowService, bookingsRepository } = makeService();
|
|
const file = { fieldname: 'file' } as Express.Multer.File;
|
|
|
|
await service.uploadDutySlip('b-general', file);
|
|
|
|
expect(filesService.upsertByCode).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
resourceId: 'b-general',
|
|
code: 'duty_tax_receipt',
|
|
file,
|
|
}),
|
|
);
|
|
expect(workflowService.completeMilestoneForBooking).toHaveBeenCalledWith(
|
|
'b-general',
|
|
'DUTY_TAX_PAID',
|
|
);
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-general',
|
|
expect.objectContaining({
|
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('uploadDeclaration', () => {
|
|
it('rejects when a prior milestone is incomplete', async () => {
|
|
const { service } = makeService({ workflowThrows: true });
|
|
await expect(
|
|
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('rejects an import declaration before Djibouti names the transit officer', async () => {
|
|
const { service, workflowService } = makeService({
|
|
docsApproved: true,
|
|
booking: {
|
|
...generalImportBooking,
|
|
transitAssigneeRequestedAt: null,
|
|
transitAssigneeName: null,
|
|
} as Booking,
|
|
});
|
|
|
|
await expect(
|
|
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
|
|
).rejects.toThrow(/Request a transit assignee/i);
|
|
expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets the export declaration through without a transit assignee', async () => {
|
|
const { service, workflowService } = makeService({
|
|
docsApproved: true,
|
|
booking: {
|
|
...generalExportBooking,
|
|
transitAssigneeRequestedAt: null,
|
|
transitAssigneeName: null,
|
|
} as Booking,
|
|
});
|
|
|
|
await service.uploadDeclaration('b-export', [
|
|
{ fieldname: 'decl' } as Express.Multer.File,
|
|
]);
|
|
|
|
expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('transit assignee handshake', () => {
|
|
it('refuses an assignment GL Ethiopia never asked for', async () => {
|
|
const { service } = makeService({
|
|
booking: {
|
|
...generalImportBooking,
|
|
transitAssigneeRequestedAt: null,
|
|
transitAssigneeName: null,
|
|
} as Booking,
|
|
});
|
|
|
|
await expect(
|
|
service.assignTransitAssignee('b-general', 'Ahmed Bourhan'),
|
|
).rejects.toThrow(/has not requested a transit assignee/i);
|
|
});
|
|
|
|
it('stamps the ask and then the name', async () => {
|
|
const { service, bookingsRepository } = makeService({
|
|
booking: {
|
|
...generalImportBooking,
|
|
transitAssigneeName: null,
|
|
} as Booking,
|
|
});
|
|
|
|
await service.requestTransitAssignee('b-general', ' night shift ');
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-general',
|
|
expect.objectContaining({
|
|
transitAssigneeRequestedAt: expect.any(Date),
|
|
transitAssigneeRequestNote: 'night shift',
|
|
}),
|
|
);
|
|
|
|
await service.assignTransitAssignee('b-general', ' Ahmed Bourhan ');
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-general',
|
|
expect.objectContaining({
|
|
transitAssigneeName: 'Ahmed Bourhan',
|
|
transitAssigneeAssignedAt: expect.any(Date),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('uploadReleaseOrder', () => {
|
|
it('places RO on hold when vessel departs too soon', async () => {
|
|
const tomorrow = new Date();
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
const dateStr = tomorrow.toISOString().slice(0, 10);
|
|
|
|
const { service, bookingsRepository } = makeService({ booking: generalExportBooking });
|
|
const result = await service.uploadReleaseOrder(
|
|
'b-export',
|
|
[{ fieldname: 'ro' } as Express.Multer.File],
|
|
dateStr,
|
|
);
|
|
|
|
expect(result.hold).toBe(true);
|
|
expect(result.holdReason).toMatch(/minimum lead time/i);
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-export',
|
|
expect.objectContaining({ roHoldReason: expect.any(String) }),
|
|
);
|
|
});
|
|
});
|
|
});
|