Files
edr-platform/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
2026-07-02 13:24:39 +00:00

318 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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