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