Pre-declaration Djibouti GL assignee request flow

This commit is contained in:
Marshal
2026-07-28 09:01:23 +00:00
parent 653d5782ee
commit e1c831211f
18 changed files with 593 additions and 20 deletions

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Transit-assignee handshake on the SHIPMENT, not the contract.
*
* Clearance runs per booking now, so the ask GL Ethiopia raises before filing a
* customs declaration ("who handles this shipment in Djibouti?") and Djibouti's
* answer belong on the booking. The contract-cycle columns added by
* 2950000000000 stay for the legacy contract-level cycles.
*/
export class AddBookingTransitAssignee3010000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS transit_assignee_requested_at,
DROP COLUMN IF EXISTS transit_assignee_request_note,
DROP COLUMN IF EXISTS transit_assignee_name,
DROP COLUMN IF EXISTS transit_assignee_assigned_at
`);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A contract's `created_at` is the DRAFT row's insert time, not when the
* customer actually submitted it for review — a DRAFT can sit edited for days
* first. `submitted_at` is stamped by ContractTransitionService.submit /
* confirmSubmit so the history UI can show a real submission time.
*/
export class AddContractSubmittedAt3020000000000 implements MigrationInterface {
name = 'AddContractSubmittedAt3020000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS submitted_at timestamptz;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS submitted_at;`,
);
}
}

View File

@@ -263,6 +263,36 @@ export class BookingLifecycleNotifierService {
this.inApp(b, 'Booking cancelled', msg);
}
/**
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
* deep-linked to the Djibouti clearance page where the name is entered — the
* customs declaration is blocked until they answer.
*/
transitAssigneeRequested(b: Booking, note: string | null): void {
const msg =
`GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` +
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
});
}
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void {
const msg = previous
? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` +
`"${previous}" to "${assignee}".`
: `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax — the customer must pay and upload the slip. */

View File

@@ -823,6 +823,34 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/request')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration',
})
async requestBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/assign')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
})
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('assignee') assignee: string,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -543,6 +543,23 @@ export class Booking extends BaseEntity {
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
preClearanceFinalizedAt?: Date | null;
/**
* Pre-declaration handshake: GL Ethiopia asks GL Djibouti who will handle this
* shipment in transit, Djibouti answers with a name (free text — the officer is
* not a platform user). The import declaration is blocked until `name` is set.
*/
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
transitAssigneeRequestedAt?: Date | null;
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
transitAssigneeRequestNote?: string | null;
@Column({ name: 'transit_assignee_name', type: 'text', nullable: true })
transitAssigneeName?: string | null;
@Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true })
transitAssigneeAssignedAt?: Date | null;
/** GL staff user bound to this shipment by the station manager. */
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
glAssignedStaffId?: string | null;

View File

@@ -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', () => {

View File

@@ -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') {

View File

@@ -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);

View File

@@ -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;