mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
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,
|
||
persistT1TransportUploads,
|
||
} from './phased-clearance.util';
|
||
|
||
/**
|
||
* Maps a GL post-booking document `code` to the milestone it auto-completes when
|
||
* uploaded (doc §11.3/§12.2 — doc-triggered milestones). Uploading the document
|
||
* marks the milestone done so the timeline advances without a separate click.
|
||
*/
|
||
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 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
|
||
export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation
|
||
};
|
||
|
||
/**
|
||
* Operational Global Logistics actions that hang off a shipment booking after GL
|
||
* creates it: station routing, damage/incident reporting, and the phased GL
|
||
* document uploads (Release Order, Delivery Order, T1, etc.) that advance
|
||
* doc-triggered milestones. See docs/new-doc.md §11–§13, gap matrix #14/#16/#18.
|
||
*/
|
||
@Injectable()
|
||
export class GlOperationsService {
|
||
constructor(
|
||
private readonly dataSource: DataSource,
|
||
private readonly filesService: FilesService,
|
||
private readonly milestoneService: ClearanceMilestoneService,
|
||
) {}
|
||
|
||
private get bookings() {
|
||
return this.dataSource.getRepository(Booking);
|
||
}
|
||
|
||
private get incidents() {
|
||
return this.dataSource.getRepository(ClearanceIncident);
|
||
}
|
||
|
||
private async getBooking(bookingId: string): Promise<Booking> {
|
||
const booking = await this.bookings.findOne({ where: { id: bookingId } });
|
||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||
return booking;
|
||
}
|
||
|
||
/**
|
||
* Route a shipment to an origin station and (optionally) bind a GL staff user
|
||
* to it (GL US-02). Setting both moves the shipment to that station's queue.
|
||
*/
|
||
async assignStation(
|
||
bookingId: string,
|
||
input: { stationYardId: string; staffId?: string },
|
||
): Promise<Booking> {
|
||
const booking = await this.getBooking(bookingId);
|
||
booking.glStationYardId = input.stationYardId;
|
||
if (input.staffId) {
|
||
booking.glAssignedStaffId = input.staffId;
|
||
booking.glAssignedAt = new Date();
|
||
}
|
||
return this.bookings.save(booking);
|
||
}
|
||
|
||
/** Log a cargo exception (seal broken, container damaged, etc.) with photos. */
|
||
async reportIncident(
|
||
bookingId: string,
|
||
input: {
|
||
incidentType: IncidentType;
|
||
description: string;
|
||
files: Express.Multer.File[];
|
||
},
|
||
userId?: string,
|
||
): Promise<ClearanceIncident> {
|
||
await this.getBooking(bookingId);
|
||
if (!input.description?.trim()) {
|
||
throw new BadRequestException('A description is required for an incident report.');
|
||
}
|
||
const photoFileIds: string[] = [];
|
||
for (const file of input.files ?? []) {
|
||
const record = await this.filesService.upload({
|
||
resourceId: bookingId,
|
||
resource: 'bookings',
|
||
code: 'incident_photo',
|
||
file,
|
||
});
|
||
photoFileIds.push(record.id);
|
||
}
|
||
const incident = this.incidents.create({
|
||
bookingId,
|
||
incidentType: input.incidentType,
|
||
description: input.description.trim(),
|
||
photoFileIds,
|
||
reportedByUserId: userId ?? null,
|
||
reportedAt: new Date(),
|
||
});
|
||
return this.incidents.save(incident);
|
||
}
|
||
|
||
async listIncidents(bookingId: string): Promise<ClearanceIncident[]> {
|
||
return this.incidents.find({
|
||
where: { bookingId },
|
||
order: { reportedAt: 'DESC' },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Customer uploads the duty/tax payment slip after GL advised the amount. The
|
||
* slip attaches to the booking and doc-triggers DUTY_TAX_PAID (§11.3 #7).
|
||
*/
|
||
async uploadDutySlip(
|
||
bookingId: string,
|
||
file: Express.Multer.File,
|
||
): Promise<{ milestoneCompleted: boolean }> {
|
||
await this.getBooking(bookingId);
|
||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||
await this.filesService.upsertByCode({
|
||
resourceId: bookingId,
|
||
resource: 'bookings',
|
||
code: 'duty_tax_receipt',
|
||
file,
|
||
});
|
||
await this.milestoneService.completeByDocTrigger({ bookingId }, 'DUTY_TAX_PAID');
|
||
return { milestoneCompleted: true };
|
||
}
|
||
|
||
/**
|
||
* GL uploads a post-booking operational document (DO, RO, T1, import release,
|
||
* interchange…). The file attaches to the booking; if the code maps to a
|
||
* doc-triggered milestone, that milestone auto-completes.
|
||
*/
|
||
async uploadDocuments(
|
||
bookingId: string,
|
||
files: Express.Multer.File[],
|
||
): Promise<{ uploaded: number; completedMilestones: string[] }> {
|
||
await this.getBooking(bookingId);
|
||
if (!files?.length) throw new BadRequestException('No documents uploaded');
|
||
|
||
const completedMilestones: string[] = [];
|
||
for (const file of files) {
|
||
await this.filesService.upsertByCode({
|
||
resourceId: bookingId,
|
||
resource: 'bookings',
|
||
code: file.fieldname,
|
||
file,
|
||
});
|
||
const milestoneCode = DOC_CODE_TO_MILESTONE[file.fieldname];
|
||
if (milestoneCode) {
|
||
await this.milestoneService.completeByDocTrigger({ bookingId }, milestoneCode);
|
||
completedMilestones.push(milestoneCode);
|
||
}
|
||
}
|
||
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).
|
||
*/
|
||
async uploadTransportDocument(
|
||
bookingId: string,
|
||
files: Express.Multer.File[],
|
||
): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> {
|
||
const booking = await this.getBooking(bookingId);
|
||
if (booking.tradeDirection !== 'EXPORT') {
|
||
throw new BadRequestException('Transport document upload applies to export shipments only.');
|
||
}
|
||
|
||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||
const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
|
||
const wagonDone =
|
||
wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED';
|
||
if (!wagonDone) {
|
||
throw new BadRequestException(
|
||
'Wagon must be allocated before the transport document can be uploaded.',
|
||
);
|
||
}
|
||
|
||
if (files.length === 0) {
|
||
throw new BadRequestException('No transit permit documents uploaded');
|
||
}
|
||
|
||
await persistExportTransportUploads(this.filesService, bookingId, files);
|
||
|
||
if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') {
|
||
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
|
||
}
|
||
|
||
await this.milestoneService.completeByDocTrigger(
|
||
{ bookingId },
|
||
'EXPORT_TRANSPORT_ISSUED',
|
||
);
|
||
|
||
return { uploaded: true, milestoneCompleted: true };
|
||
}
|
||
}
|