Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts

1667 lines
61 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
ContractDocPhase,
isDeliveryOrderFileCode,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
type ClearanceSecondDuty,
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 { ContractsRepository } from './contracts.repository';
import { ContractsService, PaginatedContracts } from './contracts.service';
import { BookingsService } from '../bookings/bookings.service';
import {
assertDoCollectionDates,
contractClearanceCodes,
} from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import {
ClearanceMilestone,
type RiskAssignmentRecord,
} from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, persistDeclarationUploads, persistDeliveryOrderUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
export interface ContractClearanceDocument {
fileKey: string;
label: string;
required: boolean;
uploadedBy: 'customer' | 'gl';
settingCode: string;
file: { id: string; name: string; url: string } | null;
reviewStatus: ContractDocReviewStatus | null;
note: string | null;
/** When the review decision (approve/query) was recorded. */
reviewedAt: string | null;
/** Staff id that recorded the decision (no user directory to resolve names). */
reviewedByStaffId: string | null;
}
export interface ContractClearanceView {
contractId: string;
status: string;
clearanceStatus: string;
cycleNumber: number;
includesCustoms: boolean;
inputCode: string | null;
outputCode: string | null;
documents: ContractClearanceDocument[];
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;
/**
* Pre-declaration handshake with GL Djibouti: who will handle the shipment in
* transit. `name` is null until Djibouti answers, and the declaration step is
* shut until it is set.
*/
transitAssignee?: {
requestedAt: string | null;
requestNote: string | null;
name: string | null;
assignedAt: string | null;
} | 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;
bookingReady?: boolean;
preClearanceFinalized?: boolean;
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
exportClearanceFinalized?: boolean;
linkedBookingId?: string | null;
/** Reference + status of the GL-created shipment booking, once it exists. */
linkedBookingReference?: string | null;
linkedBookingStatus?: string | null;
/**
* Operations' latest "needs changes" note on that booking. GL created the
* booking, so GL is the one who has to act on it — surfaced here because the
* clearance page is where GL works, not the portal.
*/
linkedBookingReviewNote?: string | null;
/** Shipment day the booking currently holds — the default when GL resubmits. */
linkedBookingScheduledDate?: string | null;
dutyAdvice?: {
amount: number;
currency: string;
declarationSerial?: string | null;
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
/**
* The customer's open objection to the advised duty — present only while GL
* has not re-advised (the advice milestone is back to PENDING). `rounds` is
* how many times it has been sent back, so both sides can see the loop.
*/
dutyDispute?: {
note: string;
raisedAt: string;
rounds: number;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until a booking is linked). */
t1?: ClearanceT1State | null;
/** Train link state for the booking (both directions; null until a booking is linked). */
train?: ClearanceTrainState | null;
gatepassGranted?: boolean;
gatepassAt?: string | null;
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for the linked booking (null until one exists). */
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;
}
@Injectable()
export class ContractClearanceService {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly contractsService: ContractsService,
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: ContractNotifierService,
private readonly transitAgentsService: TransitAgentsService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME';
}
private assertPhasedCustoms(contract: Contract): void {
if (!this.isPhasedCustoms(contract)) {
throw new BadRequestException(
'Phased clearance (Phase 1) applies to one-time customs contracts.',
);
}
}
/**
* Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing
* phased milestones. Revert that state so declaration / DO steps can proceed.
*/
private async reconcilePrematureBookingReady(
contractId: string,
contract: Contract,
bookingReady: boolean,
): Promise<Contract> {
if (
!this.isPhasedCustoms(contract) ||
contract.status !== 'CLEARANCE_READY_FOR_BOOKING' ||
bookingReady
) {
return contract;
}
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') {
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
}
return this.contractsService.findById(contractId);
}
/** The pre-booking clearance document grid for a contract (Path B). */
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
let contract = await this.contractsService.findById(contractId);
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
const files = await this.filesService.findByResource(contractId, 'contracts');
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.contractsRepository.findDocumentReviews(
contractId,
cycle?.id ?? null,
);
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
const documents: ContractClearanceDocument[] = [];
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
if (!code) return;
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(code);
} catch {
return; // setting not seeded — skip gracefully
}
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,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByStaffId: review?.reviewedByStaffId ?? null,
});
}
};
await pushSetting(inputCode, 'customer');
await pushSetting(outputCode, 'gl');
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
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,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByStaffId: review?.reviewedByStaffId ?? null,
});
}
const allApproved = await this.isClearanceFullyApproved(contract);
const milestones = await this.workflowService.listMilestones(contractId);
let boundary = await this.workflowService.isBoundaryComplete(
contractId,
contract.tradeDirection,
);
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
const dutyDispute = await this.buildDutyDispute(contractId, milestones);
const transitAssignee = cycle
? {
requestedAt: cycle.transitAssigneeRequestedAt
? cycle.transitAssigneeRequestedAt.toISOString()
: null,
requestNote: cycle.transitAssigneeRequestNote ?? null,
name: cycle.transitAssigneeName ?? null,
assignedAt: cycle.transitAssigneeAssignedAt
? cycle.transitAssigneeAssignedAt.toISOString()
: null,
}
: null;
let workflowFiles = buildWorkflowFiles(
files,
contract.tradeDirection ?? 'IMPORT',
);
let bookingFiles: Awaited<ReturnType<FilesService['findByResource']>> = [];
if (cycle?.bookingId) {
bookingFiles = await this.filesService.findByResource(
cycle.bookingId,
'bookings',
);
const bookingWorkflow = buildWorkflowFiles(
bookingFiles,
contract.tradeDirection ?? 'IMPORT',
);
const byCode = new Map(workflowFiles.map((f) => [f.code, f]));
for (const row of bookingWorkflow) {
if (row.file) byCode.set(row.code, row);
}
workflowFiles = [...byCode.values()];
}
let t1: ClearanceT1State | null = null;
if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') {
try {
t1 = await this.glOperationsService.t1State(cycle.bookingId);
} catch {
t1 = null; // linked booking missing — view stays usable
}
}
let train: ClearanceTrainState | null = null;
let bookingMilestones: ClearanceMilestone[] = [];
// Removed from the clearance flow — see gl-operations.service. Kept in the
// payload (always null) so existing consumers keep type-checking.
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
if (cycle?.bookingId) {
try {
train = await this.glOperationsService.trainState(cycle.bookingId);
} catch {
train = null;
}
bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
}
const bookingMilestone = (code: string) =>
bookingMilestones.find((m) => m.milestoneCode === code);
const gatepass = cycle?.bookingId
? await this.glOperationsService.gatepassForBooking(cycle.bookingId)
: { granted: false, grantedAt: null };
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(
bookingMilestones,
bookingFiles,
);
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
// Once GL creates the shipment booking, surface its reference + status so the
// customer sees the concrete booking instead of a stale "will be created
// shortly" message. Reuse the export booking load; fetch for import too.
let linkedBookingId: string | null = null;
let linkedBookingReference: string | null = null;
let linkedBookingStatus: string | null = null;
let linkedBookingReviewNote: string | null = null;
let linkedBookingScheduledDate: string | null = null;
// The cycle is the historical link, but it is not written on every path (an
// FCFS export booking and a GL drawdown both reach the operations queue
// without a cycle row), so fall back to the contract's own live booking —
// otherwise the clearance page sees no linked booking at all and cannot show
// its status or the actions that depend on it.
const booking = cycle?.bookingId
? await this.bookingsService.findById(cycle.bookingId)
: await this.contractsRepository.findLatestBookingForContract(contractId);
if (booking) {
linkedBookingId = booking.id ?? null;
linkedBookingReference = booking.reference ?? null;
linkedBookingStatus = booking.status ?? null;
linkedBookingScheduledDate = booking.scheduledDate
? new Date(booking.scheduledDate).toISOString()
: null;
// Newest changes-requested note (reviewNotes ride along on findById).
linkedBookingReviewNote =
[...(booking.reviewNotes ?? [])]
.filter((n) => n.type === 'CHANGES_REQUESTED')
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
if (contract.tradeDirection === 'EXPORT') {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
}
}
return {
contractId,
status: contract.status,
clearanceStatus: contract.clearanceStatus,
cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber,
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: cycle?.dutyRequired ?? null,
roHold: Boolean(cycle?.roHoldReason),
roHoldReason: cycle?.roHoldReason ?? null,
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
vesselArrivalDate: cycle?.vesselArrivalDate ?? null,
doCollectedDate: cycle?.doCollectedDate ?? null,
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
? cycle.roAmendmentRequestedAt.toISOString()
: null,
bookingReady: boundary,
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
exportClearanceFinalized: Boolean(cycle?.completedAt),
linkedBookingId,
linkedBookingReference,
linkedBookingStatus,
linkedBookingReviewNote,
linkedBookingScheduledDate,
dutyAdvice,
dutyDispute,
transitAssignee,
workflowFiles,
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: cycle?.bookingId
? await this.glOperationsService.offloadState(cycle.bookingId, bookingMilestones)
: null,
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 — see booking-clearance.service.
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[],
): ContractClearanceView['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,
};
}
/**
* The customer's duty objection, but only while it is still OPEN — i.e. the
* advice milestone sits back at PENDING because nobody has re-advised yet.
* Re-advising completes that milestone again, which closes the dispute here
* without any extra state to keep in sync; the notes stay as the audit trail
* and their count is the round number.
*/
private async buildDutyDispute(
contractId: string,
milestones: ClearanceMilestone[],
): Promise<ContractClearanceView['dutyDispute']> {
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
if (!advised || advised.status === 'COMPLETED') return null;
const notes = await this.contractsRepository.findReviewNotes(
contractId,
'DUTY_DISPUTE',
);
const latest = notes[0];
if (!latest) return null;
return {
note: latest.body,
raisedAt: latest.createdAt.toISOString(),
rounds: notes.length,
};
}
/**
* True when every REQUIRED customer-input field has an APPROVED review row in
* the current cycle. The 100% gate before clearance can be finalized.
*/
private async isClearanceFullyApproved(contract: Contract): Promise<boolean> {
const { inputCode } = contractClearanceCodes(contract);
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 cycle = await this.contractsRepository.currentCycle(contract.id);
const reviews = await this.contractsRepository.findDocumentReviews(
contract.id,
cycle?.id ?? null,
);
return required.every((field) =>
reviews.some(
(r) =>
r.settingCode === inputCode &&
r.fileKey === field.fileKey &&
r.status === 'APPROVED',
),
);
}
/** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */
private assertClearanceReviewableStatus(contract: Contract): void {
const allowed = [
'CLEARANCE_UNDER_REVIEW',
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_READY_FOR_BOOKING',
];
if (!allowed.includes(contract.status)) {
throw new ConflictException(
`Cannot review clearance documents on status "${contract.status}".`,
);
}
}
/** Finalize when docs are under review or all approved after a partial query cycle. */
private assertClearanceFinalizableStatus(contract: Contract): void {
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
if (!allowed.includes(contract.status)) {
throw new ConflictException(
`Cannot finalize document approval on status "${contract.status}".`,
);
}
}
private assertClearanceOutputUploadableStatus(contract: Contract): void {
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
if (!allowed.includes(contract.status)) {
throw new ConflictException(
`Cannot upload output documents on status "${contract.status}".`,
);
}
}
private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise<void> {
const refreshed = await this.contractsService.findById(contractId);
const allApproved = await this.isClearanceFullyApproved(refreshed);
if (
!allApproved ||
(refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING')
) {
return;
}
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
}
}
/**
* Customer uploads clearance documents on the contract. When every required
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
*/
async uploadDocuments(
contractId: string,
files: Express.Multer.File[],
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
contract.status !== 'CLEARANCE_UNDER_REVIEW'
) {
throw new ConflictException(
`Cannot upload clearance documents on status "${contract.status}".`,
);
}
const { inputCode } = contractClearanceCodes(contract);
if (!inputCode) {
throw new BadRequestException('This contract has no document-clearance step');
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
// First submission: every required input field must be present.
if (contract.status === 'AWAITING_CLEARANCE_DOCUMENTS') {
await this.assertRequiredInputsPresent(contractId, inputCode, files);
}
for (const file of files) {
const record = await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: file.fieldname,
file,
});
const settingCode = file.fieldname.startsWith('custom_') ? 'custom' : inputCode;
await this.contractsRepository.upsertDocumentReviewPending({
contractId,
clearanceCycleId: cycle?.id ?? null,
settingCode,
fileKey: file.fieldname,
fileRecordId: record.id,
uploadedByRole: 'CUSTOMER',
});
}
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', {
currentPhase: ContractDocPhase.GlEtReview,
});
}
if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') {
await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection);
await this.workflowService.onDocumentReviewReopened(contractId);
}
const updated = await this.contractsService.findById(contractId);
this.notifier.clearanceDocsUploadedToStaff(updated);
return updated;
}
private async assertRequiredInputsPresent(
contractId: string,
inputCode: string,
files: Express.Multer.File[],
): Promise<void> {
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(inputCode);
} catch {
return;
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return;
const existing = await this.filesService.findByResource(contractId, 'contracts');
const presentKeys = new Set<string>([
...existing.map((f) => f.code),
...files.map((f) => f.fieldname),
]);
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
if (missing.length > 0) {
const labels = missing.map((f) => f.fileLabel).join(', ');
throw new BadRequestException(
`Please upload all required documents before submitting: ${labels}`,
);
}
}
/**
* Path A (no customs) — the customer clears the cargo himself and uploads his
* own clearance proof, reviewed by Operations rather than GL. True when a
* clearance doc set resolves for a non-customs contract.
*/
private isSelfClear(contract: Contract): boolean {
if (contract.customsClearingEnabled) return false;
return contractClearanceCodes(contract).inputCode != null;
}
/** GL ET (Path B) reviews a single document: APPROVED or QUERIED. */
async reviewDocument(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
note?: string,
): Promise<Contract> {
return this.applyReview(contractId, fileKey, status, staffId, 'GL_ET', note);
}
/**
* Operations (Path A) reviews a customer self-clearance document. Identical
* approve/query loop to {@link reviewDocument}; rejects customs (Path B)
* contracts, which are GL-reviewed.
*/
async opsReviewDocument(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
note?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!this.isSelfClear(contract)) {
throw new ConflictException(
'Operations review applies only to self-clearance (non-customs) contracts.',
);
}
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
}
/**
* GL corrects a clearance document in place instead of bouncing it back to
* the customer. The customer's upload is NOT lost — it is retired into the
* document's version history, stamped with who replaced it and why — and the
* new version starts unreviewed, so GL still has to approve it (or query it)
* before clearance can be finalized.
*
* Use this for the small fixes staff can make faster than the customer can
* (a wrong page order, a missing stamp scan); a query is still the right tool
* when only the customer can produce the correct document.
*/
async replaceDocument(
contractId: string,
fileKey: string,
file: Express.Multer.File,
staffId: string,
reason?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertClearanceReviewableStatus(contract);
if (!file) throw new BadRequestException('No replacement file uploaded');
if (!reason?.trim()) {
throw new BadRequestException(
'Say why the document is being replaced — it is kept on the file history.',
);
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt) {
throw new BadRequestException(
'Documents cannot be changed after pre-clearance is finalized.',
);
}
const existing = await this.filesService.findByCode(
contractId,
'contracts',
fileKey,
);
if (!existing) {
throw new NotFoundException(
`No document is stored under "${fileKey}" on this contract.`,
);
}
await this.filesService.upsertByCode(
{ resourceId: contractId, resource: 'contracts', code: fileKey, file },
{ userId: staffId, reason: reason.trim() },
);
// A fresh version is unreviewed by definition: clear any earlier verdict so
// the corrected file is signed off explicitly rather than inheriting a tick.
const { inputCode, outputCode } = contractClearanceCodes(contract);
const reviews = await this.contractsRepository.findDocumentReviews(
contractId,
cycle?.id ?? null,
);
const settingCode =
reviews.find((r) => r.fileKey === fileKey)?.settingCode ??
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
await this.contractsRepository.setDocumentReviewStatus({
contractId,
clearanceCycleId: cycle?.id ?? null,
settingCode,
fileKey,
status: 'PENDING',
staffId,
note: `Replaced by staff: ${reason.trim()}`,
});
await this.contractsRepository.createReviewNote(
contractId,
`Document "${fileKey}" replaced by staff: ${reason.trim()}`,
'STAFF_NOTE',
staffId,
'GL_ET',
);
return this.contractsService.findById(contractId);
}
/** Every stored version of one clearance document, newest first. */
async documentVersions(contractId: string, fileKey: string) {
return this.filesService.versionHistory(contractId, 'contracts', fileKey);
}
private async applyReview(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
reviewerRole: 'GL_ET' | 'OPERATIONS',
note?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertClearanceReviewableStatus(contract);
if (status === 'QUERIED' && !note?.trim()) {
throw new BadRequestException('A note is required when querying a document');
}
const { inputCode, outputCode } = contractClearanceCodes(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (
status === 'QUERIED' &&
this.isPhasedCustoms(contract) &&
cycle?.preClearanceFinalizedAt
) {
throw new BadRequestException(
'Customer documents cannot be queried after pre-clearance is finalized.',
);
}
const reviews = await this.contractsRepository.findDocumentReviews(
contractId,
cycle?.id ?? null,
);
const match = reviews.find((r) => r.fileKey === fileKey);
const settingCode =
match?.settingCode ??
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
await this.contractsRepository.setDocumentReviewStatus({
contractId,
clearanceCycleId: cycle?.id ?? null,
settingCode,
fileKey,
status,
staffId,
note,
});
if (status === 'QUERIED') {
await this.contractsRepository.createReviewNote(
contractId,
`Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED',
staffId,
reviewerRole,
);
// Return the contract to the customer to re-upload the queried document.
await this.contractsRepository.update(contractId, {
status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS',
} as never);
this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? '');
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
}
if (this.isPhasedCustoms(contract)) {
await this.workflowService.onDocumentReviewReopened(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase: ContractDocPhase.GlEtReview,
});
}
}
} else if (status === 'APPROVED') {
await this.bumpToUnderReviewWhenFullyApproved(contractId);
const refreshed = await this.contractsService.findById(contractId);
if (
refreshed.customsClearingEnabled &&
refreshed.contractKind === 'ONE_TIME' &&
(await this.isClearanceFullyApproved(refreshed))
) {
await this.workflowService.onAllDocsApproved(contractId);
const c = await this.contractsRepository.currentCycle(contractId);
if (c) {
await this.contractsRepository.updateCycle(c.id, {
currentPhase:
refreshed.tradeDirection === 'EXPORT'
? ContractDocPhase.GlDjCollection
: ContractDocPhase.GlEtOutput,
});
}
}
}
return this.contractsService.findById(contractId);
}
/** GL uploads customs output documents (IM4/IM5/EX3/etc.) during clearance. */
async uploadOutputDocuments(
contractId: string,
files: Express.Multer.File[],
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertClearanceOutputUploadableStatus(contract);
const { outputCode } = contractClearanceCodes(contract);
if (!outputCode) {
throw new BadRequestException('This contract has no customs output documents');
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
}
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: file.fieldname,
file,
});
}
return this.contractsService.findById(contractId);
}
/**
* GL ET finalizes Path B pre-booking clearance: requires every customer
* document APPROVED. For phased customs (ONE_TIME), document review completes
* here — booking readiness is set only after delivery order (import) or export
* release via the milestone workflow. Non-phased customs still jump straight to
* CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts.
*/
async finalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (this.isSelfClear(contract)) {
throw new ConflictException(
'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
);
}
this.assertClearanceFinalizableStatus(contract);
const approved = await this.isClearanceFullyApproved(contract);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
);
}
if (this.isPhasedCustoms(contract)) {
await this.workflowService.onAllDocsApproved(contractId);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase:
contract.tradeDirection === 'EXPORT'
? ContractDocPhase.GlDjCollection
: ContractDocPhase.GlEtOutput,
});
}
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
return this.contractsService.findById(contractId);
}
const { outputCode } = contractClearanceCodes(contract);
if (outputCode) {
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(contractId, 'contracts');
const uploaded = new Set(files.map((f) => f.code));
const missing = (setting.fields ?? []).filter(
(f) => f.isRequired && !uploaded.has(f.fileKey),
);
if (missing.length > 0) {
throw new BadRequestException(
`Upload all required customs output documents first: ${missing
.map((m) => m.fileLabel)
.join(', ')}`,
);
}
}
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_READY_FOR_BOOKING',
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(
cycle.id,
'CLEARANCE_READY_FOR_BOOKING',
{ clearanceReadyAt: new Date() },
);
}
return this.contractsService.findById(contractId);
}
/**
* Operations finalizes Path A self-clearance: requires every customer document
* APPROVED, then the contract becomes bookable BY THE CUSTOMER. There is no GL
* output phase on Path A, so the contract goes straight to FULLY_EXECUTED
* (ONE_TIME) / CONTRACT_ACTIVE (GENERAL).
*/
async opsFinalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!this.isSelfClear(contract)) {
throw new ConflictException(
'Operations finalize applies only to self-clearance (non-customs) contracts.',
);
}
this.assertClearanceFinalizableStatus(contract);
const approved = await this.isClearanceFullyApproved(contract);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
);
}
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED',
clearanceStatus: 'SELF_CLEARED',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
clearanceReadyAt: new Date(),
});
}
return this.contractsService.findById(contractId);
}
/**
* GL ET clearance hub, Contracts tab: ONE_TIME customs (Path B) contracts in
* phased clearance that already carry at least one uploaded clearance
* document — a contract still waiting for its first document has nothing to
* review, and GENERAL contracts clear per booking, not at contract level.
*/
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
hasClearanceDocuments: true,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/** GL ET history: contracts that completed Path B clearance. */
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 50,
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
customsClearingEnabled: true,
sortBy: filter.sortBy ?? 'createdAt',
sortOrder: filter.sortOrder ?? 'DESC',
});
}
/** Operations history: contracts that completed Path A self-clearance review. */
async opsHistory(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 50,
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
customsClearingEnabled: false,
search: filter.search,
sortBy: filter.sortBy ?? 'createdAt',
sortOrder: filter.sortOrder ?? 'DESC',
});
}
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
/**
* GL Ethiopia asks Djibouti to name the officer who will handle the shipment
* in transit. Nothing else moves until Djibouti answers — the declaration is
* gated on it — so this is the first thing ET does once the documents are
* approved. Re-requesting is allowed (a nudge) and simply restamps the ask.
*/
async requestTransitAssignee(
contractId: string,
note: string | undefined,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
await this.contractsRepository.updateCycle(cycle.id, {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestedByUserId: userId ?? null,
transitAssigneeRequestNote: note?.trim() || null,
});
const updated = await this.contractsService.findById(contractId);
this.notifier.transitAssigneeRequested(updated, note?.trim() ?? null);
return updated;
}
/**
* 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(
contractId: string,
transitAgentId: string,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
if (!cycle.transitAssigneeRequestedAt) {
throw new BadRequestException(
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
const previous = cycle.transitAssigneeName ?? null;
await this.contractsRepository.updateCycle(cycle.id, {
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
transitAssigneeAssignedByUserId: userId ?? null,
});
const updated = await this.contractsService.findById(contractId);
this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
return updated;
}
private async ensureDeclarationPrerequisites(
contractId: string,
contract: Contract,
): Promise<void> {
const allApproved = await this.isClearanceFullyApproved(contract);
if (!allApproved) {
throw new BadRequestException(
'All required customer documents must be approved before uploading a declaration.',
);
}
// The transit officer must be named by Djibouti first — the declaration is
// filed against whoever will physically handle the shipment there.
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.transitAssigneeName) {
throw new BadRequestException(
cycle?.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.listMilestones(contractId);
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
await this.workflowService.onAllDocsApproved(contractId);
}
}
async uploadDeclaration(
contractId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
await this.ensureDeclarationPrerequisites(contractId, contract);
// The draft-declaration accept/change-request loop only exists on the
// booking-scoped clearance page (portal customers never see contract-scoped
// clearance) — skip it here so it can never block the ONE_TIME pre-booking
// flow, which has no UI to complete it. Contracts seeded before this step
// existed have no such row to skip — ignore, `assertPriorComplete` below
// already tolerates a missing milestone as "not required".
await this.workflowService
.skipMilestones(contractId, ['DRAFT_DECLARATION_UPLOADED', 'DRAFT_DECLARATION_ACCEPTED'])
.catch(() => undefined);
await this.workflowService.assertPriorComplete(
contractId,
contract.tradeDirection,
'UNDER_CUSTOMS_CLEARANCE',
);
if (files.length === 0) {
throw new BadRequestException('No declaration documents uploaded');
}
await persistDeclarationUploads(
this.filesService,
contractId,
'contracts',
files,
);
await this.workflowService.onDeclarationUploaded(contractId, userId);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase:
contract.tradeDirection === 'EXPORT'
? ContractDocPhase.GlEtPostClearance
: ContractDocPhase.CustomerDuty,
});
}
return this.contractsService.findById(contractId);
}
async adviseDuty(
contractId: string,
dto: AdviseContractDutyDto,
userId?: string,
attachment?: Express.Multer.File,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty advice applies only to import contracts.');
}
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED');
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
await this.contractsRepository.updateCycle(cycle.id, {
dutyRequired: dto.dutyRequired,
currentPhase: dto.dutyRequired
? ContractDocPhase.CustomerDuty
: ContractDocPhase.GlEtPostClearance,
});
if (!dto.dutyRequired) {
await this.workflowService.onDutySkipped(contractId);
} 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: contractId,
resource: 'contracts',
code: 'duty_tax_notice',
file: attachment,
});
await this.milestoneService.adviseDutyForContract(
contractId,
{
amount: dto.amount,
currency: dto.currency ?? 'ETB',
declarationSerial: dto.declarationSerial,
},
userId,
);
this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB');
}
return this.contractsService.findById(contractId);
}
/**
* The customer disagrees with the advised duty & tax and asks GL Ethiopia to
* correct it. Nothing is paid; the advice milestone reopens so the Duty & tax
* step becomes actionable again on the GL clearance page, with the customer's
* message shown beside it. GL re-advises (same endpoint as the first time),
* which closes the dispute — the loop may run as many rounds as it takes.
*/
async disputeDuty(
contractId: string,
note: string,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty applies only to import contracts.');
}
if (!note?.trim()) {
throw new BadRequestException(
'Say what is wrong with the advised amount so GL can correct it.',
);
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.dutyRequired) {
throw new BadRequestException('Duty/tax is not required for this clearance.');
}
const milestones = await this.workflowService.listMilestones(contractId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
throw new BadRequestException(
'There is no advised duty amount to dispute yet.',
);
}
// Once the slip is in, the money is paid — a dispute then is a refund
// conversation, not a re-advice.
if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
throw new BadRequestException(
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
);
}
await this.contractsRepository.createReviewNote(
contractId,
note.trim(),
'DUTY_DISPUTE',
userId,
'CUSTOMER',
);
// Back to GL: reopening the milestone is what re-arms the Duty & tax step
// (the stepper picks its active step from milestone completion).
await this.milestoneService.reopenForContract(contractId, 'DUTY_TAXES_ADVISED');
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase: ContractDocPhase.GlEtOutput,
});
const updated = await this.contractsService.findById(contractId);
this.notifier.dutyDisputed(updated, note.trim());
return updated;
}
async uploadDutySlip(
contractId: string,
file: Express.Multer.File,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty slip upload applies only to import contracts.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.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: contractId,
resource: 'contracts',
code: 'duty_tax_receipt',
file,
});
await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID');
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase: ContractDocPhase.GlEtPostClearance,
});
}
const updated = await this.contractsService.findById(contractId);
this.notifier.dutySlipUploadedToStaff(updated);
return updated;
}
async uploadTransitPermit(
contractId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Transit permit applies only to import contracts.');
}
await this.workflowService.assertPriorComplete(
contractId,
'IMPORT',
'TRANSIT_PERMIT_UPLOADED',
);
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
await persistTransitPermitUploads(
this.filesService,
contractId,
'contracts',
files,
);
await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase: ContractDocPhase.GlEtPostClearance,
});
}
return this.contractsService.findById(contractId);
}
async finalizePreClearance(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Pre-clearance finalize applies only to import contracts.');
}
await this.workflowService.assertPriorComplete(
contractId,
'IMPORT',
'TRANSIT_PERMIT_UPLOADED',
);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
if (cycle.preClearanceFinalizedAt) {
return this.contractsService.findById(contractId);
}
await this.contractsRepository.updateCycle(cycle.id, {
preClearanceFinalizedAt: new Date(),
currentPhase: ContractDocPhase.GlDjCollection,
});
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(contractId, 'contracts');
if (files.some((f) => isDeliveryOrderFileCode(f.code))) {
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED');
await this.workflowService.markReadyForBooking(contractId);
}
this.notifier.preClearanceFinalized(contract);
return this.contractsService.findById(contractId);
}
async uploadDeliveryOrder(
contractId: string,
files: Express.Multer.File[],
userId?: string,
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Delivery Order applies only to import contracts.');
}
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 booking readiness) still waits
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
await persistDeliveryOrderUploads(this.filesService, contractId, 'contracts', files ?? []);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
vesselArrivalDate,
doCollectedDate,
});
}
if (cycle?.preClearanceFinalizedAt) {
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForBooking(contractId);
}
return this.contractsService.findById(contractId);
}
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(
contractId: string,
files: Express.Multer.File[],
vesselDepartureDate: string,
userId?: string,
): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Release Order applies only to export contracts.');
}
await this.workflowService.assertPriorComplete(
contractId,
'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);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
await persistReleaseOrderUploads(this.filesService, contractId, 'contracts', files ?? []);
await this.contractsRepository.updateCycle(cycle.id, {
vesselDepartureDate,
roAmendmentRequestedAt: null,
});
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.contractsRepository.updateCycle(cycle.id, {
roHoldReason: reason,
currentPhase: ContractDocPhase.GlDjCollection,
});
return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason };
}
await this.contractsRepository.updateCycle(cycle.id, {
roHoldReason: null,
currentPhase: ContractDocPhase.GlEtOutput,
});
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
// Release Order is now the last GL DJ pre-booking action (it follows the
// declaration) — release immediately so booking creation unlocks without a
// separate confirm click.
await this.workflowService.onExportReleased(contractId, userId);
return { contract: await this.contractsService.findById(contractId), hold: false };
}
async requestRoAmendment(
contractId: string,
note?: string,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'EXPORT') {
throw new BadRequestException('RO amendment applies only to export contracts.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
const reason =
note?.trim() ||
'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';
await this.contractsRepository.updateCycle(cycle.id, {
roAmendmentRequestedAt: new Date(),
roHoldReason: reason,
currentPhase: ContractDocPhase.GlDjCollection,
});
if (userId) {
await this.contractsRepository.createReviewNote(
contractId,
reason,
'CHANGES_REQUESTED',
userId,
'GL_DJ',
);
}
return this.contractsService.findById(contractId);
}
async confirmExportRelease(contractId: string, userId?: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Export release applies only to export contracts.');
}
await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED');
await this.workflowService.onExportReleased(contractId, userId);
return this.contractsService.findById(contractId);
}
/** GL ET finalizes export clearance after post-booking transit permit is uploaded. */
async finalizeExportClearance(contractId: string, userId?: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Export clearance finalize applies only to export contracts.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.bookingId) {
throw new BadRequestException(
'A shipment booking must exist before export clearance can be finalized.',
);
}
if (cycle.completedAt) {
return this.contractsService.findById(contractId);
}
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
const transportDone = bookingMilestones.some(
(m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED',
);
if (!transportDone) {
throw new BadRequestException(
'Upload the transit permit before finalizing export clearance.',
);
}
await this.contractsRepository.updateCycle(cycle.id, {
completedAt: new Date(),
currentPhase: ContractDocPhase.GlEtPostClearance,
});
void userId;
return this.contractsService.findById(contractId);
}
}