mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
feat(bookings): add agent-driven clearance flow with forwarder/transit-agent panels and BookingClearedByAgent migration
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* A without-customs booking the customer handed to a registered clearing agent
|
||||
* (a freight forwarder on the platform) now runs the phased clearance workflow,
|
||||
* with the forwarder doing the GL Ethiopia steps and the Djibouti agent it
|
||||
* names doing the Djibouti steps. The flag marks such a booking so every
|
||||
* "phased customs" gate admits it alongside GL-cleared customs bookings.
|
||||
*/
|
||||
export class BookingClearedByAgent3970000000000 implements MigrationInterface {
|
||||
name = "BookingClearedByAgent3970000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS cleared_by_agent boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
// Bookings already handed to a registered Ethiopian agent before the flag
|
||||
// existed join the workflow too. Their milestones are seeded lazily the
|
||||
// first time the clearance view is read (BookingClearanceService), since
|
||||
// the catalog lives in code.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET cleared_by_agent = true
|
||||
WHERE b.customs_clearing_enabled = false
|
||||
AND b.contract_id IS NOT NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.transit_assignments ta
|
||||
JOIN freight.transit_agents a ON a.id = ta.transit_agent_id
|
||||
WHERE ta.booking_id = b.id
|
||||
AND ta.deleted_at IS NULL
|
||||
AND a.deleted_at IS NULL
|
||||
AND a.country = 'ET'
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cleared_by_agent
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user