mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
feat(transit-agent): timed document uploads and a real-data overview
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
@@ -34,7 +34,8 @@ import { TransitAssignmentsService } from '../transit-assignments/transit-assign
|
||||
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitArrivalUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, transitArrivalDocumentMatcher } from './phased-clearance.util';
|
||||
import type { TransitArrivalDocumentKind } from '@edr/types';
|
||||
|
||||
import {
|
||||
buildClearanceDocHistory,
|
||||
@@ -46,6 +47,8 @@ import { clearanceDocumentsOpen } from '../bookings/clearance.util';
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
export interface BookingClearanceView {
|
||||
/** Booking creation stamp — the import DO is timed from it. */
|
||||
bookingCreatedAt?: string | null;
|
||||
bookingId: string;
|
||||
status: string;
|
||||
includesCustoms: boolean;
|
||||
@@ -367,6 +370,7 @@ export class BookingClearanceService {
|
||||
return {
|
||||
bookingId,
|
||||
status: booking.status,
|
||||
bookingCreatedAt: booking.createdAt ? new Date(booking.createdAt).toISOString() : null,
|
||||
includesCustoms,
|
||||
inputCode,
|
||||
outputCode,
|
||||
@@ -387,6 +391,7 @@ export class BookingClearanceService {
|
||||
status: m.status,
|
||||
ownerRegion: m.ownerRegion,
|
||||
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
|
||||
triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null,
|
||||
sortOrder: m.sortOrder,
|
||||
})),
|
||||
nextAction,
|
||||
@@ -1174,6 +1179,83 @@ export class BookingClearanceService {
|
||||
return { booking: await this.bookingsService.findById(bookingId), hold: false };
|
||||
}
|
||||
|
||||
// ── Transit-agent arrival paperwork (export) ────────────────────────────
|
||||
// Gate pass and Djibouti T1 documents the assigned transit officer files at
|
||||
// Djibouti around train arrival. Append-only sets with per-file removal — see
|
||||
// `persistTransitArrivalUploads`. The clearance view stamps every file with
|
||||
// its upload time, so the portal can measure it against train departure and
|
||||
// arrival without a separate ledger.
|
||||
|
||||
private static readonly TRANSIT_ARRIVAL_LABELS: Record<
|
||||
TransitArrivalDocumentKind,
|
||||
{ name: string; uploaded: string; removed: string }
|
||||
> = {
|
||||
gate_pass: {
|
||||
name: 'gate pass',
|
||||
uploaded: 'GATE_PASS_DOCUMENTS_UPLOADED',
|
||||
removed: 'GATE_PASS_DOCUMENT_REMOVED',
|
||||
},
|
||||
djibouti_t1: {
|
||||
name: 'Djibouti T1',
|
||||
uploaded: 'DJIBOUTI_T1_DOCUMENTS_UPLOADED',
|
||||
removed: 'DJIBOUTI_T1_DOCUMENT_REMOVED',
|
||||
},
|
||||
};
|
||||
|
||||
async uploadTransitArrivalDocuments(
|
||||
bookingId: string,
|
||||
kind: TransitArrivalDocumentKind,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<{ uploaded: number }> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException(
|
||||
'Gate pass and Djibouti T1 documents apply only to export bookings.',
|
||||
);
|
||||
}
|
||||
const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind];
|
||||
await persistTransitArrivalUploads(this.filesService, bookingId, kind, files ?? [], userId);
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: labels.uploaded,
|
||||
label: `Uploaded ${files.length} ${labels.name} document(s)`,
|
||||
actorId: userId ?? null,
|
||||
metadata: { kind, fileNames: (files ?? []).map((f) => f.originalname) },
|
||||
});
|
||||
return { uploaded: files.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove ONE gate pass / Djibouti T1 file. Only those two code families are
|
||||
* removable here: the route is reachable by the transit agent, and it must
|
||||
* never become a way to delete a declaration or a Release Order.
|
||||
*/
|
||||
async removeTransitArrivalDocument(
|
||||
bookingId: string,
|
||||
fileId: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
await this.loadBooking(bookingId);
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const file = files.find((f) => f.id === fileId);
|
||||
const kind = (['gate_pass', 'djibouti_t1'] as const).find((k) =>
|
||||
transitArrivalDocumentMatcher(k)(file?.code),
|
||||
);
|
||||
if (!file || !kind) {
|
||||
throw new NotFoundException('Document not found on this booking.');
|
||||
}
|
||||
await this.filesService.remove(fileId);
|
||||
const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind];
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: labels.removed,
|
||||
label: `Removed ${labels.name} document ${file.name}`,
|
||||
actorId: userId ?? null,
|
||||
metadata: { kind, fileName: file.name },
|
||||
});
|
||||
}
|
||||
|
||||
async requestRoAmendment(
|
||||
bookingId: string,
|
||||
note?: string,
|
||||
|
||||
@@ -482,6 +482,7 @@ export class ContractClearanceService {
|
||||
status: m.status,
|
||||
ownerRegion: m.ownerRegion,
|
||||
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
|
||||
triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null,
|
||||
sortOrder: m.sortOrder,
|
||||
})),
|
||||
nextAction,
|
||||
|
||||
@@ -1384,7 +1384,11 @@ export class ContractsController {
|
||||
) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
|
||||
return this.glOperationsService.uploadT1Documents(
|
||||
bookingId,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/t1-close')
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('GlOperationsService — final invoice approval', () => {
|
||||
{} as never, // milestoneService
|
||||
billingService as never,
|
||||
notifier as never,
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { ClearanceEventService } from '../bookings/clearance-event.service';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ export class GlOperationsService {
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
) {}
|
||||
|
||||
private get bookings() {
|
||||
@@ -362,13 +364,15 @@ export class GlOperationsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
|
||||
* is secured on the train schedule (which itself follows wagon allocation).
|
||||
* Replaces the previous batch; locked only once GL Ethiopia closes the T1.
|
||||
* GL Djibouti / the transit agent uploads T1 transport documents (multi-file)
|
||||
* once the train has DEPARTED Djibouti. Replaces the previous batch, so the
|
||||
* batch's file stamps are always the last update; locked only once GL
|
||||
* Ethiopia closes the T1.
|
||||
*/
|
||||
async uploadT1Documents(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<{ uploaded: number }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
@@ -376,24 +380,24 @@ export class GlOperationsService {
|
||||
}
|
||||
|
||||
const state = await this.t1State(bookingId);
|
||||
if (!state.wagonAllocated) {
|
||||
if (!state.trainDepartedAt) {
|
||||
throw new BadRequestException(
|
||||
'Wagons must be allocated before T1 transport documents can be uploaded.',
|
||||
);
|
||||
}
|
||||
const gatepass = await this.gatepassForBooking(bookingId);
|
||||
if (!gatepass.granted) {
|
||||
throw new BadRequestException(
|
||||
'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.',
|
||||
'T1 transport documents can be uploaded once the train has departed.',
|
||||
);
|
||||
}
|
||||
if (state.closed) {
|
||||
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
|
||||
}
|
||||
// Departure no longer locks T1 docs — GL DJ may replace them any time until
|
||||
// GL Ethiopia closes/accepts the T1.
|
||||
|
||||
await persistT1TransportUploads(this.filesService, bookingId, files);
|
||||
// History row so the portal can tell a first upload from a replacement.
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'T1_DOCUMENTS_UPLOADED',
|
||||
label: `Uploaded T1 transport documents (${files.length} file(s))`,
|
||||
actorId: userId ?? null,
|
||||
metadata: { fileNames: files.map((f) => f.originalname) },
|
||||
});
|
||||
return { uploaded: files.length };
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,17 @@ import {
|
||||
isImportTransitPermitFileCode,
|
||||
isExportTransportFileCode,
|
||||
isT1TransportFileCode,
|
||||
isGatePassFileCode,
|
||||
isDjiboutiT1FileCode,
|
||||
exportTransportFileLabel,
|
||||
t1TransportFileLabel,
|
||||
transitPermitFileLabel,
|
||||
gatePassFileLabel,
|
||||
djiboutiT1FileLabel,
|
||||
GATE_PASS_FILE_PREFIX,
|
||||
DJIBOUTI_T1_FILE_PREFIX,
|
||||
type ClearanceWorkflowFile,
|
||||
type TransitArrivalDocumentKind,
|
||||
} from '@edr/types';
|
||||
|
||||
/** Require at least one declaration file in the upload batch. */
|
||||
@@ -46,6 +53,7 @@ type DeclarationFileStore = {
|
||||
resource: string;
|
||||
code: string;
|
||||
file: Express.Multer.File;
|
||||
uploadedByUserId?: string | null;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
|
||||
@@ -445,9 +453,88 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
|
||||
/** 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;
|
||||
|
||||
/** Prefix + matcher + label for each transit-agent arrival document set. */
|
||||
const TRANSIT_ARRIVAL_DOCUMENT_SETS: Record<
|
||||
TransitArrivalDocumentKind,
|
||||
{ prefix: string; matches: (code: string | null | undefined) => boolean; label: (i?: number) => string }
|
||||
> = {
|
||||
gate_pass: { prefix: GATE_PASS_FILE_PREFIX, matches: isGatePassFileCode, label: gatePassFileLabel },
|
||||
djibouti_t1: { prefix: DJIBOUTI_T1_FILE_PREFIX, matches: isDjiboutiT1FileCode, label: djiboutiT1FileLabel },
|
||||
};
|
||||
|
||||
export function transitArrivalDocumentMatcher(
|
||||
kind: TransitArrivalDocumentKind,
|
||||
): (code: string | null | undefined) => boolean {
|
||||
return TRANSIT_ARRIVAL_DOCUMENT_SETS[kind].matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* APPEND a batch of transit-agent arrival documents (gate pass / Djibouti T1)
|
||||
* to a booking. Unlike the DO/RO persisters this never deletes what is already
|
||||
* there: the officer collects these one at a time as the paperwork comes in,
|
||||
* and each file is removed individually. Codes continue from the highest
|
||||
* existing index so a removed file's slot is never reused.
|
||||
*/
|
||||
export async function persistTransitArrivalUploads(
|
||||
store: DeclarationFileStore,
|
||||
bookingId: string,
|
||||
kind: TransitArrivalDocumentKind,
|
||||
files: Express.Multer.File[],
|
||||
uploadedByUserId?: string | null,
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
}
|
||||
const set = TRANSIT_ARRIVAL_DOCUMENT_SETS[kind];
|
||||
const existing = await store.findByResource(bookingId, 'bookings');
|
||||
const nextIndex =
|
||||
existing
|
||||
.filter((f) => set.matches(f.code))
|
||||
.map((f) => Number.parseInt((f.code ?? '').slice(set.prefix.length), 10))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
.reduce((max, n) => Math.max(max, n + 1), 0);
|
||||
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: `${set.prefix}${nextIndex + index}`,
|
||||
file: { ...file, fieldname: `${set.prefix}${nextIndex + index}` },
|
||||
uploadedByUserId: uploadedByUserId ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
type WorkflowFileInput = {
|
||||
code?: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
createdAt?: Date | string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
size?: number | null;
|
||||
mimeType?: string | null;
|
||||
};
|
||||
|
||||
function toWorkflowFileRef(file: WorkflowFileInput): NonNullable<ClearanceWorkflowFile['file']> {
|
||||
const iso = (v: Date | string | null | undefined) =>
|
||||
v ? new Date(v).toISOString() : null;
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
uploadedAt: iso(file.createdAt),
|
||||
updatedAt: iso(file.updatedAt),
|
||||
size: file.size ?? null,
|
||||
mimeType: file.mimeType ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build labeled phased-customs file rows from resource files. */
|
||||
export function buildWorkflowFiles(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
files: WorkflowFileInput[],
|
||||
tradeDirection: string,
|
||||
): ClearanceWorkflowFile[] {
|
||||
const fileByCode = new Map(
|
||||
@@ -465,7 +552,7 @@ export function buildWorkflowFiles(
|
||||
label: entry.label,
|
||||
uploadedBy: entry.uploadedBy,
|
||||
category: entry.category,
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -481,7 +568,7 @@ export function buildWorkflowFiles(
|
||||
label: declarationFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'declaration',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -497,7 +584,7 @@ export function buildWorkflowFiles(
|
||||
label: draftDeclarationFileLabel(index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'draft_declaration',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,7 +601,7 @@ export function buildWorkflowFiles(
|
||||
label: transitPermitFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'transit',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,7 +617,7 @@ export function buildWorkflowFiles(
|
||||
label: deliveryOrderFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -546,7 +633,7 @@ export function buildWorkflowFiles(
|
||||
label: t1TransportFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -564,7 +651,7 @@ export function buildWorkflowFiles(
|
||||
label: releaseOrderFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -580,9 +667,32 @@ export function buildWorkflowFiles(
|
||||
label: exportTransportFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'transit',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
// Transit-agent arrival paperwork, ordered by slot index (upload order).
|
||||
const byIndex = (prefix: string) => (a: WorkflowFileInput, b: WorkflowFileInput) =>
|
||||
Number.parseInt((a.code ?? '').slice(prefix.length), 10) -
|
||||
Number.parseInt((b.code ?? '').slice(prefix.length), 10);
|
||||
|
||||
for (const kind of ['gate_pass', 'djibouti_t1'] as const) {
|
||||
const set = TRANSIT_ARRIVAL_DOCUMENT_SETS[kind];
|
||||
files
|
||||
.filter((f) => f.code && set.matches(f.code) && !included.has(f.code))
|
||||
.sort(byIndex(set.prefix))
|
||||
.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: set.label(index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
Reference in New Issue
Block a user