mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
finilize gl
This commit is contained in:
@@ -1,53 +1,213 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
catalogEntriesForTradeDirection,
|
||||
declarationFileLabel,
|
||||
isDeclarationFileCode,
|
||||
isImportTransitPermitFileCode,
|
||||
transitPermitFileLabel,
|
||||
type ClearanceWorkflowFile,
|
||||
} from '@edr/types';
|
||||
|
||||
const IMPORT_DECLARATION_CODES = new Set(['im4', 'im5']);
|
||||
const EXPORT_DECLARATION_CODES = new Set(['ex3', 'ex8']);
|
||||
|
||||
/** Require at least one declaration file for the trade direction (IM4 or IM5, EX3 or EX8). */
|
||||
export function assertDeclarationFiles(
|
||||
files: Express.Multer.File[],
|
||||
tradeDirection: string,
|
||||
): void {
|
||||
/** 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');
|
||||
}
|
||||
}
|
||||
|
||||
const allowed =
|
||||
tradeDirection === 'EXPORT' ? EXPORT_DECLARATION_CODES : IMPORT_DECLARATION_CODES;
|
||||
const labels = tradeDirection === 'EXPORT' ? 'EX3 or EX8' : 'IM4 or IM5';
|
||||
/** 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}`,
|
||||
}));
|
||||
}
|
||||
|
||||
const uploaded = new Set(files.map((f) => f.fieldname?.toLowerCase()));
|
||||
const hasValid = [...allowed].some((code) => uploaded.has(code));
|
||||
if (!hasValid) {
|
||||
throw new BadRequestException(`Upload at least one declaration document (${labels}).`);
|
||||
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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const preFinalized =
|
||||
cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null;
|
||||
if (tradeDirection === 'IMPORT' && preFinalized) 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',
|
||||
] 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',
|
||||
'IN_TRANSIT',
|
||||
'PAID',
|
||||
'COMPLETED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
] 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,
|
||||
documentFileKeys: Set<string> = new Set(),
|
||||
): 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)) {
|
||||
if (documentFileKeys.has(entry.code)) continue;
|
||||
const file = fileByCode.get(entry.code) ?? null;
|
||||
if (!file) continue;
|
||||
included.add(entry.code);
|
||||
out.push({
|
||||
code: entry.code,
|
||||
label: entry.label,
|
||||
@@ -57,5 +217,39 @@ export function buildWorkflowFiles(
|
||||
});
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user