feat(transit-agent): timed document uploads and a real-data overview

This commit is contained in:
marshal
2026-09-02 23:36:35 +00:00
parent 42ca020a1f
commit 3fd94c9314
20 changed files with 3717 additions and 1152 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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')

View File

@@ -50,6 +50,7 @@ describe('GlOperationsService — final invoice approval', () => {
{} as never, // milestoneService
billingService as never,
notifier as never,
{ record: jest.fn() } as never, // clearanceEvents
);
});

View File

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

View File

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