finilize gl

This commit is contained in:
marshal
2026-07-02 12:06:17 +03:00
parent 9c18d086d7
commit 4fefe4f827
50 changed files with 5150 additions and 1793 deletions

View File

@@ -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;
}