mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
finilize gl flow for export
This commit is contained in:
@@ -1,14 +1,24 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { isT1TransportFileCode, type Freight } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, In, IsNull } from 'typeorm';
|
||||
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
||||
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
ClearanceIncident,
|
||||
IncidentType,
|
||||
} from './entities/clearance-incident.entity';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import {
|
||||
persistExportTransportUploads,
|
||||
@@ -43,6 +53,7 @@ export class GlOperationsService {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly billingService: BillingService,
|
||||
) {}
|
||||
|
||||
private get bookings() {
|
||||
@@ -167,12 +178,8 @@ 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> {
|
||||
/** Wagon-allocation + train-schedule actuals for a booking (both directions). */
|
||||
async trainState(bookingId: string): Promise<Freight.ClearanceTrainState> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
|
||||
@@ -190,19 +197,35 @@ export class GlOperationsService {
|
||||
.findOne({ where: { id: booking.trainScheduleId } });
|
||||
}
|
||||
|
||||
return {
|
||||
wagonAllocated,
|
||||
departedAt: schedule?.actualDepartureAt
|
||||
? new Date(schedule.actualDepartureAt).toISOString()
|
||||
: null,
|
||||
arrivedAt: schedule?.actualArrivalAt
|
||||
? new Date(schedule.actualArrivalAt).toISOString()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 train = await this.trainState(bookingId);
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
|
||||
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,
|
||||
wagonAllocated: train.wagonAllocated,
|
||||
trainDepartedAt: train.departedAt,
|
||||
trainArrivedAt: train.arrivedAt,
|
||||
closed: Boolean(closedMilestone),
|
||||
closedAt: closedMilestone?.triggeredAt
|
||||
? new Date(closedMilestone.triggeredAt).toISOString()
|
||||
@@ -243,38 +266,418 @@ export class GlOperationsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia closes (accepts) the T1 document set once the train has arrived.
|
||||
* Completes the T1_CLOSED milestone; the document set becomes final.
|
||||
* Close (accept) the T1/transport document set.
|
||||
* Import: GL Ethiopia closes once the train has arrived (T1 files required).
|
||||
* Export: GL Djibouti closes after the gate pass (transport document required).
|
||||
*/
|
||||
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 tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||
|
||||
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.',
|
||||
);
|
||||
if (tradeDirection === 'IMPORT') {
|
||||
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.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const done = (code: string) =>
|
||||
milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED';
|
||||
if (!done('EXPORT_TRANSPORT_ISSUED')) {
|
||||
throw new BadRequestException(
|
||||
'The transport document must be uploaded before T1 can be closed.',
|
||||
);
|
||||
}
|
||||
if (!done('GATEPASS_GRANTED')) {
|
||||
throw new BadRequestException('Grant the gate pass before closing T1.');
|
||||
}
|
||||
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
||||
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
||||
}
|
||||
|
||||
await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId);
|
||||
return this.t1State(bookingId);
|
||||
}
|
||||
|
||||
/** Milestones GL DJ implicitly confirms when granting an export gate pass. */
|
||||
private static readonly EXPORT_ARRIVAL_CHAIN = [
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
'DEPARTED_TO_DJIBOUTI',
|
||||
'ARRIVED_AT_DJIBOUTI',
|
||||
];
|
||||
|
||||
/**
|
||||
* GL Djibouti grants the gate pass for a customs booking, capturing the time.
|
||||
* Export: requires the train to have arrived at Djibouti; back-fills the
|
||||
* arrival-chain milestones. Import: requires wagon allocation (pre-loading).
|
||||
*/
|
||||
async grantGatepass(
|
||||
bookingId: string,
|
||||
gatepassAt?: string,
|
||||
userId?: string,
|
||||
): Promise<{ bookingId: string; gatepassAt: string }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (!booking.customsClearingEnabled) {
|
||||
throw new BadRequestException('Gate pass applies to customs bookings only.');
|
||||
}
|
||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||
|
||||
const existing = byCode.get('GATEPASS_GRANTED');
|
||||
if (existing?.status === 'COMPLETED') {
|
||||
return {
|
||||
bookingId,
|
||||
gatepassAt:
|
||||
existing.metadata?.gatepassAt ??
|
||||
(existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''),
|
||||
};
|
||||
}
|
||||
|
||||
const train = await this.trainState(bookingId);
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!train.arrivedAt) {
|
||||
throw new BadRequestException(
|
||||
'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.',
|
||||
);
|
||||
}
|
||||
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
||||
if (byCode.get(code)?.status === 'PENDING') {
|
||||
await this.milestoneService.completeForBooking(bookingId, code, userId);
|
||||
}
|
||||
}
|
||||
} else if (!train.wagonAllocated) {
|
||||
throw new BadRequestException(
|
||||
'Wagons must be allocated before the gate pass can be granted.',
|
||||
);
|
||||
}
|
||||
|
||||
const at = gatepassAt?.trim() || new Date().toISOString();
|
||||
await this.milestoneService.completeWithMetadataForBooking(
|
||||
bookingId,
|
||||
'GATEPASS_GRANTED',
|
||||
{ gatepassAt: at },
|
||||
userId,
|
||||
);
|
||||
return { bookingId, gatepassAt: at };
|
||||
}
|
||||
|
||||
/** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */
|
||||
async djSchedules(): Promise<Freight.DjClearanceSchedule[]> {
|
||||
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
||||
relations: {
|
||||
scheduleBookings: { booking: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
},
|
||||
order: { scheduledDepartureDate: 'DESC' },
|
||||
});
|
||||
|
||||
const withCustoms = schedules
|
||||
.filter((s) => s.status !== 'CANCELLED')
|
||||
.map((s) => ({
|
||||
schedule: s,
|
||||
customs: (s.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled)),
|
||||
}))
|
||||
.filter((s) => s.customs.length > 0);
|
||||
|
||||
const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id));
|
||||
const gatepassRows = bookingIds.length
|
||||
? await this.dataSource.getRepository(ClearanceMilestone).find({
|
||||
where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' },
|
||||
})
|
||||
: [];
|
||||
const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m]));
|
||||
|
||||
return withCustoms.map(({ schedule, customs }) => {
|
||||
const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))];
|
||||
return {
|
||||
id: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
routeName: null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
status: schedule.status,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate
|
||||
? new Date(schedule.scheduledDepartureDate).toISOString()
|
||||
: null,
|
||||
actualDepartureAt: schedule.actualDepartureAt
|
||||
? new Date(schedule.actualDepartureAt).toISOString()
|
||||
: null,
|
||||
actualArrivalAt: schedule.actualArrivalAt
|
||||
? new Date(schedule.actualArrivalAt).toISOString()
|
||||
: null,
|
||||
freightType:
|
||||
freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null,
|
||||
customsBookings: customs.map((b) => {
|
||||
const m = gatepassByBooking.get(b.id);
|
||||
const granted = m?.status === 'COMPLETED';
|
||||
return {
|
||||
bookingId: b.id,
|
||||
reference: b.reference ?? b.id,
|
||||
tradeDirection: b.tradeDirection ?? 'IMPORT',
|
||||
contractId: b.contractId ?? null,
|
||||
gatepassGranted: granted,
|
||||
gatepassAt: granted
|
||||
? (m?.metadata?.gatepassAt ??
|
||||
(m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null))
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click gate pass for every customs booking on a train schedule. Per-booking
|
||||
* guard failures are collected, not fatal. Import schedules also get the
|
||||
* schedule-level ImportDjiboutiOperation gate pass so loading unblocks.
|
||||
*/
|
||||
async grantScheduleGatepass(
|
||||
scheduleId: string,
|
||||
gatepassAt?: string,
|
||||
userId?: string,
|
||||
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> {
|
||||
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||
where: { id: scheduleId },
|
||||
relations: { scheduleBookings: { booking: true } },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
|
||||
const customs = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled));
|
||||
if (customs.length === 0) {
|
||||
throw new BadRequestException('No customs bookings ride this schedule.');
|
||||
}
|
||||
|
||||
let granted = 0;
|
||||
const skipped: Array<{ bookingId: string; error: string }> = [];
|
||||
for (const booking of customs) {
|
||||
try {
|
||||
await this.grantGatepass(booking.id, gatepassAt, userId);
|
||||
granted += 1;
|
||||
} catch (e) {
|
||||
skipped.push({
|
||||
bookingId: booking.id,
|
||||
error: e instanceof Error ? e.message : 'Failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) {
|
||||
const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||
let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
if (!operation) {
|
||||
operation = opRepo.create({ trainScheduleId: scheduleId });
|
||||
}
|
||||
if (!operation.gatepassGrantedAt) {
|
||||
operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date();
|
||||
await opRepo.save(operation);
|
||||
}
|
||||
}
|
||||
|
||||
return { granted, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||||
* attached invoice document. The customer pays offline and attaches a slip;
|
||||
* GL (ET or DJ) then confirms to settle it.
|
||||
*/
|
||||
async createFinalInvoice(
|
||||
bookingId: string,
|
||||
input: { amount: number; currency: string; description?: string },
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (!booking.customsClearingEnabled) {
|
||||
throw new BadRequestException('Final invoice applies to customs bookings only.');
|
||||
}
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Invoice amount must be greater than zero.');
|
||||
}
|
||||
if (!file) throw new BadRequestException('Attach the invoice document.');
|
||||
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const offloaded = milestones.find(
|
||||
(m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!offloaded) {
|
||||
throw new BadRequestException(
|
||||
'Cargo must be offloaded before the final invoice can be raised.',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (
|
||||
existing &&
|
||||
existing.status !== Freight.InvoiceStatus.Cancelled &&
|
||||
existing.status !== Freight.InvoiceStatus.Expired
|
||||
) {
|
||||
throw new ConflictException('A final invoice already exists for this shipment.');
|
||||
}
|
||||
|
||||
const description = input.description?.trim() || 'Post-offload charges (Djibouti)';
|
||||
await this.billingService.generateInvoice({
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: bookingId,
|
||||
type: GL_FINAL_INVOICE_TYPE,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: input.currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: GL_FINAL_INVOICE_TYPE,
|
||||
description,
|
||||
quantity: 1,
|
||||
unitRate: input.amount,
|
||||
amount: input.amount,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
});
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'final_invoice',
|
||||
file,
|
||||
});
|
||||
|
||||
// Export clearance is administratively done once the final invoice goes out.
|
||||
await this.dataSource
|
||||
.getRepository(ContractClearanceCycle)
|
||||
.update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() });
|
||||
|
||||
void userId;
|
||||
const summary = await this.finalInvoiceSummary(bookingId);
|
||||
if (!summary) throw new NotFoundException('Final invoice could not be created.');
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Customer attaches the payment slip for the final invoice. */
|
||||
async uploadFinalInvoiceSlip(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<{ uploaded: boolean }> {
|
||||
await this.getBooking(bookingId);
|
||||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||
|
||||
const invoice = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (!invoice) {
|
||||
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('The final invoice is already paid.');
|
||||
}
|
||||
if (
|
||||
invoice.status === Freight.InvoiceStatus.Cancelled ||
|
||||
invoice.status === Freight.InvoiceStatus.Expired
|
||||
) {
|
||||
throw new BadRequestException('The final invoice is no longer payable.');
|
||||
}
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'final_invoice_slip',
|
||||
file,
|
||||
});
|
||||
return { uploaded: true };
|
||||
}
|
||||
|
||||
/** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */
|
||||
async confirmFinalInvoicePaid(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||
await this.getBooking(bookingId);
|
||||
const invoice = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (!invoice) {
|
||||
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||||
}
|
||||
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
if (!files.some((f) => f.code === 'final_invoice_slip')) {
|
||||
throw new BadRequestException(
|
||||
'The customer has not attached a payment slip yet.',
|
||||
);
|
||||
}
|
||||
await this.billingService.markInvoiceAsPaid(invoice.id);
|
||||
}
|
||||
|
||||
void userId;
|
||||
const summary = await this.finalInvoiceSummary(bookingId);
|
||||
if (!summary) throw new NotFoundException('Final invoice not found.');
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Final-invoice state joined with its document + slip files, for clearance views. */
|
||||
async finalInvoiceSummary(
|
||||
bookingId: string,
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {
|
||||
const invoice = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (!invoice) return null;
|
||||
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const toRef = (code: string) => {
|
||||
const f = files.find((x) => x.code === code);
|
||||
return f ? { id: f.id, name: f.name, url: f.url } : null;
|
||||
};
|
||||
const line = await this.dataSource
|
||||
.getRepository(InvoiceLine)
|
||||
.findOne({ where: { invoiceId: invoice.id } });
|
||||
|
||||
return {
|
||||
id: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
status: invoice.status,
|
||||
totalAmount: Number(invoice.totalAmount),
|
||||
currency: invoice.currency,
|
||||
description: line?.description ?? null,
|
||||
invoiceFile: toRef('final_invoice'),
|
||||
slipFile: toRef('final_invoice_slip'),
|
||||
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user