Files
edr-platform/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts

398 lines
12 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import {
catalogEntriesForTradeDirection,
declarationFileLabel,
isDeclarationFileCode,
isImportTransitPermitFileCode,
isExportTransportFileCode,
isT1TransportFileCode,
exportTransportFileLabel,
t1TransportFileLabel,
transitPermitFileLabel,
type ClearanceWorkflowFile,
} from '@edr/types';
/** Require at least one declaration file in the upload batch. */
export function assertDeclarationFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No declaration documents uploaded');
}
}
/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */
export function normalizeDeclarationFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `declaration_${index}`,
}));
}
type DeclarationFileStore = {
findByResource(
resourceId: string,
resource: string,
): Promise<Array<{ code?: string | null }>>;
deleteByCode(resourceId: string, resource: string, code: string): Promise<void>;
upload(input: {
resourceId: string;
resource: string;
code: string;
file: Express.Multer.File;
}): Promise<unknown>;
};
/** Replace all declaration files on a resource with a new multi-file upload batch. */
export async function persistDeclarationUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeDeclarationFieldNames(files);
assertDeclarationFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isDeclarationFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `declaration_${index}`,
file,
}),
),
);
}
/** Require at least one transit permit file in the upload batch. */
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
}
/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */
export function normalizeTransitPermitFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `transit_permit_${index}`,
}));
}
/** Replace all import transit permit files on a resource with a new multi-file batch. */
export async function persistTransitPermitUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeTransitPermitFieldNames(files);
assertTransitPermitFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isImportTransitPermitFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `transit_permit_${index}`,
file,
}),
),
);
}
/** Require at least one export transport document in the upload batch. */
export function assertExportTransportFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
}
export function normalizeExportTransportFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `export_transport_document_${index}`,
}));
}
/** Replace all export transport documents on a booking with a new multi-file batch. */
export async function persistExportTransportUploads(
store: DeclarationFileStore,
bookingId: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeExportTransportFieldNames(files);
assertExportTransportFiles(normalized);
const existing = await store.findByResource(bookingId, 'bookings');
await Promise.all(
existing
.filter((f) => f.code && isExportTransportFileCode(f.code))
.map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId: bookingId,
resource: 'bookings',
code: `export_transport_document_${index}`,
file,
}),
),
);
}
/** Require at least one T1 transport document in the upload batch. */
export function assertT1TransportFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No T1 transport documents uploaded');
}
}
export function normalizeT1TransportFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `t1_transport_document_${index}`,
}));
}
/** Replace all T1 transport documents on a booking with a new multi-file batch. */
export async function persistT1TransportUploads(
store: DeclarationFileStore,
bookingId: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeT1TransportFieldNames(files);
assertT1TransportFiles(normalized);
const existing = await store.findByResource(bookingId, 'bookings');
await Promise.all(
existing
.filter((f) => f.code && isT1TransportFileCode(f.code))
.map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId: bookingId,
resource: 'bookings',
code: `t1_transport_document_${index}`,
file,
}),
),
);
}
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
if (typeof value === 'boolean') return value;
if (value === undefined || value === '') return false;
return value === 'true' || value === '1';
}
type DjQueueMilestone = {
ownerRegion?: string | null;
status: string;
};
type DjQueueCycle = {
preClearanceFinalizedAt?: Date | null;
roHoldReason?: string | null;
} | null | undefined;
/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */
export function belongsOnDjClearanceQueue(
tradeDirection: string | null | undefined,
cycle: DjQueueCycle,
milestones: DjQueueMilestone[],
extras?: {
roHoldReason?: string | null;
preClearanceFinalizedAt?: Date | null;
},
): boolean {
const roHold = cycle?.roHoldReason ?? extras?.roHoldReason;
if (roHold) return true;
const hasDjActivity = milestones.some(
(m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'),
);
if (hasDjActivity) return true;
// Import DO upload is un-gated — Djibouti GL must see import customs items from
// the start, not only after Ethiopia finalizes pre-clearance.
if (tradeDirection === 'IMPORT') return true;
return false;
}
/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */
export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
// Terminal contracts stay on the list — the clearance hub is GL's history of
// everything that passed through, not just the live work queue.
'EXPIRED',
'CANCELLED',
'REJECTED',
] as const;
/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */
export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean {
return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED');
}
/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */
export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES;
/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */
export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
'FULLY_EXECUTED',
'OPERATION_REQUEST_PENDING',
'OPERATION_CHANGES_REQUESTED',
'ROAD_DISPATCH_PENDING',
// Payment phase — the booking is selected/awaiting the customer's payment.
'SELECTED_FOR_BATCH',
'PNR_GENERATED',
'AWAITING_PAYMENT',
'PAYMENT_VERIFICATION_IN_PROGRESS',
'IN_TRANSIT',
'ARRIVED',
'PAID',
'COMPLETED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
// Terminal bookings stay on the list — EXPIRED especially: GL rebooks it
// from here, and the hub doubles as clearance history.
'EXPIRED',
'CANCELLED',
'REJECTED',
] as const;
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */
export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES;
/** Build labeled phased-customs file rows from resource files. */
export function buildWorkflowFiles(
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
tradeDirection: string,
): ClearanceWorkflowFile[] {
const fileByCode = new Map(
files.filter((f) => f.code).map((f) => [f.code as string, f]),
);
const out: ClearanceWorkflowFile[] = [];
const included = new Set<string>();
for (const entry of catalogEntriesForTradeDirection(tradeDirection)) {
const file = fileByCode.get(entry.code) ?? null;
if (!file) continue;
included.add(entry.code);
out.push({
code: entry.code,
label: entry.label,
uploadedBy: entry.uploadedBy,
category: entry.category,
file: { id: file.id, name: file.name, url: file.url },
});
}
const extraDeclarations = files
.filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraDeclarations.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: declarationFileLabel(file.code, index),
uploadedBy: 'gl_et',
category: 'declaration',
file: { id: file.id, name: file.name, url: file.url },
});
});
if (tradeDirection === 'IMPORT') {
const extraTransit = files
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraTransit.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: transitPermitFileLabel(file.code, index),
uploadedBy: 'gl_et',
category: 'transit',
file: { id: file.id, name: file.name, url: file.url },
});
});
const extraT1 = files
.filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraT1.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: t1TransportFileLabel(file.code, index),
uploadedBy: 'gl_dj',
category: 'djibouti',
file: { id: file.id, name: file.name, url: file.url },
});
});
}
if (tradeDirection === 'EXPORT') {
const extraExportTransport = files
.filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraExportTransport.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: exportTransportFileLabel(file.code, index),
uploadedBy: 'gl_et',
category: 'transit',
file: { id: file.id, name: file.name, url: file.url },
});
});
}
return out;
}