mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
- Added functionality to cancel contracts, allowing users to provide a reason for cancellation. - Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED. - Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses. - Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts. - Removed clearance document management from the contract detail page, as it is now handled per booking. - Introduced a SQL script to reset bookings and train schedules for development purposes.
1657 lines
60 KiB
TypeScript
1657 lines
60 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import {
|
|
ContractDocPhase,
|
|
type ClearanceFinalInvoiceSummary,
|
|
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 {
|
|
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, 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;
|
|
/** 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 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[] = [];
|
|
let 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,
|
|
);
|
|
finalInvoice = await this.glOperationsService.finalInvoiceSummary(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 linkedBookingReference: string | null = null;
|
|
let linkedBookingStatus: string | null = null;
|
|
let linkedBookingReviewNote: string | null = null;
|
|
let linkedBookingScheduledDate: string | null = null;
|
|
if (cycle?.bookingId) {
|
|
const booking = await this.bookingsService.findById(cycle.bookingId);
|
|
if (booking) {
|
|
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: cycle?.bookingId ?? null,
|
|
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',
|
|
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 names the transit officer — free text, because the person is
|
|
* not a platform user. Answering unblocks the declaration for Ethiopia. A
|
|
* later call overwrites the name (reassignment) and re-notifies.
|
|
*/
|
|
async assignTransitAssignee(
|
|
contractId: string,
|
|
assignee: string,
|
|
userId?: string,
|
|
): Promise<Contract> {
|
|
const contract = await this.contractsService.findById(contractId);
|
|
this.assertPhasedCustoms(contract);
|
|
if (!assignee?.trim()) {
|
|
throw new BadRequestException('Name the officer who will handle the transit.');
|
|
}
|
|
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 previous = cycle.transitAssigneeName ?? null;
|
|
await this.contractsRepository.updateCycle(cycle.id, {
|
|
transitAssigneeName: assignee.trim(),
|
|
transitAssigneeAssignedAt: new Date(),
|
|
transitAssigneeAssignedByUserId: userId ?? null,
|
|
});
|
|
|
|
const updated = await this.contractsService.findById(contractId);
|
|
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), 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);
|
|
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,
|
|
});
|
|
}
|
|
|
|
// Export: the declaration is the last GL ET pre-booking action — release
|
|
// immediately so booking creation unlocks without a separate confirm click.
|
|
if (contract.tradeDirection === 'EXPORT') {
|
|
await this.workflowService.onExportReleased(contractId, userId);
|
|
}
|
|
|
|
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) => f.code === 'delivery_order')) {
|
|
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,
|
|
file: 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.');
|
|
}
|
|
|
|
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
|
|
|
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 this.filesService.upsertByCode({
|
|
resourceId: contractId,
|
|
resource: 'contracts',
|
|
code: 'delivery_order',
|
|
file,
|
|
});
|
|
|
|
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,
|
|
file: 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 (!file) throw new BadRequestException('No Release Order uploaded');
|
|
if (!vesselDepartureDate?.trim()) {
|
|
throw new BadRequestException('Vessel departure date is required');
|
|
}
|
|
|
|
const minDays = await this.resolveRoMinDays();
|
|
const leadDays = this.daysUntil(vesselDepartureDate);
|
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
|
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
|
|
|
await this.filesService.upsertByCode({
|
|
resourceId: contractId,
|
|
resource: 'contracts',
|
|
code: 'release_order',
|
|
file,
|
|
});
|
|
|
|
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);
|
|
|
|
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);
|
|
}
|
|
|
|
}
|