mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices
This commit is contained in:
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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})`,
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user