finilize gl flow for export

This commit is contained in:
Marshal
2026-07-02 17:55:59 +00:00
parent f77e4d150b
commit dd47068a4b
24 changed files with 2462 additions and 598 deletions

View File

@@ -1,5 +1,10 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ContractDocPhase, type ClearanceT1State } from '@edr/types';
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceT1State,
type ClearanceTrainState,
} from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -66,6 +71,15 @@ export interface BookingClearanceView {
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until wagon allocation). */
t1?: ClearanceT1State | null;
/** Train link state for the booking (both directions). */
train?: ClearanceTrainState | null;
gatepassGranted?: boolean;
gatepassAt?: string | null;
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
}
@Injectable()
@@ -175,6 +189,18 @@ export class BookingClearanceService {
}
}
let train: ClearanceTrainState | null = null;
try {
train = await this.glOperationsService.trainState(bookingId);
} catch {
train = null;
}
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
const bookingMilestone = (code: string) =>
milestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
return {
bookingId,
status: booking.status,
@@ -206,6 +232,22 @@ export class BookingClearanceService {
dutyAdvice,
workflowFiles,
t1,
train,
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
gatepassAt:
gatepassMilestone?.status === 'COMPLETED'
? (gatepassMilestone.metadata?.gatepassAt ??
(gatepassMilestone.triggeredAt
? gatepassMilestone.triggeredAt.toISOString()
: null))
: null,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
finalInvoice,
};
}
@@ -302,6 +344,12 @@ export class BookingClearanceService {
: ContractDocPhase.CustomerDuty,
} as never);
// Export: the declaration is the last GL ET pre-operation action — release
// immediately so the customer can proceed without a separate confirm click.
if (tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
}
return this.bookingsService.findById(bookingId);
}

View File

@@ -68,6 +68,7 @@ const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false },
ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'DJ', triggeredByDoc: false },
OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true },
};

View File

@@ -91,6 +91,39 @@ export class ClearanceMilestoneService {
});
}
/**
* Find-or-create a post-booking milestone row from the catalog. Needed for codes
* added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED).
*/
async ensureForBooking(
bookingId: string,
code: string,
tradeDirection: string,
): Promise<ClearanceMilestone> {
const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
if (existing) return existing;
const { postBooking } = splitMilestones(tradeDirection);
const idx = postBooking.findIndex((d) => d.code === code);
if (idx < 0) {
throw new NotFoundException(
`Milestone ${code} is not a ${tradeDirection} post-booking milestone`,
);
}
const def = postBooking[idx]!;
return this.repo.save(
this.repo.create({
bookingId,
milestoneCode: def.code,
milestoneLabel: def.label,
ownerRegion: def.ownerRegion,
triggeredByDoc: def.triggeredByDoc,
status: 'PENDING',
sortOrder: idx,
}),
);
}
/** Mark a milestone complete (by code) on a booking. */
async completeForBooking(
bookingId: string,

View File

@@ -1,5 +1,10 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { ContractDocPhase, type ClearanceT1State } from '@edr/types';
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceT1State,
type ClearanceTrainState,
} from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -80,6 +85,15 @@ export interface ContractClearanceView {
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until a booking is linked). */
t1?: ClearanceT1State | null;
/** Train link state for the booking (both directions; null until a booking is linked). */
train?: ClearanceTrainState | null;
gatepassGranted?: boolean;
gatepassAt?: string | null;
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
}
@Injectable()
@@ -237,11 +251,27 @@ export class ContractClearanceService {
}
}
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
let train: ClearanceTrainState | null = null;
let bookingMilestones: ClearanceMilestone[] = [];
let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
if (cycle?.bookingId) {
try {
train = await this.glOperationsService.trainState(cycle.bookingId);
} catch {
train = null;
}
bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
}
const bookingMilestone = (code: string) =>
bookingMilestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
const booking = await this.bookingsService.findById(cycle.bookingId);
if (booking) {
nextAction = this.workflowService.computeNextActionForBooking(
@@ -286,6 +316,22 @@ export class ContractClearanceService {
dutyAdvice,
workflowFiles,
t1,
train,
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
gatepassAt:
gatepassMilestone?.status === 'COMPLETED'
? (gatepassMilestone.metadata?.gatepassAt ??
(gatepassMilestone.triggeredAt
? gatepassMilestone.triggeredAt.toISOString()
: null))
: null,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
finalInvoice,
};
}
@@ -873,6 +919,12 @@ export class ContractClearanceService {
});
}
// Export: the declaration is the last GL ET pre-booking action — release
// immediately so booking creation unlocks without a separate confirm click.
if (contract.tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleased(contractId, userId);
}
return this.contractsService.findById(contractId);
}

View File

@@ -77,6 +77,7 @@ import {
} from './dto/gl-operations.dto';
import {
AdviseContractDutyDto,
GatepassDto,
RoAmendmentDto,
} from './dto/phased-clearance.dto';
@@ -681,6 +682,30 @@ export class ContractsController {
return this.clearanceService.djQueue(filter);
}
@Get('clearance/dj-schedules')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' })
djClearanceSchedules() {
return this.glOperationsService.djSchedules();
}
@Post('clearance/schedules/:scheduleId/gatepass')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary: 'GL DJ grants the gate pass for every customs booking on a train schedule',
})
grantScheduleGatepass(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: GatepassDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.grantScheduleGatepass(
scheduleId,
dto?.gatepassAt,
resolveAuthUserId(user),
);
}
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
@Get('clearance/ops-queue')
@@ -889,9 +914,13 @@ export class ContractsController {
}
@Post('bookings/:bookingId/t1-close')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary: 'GL Ethiopia closes (accepts) the T1 document set after the train arrives',
summary:
'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)',
})
closeT1(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -900,6 +929,75 @@ export class ContractsController {
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
}
@Post('bookings/:bookingId/gatepass')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' })
grantGatepass(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: GatepassDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.grantGatepass(
bookingId,
dto?.gatepassAt,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/final-invoice')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)',
})
createFinalInvoice(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body('amount') amountRaw: string,
@Body('currency') currency: string | undefined,
@Body('description') description: string | undefined,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.createFinalInvoice(
bookingId,
{
amount: Number(amountRaw),
currency: currency?.trim() || 'ETB',
description,
},
file,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/final-invoice-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' })
uploadFinalInvoiceSlip(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file);
}
@Post('bookings/:bookingId/final-invoice/confirm')
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceDjActions,
FREIGHT_PERMS.contracts.clearanceEtActions,
])
@ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' })
confirmFinalInvoicePaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.confirmFinalInvoicePaid(
bookingId,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { BillingModule } from '../billing/billing.module';
import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
@@ -64,6 +65,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
Booking,
BookingContainerUnit,
]),
BillingModule,
RuleEngineModule,
FileUploadSettingsModule,
DropdownSettingsModule,

View File

@@ -35,3 +35,12 @@ export class RoAmendmentDto {
@IsString()
note?: string;
}
export class GatepassDto {
@ApiPropertyOptional({
description: 'When the gate pass was granted (ISO datetime; defaults to now)',
})
@IsOptional()
@IsString()
gatepassAt?: string;
}

View File

@@ -23,6 +23,8 @@ export interface MilestoneMetadata {
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;
/** When the gate pass was physically granted (GL DJ captures the time). */
gatepassAt?: string;
}
/**

View File

@@ -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).
*/

View File

@@ -107,8 +107,12 @@ describe('belongsOnDjClearanceQueue', () => {
).toBe(true);
});
it('excludes import contracts still on Ethiopia-side clearance only', () => {
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false);
it('keeps import contracts from the start — DO upload is un-gated', () => {
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(true);
});
it('excludes export contracts with no DJ activity or RO hold', () => {
expect(belongsOnDjClearanceQueue('EXPORT', null, [])).toBe(false);
});
});