update import gl flow

This commit is contained in:
Marshal
2026-07-02 13:24:39 +00:00
parent 03740ee719
commit fe40d9f4be
8 changed files with 280 additions and 29 deletions

View File

@@ -1,14 +1,19 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { isT1TransportFileCode, type Freight } from '@edr/types';
import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import {
ClearanceIncident,
IncidentType,
} from './entities/clearance-incident.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { persistExportTransportUploads } from './phased-clearance.util';
import {
persistExportTransportUploads,
persistT1TransportUploads,
} from './phased-clearance.util';
/**
* Maps a GL post-booking document `code` to the milestone it auto-completes when
@@ -18,7 +23,8 @@ import { persistExportTransportUploads } from './phased-clearance.util';
const DOC_CODE_TO_MILESTONE: Record<string, string> = {
release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ
delivery_order: 'DO_COLLECTED', // import — GL DJ
t1_transport_document: 'T1_CLOSED', // import — GL ET
// t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only
// when GL Ethiopia accepts the T1 set after the train arrives (closeT1).
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
full_in_interchange: 'OFFLOADED', // export — GL DJ
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
@@ -161,6 +167,114 @@ export class GlOperationsService {
return { uploaded: files.length, completedMilestones };
}
/**
* T1 transit-document lifecycle state for an import shipment booking. Wagon
* allocation opens the upload window; train departure locks it; train arrival
* lets GL Ethiopia close (accept) the T1 set.
*/
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
const booking = await this.getBooking(bookingId);
const milestones = await this.milestoneService.listForBooking(bookingId);
const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
const wagonAllocated =
wagonMilestone?.status === 'COMPLETED' ||
booking.schedulingStatus === 'SCHEDULED' ||
booking.schedulingStatus === 'DISPATCHED' ||
Boolean(booking.trainScheduleId);
let schedule: TrainSchedule | null = null;
if (booking.trainScheduleId) {
schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: booking.trainScheduleId } });
}
const closedMilestone = milestones.find(
(m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED',
);
return {
bookingId,
wagonAllocated,
trainDepartedAt: schedule?.actualDepartureAt
? new Date(schedule.actualDepartureAt).toISOString()
: null,
trainArrivedAt: schedule?.actualArrivalAt
? new Date(schedule.actualArrivalAt).toISOString()
: null,
closed: Boolean(closedMilestone),
closedAt: closedMilestone?.triggeredAt
? new Date(closedMilestone.triggeredAt).toISOString()
: null,
};
}
/**
* GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation.
* Replaces the previous batch; locked once the train departs or T1 is closed.
*/
async uploadT1Documents(
bookingId: string,
files: Express.Multer.File[],
): Promise<{ uploaded: number }> {
const booking = await this.getBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('T1 transport documents apply to import shipments only.');
}
const state = await this.t1State(bookingId);
if (!state.wagonAllocated) {
throw new BadRequestException(
'Wagons must be allocated before T1 transport documents can be uploaded.',
);
}
if (state.closed) {
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
}
if (state.trainDepartedAt) {
throw new BadRequestException(
'The train has departed — T1 transport documents can no longer be changed.',
);
}
await persistT1TransportUploads(this.filesService, bookingId, files);
return { uploaded: files.length };
}
/**
* GL Ethiopia closes (accepts) the T1 document set once the train has arrived.
* Completes the T1_CLOSED milestone; the document set becomes final.
*/
async closeT1(
bookingId: string,
userId?: string,
): Promise<Freight.ClearanceT1State> {
const booking = await this.getBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('T1 closure applies to import shipments only.');
}
const state = await this.t1State(bookingId);
if (state.closed) return state;
if (!state.trainArrivedAt) {
throw new BadRequestException(
'The train has not arrived yet — T1 can be closed only after arrival.',
);
}
const files = await this.filesService.findByResource(bookingId, 'bookings');
const hasT1 = files.some((f) => isT1TransportFileCode(f.code));
if (!hasT1) {
throw new BadRequestException(
'No T1 transport documents on file — GL Djibouti must upload them first.',
);
}
await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId);
return this.t1State(bookingId);
}
/**
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
*/