mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
1265 lines
47 KiB
TypeScript
1265 lines
47 KiB
TypeScript
import { BadRequestException, Injectable } from '@nestjs/common';
|
|
import { In } from 'typeorm';
|
|
import {
|
|
ContractDocPhase,
|
|
isDeliveryOrderFileCode,
|
|
isDraftDeclarationFileCode,
|
|
type ClearanceFinalInvoiceSummary,
|
|
type ClearanceOffloadState,
|
|
type ClearanceSecondDuty,
|
|
type ClearanceT1State,
|
|
type ClearanceTrainState,
|
|
type GlExchangeDocument,
|
|
} 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 { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
|
import {
|
|
ClearanceMilestone,
|
|
type RiskAssignmentRecord,
|
|
} from './entities/clearance-milestone.entity';
|
|
import { Booking } from '../bookings/entities/booking.entity';
|
|
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
|
import { assertDoCollectionDates } from './contract-clearance.util';
|
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
|
import { GlOperationsService } from './gl-operations.service';
|
|
import { GlExchangeService } from './gl-exchange.service';
|
|
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
|
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
|
import { ContractsRepository } from './contracts.repository';
|
|
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
|
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
|
|
|
import {
|
|
buildClearanceDocHistory,
|
|
type ClearanceDocEvent,
|
|
} from '../bookings/clearance-doc-history.util';
|
|
import { ClearanceEventService } from '../bookings/clearance-event.service';
|
|
import { clearanceDocumentsOpen } from '../bookings/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;
|
|
uploadedAt: string | null;
|
|
reviewedAt: string | null;
|
|
reviewedByName: string | null;
|
|
history: ClearanceDocEvent[];
|
|
}>;
|
|
allApproved: boolean;
|
|
documentsOpen: 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;
|
|
/** Import DO dates recorded by GL Djibouti on upload. */
|
|
vesselArrivalDate?: string | null;
|
|
doCollectedDate?: string | null;
|
|
roAmendmentRequestedAt?: string | null;
|
|
operationReady?: boolean;
|
|
preClearanceFinalized?: boolean;
|
|
/**
|
|
* Pre-declaration handshake with GL Djibouti: who handles this shipment in
|
|
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot
|
|
* file the import customs declaration before it is set.
|
|
*/
|
|
transitAssignee?: {
|
|
requestedAt: string | null;
|
|
requestNote: string | null;
|
|
name: string | null;
|
|
assignedAt: string | null;
|
|
} | null;
|
|
dutyAdvice?: {
|
|
amount: number;
|
|
currency: string;
|
|
declarationSerial?: string | null;
|
|
noticeFile?: { id: string; name: string; url: string } | null;
|
|
} | null;
|
|
/**
|
|
* Import only: the draft customs declaration GL Ethiopia sends before filing
|
|
* the real one. Present once a draft has been uploaded, regardless of
|
|
* accept state — `accepted` tells the caller which.
|
|
*/
|
|
draftDeclaration?: {
|
|
price: number;
|
|
currency: string;
|
|
files: Array<{ id: string; name: string; url: string }>;
|
|
accepted: boolean;
|
|
} | null;
|
|
/**
|
|
* The customer's open change request on the current draft declaration.
|
|
* Present only until GL sends a corrected draft; `rounds` counts how many
|
|
* times it has been sent back.
|
|
*/
|
|
draftDeclarationChangeRequest?: {
|
|
note: string;
|
|
raisedAt: string;
|
|
rounds: number;
|
|
} | 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;
|
|
/** Offload stats for this booking (what came off the train, and where). */
|
|
offload?: ClearanceOffloadState | null;
|
|
/** GL Djibouti post-offload final invoice (export). */
|
|
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
|
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
|
riskLevel?: string | null;
|
|
riskAssignedAt?: string | null;
|
|
/** Every risk decision, oldest first; the last entry is the current level. */
|
|
riskHistory?: RiskAssignmentRecord[];
|
|
/** Post-arrival additional duty/tax round (import). */
|
|
secondDuty?: ClearanceSecondDuty | null;
|
|
importReleaseGranted?: boolean;
|
|
/** GL-shared documents this booking's uploader marked visible to the customer. */
|
|
exchangeDocuments?: GlExchangeDocument[];
|
|
}
|
|
|
|
@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 readonly notifier: BookingLifecycleNotifierService,
|
|
private readonly glExchangeService: GlExchangeService,
|
|
private readonly transitAgentsService: TransitAgentsService,
|
|
private readonly contractsRepository: ContractsRepository,
|
|
private readonly yardScope: YardScopeService,
|
|
private readonly clearanceEvents: ClearanceEventService,
|
|
) {}
|
|
|
|
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
|
if (!booking.customsClearingEnabled) {
|
|
throw new BadRequestException('Phased clearance applies only to customs bookings.');
|
|
}
|
|
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.assertPhasedCustoms(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 allVersions = await this.filesService.findAllVersionsByResource(
|
|
bookingId,
|
|
'bookings',
|
|
);
|
|
const queryNotes = await this.bookingsRepository.findReviewNotes(
|
|
bookingId,
|
|
'CHANGES_REQUESTED',
|
|
);
|
|
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
|
|
...reviews.map((r) => r.reviewedByStaffId),
|
|
...queryNotes.map((n) => n.authorId),
|
|
]);
|
|
|
|
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,
|
|
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
|
|
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
|
reviewedByName: review?.reviewedByStaffId
|
|
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
|
: null,
|
|
history: buildClearanceDocHistory({
|
|
fileKey: field.fileKey,
|
|
allVersions,
|
|
queryNotes,
|
|
review,
|
|
names: reviewerNames,
|
|
}),
|
|
});
|
|
}
|
|
};
|
|
|
|
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,
|
|
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
|
|
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
|
|
reviewedByName: review?.reviewedByStaffId
|
|
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
|
|
: null,
|
|
history: buildClearanceDocHistory({
|
|
fileKey: f.code,
|
|
allVersions,
|
|
queryNotes,
|
|
review,
|
|
names: reviewerNames,
|
|
}),
|
|
});
|
|
}
|
|
|
|
const allApproved = await this.isClearanceFullyApproved(booking);
|
|
let milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
|
|
|
// Self-heal: a booking that has settled its freight payment must have
|
|
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
|
|
// export FCFS booking (linked to its train at booking time) paid via the
|
|
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
|
|
// wagon allocation" step then never ticks. Backfill it here so already-stuck
|
|
// rows recover without a migration; idempotent (no-op once COMPLETED).
|
|
const paymentSettled = milestones.find(
|
|
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
|
|
);
|
|
if (
|
|
paymentSettled &&
|
|
paymentSettled.status === 'PENDING' &&
|
|
(booking.paymentStatus === 'PAID' || booking.status === 'PAID')
|
|
) {
|
|
await this.workflowService.completeMilestoneForBooking(
|
|
bookingId,
|
|
'FREIGHT_PAYMENT_SETTLED',
|
|
);
|
|
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 draftDeclaration = this.buildDraftDeclaration(files, milestones);
|
|
const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest(
|
|
bookingId,
|
|
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 gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
|
|
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
|
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
|
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
|
// GL↔GL exchange documents shared with the customer. The two desks may work
|
|
// the thread on the booking (per-booking customs) or on its contract
|
|
// (pre-booking clearance), so the customer's view spans both.
|
|
const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer(
|
|
[bookingId, booking.contractId ?? ''],
|
|
);
|
|
|
|
return {
|
|
bookingId,
|
|
status: booking.status,
|
|
includesCustoms,
|
|
inputCode,
|
|
outputCode,
|
|
documents,
|
|
allApproved,
|
|
documentsOpen: clearanceDocumentsOpen(booking),
|
|
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,
|
|
vesselArrivalDate: booking.vesselArrivalDate ?? null,
|
|
doCollectedDate: booking.doCollectedDate ?? null,
|
|
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
|
|
? booking.roAmendmentRequestedAt.toISOString()
|
|
: null,
|
|
operationReady: boundary,
|
|
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
|
|
transitAssignee: {
|
|
requestedAt: booking.transitAssigneeRequestedAt
|
|
? booking.transitAssigneeRequestedAt.toISOString()
|
|
: null,
|
|
requestNote: booking.transitAssigneeRequestNote ?? null,
|
|
name: booking.transitAssigneeName ?? null,
|
|
assignedAt: booking.transitAssigneeAssignedAt
|
|
? booking.transitAssigneeAssignedAt.toISOString()
|
|
: null,
|
|
},
|
|
dutyAdvice,
|
|
draftDeclaration,
|
|
draftDeclarationChangeRequest,
|
|
workflowFiles,
|
|
exchangeDocuments,
|
|
t1,
|
|
train,
|
|
gatepassGranted: gatepass.granted,
|
|
gatepassAt: gatepass.grantedAt,
|
|
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
|
t1ClosedAt:
|
|
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
|
? t1ClosedMilestone.triggeredAt.toISOString()
|
|
: null,
|
|
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
|
offload: await this.glOperationsService.offloadState(bookingId, milestones),
|
|
finalInvoice,
|
|
riskLevel:
|
|
riskMilestone?.status === 'COMPLETED'
|
|
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
|
|
: null,
|
|
riskAssignedAt:
|
|
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
|
? riskMilestone.triggeredAt.toISOString()
|
|
: null,
|
|
// Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are
|
|
// the current one; this is the trail behind it.
|
|
riskHistory:
|
|
riskMilestone?.status === 'COMPLETED'
|
|
? (riskMilestone.metadata?.riskHistory ?? [])
|
|
: [],
|
|
secondDuty,
|
|
importReleaseGranted:
|
|
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
|
};
|
|
}
|
|
|
|
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 buildDraftDeclaration(
|
|
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
|
milestones: ClearanceMilestone[],
|
|
): BookingClearanceView['draftDeclaration'] {
|
|
const uploaded = milestones.find(
|
|
(m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED',
|
|
);
|
|
if (!uploaded?.metadata) return null;
|
|
const price = uploaded.metadata.draftDeclarationPrice;
|
|
const currency = uploaded.metadata.draftDeclarationCurrency;
|
|
if (typeof price !== 'number' || typeof currency !== 'string') return null;
|
|
const draftFiles = files
|
|
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
|
|
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''))
|
|
.map((f) => ({ id: f.id, name: f.name, url: f.url }));
|
|
const accepted =
|
|
milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status ===
|
|
'COMPLETED';
|
|
return { price, currency, files: draftFiles, accepted };
|
|
}
|
|
|
|
private async buildDraftDeclarationChangeRequest(
|
|
bookingId: string,
|
|
milestones: ClearanceMilestone[],
|
|
): Promise<BookingClearanceView['draftDeclarationChangeRequest']> {
|
|
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
|
|
if (!uploaded || uploaded.status === 'COMPLETED') return null;
|
|
const notes = await this.bookingsRepository.findReviewNotes(
|
|
bookingId,
|
|
'DRAFT_DECL_CHANGE_REQUEST',
|
|
);
|
|
const latest = notes[0];
|
|
if (!latest) return null;
|
|
return {
|
|
note: latest.note,
|
|
raisedAt: latest.createdAt.toISOString(),
|
|
rounds: notes.length,
|
|
};
|
|
}
|
|
|
|
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',
|
|
),
|
|
);
|
|
}
|
|
|
|
/** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */
|
|
isPhasedCustomsBooking(booking: Booking): boolean {
|
|
return (
|
|
Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* GL Ethiopia asks Djibouti to name the officer who will handle this shipment
|
|
* in transit. The import declaration is gated on the answer, so this is the
|
|
* first thing ET does once the customer documents are approved. Re-requesting
|
|
* is allowed (a nudge) and simply restamps the ask.
|
|
*/
|
|
async requestTransitAssignee(
|
|
bookingId: string,
|
|
note: string | undefined,
|
|
userId?: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* GL Djibouti picks the transit officer from the admin-managed roster —
|
|
* rejected unless the agent is active and inside its validity window.
|
|
* Answering unblocks the declaration for Ethiopia. A later call overwrites
|
|
* the name (reassignment) and re-notifies.
|
|
*/
|
|
async assignTransitAssignee(
|
|
bookingId: string,
|
|
transitAgentId: string,
|
|
userId?: string,
|
|
): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
if (!booking.transitAssigneeRequestedAt) {
|
|
throw new BadRequestException(
|
|
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
|
|
);
|
|
}
|
|
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
|
|
|
|
const previous = booking.transitAssigneeName ?? null;
|
|
await this.bookingsRepository.update(bookingId, {
|
|
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);
|
|
}
|
|
|
|
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.',
|
|
);
|
|
}
|
|
// Import only: the declaration is filed against whoever physically handles
|
|
// the shipment in Djibouti, so that name must be in first. Exports have no
|
|
// such handshake — their Djibouti steps come after the declaration.
|
|
if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) {
|
|
throw new BadRequestException(
|
|
booking.transitAssigneeRequestedAt
|
|
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
|
|
: 'Request a transit assignee from GL Djibouti before filing the customs 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);
|
|
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);
|
|
}
|
|
|
|
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,
|
|
);
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
|
|
* the customer to review before the real declaration is filed. Repeatable —
|
|
* each call replaces the previous draft's files/price and re-arms the step,
|
|
* which is what a re-send after a change request needs.
|
|
*/
|
|
async uploadDraftDeclaration(
|
|
bookingId: string,
|
|
files: Express.Multer.File[],
|
|
price: number,
|
|
currency: 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.');
|
|
}
|
|
if (files.length === 0) {
|
|
throw new BadRequestException('No draft declaration documents uploaded');
|
|
}
|
|
if (!Number.isFinite(price) || price < 0) {
|
|
throw new BadRequestException('A valid estimated price is required.');
|
|
}
|
|
// Backfills the two new milestone rows for bookings seeded before this step
|
|
// existed — a blind complete() 404s on a booking with no such row yet.
|
|
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
|
|
await this.workflowService.assertPriorCompleteForBooking(
|
|
bookingId,
|
|
'IMPORT',
|
|
'DRAFT_DECLARATION_UPLOADED',
|
|
);
|
|
|
|
await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files);
|
|
await this.milestoneService.completeWithMetadataForBooking(
|
|
bookingId,
|
|
'DRAFT_DECLARATION_UPLOADED',
|
|
{ draftDeclarationPrice: price, draftDeclarationCurrency: currency },
|
|
userId,
|
|
);
|
|
await this.bookingsRepository.update(bookingId, {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
|
* real customs declaration.
|
|
*/
|
|
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.');
|
|
}
|
|
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
|
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
|
|
if (uploaded?.status !== 'COMPLETED') {
|
|
throw new BadRequestException('There is no draft declaration to accept yet.');
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* The customer sends the draft declaration back with a reason. Nothing is
|
|
* filed; the upload milestone reopens so the step becomes actionable again
|
|
* for GL Ethiopia, with the customer's message shown beside it. GL re-sends
|
|
* (same endpoint as the first time), which closes the request — the loop may
|
|
* run as many rounds as it takes.
|
|
*/
|
|
async requestDraftDeclarationChange(
|
|
bookingId: string,
|
|
note: 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.');
|
|
}
|
|
if (!note?.trim()) {
|
|
throw new BadRequestException(
|
|
'Say what needs to change so GL can correct the draft.',
|
|
);
|
|
}
|
|
|
|
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
|
if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') {
|
|
throw new BadRequestException('There is no draft declaration to request a change on yet.');
|
|
}
|
|
if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') {
|
|
throw new BadRequestException(
|
|
'The draft declaration has already been accepted — contact GL Ethiopia directly.',
|
|
);
|
|
}
|
|
|
|
await this.bookingsRepository.createReviewNote(
|
|
bookingId,
|
|
note.trim(),
|
|
'DRAFT_DECL_CHANGE_REQUEST',
|
|
userId,
|
|
);
|
|
// Back to GL: reopening the milestone is what re-arms the step (the
|
|
// stepper picks its active step from milestone completion).
|
|
await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED');
|
|
await this.bookingsRepository.update(bookingId, {
|
|
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,
|
|
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.');
|
|
}
|
|
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);
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
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, 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.');
|
|
}
|
|
|
|
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);
|
|
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');
|
|
if (files.some((f) => isDeliveryOrderFileCode(f.code))) {
|
|
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED');
|
|
await this.workflowService.markReadyForOperation(bookingId);
|
|
}
|
|
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
async uploadDeliveryOrder(
|
|
bookingId: string,
|
|
files: Express.Multer.File[],
|
|
userId?: string,
|
|
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
|
): Promise<Booking> {
|
|
const booking = await this.loadBooking(bookingId);
|
|
if (booking.tradeDirection !== 'IMPORT') {
|
|
throw new BadRequestException('Delivery Order applies only to import bookings.');
|
|
}
|
|
|
|
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
|
|
|
// 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 persistDeliveryOrderUploads(this.filesService, bookingId, 'bookings', files ?? []);
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
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);
|
|
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,
|
|
files: 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 (!vesselDepartureDate?.trim()) {
|
|
throw new BadRequestException('Vessel departure date is required');
|
|
}
|
|
|
|
const minDays = await this.resolveRoMinDays();
|
|
const leadDays = this.daysUntil(vesselDepartureDate);
|
|
|
|
await persistReleaseOrderUploads(this.filesService, bookingId, 'bookings', files ?? []);
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
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.`;
|
|
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,
|
|
);
|
|
// Release Order is now the last GL DJ pre-operation action (it follows the
|
|
// declaration) — release immediately so booking creation unlocks without a
|
|
// separate confirm click.
|
|
await this.workflowService.onExportReleasedForBooking(bookingId, 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,
|
|
);
|
|
}
|
|
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);
|
|
}
|
|
|
|
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);
|
|
await this.clearanceEvents.record({
|
|
bookingId,
|
|
action: 'EXPORT_RELEASE_CONFIRMED',
|
|
label: 'Confirmed export release',
|
|
actorId: userId ?? null,
|
|
});
|
|
return this.bookingsService.findById(bookingId);
|
|
}
|
|
|
|
async etQueue(user?: unknown): Promise<Booking[]> {
|
|
const candidates = await this.bookingsRepository.findByStatuses([
|
|
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
|
|
]);
|
|
const filtered: Booking[] = [];
|
|
for (const b of candidates) {
|
|
if (!this.isPhasedCustomsBooking(b)) continue;
|
|
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
|
if (!belongsOnEtClearanceQueue(milestones)) continue;
|
|
// Surfaced on the queue row: every required document is approved even
|
|
// though the booking status stays DOCUMENTS_UNDER_REVIEW until finalize.
|
|
(b as Booking & { allDocsApproved?: boolean }).allDocsApproved =
|
|
milestones.some(
|
|
(m) =>
|
|
m.milestoneCode === 'DOCUMENTS_APPROVED' &&
|
|
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
|
|
);
|
|
filtered.push(b);
|
|
}
|
|
|
|
// A document added after clearance was finalized lands as PENDING without
|
|
// moving the booking's status — the row would otherwise still read
|
|
// "Clearance ready" while GL has something waiting. Ad-hoc documents are
|
|
// tracked by no milestone, so this reads the review rows directly.
|
|
const pending = await this.bookingsRepository.findBookingsWithUnreviewedDocuments(
|
|
filtered.map((b) => b.id),
|
|
);
|
|
for (const b of filtered) {
|
|
(b as Booking & { hasDocumentsAwaitingReview?: boolean })
|
|
.hasDocumentsAwaitingReview = pending.has(b.id);
|
|
}
|
|
|
|
const rows = await this.attachContractSummary(filtered);
|
|
return this.narrowToYardScope(rows, user);
|
|
}
|
|
|
|
/**
|
|
* Keep only bookings whose ORIGIN or DESTINATION yard is one of the caller's
|
|
* assigned yards (`freight.yard_positions` via the active position). Yards in
|
|
* the middle of a route do not count. An unmapped position, super admin or
|
|
* `yards:view_all` holder sees everything (scope resolves to `null`).
|
|
* Runs after {@link attachContractSummary} so route-fallback yards count too.
|
|
*/
|
|
private async narrowToYardScope(bookings: Booking[], user: unknown): Promise<Booking[]> {
|
|
const scope = await this.yardScope.getScopedYardIds(user as never);
|
|
if (scope === null) return bookings;
|
|
const inScope = (id: string | null | undefined) => !!id && scope.includes(id);
|
|
return bookings.filter(
|
|
(b) =>
|
|
inScope(b.originYardId ?? b.originYard?.id) ||
|
|
inScope(b.destinationYardId ?? b.destinationYard?.id),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Queue rows show the parent contract's reference and lane. Booking has no
|
|
* contract relation, and a bare initiated instance may not carry yards yet —
|
|
* so batch-load the contracts (with routes) and fill in what's missing:
|
|
* `contractReference` always, origin/destination yards only when the booking
|
|
* lacks them (its own route wins).
|
|
*/
|
|
private async attachContractSummary(bookings: Booking[]): Promise<Booking[]> {
|
|
const ids = [...new Set(bookings.map((b) => b.contractId).filter(Boolean))] as string[];
|
|
if (!ids.length) return bookings;
|
|
const contracts = await this.contractsRepository.findAll({
|
|
where: { id: In(ids) },
|
|
relations: { routes: { originYard: true, destinationYard: true } },
|
|
});
|
|
const byId = new Map(contracts.map((c) => [c.id, c]));
|
|
for (const b of bookings) {
|
|
const contract = b.contractId ? byId.get(b.contractId) : undefined;
|
|
if (!contract) continue;
|
|
const row = b as Booking & { contractReference?: string | null };
|
|
row.contractReference = contract.reference ?? null;
|
|
if (b.originYard && b.destinationYard) continue;
|
|
const routes = contract.routes ?? [];
|
|
const route =
|
|
routes.find((r) => r.id === b.contractRouteId) ??
|
|
(routes.length === 1 ? routes[0] : undefined);
|
|
if (!route) continue;
|
|
b.originYard = b.originYard ?? route.originYard;
|
|
b.destinationYard = b.destinationYard ?? route.destinationYard;
|
|
}
|
|
return bookings;
|
|
}
|
|
|
|
async djQueue(): Promise<Booking[]> {
|
|
const candidates = await this.bookingsRepository.findByStatuses([
|
|
...DJ_BOOKING_QUEUE_STATUSES,
|
|
]);
|
|
const filtered: Booking[] = [];
|
|
for (const b of candidates) {
|
|
if (!this.isPhasedCustomsBooking(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 this.attachContractSummary(filtered);
|
|
}
|
|
}
|