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;

View File

@@ -87,6 +87,8 @@ export interface BookingClearanceView {
metadata?: Record<string, unknown> | null;
sortOrder: number;
}>;
/** Handed to a registered clearing agent: the forwarder does GL Ethiopia's steps. */
clearedByAgent?: boolean;
nextAction?: {
actor: string;
action: string;
@@ -188,8 +190,10 @@ export class BookingClearanceService {
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
if (!booking.customsClearingEnabled) {
throw new BadRequestException('Phased clearance applies only to customs bookings.');
if (!booking.customsClearingEnabled && !booking.clearedByAgent) {
throw new BadRequestException(
'Phased clearance applies only to customs bookings and bookings cleared by a registered agent.',
);
}
if (!booking.contractId) {
throw new BadRequestException('Booking is not linked to a contract.');
@@ -301,6 +305,83 @@ export class BookingClearanceService {
const allApproved = await this.isClearanceFullyApproved(booking);
let milestones = await this.workflowService.listMilestonesForBooking(bookingId);
// An agent-cleared booking that predates the flag (or was flagged by the
// migration) has no timeline yet. Seed the pre-booking half now, exactly
// as initiation would have, with the duty round off — idempotent, since
// ensure* only adds codes that are missing.
if (booking.clearedByAgent && milestones.length === 0) {
const direction = booking.tradeDirection ?? 'IMPORT';
await this.milestoneService.seedPreBookingMilestonesOnBooking(bookingId, direction);
if (direction === 'IMPORT') {
for (const code of ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']) {
await this.milestoneService.skipForBooking(bookingId, code).catch(() => undefined);
}
if (booking.dutyRequired !== false) {
await this.bookingsRepository.update(bookingId, { dutyRequired: false } as never);
}
}
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
// An agent-cleared booking completed before it joined the workflow has
// only the pre-booking half of its timeline: the post-booking milestones
// are seeded at completion, and that ran before the flag existed. Add
// them now (ensure* only adds what is missing), duty round off, and stamp
// the wagon allocation the scheduler already made — the payment stamp
// follows in the self-heal below.
if (
booking.clearedByAgent &&
milestones.length > 0 &&
Number(booking.totalAmount ?? 0) > 0 &&
!milestones.some((m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED')
) {
const direction = booking.tradeDirection ?? 'IMPORT';
await this.milestoneService.ensureBookingMilestones(bookingId, direction);
if (direction === 'IMPORT') {
for (const code of ['SECOND_DUTY_ADVISED', 'SECOND_DUTY_PAID']) {
await this.milestoneService.skipForBooking(bookingId, code).catch(() => undefined);
}
}
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
if (
booking.clearedByAgent &&
(booking.schedulingStatus === 'SCHEDULED' ||
booking.schedulingStatus === 'DISPATCHED' ||
Boolean(booking.trainScheduleId)) &&
milestones.some(
(m) => m.milestoneCode === 'WAGON_ALLOCATED' && m.status === 'PENDING',
)
) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'WAGON_ALLOCATED');
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
// Self-heal the document milestones on an agent-cleared booking: the
// forwarder may have uploaded, reviewed and approved before the booking
// joined the workflow (flagged by migration, or milestones seeded just
// above), so the review path never stamped them. Completing is idempotent.
if (booking.clearedByAgent && milestones.length > 0) {
const state = (code: string) =>
milestones.find((m) => m.milestoneCode === code)?.status;
const isDone = (code: string) =>
state(code) === 'COMPLETED' || state(code) === 'SKIPPED';
const direction = booking.tradeDirection ?? 'IMPORT';
const hasCustomerFiles = files.length > 0;
let healed = false;
if (hasCustomerFiles && !isDone('PENDING_DOCUMENT_REVIEW')) {
await this.workflowService.onCustomerDocsUploadedForBooking(bookingId, direction);
healed = true;
}
if (allApproved && !isDone('DOCUMENTS_APPROVED')) {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
healed = true;
}
if (healed) {
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
}
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
@@ -396,6 +477,7 @@ export class BookingClearanceService {
sortOrder: m.sortOrder,
})),
nextAction,
clearedByAgent: Boolean(booking.clearedByAgent),
dutyRequired: booking.dutyRequired ?? null,
roHold: Boolean(booking.roHoldReason),
roHoldReason: booking.roHoldReason ?? null,
@@ -541,10 +623,15 @@ export class BookingClearanceService {
);
}
/** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */
/**
* Any contract booking (ONE_TIME or GENERAL) whose service bundles customs —
* or one the customer handed to a registered clearing agent, which runs the
* same phased workflow with the forwarder in GL Ethiopia's place.
*/
isPhasedCustomsBooking(booking: Booking): boolean {
return (
Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId)
(Boolean(booking.customsClearingEnabled) || Boolean(booking.clearedByAgent)) &&
Boolean(booking.contractId)
);
}
@@ -660,8 +747,14 @@ export class BookingClearanceService {
}
// 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) {
// such handshake — their Djibouti steps come after the declaration. An
// agent-cleared booking has no GL Djibouti desk to ask: the forwarder names
// the Djibouti agent itself, whenever it likes, so the gate does not apply.
if (
tradeDirection === 'IMPORT' &&
!booking.transitAssigneeName &&
!booking.clearedByAgent
) {
throw new BadRequestException(
booking.transitAssigneeRequestedAt
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
@@ -700,6 +793,30 @@ export class BookingClearanceService {
metadata: { fileNames: files.map((f) => f.originalname) },
});
// Agent-cleared export: the Djibouti agent's Release Order may already be
// secured (it does not wait for the declaration there), in which case this
// declaration is the last pre-operation step — release the export now so
// the customer can create the booking. See uploadReleaseOrder for the
// mirror case.
if (booking.clearedByAgent && tradeDirection === 'EXPORT') {
const after = await this.workflowService.listMilestonesForBooking(bookingId);
const isDone = (code: string) =>
after.some(
(m) =>
m.milestoneCode === code &&
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
);
if (isDone('RELEASE_ORDER_SECURED') && !isDone('EXPORT_RELEASED')) {
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
await this.clearanceEvents.record({
bookingId,
action: 'EXPORT_RELEASE_CONFIRMED',
label: 'Export released — declaration and Release Order both on file',
actorId: userId ?? null,
});
}
}
return this.bookingsService.findById(bookingId);
}
@@ -1141,11 +1258,24 @@ export class BookingClearanceService {
if (booking.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Release Order applies only to export bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'EXPORT',
'RELEASE_ORDER_SECURED',
);
if (booking.clearedByAgent) {
// The Djibouti agent may secure the RO as soon as the customer's
// documents are approved — it does not wait for the forwarder's
// declaration, which can be filed before or after it.
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const approved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (approved?.status !== 'COMPLETED' && approved?.status !== 'SKIPPED') {
throw new BadRequestException(
'The customer documents must be approved before the Release Order can be uploaded.',
);
}
} else {
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'EXPORT',
'RELEASE_ORDER_SECURED',
);
}
if (!vesselDepartureDate?.trim()) {
throw new BadRequestException('Vessel departure date is required');
@@ -1195,8 +1325,20 @@ export class BookingClearanceService {
);
// Release Order is now the last GL DJ pre-operation action (it follows the
// declaration) — release immediately so booking creation unlocks without a
// separate confirm click.
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
// separate confirm click. On an agent-cleared booking the RO may come
// FIRST, so the export is released by whichever of the two lands second:
// here only once the declaration is already on file, otherwise from the
// declaration upload.
const declared = booking.clearedByAgent
? (await this.workflowService.listMilestonesForBooking(bookingId)).some(
(m) =>
m.milestoneCode === 'DECLARED' &&
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
)
: true;
if (declared) {
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
}
return { booking: await this.bookingsService.findById(bookingId), hold: false };
}

View File

@@ -552,6 +552,11 @@ export class ContractBookingService {
paymentCurrency: this.resolveShipmentCurrency(contract, null),
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
// Handed to a registered clearing agent: the phased workflow runs on
// this booking with the forwarder in GL Ethiopia's place. Duty is
// never advised on that path, so it is settled up front.
clearedByAgent: Boolean(clearingAgent?.assigned),
...(clearingAgent?.assigned ? { dutyRequired: false } : {}),
customsClearingAgent:
clearingAgent?.fields.customsClearingAgent ??
contract.customsClearingAgent ??
@@ -580,12 +585,20 @@ export class ContractBookingService {
// Customs: the instance runs the phased ET/DJ workflow, so its pre-booking
// milestones exist from initiation (the post-booking half is seeded when the
// booking is completed). Self-clearance has no milestone timeline.
if (contract.customsClearingEnabled) {
// booking is completed). Self-clearance has no milestone timeline — unless
// a registered clearing agent was named, which runs the same workflow with
// the forwarder in GL Ethiopia's place and no duty round.
if (contract.customsClearingEnabled || clearingAgent?.assigned) {
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
contract.tradeDirection,
);
if (clearingAgent?.assigned && contract.tradeDirection === 'IMPORT') {
await this.skipAgentClearedDutyMilestones(booking.id, [
'DUTY_TAXES_ADVISED',
'DUTY_TAX_PAID',
]);
}
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
@@ -609,6 +622,28 @@ export class ContractBookingService {
return { booking: result ?? booking, warnings: [] };
}
/**
* Duty is never advised on an agent-cleared import: the forwarder settles it
* with customs outside the platform. The milestones are skipped rather than
* left pending, or every later step would wait on them.
*/
private async skipAgentClearedDutyMilestones(
bookingId: string,
codes: string[],
): Promise<void> {
for (const code of codes) {
try {
await this.milestoneService.skipForBooking(bookingId, code);
} catch (err) {
this.logger.warn(
`Could not skip ${code} on agent-cleared booking ${bookingId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
}
/**
* The booking company as its own clearing agent — when it holds the transit
* agent or freight forwarder role, it clears its own customs, and its own
@@ -1398,11 +1433,18 @@ export class ContractBookingService {
// a consolidation pairing replay must not duplicate the timeline. The
// contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it
// holds no clearance state at all.
if (contract.customsClearingEnabled) {
if (contract.customsClearingEnabled || booking.clearedByAgent) {
await this.milestoneService.ensureBookingMilestones(
bookingId,
contract.tradeDirection,
);
// The post-arrival duty round is off the agent-cleared path as well.
if (booking.clearedByAgent && contract.tradeDirection === 'IMPORT') {
await this.skipAgentClearedDutyMilestones(bookingId, [
'SECOND_DUTY_ADVISED',
'SECOND_DUTY_PAID',
]);
}
}
// Contract bookings are born past the billable gate (the contract is already

View File

@@ -1360,13 +1360,20 @@ export class ContractsController {
// ── GL operational actions on a booking (doc §11§13) ──────────────────────
@Post('bookings/:bookingId/risk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL ET assigns a customs risk level (GREEN/YELLOW/RED)' })
assignRisk(
@MixedAudience(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'GL ET — or the assigned clearing agent — assigns a customs risk level (GREEN/YELLOW/RED)',
})
async assignRisk(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignRiskDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrAgentOnAgentClearedBooking(
bookingId,
user,
FREIGHT_PERMS.bookings.operations,
);
return this.milestoneService.assignRisk(
bookingId,
dto.riskLevel,
@@ -1408,17 +1415,46 @@ export class ContractsController {
}
@Post('bookings/:bookingId/transport-document')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' })
uploadTransportDocument(
@ApiOperation({
summary: 'GL ET — or the assigned clearing agent — uploads export transport documents (multi-file)',
})
async uploadTransportDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrAgentOnAgentClearedBooking(
bookingId,
user,
FREIGHT_PERMS.contracts.clearanceEtActions,
);
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
}
/**
* Staff pass on their permission. A portal caller must be an agent assigned
* to THIS booking (the forwarder or the Djibouti agent it named) and the
* booking must be agent-cleared — on a GL-cleared customs booking these
* steps stay with the desks. Hidden behind a NotFound like ownership checks.
*/
private async assertStaffOrAgentOnAgentClearedBooking(
bookingId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (staffPermission && hasFreightPermission(user, staffPermission)) return;
const booking = await this.bookingsService.findById(bookingId);
if (
!booking.clearedByAgent ||
!(await this.bookingsService.isTransitAgentForBooking(user?.id, bookingId))
) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
}
// Also filed by the transit agent assigned to the shipment — T1 is their own
// transit paperwork. Any other portal caller is rejected below.
@Post('bookings/:bookingId/t1-documents')
@@ -1450,19 +1486,27 @@ export class ContractsController {
);
}
// On an agent-cleared booking the assigned agents close it in the desks'
// place: the forwarder after arrival (import), the Djibouti agent (export).
@Post('bookings/:bookingId/t1-close')
@BookingStaff([
@MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary:
'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)',
'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export); also the assigned agents on an agent-cleared booking',
})
closeT1(
async closeT1(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
if (
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
) {
await this.assertStaffOrAgentOnAgentClearedBooking(bookingId, user, '');
}
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
}
@@ -1575,17 +1619,25 @@ export class ContractsController {
return this.glOperationsService.uploadSecondDutySlip(bookingId, file);
}
// Also the clearing agent (forwarder) on an agent-cleared booking, for the
// documents GL Ethiopia would otherwise file (import release).
@Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@MixedAudience(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'GL uploads post-booking operational documents (DO/RO/T1/…)',
summary: 'GL — or the assigned clearing agent — uploads post-booking operational documents (DO/RO/T1/…)',
})
uploadGlDocuments(
async uploadGlDocuments(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrAgentOnAgentClearedBooking(
bookingId,
user,
FREIGHT_PERMS.bookings.uploadClearanceOutput,
);
return this.glOperationsService.uploadDocuments(bookingId, files ?? []);
}