Files
edr-platform/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts
2026-07-02 17:55:59 +00:00

686 lines
24 KiB
TypeScript

import { BadRequestException, Injectable } from '@nestjs/common';
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceT1State,
type ClearanceTrainState,
} from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { FilesService } from '../files/files.service';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
export interface BookingClearanceView {
bookingId: string;
status: string;
includesCustoms: boolean;
inputCode: string | null;
outputCode: string | null;
documents: Array<{
fileKey: string;
label: string;
required: boolean;
uploadedBy: 'customer' | 'gl';
settingCode: string;
file: { id: string; name: string; url: string } | null;
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
note: string | null;
}>;
allApproved: boolean;
phase?: string | null;
milestones?: Array<{
id: string;
milestoneCode: string;
milestoneLabel: string;
status: string;
ownerRegion?: string | null;
metadata?: Record<string, unknown> | null;
sortOrder: number;
}>;
nextAction?: {
actor: string;
action: string;
milestoneCode?: string | null;
blockedReason?: string | null;
} | null;
dutyRequired?: boolean | null;
roHold?: boolean;
roHoldReason?: string | null;
vesselDepartureDate?: string | null;
roAmendmentRequestedAt?: string | null;
operationReady?: boolean;
preClearanceFinalized?: boolean;
dutyAdvice?: {
amount: number;
currency: string;
declarationSerial?: string | null;
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until wagon allocation). */
t1?: ClearanceT1State | null;
/** Train link state for the booking (both directions). */
train?: ClearanceTrainState | null;
gatepassGranted?: boolean;
gatepassAt?: string | null;
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
}
@Injectable()
export class BookingClearanceService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly bookingsService: BookingsService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly workflowService: ClearanceWorkflowService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
if (!booking.customsClearingEnabled) {
throw new BadRequestException('Phased clearance applies only to customs bookings.');
}
if (booking.contractKind !== 'GENERAL') {
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
}
if (!booking.contractId) {
throw new BadRequestException('Booking is not linked to a contract.');
}
}
private async loadBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
await this.assertPhasedGeneralCustoms(booking);
return booking;
}
async getClearanceView(bookingId: string): Promise<BookingClearanceView> {
const booking = await this.loadBooking(bookingId);
const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking);
const files = await this.filesService.findByResource(bookingId, 'bookings');
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
const documents: BookingClearanceView['documents'] = [];
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
if (!code) return;
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(code);
} catch {
return;
}
for (const field of setting.fields ?? []) {
const file = fileByCode.get(field.fileKey) ?? null;
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
documents.push({
fileKey: field.fileKey,
label: field.fileLabel,
required: field.isRequired,
uploadedBy,
settingCode: code,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
});
}
};
await pushSetting(inputCode, 'customer');
await pushSetting(outputCode, 'gl');
for (const f of files) {
if (!f.code?.startsWith('custom_')) continue;
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
required: false,
uploadedBy: 'customer',
settingCode: 'custom',
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
});
}
const allApproved = await this.isClearanceFullyApproved(booking);
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
const boundary = await this.workflowService.isBoundaryCompleteForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
const workflowFiles = buildWorkflowFiles(
files,
booking.tradeDirection ?? 'IMPORT',
);
let t1: ClearanceT1State | null = null;
if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') {
try {
t1 = await this.glOperationsService.t1State(bookingId);
} catch {
t1 = null;
}
}
let train: ClearanceTrainState | null = null;
try {
train = await this.glOperationsService.trainState(bookingId);
} catch {
train = null;
}
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
const bookingMilestone = (code: string) =>
milestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
return {
bookingId,
status: booking.status,
includesCustoms,
inputCode,
outputCode,
documents,
allApproved,
phase,
milestones: milestones.map((m) => ({
id: m.id,
milestoneCode: m.milestoneCode,
milestoneLabel: m.milestoneLabel,
status: m.status,
ownerRegion: m.ownerRegion,
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
sortOrder: m.sortOrder,
})),
nextAction,
dutyRequired: booking.dutyRequired ?? null,
roHold: Boolean(booking.roHoldReason),
roHoldReason: booking.roHoldReason ?? null,
vesselDepartureDate: booking.vesselDepartureDate ?? null,
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
? booking.roAmendmentRequestedAt.toISOString()
: null,
operationReady: boundary,
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
dutyAdvice,
workflowFiles,
t1,
train,
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
gatepassAt:
gatepassMilestone?.status === 'COMPLETED'
? (gatepassMilestone.metadata?.gatepassAt ??
(gatepassMilestone.triggeredAt
? gatepassMilestone.triggeredAt.toISOString()
: null))
: null,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
finalInvoice,
};
}
private buildDutyAdvice(
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
milestones: ClearanceMilestone[],
): BookingClearanceView['dutyAdvice'] {
const advised = milestones.find(
(m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED',
);
if (!advised?.metadata) return null;
const amount = advised.metadata.dutyAmount;
const currency = advised.metadata.dutyCurrency;
if (typeof amount !== 'number' || typeof currency !== 'string') return null;
const notice = files.find((f) => f.code === 'duty_tax_notice');
return {
amount,
currency,
declarationSerial:
typeof advised.metadata.declarationSerial === 'string'
? advised.metadata.declarationSerial
: null,
noticeFile: notice
? { id: notice.id, name: notice.name, url: notice.url }
: null,
};
}
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) return true;
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(inputCode);
} catch {
return false;
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return true;
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
return required.every((field) =>
reviews.some(
(r) =>
r.settingCode === inputCode &&
r.fileKey === field.fileKey &&
r.status === 'APPROVED',
),
);
}
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
return (
Boolean(booking.customsClearingEnabled) &&
booking.contractKind === 'GENERAL' &&
Boolean(booking.contractId)
);
}
async uploadDeclaration(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
const allApproved = await this.isClearanceFullyApproved(booking);
if (!allApproved) {
throw new BadRequestException(
'All required customer documents must be approved before uploading a declaration.',
);
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
tradeDirection,
'UNDER_CUSTOMS_CLEARANCE',
);
if (files.length === 0) {
throw new BadRequestException('No declaration documents uploaded');
}
await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files);
await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase:
tradeDirection === 'EXPORT'
? ContractDocPhase.GlEtPostClearance
: ContractDocPhase.CustomerDuty,
} as never);
// Export: the declaration is the last GL ET pre-operation action — release
// immediately so the customer can proceed without a separate confirm click.
if (tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
}
return this.bookingsService.findById(bookingId);
}
async adviseDuty(
bookingId: string,
dto: AdviseContractDutyDto,
userId?: string,
attachment?: Express.Multer.File,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty advice applies only to import bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'DUTY_TAXES_ADVISED',
);
await this.bookingsRepository.update(bookingId, {
dutyRequired: dto.dutyRequired,
clearanceCurrentPhase: dto.dutyRequired
? ContractDocPhase.CustomerDuty
: ContractDocPhase.GlEtPostClearance,
} as never);
if (!dto.dutyRequired) {
await this.workflowService.onDutySkippedForBooking(bookingId);
} else {
if (dto.amount == null || dto.amount < 0) {
throw new BadRequestException('Duty amount is required when duty applies.');
}
if (!attachment) {
throw new BadRequestException('Duty notice attachment is required when duty applies.');
}
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'duty_tax_notice',
file: attachment,
});
await this.milestoneService.adviseDuty(
bookingId,
{
amount: dto.amount,
currency: dto.currency ?? 'ETB',
declarationSerial: dto.declarationSerial,
},
userId,
);
}
return this.bookingsService.findById(bookingId);
}
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty slip upload applies only to import bookings.');
}
if (!booking.dutyRequired) {
throw new BadRequestException('Duty/tax is not required for this clearance.');
}
if (!file) throw new BadRequestException('No payment slip uploaded');
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'duty_tax_receipt',
file,
});
await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID');
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
return this.bookingsService.findById(bookingId);
}
async uploadTransitPermit(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Transit permit applies only to import bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'TRANSIT_PERMIT_UPLOADED',
);
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files);
await this.workflowService.completeMilestoneForBooking(
bookingId,
'TRANSIT_PERMIT_UPLOADED',
userId,
);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
return this.bookingsService.findById(bookingId);
}
async finalizePreClearance(bookingId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'TRANSIT_PERMIT_UPLOADED',
);
if (booking.preClearanceFinalizedAt) {
return this.bookingsService.findById(bookingId);
}
await this.bookingsRepository.update(bookingId, {
preClearanceFinalizedAt: new Date(),
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(bookingId, 'bookings');
if (files.some((f) => f.code === 'delivery_order')) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED');
await this.workflowService.markReadyForOperation(bookingId);
}
return this.bookingsService.findById(bookingId);
}
async uploadDeliveryOrder(
bookingId: string,
file: Express.Multer.File,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Delivery Order applies only to import bookings.');
}
if (!file) throw new BadRequestException('No Delivery Order uploaded');
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
// any file type. The DO_COLLECTED milestone (and operation readiness) still
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'delivery_order',
file,
});
if (booking.preClearanceFinalizedAt) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForOperation(bookingId);
}
return this.bookingsService.findById(bookingId);
}
private async resolveRoMinDays(): Promise<number> {
try {
const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE);
const first = setting.children?.[0];
const n = Number(first?.value);
return Number.isFinite(n) && n > 0 ? n : 2;
} catch {
return 2;
}
}
private daysUntil(dateStr: string): number {
const target = new Date(dateStr);
const today = new Date();
today.setHours(0, 0, 0, 0);
target.setHours(0, 0, 0, 0);
return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
}
async uploadReleaseOrder(
bookingId: string,
file: Express.Multer.File,
vesselDepartureDate: string,
userId?: string,
): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Release Order applies only to export bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'EXPORT',
'RELEASE_ORDER_SECURED',
);
if (!file) throw new BadRequestException('No Release Order uploaded');
if (!vesselDepartureDate?.trim()) {
throw new BadRequestException('Vessel departure date is required');
}
const minDays = await this.resolveRoMinDays();
const leadDays = this.daysUntil(vesselDepartureDate);
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'release_order',
file,
});
await this.bookingsRepository.update(bookingId, {
vesselDepartureDate,
roAmendmentRequestedAt: null,
} as never);
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.`;
await this.bookingsRepository.update(bookingId, {
roHoldReason: reason,
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
return {
booking: await this.bookingsService.findById(bookingId),
hold: true,
holdReason: reason,
};
}
await this.bookingsRepository.update(bookingId, {
roHoldReason: null,
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.workflowService.completeMilestoneForBooking(
bookingId,
'RELEASE_ORDER_SECURED',
userId,
);
return { booking: await this.bookingsService.findById(bookingId), hold: false };
}
async requestRoAmendment(
bookingId: string,
note?: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'EXPORT') {
throw new BadRequestException('RO amendment applies only to export bookings.');
}
const reason =
note?.trim() ||
'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';
await this.bookingsRepository.update(bookingId, {
roAmendmentRequestedAt: new Date(),
roHoldReason: reason,
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
if (userId) {
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'CHANGES_REQUESTED',
userId,
);
}
return this.bookingsService.findById(bookingId);
}
async confirmExportRelease(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Export release applies only to export bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'EXPORT',
'EXPORT_RELEASED',
);
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
return this.bookingsService.findById(bookingId);
}
async etQueue(): Promise<Booking[]> {
const candidates = await this.bookingsRepository.findByStatuses([
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
return filtered;
}
async djQueue(): Promise<Booking[]> {
const candidates = await this.bookingsRepository.findByStatuses([
...DJ_BOOKING_QUEUE_STATUSES,
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {
roHoldReason: b.roHoldReason,
preClearanceFinalizedAt: b.preClearanceFinalizedAt,
})
) {
filtered.push(b);
}
}
return filtered;
}
}