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/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/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/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/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/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"],
|
"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,
|
BookingClearanceCharge,
|
||||||
ClearanceChargeType,
|
ClearanceChargeType,
|
||||||
} from './entities/booking-clearance-charge.entity';
|
} from './entities/booking-clearance-charge.entity';
|
||||||
|
import { ClearanceEventService } from './clearance-event.service';
|
||||||
|
|
||||||
/** File-record codes the charge documents are stored under on the booking. */
|
/** File-record codes the charge documents are stored under on the booking. */
|
||||||
const CHARGE_FILE_CODE: Record<ClearanceChargeType, string> = {
|
const CHARGE_FILE_CODE: Record<ClearanceChargeType, string> = {
|
||||||
@@ -49,6 +50,7 @@ export class BookingClearanceChargeService {
|
|||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
private readonly bookingsService: BookingsService,
|
private readonly bookingsService: BookingsService,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly clearanceEvents: ClearanceEventService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private repo() {
|
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);
|
return this.list(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +216,20 @@ export class BookingClearanceChargeService {
|
|||||||
billedByStaffId: staffId,
|
billedByStaffId: staffId,
|
||||||
billedAt: new Date(),
|
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);
|
return this.list(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +237,7 @@ export class BookingClearanceChargeService {
|
|||||||
async sendCharge(
|
async sendCharge(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
chargeId: string,
|
chargeId: string,
|
||||||
|
staffId?: string,
|
||||||
): Promise<Freight.ClearanceCharge[]> {
|
): Promise<Freight.ClearanceCharge[]> {
|
||||||
const charge = await this.repo().findOne({
|
const charge = await this.repo().findOne({
|
||||||
where: { id: chargeId, bookingId },
|
where: { id: chargeId, bookingId },
|
||||||
@@ -246,6 +272,18 @@ export class BookingClearanceChargeService {
|
|||||||
status: 'SENT',
|
status: 'SENT',
|
||||||
invoiceId: invoice.id,
|
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(
|
this.logger.log(
|
||||||
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
|
`Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`,
|
||||||
);
|
);
|
||||||
@@ -311,6 +349,17 @@ export class BookingClearanceChargeService {
|
|||||||
billedAt: new Date(),
|
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);
|
return this.list(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +374,16 @@ export class BookingClearanceChargeService {
|
|||||||
status: 'PAID',
|
status: 'PAID',
|
||||||
paidAt: new Date(),
|
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(
|
this.logger.log(
|
||||||
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
|
`Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, ruleEngineService, contractService };
|
return { service, bookingsRepository, ruleEngineService, contractService };
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
@@ -172,6 +173,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
@@ -262,6 +264,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, filesService };
|
return { service, bookingsRepository, filesService };
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
||||||
@@ -172,6 +173,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
|
|||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
notifier as never,
|
notifier as never,
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{ emit: jest.fn() } as never, // events
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService };
|
return { service, bookingsRepository, bookingBatchService };
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ describe('BookingTransitionService — paired staff decisions', () => {
|
|||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{} as never, // containerValidationService
|
{} as never, // containerValidationService
|
||||||
{} as never, // notifier
|
{} as never, // notifier
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
{} as never, // events
|
{} as never, // events
|
||||||
undefined, // milestoneService
|
undefined, // milestoneService
|
||||||
dataSource as never,
|
dataSource as never,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
buildClearanceDocHistory,
|
buildClearanceDocHistory,
|
||||||
type ClearanceDocEvent,
|
type ClearanceDocEvent,
|
||||||
} from './clearance-doc-history.util';
|
} from './clearance-doc-history.util';
|
||||||
|
import { ClearanceEventService } from './clearance-event.service';
|
||||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
@@ -72,6 +73,7 @@ export class BookingTransitionService {
|
|||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
private readonly notifier: BookingLifecycleNotifierService,
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
|
private readonly clearanceEvents: ClearanceEventService,
|
||||||
private readonly events: EventEmitter2,
|
private readonly events: EventEmitter2,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
// Optional + last so the hand-constructed service in *.spec.ts files keeps
|
// Optional + last so the hand-constructed service in *.spec.ts files keeps
|
||||||
@@ -795,6 +797,7 @@ export class BookingTransitionService {
|
|||||||
async submitClearanceDocuments(
|
async submitClearanceDocuments(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
|
userId?: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
@@ -853,6 +856,16 @@ export class BookingTransitionService {
|
|||||||
} as never);
|
} 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);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.clearanceDocsUploadedToStaff(fresh);
|
this.notifier.clearanceDocsUploadedToStaff(fresh);
|
||||||
return fresh;
|
return fresh;
|
||||||
@@ -940,6 +953,16 @@ export class BookingTransitionService {
|
|||||||
staffId,
|
staffId,
|
||||||
note,
|
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") {
|
if (status === "QUERIED") {
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -980,6 +1003,7 @@ export class BookingTransitionService {
|
|||||||
async uploadClearanceOutputDocuments(
|
async uploadClearanceOutputDocuments(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
|
userId?: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||||
@@ -1000,6 +1024,15 @@ export class BookingTransitionService {
|
|||||||
file,
|
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);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,7 +1040,7 @@ export class BookingTransitionService {
|
|||||||
* GL confirms clearance: requires every customer document APPROVED (100% gate)
|
* GL confirms clearance: requires every customer document APPROVED (100% gate)
|
||||||
* and, for customs, the required output documents present → CLEARANCE_READY.
|
* 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);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
if (this.isPhasedCustoms(booking)) {
|
if (this.isPhasedCustoms(booking)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -1063,6 +1096,12 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "CLEARANCE_READY",
|
status: "CLEARANCE_READY",
|
||||||
} as never);
|
} 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);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.clearanceReady(fresh);
|
this.notifier.clearanceReady(fresh);
|
||||||
return fresh;
|
return fresh;
|
||||||
@@ -1089,6 +1128,8 @@ export class BookingTransitionService {
|
|||||||
* the customer pools, so the gate here would wrongly reject them).
|
* the customer pools, so the gate here would wrongly reject them).
|
||||||
*/
|
*/
|
||||||
bypassDayPool?: boolean;
|
bypassDayPool?: boolean;
|
||||||
|
/** Acting user, recorded in the clearance history. */
|
||||||
|
userId?: string;
|
||||||
},
|
},
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
@@ -1203,6 +1244,14 @@ export class BookingTransitionService {
|
|||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
requestedTrainScheduleId: requestedId,
|
requestedTrainScheduleId: requestedId,
|
||||||
} as never);
|
} 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);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.operationRequestedToStaff(fresh);
|
this.notifier.operationRequestedToStaff(fresh);
|
||||||
return fresh;
|
return fresh;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
|
|
||||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
||||||
|
import { ClearanceEventService } from './clearance-event.service';
|
||||||
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
|
import { BillClearanceChargeDto } from './dto/clearance-charge.dto';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
@@ -174,6 +175,7 @@ export class BookingsController {
|
|||||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||||
private readonly clearanceChargeService: BookingClearanceChargeService,
|
private readonly clearanceChargeService: BookingClearanceChargeService,
|
||||||
|
private readonly clearanceEventService: ClearanceEventService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -975,10 +977,12 @@ export class BookingsController {
|
|||||||
async submitClearanceDocuments(
|
async submitClearanceDocuments(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.submitClearanceDocuments(
|
const booking = await this.transitionService.submitClearanceDocuments(
|
||||||
id,
|
id,
|
||||||
files ?? [],
|
files ?? [],
|
||||||
|
resolveAuthUserId(user),
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
@@ -995,11 +999,13 @@ export class BookingsController {
|
|||||||
async proceedToOperation(
|
async proceedToOperation(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: RequestOperationDto,
|
@Body() dto: RequestOperationDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.requestOperation(
|
const booking = await this.transitionService.requestOperation(
|
||||||
id,
|
id,
|
||||||
dto.scheduledDate,
|
dto.scheduledDate,
|
||||||
dto.trainScheduleId ?? null,
|
dto.trainScheduleId ?? null,
|
||||||
|
{ userId: resolveAuthUserId(user) },
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
@@ -1077,6 +1083,19 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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) ────────────────
|
// ── Clearance charges (post-finalization customer billing) ────────────────
|
||||||
|
|
||||||
@Get(":id/clearance/charges")
|
@Get(":id/clearance/charges")
|
||||||
@@ -1140,8 +1159,13 @@ export class BookingsController {
|
|||||||
sendClearanceCharge(
|
sendClearanceCharge(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Param("chargeId", ParseUUIDPipe) chargeId: 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")
|
@Post(":id/clearance/charges/miscellaneous")
|
||||||
@@ -1175,10 +1199,12 @@ export class BookingsController {
|
|||||||
async uploadClearanceOutput(
|
async uploadClearanceOutput(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
||||||
id,
|
id,
|
||||||
files ?? [],
|
files ?? [],
|
||||||
|
resolveAuthUserId(user),
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
@@ -1189,8 +1215,14 @@ export class BookingsController {
|
|||||||
summary:
|
summary:
|
||||||
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
|
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
|
||||||
})
|
})
|
||||||
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
|
async finalizeClearance(
|
||||||
const booking = await this.transitionService.finalizeClearance(id);
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.finalizeClearance(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,8 +1235,13 @@ export class BookingsController {
|
|||||||
async requestBookingTransitAssignee(
|
async requestBookingTransitAssignee(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body('note') note: string | undefined,
|
@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);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1217,8 +1254,13 @@ export class BookingsController {
|
|||||||
async assignBookingTransitAssignee(
|
async assignBookingTransitAssignee(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: 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);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1302,8 +1344,14 @@ export class BookingsController {
|
|||||||
summary:
|
summary:
|
||||||
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
|
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
|
||||||
})
|
})
|
||||||
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
|
async acceptBookingDraftDeclaration(
|
||||||
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingClearanceService.acceptDraftDeclaration(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1329,8 +1377,14 @@ export class BookingsController {
|
|||||||
@Post(':id/clearance/finalize-pre-clearance')
|
@Post(':id/clearance/finalize-pre-clearance')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||||||
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
async finalizeBookingPreClearance(
|
||||||
const booking = await this.bookingClearanceService.finalizePreClearance(id);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingClearanceService.finalizePreClearance(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1342,8 +1396,13 @@ export class BookingsController {
|
|||||||
async uploadBookingDutySlip(
|
async uploadBookingDutySlip(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@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);
|
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 { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||||
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
|
import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity';
|
||||||
import { BookingClearanceChargeService } from './booking-clearance-charge.service';
|
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 { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
@@ -79,6 +81,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
CustomerTruckContainer,
|
CustomerTruckContainer,
|
||||||
ConsolidationApproval,
|
ConsolidationApproval,
|
||||||
BookingClearanceCharge,
|
BookingClearanceCharge,
|
||||||
|
BookingClearanceEvent,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
@@ -115,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingContractService,
|
BookingContractService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
BookingClearanceChargeService,
|
BookingClearanceChargeService,
|
||||||
|
ClearanceEventService,
|
||||||
ContractTemplateResolver,
|
ContractTemplateResolver,
|
||||||
ContractViewModelBuilder,
|
ContractViewModelBuilder,
|
||||||
ContractPricingScheduleBuilder,
|
ContractPricingScheduleBuilder,
|
||||||
@@ -130,6 +134,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
exports: [
|
exports: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
|
ClearanceEventService,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
ContainerValidationService,
|
ContainerValidationService,
|
||||||
BookingInvoiceService,
|
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
|
} as never, // transit agents
|
||||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||||
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
||||||
|
{ record: jest.fn() } as never, // clearanceEvents
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
buildClearanceDocHistory,
|
buildClearanceDocHistory,
|
||||||
type ClearanceDocEvent,
|
type ClearanceDocEvent,
|
||||||
} from '../bookings/clearance-doc-history.util';
|
} from '../bookings/clearance-doc-history.util';
|
||||||
|
import { ClearanceEventService } from '../bookings/clearance-event.service';
|
||||||
|
|
||||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@ export class BookingClearanceService {
|
|||||||
private readonly transitAgentsService: TransitAgentsService,
|
private readonly transitAgentsService: TransitAgentsService,
|
||||||
private readonly contractsRepository: ContractsRepository,
|
private readonly contractsRepository: ContractsRepository,
|
||||||
private readonly yardScope: YardScopeService,
|
private readonly yardScope: YardScopeService,
|
||||||
|
private readonly clearanceEvents: ClearanceEventService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
||||||
@@ -524,6 +526,7 @@ export class BookingClearanceService {
|
|||||||
async requestTransitAssignee(
|
async requestTransitAssignee(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
note: string | undefined,
|
note: string | undefined,
|
||||||
|
userId?: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
|
|
||||||
@@ -531,6 +534,13 @@ export class BookingClearanceService {
|
|||||||
transitAssigneeRequestedAt: new Date(),
|
transitAssigneeRequestedAt: new Date(),
|
||||||
transitAssigneeRequestNote: note?.trim() || null,
|
transitAssigneeRequestNote: note?.trim() || null,
|
||||||
} as never);
|
} 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);
|
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
@@ -542,7 +552,11 @@ export class BookingClearanceService {
|
|||||||
* Answering unblocks the declaration for Ethiopia. A later call overwrites
|
* Answering unblocks the declaration for Ethiopia. A later call overwrites
|
||||||
* the name (reassignment) and re-notifies.
|
* 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);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (!booking.transitAssigneeRequestedAt) {
|
if (!booking.transitAssigneeRequestedAt) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -556,6 +570,13 @@ export class BookingClearanceService {
|
|||||||
transitAssigneeName: agent.name,
|
transitAssigneeName: agent.name,
|
||||||
transitAssigneeAssignedAt: new Date(),
|
transitAssigneeAssignedAt: new Date(),
|
||||||
} as never);
|
} 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);
|
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
@@ -608,6 +629,13 @@ export class BookingClearanceService {
|
|||||||
? ContractDocPhase.GlEtPostClearance
|
? ContractDocPhase.GlEtPostClearance
|
||||||
: ContractDocPhase.CustomerDuty,
|
: ContractDocPhase.CustomerDuty,
|
||||||
} as never);
|
} 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);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
@@ -661,6 +689,19 @@ export class BookingClearanceService {
|
|||||||
);
|
);
|
||||||
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
|
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);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
@@ -708,6 +749,14 @@ export class BookingClearanceService {
|
|||||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||||
} as never);
|
} 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);
|
const updated = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.draftDeclarationReady(updated, price, currency);
|
this.notifier.draftDeclarationReady(updated, price, currency);
|
||||||
return updated;
|
return updated;
|
||||||
@@ -717,7 +766,7 @@ export class BookingClearanceService {
|
|||||||
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
||||||
* real customs declaration.
|
* real customs declaration.
|
||||||
*/
|
*/
|
||||||
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
|
async acceptDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
throw new BadRequestException('Draft declaration applies only to import bookings.');
|
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.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);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,12 +834,25 @@ export class BookingClearanceService {
|
|||||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||||
} as never);
|
} 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);
|
const updated = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
|
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
|
||||||
return updated;
|
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);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
throw new BadRequestException('Duty slip upload applies only to import bookings.');
|
throw new BadRequestException('Duty slip upload applies only to import bookings.');
|
||||||
@@ -805,6 +874,15 @@ export class BookingClearanceService {
|
|||||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
} as never);
|
} 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');
|
this.notifier.dutySlipUploadedToStaff(booking, 'first');
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
@@ -837,11 +915,18 @@ export class BookingClearanceService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
} as never);
|
} 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);
|
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);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
|
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
|
||||||
@@ -861,6 +946,12 @@ export class BookingClearanceService {
|
|||||||
preClearanceFinalizedAt: new Date(),
|
preClearanceFinalizedAt: new Date(),
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
||||||
} as never);
|
} 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.
|
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
||||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||||
@@ -894,6 +985,17 @@ export class BookingClearanceService {
|
|||||||
vesselArrivalDate,
|
vesselArrivalDate,
|
||||||
doCollectedDate,
|
doCollectedDate,
|
||||||
} as never);
|
} 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) {
|
if (booking.preClearanceFinalizedAt) {
|
||||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||||
@@ -951,6 +1053,16 @@ export class BookingClearanceService {
|
|||||||
vesselDepartureDate,
|
vesselDepartureDate,
|
||||||
roAmendmentRequestedAt: null,
|
roAmendmentRequestedAt: null,
|
||||||
} as never);
|
} 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) {
|
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.`;
|
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,
|
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);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
@@ -1025,6 +1144,12 @@ export class BookingClearanceService {
|
|||||||
'EXPORT_RELEASED',
|
'EXPORT_RELEASED',
|
||||||
);
|
);
|
||||||
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
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);
|
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 type { ReactNode } from "react";
|
||||||
import { Badge, Stack, Tabs, Text } from "@mantine/core";
|
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 type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
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 { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
|
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
|
||||||
|
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
|
||||||
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
||||||
|
|
||||||
export interface ClearanceOpsTabsProps {
|
export interface ClearanceOpsTabsProps {
|
||||||
@@ -112,6 +113,11 @@ export function ClearanceOpsTabs({
|
|||||||
Customer charges
|
Customer charges
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
) : null}
|
) : null}
|
||||||
|
{bookingId && showExchange ? (
|
||||||
|
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||||
|
History
|
||||||
|
</Tabs.Tab>
|
||||||
|
) : null}
|
||||||
{showOpsTabs && canOps && riskMs ? (
|
{showOpsTabs && canOps && riskMs ? (
|
||||||
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
|
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
|
||||||
Risk assignment
|
Risk assignment
|
||||||
@@ -153,6 +159,12 @@ export function ClearanceOpsTabs({
|
|||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{bookingId && showExchange ? (
|
||||||
|
<Tabs.Panel value="history">
|
||||||
|
<ClearanceHistoryTab bookingId={bookingId} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showOpsTabs && canOps && riskMs && bookingId ? (
|
{showOpsTabs && canOps && riskMs && bookingId ? (
|
||||||
<Tabs.Panel value="risk">
|
<Tabs.Panel value="risk">
|
||||||
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
|
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
|
History,
|
||||||
Receipt,
|
Receipt,
|
||||||
Share2,
|
Share2,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -38,6 +39,7 @@ import { ContractClearanceReviewSection } from "@/components/contracts/ContractC
|
|||||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||||
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
|
||||||
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
|
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
|
||||||
|
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
|
||||||
import {
|
import {
|
||||||
GlClearanceUploadModal,
|
GlClearanceUploadModal,
|
||||||
type GlClearanceUploadKind,
|
type GlClearanceUploadKind,
|
||||||
@@ -254,6 +256,11 @@ export default function GlClearanceDetailPage() {
|
|||||||
Customer charges
|
Customer charges
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
) : null}
|
) : null}
|
||||||
|
{data.kind === "booking" ? (
|
||||||
|
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||||
|
History
|
||||||
|
</Tabs.Tab>
|
||||||
|
) : null}
|
||||||
{incidentBookingId ? (
|
{incidentBookingId ? (
|
||||||
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
|
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
|
||||||
Incidents
|
Incidents
|
||||||
@@ -382,6 +389,12 @@ export default function GlClearanceDetailPage() {
|
|||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{data.kind === "booking" ? (
|
||||||
|
<Tabs.Panel value="history">
|
||||||
|
<ClearanceHistoryTab bookingId={id!} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{incidentBookingId ? (
|
{incidentBookingId ? (
|
||||||
<Tabs.Panel value="incidents">
|
<Tabs.Panel value="incidents">
|
||||||
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
|
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
|
||||||
|
|||||||
@@ -432,6 +432,14 @@ export const bookingsService = {
|
|||||||
return unwrap(response.data) as Freight.ClearanceView;
|
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) ──
|
// ── Clearance charges (post-finalization customer billing) ──
|
||||||
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
|
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
|
||||||
const response = await client.get(`/bookings/${id}/clearance/charges`);
|
const response = await client.get(`/bookings/${id}/clearance/charges`);
|
||||||
|
|||||||
@@ -807,6 +807,21 @@ export interface PricingBreakdown {
|
|||||||
|
|
||||||
export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED";
|
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) ──────────────────
|
// ── Clearance charges (post-finalization customer billing) ──────────────────
|
||||||
|
|
||||||
export type ClearanceChargeType = "PORT_CHARGES" | "MISCELLANEOUS";
|
export type ClearanceChargeType = "PORT_CHARGES" | "MISCELLANEOUS";
|
||||||
|
|||||||
Reference in New Issue
Block a user