mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Pre-declaration Djibouti GL assignee request flow
This commit is contained in:
@@ -15,6 +15,9 @@ const generalImportBooking = {
|
||||
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 = {
|
||||
@@ -27,6 +30,8 @@ const generalExportBooking = {
|
||||
function makeService(overrides?: {
|
||||
booking?: Booking;
|
||||
workflowThrows?: boolean;
|
||||
/** Resolve the input doc set with no required fields → every doc counts approved. */
|
||||
docsApproved?: boolean;
|
||||
}) {
|
||||
const booking = overrides?.booking ?? generalImportBooking;
|
||||
const bookingsRepository = {
|
||||
@@ -38,10 +43,14 @@ function makeService(overrides?: {
|
||||
};
|
||||
const filesService = {
|
||||
upsertByCode: jest.fn().mockResolvedValue({}),
|
||||
upload: jest.fn().mockResolvedValue({}),
|
||||
deleteByCode: jest.fn().mockResolvedValue(undefined),
|
||||
findByResource: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const fileUploadSettingsService = {
|
||||
getByCode: jest.fn().mockRejectedValue(new Error('no setting')),
|
||||
getByCode: overrides?.docsApproved
|
||||
? jest.fn().mockResolvedValue({ fields: [] })
|
||||
: jest.fn().mockRejectedValue(new Error('no setting')),
|
||||
};
|
||||
const workflowService = {
|
||||
assertPriorCompleteForBooking: overrides?.workflowThrows
|
||||
@@ -49,6 +58,7 @@ function makeService(overrides?: {
|
||||
: 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),
|
||||
@@ -91,6 +101,8 @@ function makeService(overrides?: {
|
||||
documentQueried: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
transitAssigneeRequested: jest.fn(),
|
||||
transitAssigneeAssigned: jest.fn(),
|
||||
} as never, // notifier
|
||||
);
|
||||
|
||||
@@ -186,6 +198,82 @@ describe('BookingClearanceService', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -71,6 +71,17 @@ export interface BookingClearanceView {
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
operationReady?: boolean;
|
||||
preClearanceFinalized?: boolean;
|
||||
/**
|
||||
* Pre-declaration handshake with GL Djibouti: who handles this shipment in
|
||||
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot
|
||||
* file the import customs declaration before it is set.
|
||||
*/
|
||||
transitAssignee?: {
|
||||
requestedAt: string | null;
|
||||
requestNote: string | null;
|
||||
name: string | null;
|
||||
assignedAt: string | null;
|
||||
} | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
@@ -269,6 +280,16 @@ export class BookingClearanceService {
|
||||
: null,
|
||||
operationReady: boundary,
|
||||
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
|
||||
transitAssignee: {
|
||||
requestedAt: booking.transitAssigneeRequestedAt
|
||||
? booking.transitAssigneeRequestedAt.toISOString()
|
||||
: null,
|
||||
requestNote: booking.transitAssigneeRequestNote ?? null,
|
||||
name: booking.transitAssigneeName ?? null,
|
||||
assignedAt: booking.transitAssigneeAssignedAt
|
||||
? booking.transitAssigneeAssignedAt.toISOString()
|
||||
: null,
|
||||
},
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
t1,
|
||||
@@ -356,6 +377,53 @@ export class BookingClearanceService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia asks Djibouti to name the officer who will handle this shipment
|
||||
* in transit. The import declaration is gated on the answer, so this is the
|
||||
* first thing ET does once the customer documents are approved. Re-requesting
|
||||
* is allowed (a nudge) and simply restamps the ask.
|
||||
*/
|
||||
async requestTransitAssignee(
|
||||
bookingId: string,
|
||||
note: string | undefined,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
transitAssigneeRequestedAt: new Date(),
|
||||
transitAssigneeRequestNote: note?.trim() || null,
|
||||
} as never);
|
||||
|
||||
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti names the transit officer — free text, because the person is not
|
||||
* a platform user. Answering unblocks the declaration for Ethiopia. A later
|
||||
* call overwrites the name (reassignment) and re-notifies.
|
||||
*/
|
||||
async assignTransitAssignee(bookingId: string, assignee: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (!assignee?.trim()) {
|
||||
throw new BadRequestException('Name the officer who will handle the transit.');
|
||||
}
|
||||
if (!booking.transitAssigneeRequestedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
|
||||
);
|
||||
}
|
||||
|
||||
const previous = booking.transitAssigneeName ?? null;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
transitAssigneeName: assignee.trim(),
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async uploadDeclaration(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
@@ -369,6 +437,16 @@ export class BookingClearanceService {
|
||||
'All required customer documents must be approved before uploading a declaration.',
|
||||
);
|
||||
}
|
||||
// Import only: the declaration is filed against whoever physically handles
|
||||
// the shipment in Djibouti, so that name must be in first. Exports have no
|
||||
// such handshake — their Djibouti steps come after the declaration.
|
||||
if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) {
|
||||
throw new BadRequestException(
|
||||
booking.transitAssigneeRequestedAt
|
||||
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
|
||||
: 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
|
||||
);
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||
|
||||
@@ -225,6 +225,7 @@ export class ContractTransitionService {
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: new Date(),
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.submittedToStaff(updated);
|
||||
@@ -241,6 +242,7 @@ export class ContractTransitionService {
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SUBMITTED',
|
||||
submittedAt: new Date(),
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.submittedToStaff(updated);
|
||||
|
||||
@@ -216,6 +216,10 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
/** When the customer last submitted this contract (DRAFT/CHANGES_REQUESTED → SUBMITTED). */
|
||||
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
|
||||
submittedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||
status!: string;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user