feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices

This commit is contained in:
Marshal
2026-08-20 05:48:16 +00:00
parent c138da6137
commit c38fcff00d
19 changed files with 663 additions and 16 deletions

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Per-booking clearance action history — drives the History tab. */
export class BookingClearanceEvent3600000000000 implements MigrationInterface {
name = 'BookingClearanceEvent3600000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_event" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
"booking_id" uuid NOT NULL,
"action" character varying(64) NOT NULL,
"label" character varying(500) NOT NULL,
"actor_type" character varying(16) NOT NULL DEFAULT 'STAFF',
"actor_id" uuid,
"actor_name" character varying(150),
"metadata" jsonb,
CONSTRAINT "pk_booking_clearance_event" PRIMARY KEY ("id"),
CONSTRAINT "fk_booking_clearance_event_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_booking_clearance_event_booking_created"
ON "freight"."booking_clearance_event" ("booking_id", "created_at")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "freight"."booking_clearance_event"`,
);
}
}

View File

@@ -47,6 +47,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
"POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"],
"POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"],
"POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],

View File

@@ -19,6 +19,7 @@ import {
BookingClearanceCharge,
ClearanceChargeType,
} from './entities/booking-clearance-charge.entity';
import { ClearanceEventService } from './clearance-event.service';
/** File-record codes the charge documents are stored under on the booking. */
const CHARGE_FILE_CODE: Record<ClearanceChargeType, string> = {
@@ -49,6 +50,7 @@ export class BookingClearanceChargeService {
private readonly billing: BillingService,
private readonly bookingsService: BookingsService,
private readonly bookingsRepository: BookingsRepository,
private readonly clearanceEvents: ClearanceEventService,
) {}
private repo() {
@@ -165,6 +167,15 @@ export class BookingClearanceChargeService {
}),
);
}
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_PORT_DOC_UPLOADED',
label: existing
? 'Replaced the port-charges document'
: 'Uploaded the port-charges document',
actorId: staffId,
metadata: { fileName: file.originalname },
});
return this.list(bookingId);
}
@@ -205,6 +216,20 @@ export class BookingClearanceChargeService {
billedByStaffId: staffId,
billedAt: new Date(),
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_BILLED',
label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[
charge.type
].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`,
actorId: staffId,
metadata: {
chargeType: charge.type,
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
revised: charge.status === 'SENT',
},
});
return this.list(bookingId);
}
@@ -212,6 +237,7 @@ export class BookingClearanceChargeService {
async sendCharge(
bookingId: string,
chargeId: string,
staffId?: string,
): Promise<Freight.ClearanceCharge[]> {
const charge = await this.repo().findOne({
where: { id: chargeId, bookingId },
@@ -246,6 +272,18 @@ export class BookingClearanceChargeService {
status: 'SENT',
invoiceId: invoice.id,
});
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_INVOICE_SENT',
label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`,
actorId: staffId ?? null,
metadata: {
chargeType: charge.type,
invoiceNumber: invoice.invoiceNumber,
amount: Number(charge.amount),
currency: charge.currency,
},
});
this.logger.log(
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
);
@@ -311,6 +349,17 @@ export class BookingClearanceChargeService {
billedAt: new Date(),
}),
);
await this.clearanceEvents.record({
bookingId,
action: 'CHARGE_MISC_CREATED',
label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`,
actorId: staffId,
metadata: {
amount: input.amount,
currency: input.currency.trim().toUpperCase(),
fileName: file.originalname,
},
});
return this.list(bookingId);
}
@@ -325,6 +374,16 @@ export class BookingClearanceChargeService {
status: 'PAID',
paidAt: new Date(),
});
await this.clearanceEvents.record({
bookingId: charge.bookingId,
action: 'CHARGE_PAID',
label: `${CHARGE_LABEL[charge.type]} paid (invoice ${payload.invoiceNumber})`,
actorType: 'SYSTEM',
metadata: {
chargeType: charge.type,
invoiceNumber: payload.invoiceNumber,
},
});
this.logger.log(
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
);

View File

@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, ruleEngineService, contractService };

View File

@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
@@ -172,6 +173,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
@@ -262,6 +264,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, filesService };

View File

@@ -71,6 +71,7 @@ describe('BookingTransitionService — operation review', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, invoiceService };
@@ -172,6 +173,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
{ record: jest.fn() } as never, // clearanceEvents
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService };

View File

@@ -32,6 +32,7 @@ describe('BookingTransitionService — paired staff decisions', () => {
{} as never, // invoiceService
{} as never, // containerValidationService
{} as never, // notifier
{ record: jest.fn() } as never, // clearanceEvents
{} as never, // events
undefined, // milestoneService
dataSource as never,

View File

@@ -32,6 +32,7 @@ import {
buildClearanceDocHistory,
type ClearanceDocEvent,
} from './clearance-doc-history.util';
import { ClearanceEventService } from './clearance-event.service';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -72,6 +73,7 @@ export class BookingTransitionService {
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly clearanceEvents: ClearanceEventService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
// Optional + last so the hand-constructed service in *.spec.ts files keeps
@@ -795,6 +797,7 @@ export class BookingTransitionService {
async submitClearanceDocuments(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -853,6 +856,16 @@ export class BookingTransitionService {
} as never);
}
const fileKeys = files.map((f) => f.fieldname);
await this.clearanceEvents.record({
bookingId,
action: 'DOCS_SUBMITTED',
label: `Customer submitted ${files.length} clearance document(s): ${fileKeys.join(', ')}`,
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { fileKeys },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceDocsUploadedToStaff(fresh);
return fresh;
@@ -940,6 +953,16 @@ export class BookingTransitionService {
staffId,
note,
);
await this.clearanceEvents.record({
bookingId,
action: status === 'APPROVED' ? 'DOC_APPROVED' : 'DOC_QUERIED',
label:
status === 'APPROVED'
? `Approved document "${fileKey.replace(/_/g, ' ')}"`
: `Opened query on document "${fileKey.replace(/_/g, ' ')}"`,
actorId: staffId,
metadata: { fileKey, note: note ?? null },
});
if (status === "QUERIED") {
await this.bookingsRepository.createReviewNote(
bookingId,
@@ -980,6 +1003,7 @@ export class BookingTransitionService {
async uploadClearanceOutputDocuments(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
@@ -1000,6 +1024,15 @@ export class BookingTransitionService {
file,
});
}
await this.clearanceEvents.record({
bookingId,
action: 'OUTPUT_DOCS_UPLOADED',
label: `Uploaded customs output document(s): ${files
.map((f) => f.fieldname.replace(/_/g, ' '))
.join(', ')}`,
actorId: userId ?? null,
metadata: { fileKeys: files.map((f) => f.fieldname) },
});
return this.bookingsService.findById(bookingId);
}
@@ -1007,7 +1040,7 @@ export class BookingTransitionService {
* GL confirms clearance: requires every customer document APPROVED (100% gate)
* and, for customs, the required output documents present → CLEARANCE_READY.
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
async finalizeClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedCustoms(booking)) {
throw new BadRequestException(
@@ -1063,6 +1096,12 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'CLEARANCE_FINALIZED',
label: 'Finalized document approval — clearance ready',
actorId: userId ?? null,
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceReady(fresh);
return fresh;
@@ -1089,6 +1128,8 @@ export class BookingTransitionService {
* the customer pools, so the gate here would wrongly reject them).
*/
bypassDayPool?: boolean;
/** Acting user, recorded in the clearance history. */
userId?: string;
},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
@@ -1203,6 +1244,14 @@ export class BookingTransitionService {
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'OPERATION_REQUESTED',
label: `Requested operation for shipment day ${scheduledDate}`,
actorType: 'CUSTOMER',
actorId: opts?.userId ?? null,
metadata: { scheduledDate },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
return fresh;

View File

@@ -40,6 +40,7 @@ import {
import type { Response } from "express";
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { ClearanceEventService } from './clearance-event.service';
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
@@ -174,6 +175,7 @@ export class BookingsController {
private readonly wagonCancellationService: BookingWagonCancellationService,
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly clearanceChargeService: BookingClearanceChargeService,
private readonly clearanceEventService: ClearanceEventService,
) {}
@Post()
@@ -975,10 +977,12 @@ export class BookingsController {
async submitClearanceDocuments(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.submitClearanceDocuments(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -995,11 +999,13 @@ export class BookingsController {
async proceedToOperation(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
{ userId: resolveAuthUserId(user) },
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1077,6 +1083,19 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/clearance/history")
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary:
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
})
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.clearanceEventService.list(id);
}
// ── Clearance charges (post-finalization customer billing) ────────────────
@Get(":id/clearance/charges")
@@ -1140,8 +1159,13 @@ export class BookingsController {
sendClearanceCharge(
@Param("id", ParseUUIDPipe) id: string,
@Param("chargeId", ParseUUIDPipe) chargeId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceChargeService.sendCharge(id, chargeId);
return this.clearanceChargeService.sendCharge(
id,
chargeId,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/charges/miscellaneous")
@@ -1175,10 +1199,12 @@ export class BookingsController {
async uploadClearanceOutput(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1189,8 +1215,14 @@ export class BookingsController {
summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
})
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.finalizeClearance(id);
async finalizeClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.finalizeClearance(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1203,8 +1235,13 @@ export class BookingsController {
async requestBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
const booking = await this.bookingClearanceService.requestTransitAssignee(
id,
note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1217,8 +1254,13 @@ export class BookingsController {
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
const booking = await this.bookingClearanceService.assignTransitAssignee(
id,
transitAgentId,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1302,8 +1344,14 @@ export class BookingsController {
summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
})
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
async acceptBookingDraftDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1329,8 +1377,14 @@ export class BookingsController {
@Post(':id/clearance/finalize-pre-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.finalizePreClearance(id);
async finalizeBookingPreClearance(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.finalizePreClearance(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1342,8 +1396,13 @@ export class BookingsController {
async uploadBookingDutySlip(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
const booking = await this.bookingClearanceService.uploadDutySlip(
id,
file,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}

View File

@@ -39,6 +39,8 @@ import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
import { BookingClearanceEvent } from './entities/booking-clearance-event.entity';
import { ClearanceEventService } from './clearance-event.service';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
@@ -79,6 +81,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckContainer,
ConsolidationApproval,
BookingClearanceCharge,
BookingClearanceEvent,
]),
BillingModule,
DocumentsModule,
@@ -115,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingContractService,
BookingInvoiceService,
BookingClearanceChargeService,
ClearanceEventService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,
@@ -130,6 +134,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
exports: [
BookingsService,
BookingsRepository,
ClearanceEventService,
BookingPricingService,
ContainerValidationService,
BookingInvoiceService,

View File

@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { Freight } from '@edr/types';
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
import {
BookingClearanceEvent,
ClearanceEventActorType,
} from './entities/booking-clearance-event.entity';
export interface RecordClearanceEventInput {
bookingId: string;
action: string;
/** Human sentence for the History tab, frozen at write time. */
label: string;
actorType?: ClearanceEventActorType;
/** IAM user id (staff or portal customer); name is resolved here. */
actorId?: string | null;
metadata?: Record<string, unknown> | null;
/** Join the caller's transaction so the event commits (or rolls back) with the action. */
manager?: EntityManager;
}
/**
* The clearance History tab's write/read path. Every clearance mutation calls
* {@link record} — document reviews, phased workflow steps, customer charges.
* Recording is deliberately NOT fire-and-forget: the insert shares the caller's
* transaction when a manager is passed, and otherwise a failed insert fails the
* action, because a silent gap in an audit trail is worse than a retry.
*/
@Injectable()
export class ClearanceEventService {
private readonly logger = new Logger(ClearanceEventService.name);
constructor(private readonly dataSource: DataSource) {}
async record(input: RecordClearanceEventInput): Promise<void> {
const mg = input.manager ?? this.dataSource.manager;
const actorName = input.actorId
? ((await resolveIamUserNames(this.dataSource, [input.actorId])).get(
input.actorId,
) ?? null)
: null;
await mg.save(
mg.create(BookingClearanceEvent, {
bookingId: input.bookingId,
action: input.action,
label: input.label,
actorType: input.actorType ?? 'STAFF',
actorId: input.actorId ?? null,
actorName,
metadata: input.metadata ?? null,
}),
);
this.logger.log(
`clearance-history ${input.action} on booking ${input.bookingId}${
actorName ? ` by ${actorName}` : ''
}`,
);
}
/** History for one booking, newest first. */
async list(bookingId: string): Promise<Freight.ClearanceHistoryEvent[]> {
const rows = await this.dataSource
.getRepository(BookingClearanceEvent)
.find({ where: { bookingId }, order: { createdAt: 'DESC' } });
// Rows whose actor name failed to resolve at write time get one more try.
const missing = rows
.filter((r) => !r.actorName && r.actorId)
.map((r) => r.actorId as string);
const names = missing.length
? await resolveIamUserNames(this.dataSource, missing).catch(
() => new Map<string, string>(),
)
: new Map<string, string>();
return rows.map((r) => ({
id: r.id,
action: r.action,
label: r.label,
actorType: r.actorType,
actorName:
r.actorName ?? (r.actorId ? (names.get(r.actorId) ?? null) : null),
metadata: r.metadata ?? null,
at: r.createdAt.toISOString(),
}));
}
}

View File

@@ -0,0 +1,48 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const CLEARANCE_EVENT_ACTOR_TYPES = ['STAFF', 'CUSTOMER', 'SYSTEM'] as const;
export type ClearanceEventActorType = (typeof CLEARANCE_EVENT_ACTOR_TYPES)[number];
/**
* One row per action in a booking's clearance flow — the History tab's source
* of truth. Written explicitly (and, where the caller runs one, inside the
* caller's transaction) by every clearance mutation: document review, phased
* workflow steps (transit, declaration, duty, DO/RO, permits), and customer
* charges. `action` is a stable machine code; `label` is the human sentence
* rendered as written, so old rows survive later wording changes.
*/
@Entity({ schema: 'freight', name: 'booking_clearance_event' })
@Index(['bookingId', 'createdAt'])
export class BookingClearanceEvent extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
/** Stable machine code, e.g. DOC_APPROVED, DECLARATION_UPLOADED. */
@Column({ name: 'action', type: 'varchar', length: 64 })
action!: string;
/** Human sentence shown in the History tab, frozen at write time. */
@Column({ name: 'label', type: 'varchar', length: 500 })
label!: string;
@Column({ name: 'actor_type', type: 'varchar', length: 16, default: 'STAFF' })
actorType!: ClearanceEventActorType;
/** IAM user id of the actor (null for SYSTEM events). */
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
actorId?: string | null;
/** Display name resolved at write time (iam.users); null when unresolvable. */
@Column({ name: 'actor_name', type: 'varchar', length: 150, nullable: true })
actorName?: string | null;
/** Action details: fileKey, note, amount, currency, file names, … */
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
metadata?: Record<string, unknown> | null;
}

View File

@@ -115,6 +115,7 @@ function makeService(overrides?: {
} as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
{ record: jest.fn() } as never, // clearanceEvents
);
return {

View File

@@ -39,6 +39,7 @@ import {
buildClearanceDocHistory,
type ClearanceDocEvent,
} from '../bookings/clearance-doc-history.util';
import { ClearanceEventService } from '../bookings/clearance-event.service';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -169,6 +170,7 @@ export class BookingClearanceService {
private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
private readonly yardScope: YardScopeService,
private readonly clearanceEvents: ClearanceEventService,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -524,6 +526,7 @@ export class BookingClearanceService {
async requestTransitAssignee(
bookingId: string,
note: string | undefined,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
@@ -531,6 +534,13 @@ export class BookingClearanceService {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestNote: note?.trim() || null,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_ASSIGNEE_REQUESTED',
label: 'Requested a transit assignee from GL Djibouti',
actorId: userId ?? null,
metadata: { note: note?.trim() || null },
});
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
return this.bookingsService.findById(bookingId);
@@ -542,7 +552,11 @@ export class BookingClearanceService {
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
async assignTransitAssignee(
bookingId: string,
transitAgentId: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
@@ -556,6 +570,13 @@ export class BookingClearanceService {
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_ASSIGNEE_ASSIGNED',
label: `Assigned transit officer "${agent.name}"`,
actorId: userId ?? null,
metadata: { transitAgentId, agentName: agent.name, previous },
});
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
return this.bookingsService.findById(bookingId);
@@ -608,6 +629,13 @@ export class BookingClearanceService {
? ContractDocPhase.GlEtPostClearance
: ContractDocPhase.CustomerDuty,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DECLARATION_UPLOADED',
label: `Uploaded customs declaration (${files.length} file(s))`,
actorId: userId ?? null,
metadata: { fileNames: files.map((f) => f.originalname) },
});
return this.bookingsService.findById(bookingId);
}
@@ -661,6 +689,19 @@ export class BookingClearanceService {
);
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
}
await this.clearanceEvents.record({
bookingId,
action: 'DUTY_ADVISED',
label: dto.dutyRequired
? `Advised duty/tax of ${dto.amount} ${dto.currency ?? 'ETB'}`
: 'Advised that no duty/tax applies',
actorId: userId ?? null,
metadata: {
dutyRequired: dto.dutyRequired,
amount: dto.amount ?? null,
currency: dto.currency ?? null,
},
});
return this.bookingsService.findById(bookingId);
}
@@ -708,6 +749,14 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_SENT',
label: `Sent draft customs declaration (estimated ${price} ${currency})`,
actorId: userId ?? null,
metadata: { price, currency, fileNames: files.map((f) => f.originalname) },
});
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationReady(updated, price, currency);
return updated;
@@ -717,7 +766,7 @@ export class BookingClearanceService {
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.
*/
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
async acceptDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
@@ -729,6 +778,13 @@ export class BookingClearanceService {
}
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_ACCEPTED',
label: 'Customer accepted the draft customs declaration',
actorType: 'CUSTOMER',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
@@ -778,12 +834,25 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_CHANGE_REQUESTED',
label: 'Customer requested a change to the draft declaration',
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { note: note.trim() },
});
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
return updated;
}
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
async uploadDutySlip(
bookingId: string,
file: Express.Multer.File,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty slip upload applies only to import bookings.');
@@ -805,6 +874,15 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DUTY_SLIP_UPLOADED',
label: 'Customer uploaded the duty/tax payment slip',
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { fileName: file.originalname },
});
this.notifier.dutySlipUploadedToStaff(booking, 'first');
return this.bookingsService.findById(bookingId);
}
@@ -837,11 +915,18 @@ export class BookingClearanceService {
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_PERMIT_UPLOADED',
label: `Uploaded transit permit (${files.length} file(s))`,
actorId: userId ?? null,
metadata: { fileNames: files.map((f) => f.originalname) },
});
return this.bookingsService.findById(bookingId);
}
async finalizePreClearance(bookingId: string): Promise<Booking> {
async finalizePreClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
@@ -861,6 +946,12 @@ export class BookingClearanceService {
preClearanceFinalizedAt: new Date(),
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'PRE_CLEARANCE_FINALIZED',
label: 'Finalized pre-clearance — handed over to GL Djibouti collection',
actorId: userId ?? null,
});
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(bookingId, 'bookings');
@@ -894,6 +985,17 @@ export class BookingClearanceService {
vesselArrivalDate,
doCollectedDate,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DELIVERY_ORDER_UPLOADED',
label: 'Uploaded Delivery Order',
actorId: userId ?? null,
metadata: {
vesselArrivalDate: vesselArrivalDate ?? null,
doCollectedDate: doCollectedDate ?? null,
fileNames: (files ?? []).map((f) => f.originalname),
},
});
if (booking.preClearanceFinalizedAt) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
@@ -951,6 +1053,16 @@ export class BookingClearanceService {
vesselDepartureDate,
roAmendmentRequestedAt: null,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'RELEASE_ORDER_UPLOADED',
label: `Uploaded Release Order (vessel departs ${vesselDepartureDate})`,
actorId: userId ?? null,
metadata: {
vesselDepartureDate,
fileNames: (files ?? []).map((f) => f.originalname),
},
});
if (leadDays < minDays) {
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
@@ -1010,6 +1122,13 @@ export class BookingClearanceService {
userId,
);
}
await this.clearanceEvents.record({
bookingId,
action: 'RO_AMENDMENT_REQUESTED',
label: 'Requested a port amendment on the Release Order',
actorId: userId ?? null,
metadata: { note: reason },
});
return this.bookingsService.findById(bookingId);
}
@@ -1025,6 +1144,12 @@ export class BookingClearanceService {
'EXPORT_RELEASED',
);
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
await this.clearanceEvents.record({
bookingId,
action: 'EXPORT_RELEASE_CONFIRMED',
label: 'Confirmed export release',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}

View File

@@ -0,0 +1,116 @@
import { useQuery } from "@tanstack/react-query";
import { Badge, Group, Loader, Paper, Text, Timeline } from "@mantine/core";
import {
CheckCircle2,
CircleDot,
FileText,
MessageSquareWarning,
Receipt,
Send,
Ship,
Upload,
UserCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
/** Icon + color per action family; unknown actions fall back to a neutral dot. */
function eventMeta(action: string): { icon: typeof Upload; color: string } {
if (action === "DOC_APPROVED" || action.endsWith("_ACCEPTED") || action.endsWith("_FINALIZED") || action.endsWith("_CONFIRMED"))
return { icon: CheckCircle2, color: "edr-green" };
if (action === "DOC_QUERIED" || action.includes("CHANGE_REQUESTED") || action.includes("AMENDMENT"))
return { icon: MessageSquareWarning, color: "red" };
if (action.startsWith("CHARGE_"))
return { icon: Receipt, color: action === "CHARGE_PAID" ? "edr-green" : "orange" };
if (action.includes("TRANSIT_ASSIGNEE")) return { icon: UserCheck, color: "blue" };
if (action.includes("ORDER")) return { icon: Ship, color: "blue" };
if (action.includes("SENT")) return { icon: Send, color: "blue" };
if (action.includes("UPLOAD") || action.includes("SUBMITTED"))
return { icon: Upload, color: "blue" };
if (action.includes("DOC")) return { icon: FileText, color: "gray" };
return { icon: CircleDot, color: "gray" };
}
const ACTOR_BADGE: Record<
Freight.ClearanceHistoryEvent["actorType"],
{ label: string; color: string }
> = {
STAFF: { label: "Staff", color: "blue" },
CUSTOMER: { label: "Customer", color: "grape" },
SYSTEM: { label: "System", color: "gray" },
};
/**
* Full per-booking clearance action trail: document reviews, phased workflow
* steps (transit, declaration, duty, DO/RO, permits) and customer charges —
* every event with who did it and when, newest first.
*/
export function ClearanceHistoryTab({ bookingId }: { bookingId: string }) {
const { data: events, isLoading } = useQuery({
queryKey: ["clearance-history", bookingId],
queryFn: () => bookingsService.getClearanceHistory(bookingId),
});
if (isLoading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading history</Text>
</Group>
);
}
if (!events || events.length === 0) {
return (
<Paper withBorder radius="md" p="lg">
<Text size="sm" c="dimmed">
No clearance actions recorded yet. Actions from now on approvals,
queries, workflow steps, charges appear here automatically.
</Text>
</Paper>
);
}
return (
<Paper withBorder radius="md" p="lg" maw={760}>
<Timeline bulletSize={22} lineWidth={2} active={events.length - 1} color="gray">
{events.map((ev) => {
const meta = eventMeta(ev.action);
const Icon = meta.icon;
const actor = ACTOR_BADGE[ev.actorType];
const note =
typeof ev.metadata?.note === "string" ? ev.metadata.note : null;
return (
<Timeline.Item
key={ev.id}
color={meta.color}
bullet={<Icon size={12} />}
title={
<Group gap={8} wrap="wrap">
<Text fz="13px" fw={600} c="edr-text" lh={1.35}>
{ev.label}
</Text>
<Badge size="xs" variant="light" color={actor.color} radius="sm">
{actor.label}
</Badge>
</Group>
}
>
<Text fz="11.5px" c="dimmed">
{ev.actorName ? `${ev.actorName} · ` : ""}
{formatDateTime(ev.at)}
</Text>
{note ? (
<Text fz="12px" c="red.8" mt={2}>
{note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
</Paper>
);
}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, Receipt, Share2, ShieldAlert } from "lucide-react";
import { AlertTriangle, FileText, History, Receipt, Share2, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
@@ -10,6 +10,7 @@ import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
export interface ClearanceOpsTabsProps {
@@ -112,6 +113,11 @@ export function ClearanceOpsTabs({
Customer charges
</Tabs.Tab>
) : null}
{bookingId && showExchange ? (
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
) : null}
{showOpsTabs && canOps && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
@@ -153,6 +159,12 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{bookingId && showExchange ? (
<Tabs.Panel value="history">
<ClearanceHistoryTab bookingId={bookingId} />
</Tabs.Panel>
) : null}
{showOpsTabs && canOps && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">

View File

@@ -20,6 +20,7 @@ import {
AlertTriangle,
ClipboardList,
FileText,
History,
Receipt,
Share2,
Upload,
@@ -38,6 +39,7 @@ import { ContractClearanceReviewSection } from "@/components/contracts/ContractC
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
import {
GlClearanceUploadModal,
type GlClearanceUploadKind,
@@ -254,6 +256,11 @@ export default function GlClearanceDetailPage() {
Customer charges
</Tabs.Tab>
) : null}
{data.kind === "booking" ? (
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
) : null}
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
@@ -382,6 +389,12 @@ export default function GlClearanceDetailPage() {
</Tabs.Panel>
) : null}
{data.kind === "booking" ? (
<Tabs.Panel value="history">
<ClearanceHistoryTab bookingId={id!} />
</Tabs.Panel>
) : null}
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">

View File

@@ -432,6 +432,14 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceView;
},
/** Clearance action history — reviews, workflow steps, charges (newest first). */
getClearanceHistory: async (
id: string,
): Promise<Freight.ClearanceHistoryEvent[]> => {
const response = await client.get(`/bookings/${id}/clearance/history`);
return unwrap(response.data) as Freight.ClearanceHistoryEvent[];
},
// ── Clearance charges (post-finalization customer billing) ──
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
const response = await client.get(`/bookings/${id}/clearance/charges`);

View File

@@ -807,6 +807,21 @@ export interface PricingBreakdown {
export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED";
// ── Clearance history (per-booking action trail, History tab) ───────────────
/** One recorded clearance action — review, workflow step, or charge. */
export interface ClearanceHistoryEvent {
id: string;
/** Stable machine code, e.g. DOC_APPROVED, DECLARATION_UPLOADED. */
action: string;
/** Human sentence, frozen at write time. */
label: string;
actorType: "STAFF" | "CUSTOMER" | "SYSTEM";
actorName: string | null;
metadata: Record<string, unknown> | null;
at: string;
}
// ── Clearance charges (post-finalization customer billing) ──────────────────
export type ClearanceChargeType = "PORT_CHARGES" | "MISCELLANEOUS";