Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-04 09:28:05 +00:00
38 changed files with 1061 additions and 908 deletions

View File

@@ -22,6 +22,7 @@
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
"seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts",
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",

View File

@@ -64,6 +64,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
@@ -170,6 +171,7 @@ import { LoggerMiddleware } from "./logger.middleware";
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {

View File

@@ -957,12 +957,27 @@ export class BillingService {
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
//
// Link the intent to the invoice BEFORE any settlement can correlate against it.
await this.dataSource
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,
referenceId: invoice.sourceId,
intentId: result.intentId,
providerTxnId: result.providerTxnId,
paidAt: (result.paidAt ?? new Date()).toISOString(),
});
}
if (result.immediateSuccess) {
await this.settleByPaymentId(
result.intentId,

View File

@@ -1086,6 +1086,9 @@ export class BookingTransitionService {
offeredAmount: number;
paymentDeadline: Date;
} | null;
/** Flat list of physical container numbers on this booking (for the
* customer truck-assignment container picker). */
containerNumbers: string[];
}
> {
// This enrichment runs AFTER the transition has committed. A failure here
@@ -1141,12 +1144,20 @@ export class BookingTransitionService {
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
// Physical container numbers entered at booking time (booking_container
// units), flattened for the customer truck-assignment container picker.
const containerNumbers = (booking.bookingContainers ?? [])
.flatMap((bc) => bc.units ?? [])
.map((unit) => unit.containerNumber)
.filter((n): n is string => Boolean(n));
return {
...booking,
latestChangeRequestNote: note?.note ?? null,
contractSummary: summary,
nextStep,
activeBatchOffer,
containerNumbers,
};
}
}

View File

@@ -205,7 +205,7 @@ export class BookingClearanceService {
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
const bookingMilestone = (code: string) =>
milestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
@@ -242,14 +242,8 @@ export class BookingClearanceService {
workflowFiles,
t1,
train,
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
gatepassAt:
gatepassMilestone?.status === 'COMPLETED'
? (gatepassMilestone.metadata?.gatepassAt ??
(gatepassMilestone.triggeredAt
? gatepassMilestone.triggeredAt.toISOString()
: null))
: null,
gatepassGranted: gatepass.granted,
gatepassAt: gatepass.grantedAt,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt

View File

@@ -278,7 +278,9 @@ export class ContractClearanceService {
}
const bookingMilestone = (code: string) =>
bookingMilestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const gatepass = cycle?.bookingId
? await this.glOperationsService.gatepassForBooking(cycle.bookingId)
: { granted: false, grantedAt: null };
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(
@@ -344,14 +346,8 @@ export class ContractClearanceService {
workflowFiles,
t1,
train,
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
gatepassAt:
gatepassMilestone?.status === 'COMPLETED'
? (gatepassMilestone.metadata?.gatepassAt ??
(gatepassMilestone.triggeredAt
? gatepassMilestone.triggeredAt.toISOString()
: null))
: null,
gatepassGranted: gatepass.granted,
gatepassAt: gatepass.grantedAt,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt

View File

@@ -77,7 +77,6 @@ import {
} from './dto/gl-operations.dto';
import {
AdviseContractDutyDto,
GatepassDto,
RoAmendmentDto,
} from './dto/phased-clearance.dto';
@@ -688,30 +687,6 @@ 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')
@@ -947,21 +922,6 @@ 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'))

View File

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

View File

@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, In, IsNull } from 'typeorm';
import { DataSource, IsNull } from 'typeorm';
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
import { BillingService } from '../billing/billing.service';
@@ -17,7 +17,6 @@ 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 {
@@ -198,6 +197,7 @@ export class GlOperationsService {
}
return {
scheduleId: schedule?.id ?? null,
wagonAllocated,
departedAt: schedule?.actualDepartureAt
? new Date(schedule.actualDepartureAt).toISOString()
@@ -208,6 +208,41 @@ export class GlOperationsService {
};
}
/**
* Gate pass status for a booking, sourced from the train schedule's Djibouti
* gate-pass operation (secured via the train-scheduling "Save as Secured"
* action) rather than a clearance milestone. For EXPORT bookings this also
* backfills the arrival-chain milestones once secured, same as the retired
* clearance-side grant action used to.
*/
async gatepassForBooking(
bookingId: string,
): Promise<{ granted: boolean; grantedAt: string | null }> {
const train = await this.trainState(bookingId);
if (!train.scheduleId) return { granted: false, grantedAt: null };
const operation = await this.dataSource
.getRepository(ImportDjiboutiOperation)
.findOne({ where: { trainScheduleId: train.scheduleId } });
const grantedAt = operation?.gatepassGrantedAt
? new Date(operation.gatepassGrantedAt).toISOString()
: null;
if (grantedAt) {
const booking = await this.getBooking(bookingId);
if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') {
const milestones = await this.milestoneService.listForBooking(bookingId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
if (byCode.get(code)?.status === 'PENDING') {
await this.milestoneService.completeForBooking(bookingId, code);
}
}
}
}
return { granted: Boolean(grantedAt), grantedAt };
}
/**
* T1 transit-document lifecycle state for an import shipment booking. Wagon
* allocation opens the upload window; train departure locks it; train arrival
@@ -302,8 +337,11 @@ export class GlOperationsService {
'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.');
const gatepass = await this.gatepassForBooking(bookingId);
if (!gatepass.granted) {
throw new BadRequestException(
'Secure the Djibouti gate pass on the train schedule before closing T1.',
);
}
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
@@ -322,182 +360,6 @@ export class GlOperationsService {
'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 +

View File

@@ -479,7 +479,7 @@ export class PaymentService {
alreadyFinalized?: boolean;
reason?: string;
}> {
console.log(`Received payment event: ${JSON.stringify(event)}`);
this.logger.log(`Received payment event: ${JSON.stringify(event)}`);
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({
refId: event.referenceId,
@@ -490,13 +490,12 @@ export class PaymentService {
reason: `No local intent for reference ${event.referenceId}`,
};
}
console.log(`Processing payment succeeded event for intent: }`, intent);
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
notify: true,
});
console.log(
this.logger.log(
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
);

View File

@@ -310,7 +310,10 @@ export class BookingBatchService implements OnModuleInit {
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = (
await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: "OPEN" },
where: [
{ bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft },
{ bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled },
],
})
).filter((s) => s.windowPhase == null);
const groups = new Map<string, RouteDayGroup>();

View File

@@ -20,6 +20,7 @@ import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
@@ -1168,12 +1169,47 @@ export class TrainSchedulingService {
notes: dto.notes ?? operation.notes ?? null,
});
await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt);
console.log(
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
);
return this.getImportDjiboutiOperation(schedule.id);
}
/**
* Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED
* milestone for every customs booking on this schedule, so contract/booking
* clearance views still reading that milestone (older deployed builds) see
* the gate pass as done. Drop once every clearance-api deployment reads
* ImportDjiboutiOperation.gatepassGrantedAt directly.
*/
private async completeGatepassMilestoneForSchedule(
scheduleId: string,
securedAt: Date,
): Promise<void> {
const bookings = await this.dataSource.getRepository(Booking).find({
where: { trainScheduleId: scheduleId, customsClearingEnabled: true },
});
if (bookings.length === 0) return;
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
const rows = await milestoneRepo.find({
where: {
bookingId: In(bookings.map((b) => b.id)),
milestoneCode: 'GATEPASS_GRANTED',
},
});
for (const row of rows) {
if (row.status === 'COMPLETED') continue;
row.status = 'COMPLETED';
row.triggeredAt = securedAt;
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
await milestoneRepo.save(row);
}
}
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
@@ -1265,7 +1301,9 @@ export class TrainSchedulingService {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
// Generic render — NOT the release-order fallback (would mislabel this as a
// gate-clearance / release order when Chromium is unavailable).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
return {
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -1283,7 +1321,8 @@ export class TrainSchedulingService {
}
const html = this.buildExportLoadListHtml(schedule);
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
// Generic render — NOT the release-order fallback (see importLoadListDocument).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -2049,7 +2088,9 @@ export class TrainSchedulingService {
await this.trainSchedulesRepository.updateStatus(
id,
TrainScheduleStatusEnum.Cancelled,
{},
// Retire the booking window so a canceled schedule never lingers as an
// "open window" in booking-window lists or the legacy batch fill.
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
manager,
);
if (schedule.trainSetId) {

View File

@@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService {
});
}
/**
* Render arbitrary document HTML to PDF via the shared renderer WITHOUT the
* release-order fallback. Non-release documents (e.g. the import/export
* marshalling load list) must use this so a Chromium-less fallback degrades to
* a plain-text dump of *their own* content — instead of masquerading as a
* "Warehouse Gate Clearance / Release Order", which the release-specific
* fallback would otherwise draw regardless of the input HTML.
*/
renderDocumentHtml(html: string, label = 'Document'): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, { label });
}
private htmlToBasicPdfBuffer(html: string): Buffer {
const doc = this.extractReleaseDocument(html);
const body: string[] = [

View File

@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PaidImportExportMileDemoSeeder } from '../seed/paid-import-export-mile-demo.seeder';
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
const seeder = app.get(PaidImportExportMileDemoSeeder);
await seeder.run();
console.log('Paid import/export mile demo bookings seeded.');
} finally {
await app.close();
}
}
main().catch((err) => {
console.error('Paid import/export mile demo booking seed failed:', err);
process.exit(1);
});

View File

@@ -0,0 +1,299 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { DataSource } from 'typeorm';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE';
const COMPANY_TIN = 'PAIDMILE001';
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
];
const CONTAINER_TYPES = [
{ code: '20FT', label: '20FT', sizeFt: 20 },
{ code: '40FT', label: '40FT', sizeFt: 40 },
];
/**
* Six paid, approved container bookings that mirror the real trucking legs:
* - EXPORT (Ethiopia -> Djibouti) carries a FIRST-MILE leg (factory -> rail terminal).
* - IMPORT (Djibouti -> Ethiopia) carries a LAST-MILE leg (dry port -> final delivery).
* Each booking is paymentStatus PAID and its single mile leg is marked paid + ready to transit.
*/
const DEMO_BOOKINGS = [
// ── IMPORT: last mile only ─────────────────────────────────────────────
{
reference: 'PAID-IMP-001',
tradeDirection: 'IMPORT',
containerCode: '40FT',
quantity: 8,
totalWeightTons: 224,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-01T08:00:00.000Z',
lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa',
lastMileDeliveryLat: 8.8808,
lastMileDeliveryLng: 38.7876,
},
{
reference: 'PAID-IMP-002',
tradeDirection: 'IMPORT',
containerCode: '20FT',
quantity: 12,
totalWeightTons: 240,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-02T08:00:00.000Z',
lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa',
lastMileDeliveryLat: 8.9137,
lastMileDeliveryLng: 38.7815,
},
{
reference: 'PAID-IMP-003',
tradeDirection: 'IMPORT',
containerCode: '40FT',
quantity: 6,
totalWeightTons: 180,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-07-03T08:00:00.000Z',
lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa',
lastMileDeliveryLat: 8.9806,
lastMileDeliveryLng: 38.8736,
},
// ── EXPORT: first mile only ────────────────────────────────────────────
{
reference: 'PAID-EXP-001',
tradeDirection: 'EXPORT',
containerCode: '40FT',
quantity: 7,
totalWeightTons: 196,
originCode: 'ADDIS_ABABA',
destinationCode: 'DJIBOUTI',
scheduledDate: '2026-07-01T10:00:00.000Z',
firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa',
firstMilePickupLat: 8.9806,
firstMilePickupLng: 38.8736,
},
{
reference: 'PAID-EXP-002',
tradeDirection: 'EXPORT',
containerCode: '20FT',
quantity: 11,
totalWeightTons: 220,
originCode: 'ADDIS_ABABA',
destinationCode: 'DJIBOUTI',
scheduledDate: '2026-07-02T10:00:00.000Z',
firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa',
firstMilePickupLat: 8.8808,
firstMilePickupLng: 38.7876,
},
{
reference: 'PAID-EXP-003',
tradeDirection: 'EXPORT',
containerCode: '40FT',
quantity: 4,
totalWeightTons: 128,
originCode: 'ADDIS_ABABA',
destinationCode: 'DJIBOUTI',
scheduledDate: '2026-07-03T10:00:00.000Z',
firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa',
firstMilePickupLat: 8.9137,
firstMilePickupLng: 38.7815,
},
] as const;
@Injectable()
export class PaidImportExportMileDemoSeeder {
private readonly logger = new Logger(PaidImportExportMileDemoSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(Yard).upsert(
YARDS.map((yard) => ({ ...yard, isActive: true })),
{ conflictPaths: { code: true } },
);
await manager.getRepository(ServiceType).upsert(
{
code: SERVICE_TYPE_CODE,
serviceName: 'Rail Container with Paid First/Last Mile',
description: 'Demo service type for paid import/export bookings with a single mile leg',
canBeBookedAlone: true,
includesFirstMile: true,
includesLastMile: true,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 11,
},
{ conflictPaths: { code: true } },
);
await manager.getRepository(ContainerType).upsert(
CONTAINER_TYPES.map((containerType, index) => ({
...containerType,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: index + 1,
})),
{ conflictPaths: { code: true } },
);
await manager.getRepository(Company).upsert(
{
name: 'Paid Import/Export Mile Demo Customer',
type: CompanyType.Customer,
status: CompanyStatus.Active,
tin: COMPANY_TIN,
vatNumber: COMPANY_TIN,
fanNumber: 'PMD0000000000001',
country: 'Ethiopia',
address: 'Addis Ababa',
phone: '251900000202',
email: COMPANY_EMAIL,
website: null,
contactPersonName: 'Paid Mile Demo',
contactPersonPhone: '251900000202',
generalManagerName: 'Demo Manager',
generalManagerEmail: COMPANY_EMAIL,
generalManagerPhone: '251900000202',
},
{ conflictPaths: { tin: true } },
);
const [serviceType, company, yards, containerTypes] = await Promise.all([
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }),
manager.getRepository(Yard).find(),
manager.getRepository(ContainerType).find(),
]);
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
const containerTypeByCode = new Map(
containerTypes.map((containerType) => [containerType.code, containerType]),
);
for (const demoBooking of DEMO_BOOKINGS) {
const origin = yardByCode.get(demoBooking.originCode);
const destination = yardByCode.get(demoBooking.destinationCode);
const containerType = containerTypeByCode.get(demoBooking.containerCode);
if (!origin || !destination || !containerType) {
throw new Error(`paid_import_export_mile_demo_dependency_missing:${demoBooking.reference}`);
}
const isImport = demoBooking.tradeDirection === 'IMPORT';
const wagonsRequired =
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
await manager.getRepository(Booking).upsert(
{
reference: demoBooking.reference,
companyId: company.id,
status: 'APPROVED',
scheduledDate: new Date(demoBooking.scheduledDate),
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
totalAmount: demoBooking.totalWeightTons * 25,
paymentStatus: 'PAID',
contractType: 'NEW',
serviceTypeId: serviceType.id,
// Only the leg that matches the trade direction carries an address.
firstMilePickupAddress: isImport ? null : demoBooking.firstMilePickupAddress,
firstMilePickupLat: isImport ? null : demoBooking.firstMilePickupLat,
firstMilePickupLng: isImport ? null : demoBooking.firstMilePickupLng,
lastMileDeliveryAddress: isImport ? demoBooking.lastMileDeliveryAddress : null,
lastMileDeliveryLat: isImport ? demoBooking.lastMileDeliveryLat : null,
lastMileDeliveryLng: isImport ? demoBooking.lastMileDeliveryLng : null,
equipmentReturn: 'WITHOUT_RETURN',
originYardId: origin.id,
destinationYardId: destination.id,
tradeDirection: demoBooking.tradeDirection,
freightType: 'CONTAINER',
cargoTypeId: null,
cargoFreeText: 'Demo container cargo',
shippingLineId: null,
cargoTotalWeightVgm: demoBooking.totalWeightTons,
isHazardous: false,
isReefer: false,
paymentCurrency: 'ETB',
approvedByStaffAt: new Date(),
priorityScore: 20,
wagonsRequired,
schedulingStatus: 'NOT_SCHEDULED',
versionNumber: 1,
},
{ conflictPaths: { reference: true } },
);
const booking = await manager.getRepository(Booking).findOneByOrFail({
reference: demoBooking.reference,
});
await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
await manager.getRepository(BookingContainer).insert({
id: randomUUID(),
bookingId: booking.id,
containerTypeId: containerType.id,
quantity: demoBooking.quantity,
vgmPerUnitTons,
totalVgmTons: demoBooking.totalWeightTons,
wagonsRequired,
weightLimitRuleId: null,
isOverweight: vgmPerUnitTons > 35,
overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
});
// Reset any existing legs for idempotency, then create the single paid leg.
await manager.getRepository(FirstMile).delete({ bookingId: booking.id });
await manager.getRepository(LastMile).delete({ bookingId: booking.id });
const paidAmount = demoBooking.totalWeightTons * 25;
if (isImport) {
await manager.getRepository(LastMile).insert({
bookingId: booking.id,
status: 'READY_TO_TRANSIT',
advancedPayment: paidAmount,
remainingPayment: 0,
paid: true,
estimatedKm: 22,
exactKm: null,
vehicleId: null,
});
} else {
await manager.getRepository(FirstMile).insert({
bookingId: booking.id,
status: 'READY_TO_TRANSIT',
advancedPayment: paidAmount,
remainingPayment: 0,
paid: true,
estimatedKm: 18,
exactKm: null,
vehicleId: null,
});
}
}
});
this.logger.log(
'Seeded 6 paid bookings: 3 import (last-mile) + 3 export (first-mile).',
);
}
}