mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
398 lines
12 KiB
TypeScript
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;
|
|
}
|