feat(bookings): add agent-driven clearance flow with forwarder/transit-agent panels and BookingClearedByAgent migration

This commit is contained in:
marshal
2026-09-08 08:49:33 +00:00
parent 850e0753d2
commit 7af3ded7d7
14 changed files with 1716 additions and 57 deletions

View File

@@ -1800,8 +1800,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/declaration")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -1812,6 +1814,7 @@ export class BookingsController {
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.uploadDeclaration(
id,
files ?? [],
@@ -1853,8 +1856,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/draft-declaration")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -1868,6 +1873,7 @@ export class BookingsController {
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.uploadDraftDeclaration(
id,
files ?? [],
@@ -1878,8 +1884,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/draft-declaration/skip")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default",
@@ -1888,6 +1896,7 @@ export class BookingsController {
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.skipDraftDeclaration(
id,
resolveAuthUserId(user),
@@ -1932,13 +1941,16 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/finalize-pre-clearance")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: "GL ET finalizes import pre-clearance on booking" })
async finalizeBookingPreClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.finalizePreClearance(
id,
resolveAuthUserId(user),
@@ -1966,8 +1978,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/transit-permit")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingTransitPermit(
@@ -1975,6 +1989,7 @@ export class BookingsController {
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.uploadTransitPermit(
id,
files ?? [],
@@ -2101,12 +2116,15 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/export-release")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
async confirmBookingExportRelease(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.confirmExportRelease(
id,
resolveAuthUserId(user),

View File

@@ -370,6 +370,16 @@ export class Booking extends BaseEntity {
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;
/**
* A without-customs booking the customer handed to a registered clearing
* agent (a freight forwarder on the platform). It runs the phased clearance
* workflow like a customs booking, except the forwarder performs the GL
* Ethiopia steps from its portal and the Djibouti agent it names performs
* the Djibouti steps; the customer still creates the booking itself.
*/
@Column({ name: 'cleared_by_agent', type: 'boolean', default: false })
clearedByAgent!: boolean;
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
customsClearingAgent?: string | null;