Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/last_mile_invoice

This commit is contained in:
yaschalew
2026-06-29 15:09:20 +03:00
32 changed files with 2240 additions and 187 deletions

View File

@@ -53,6 +53,7 @@ import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
@@ -150,6 +151,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
Batch8TestDataSeeder,
WarehouseDemoSeeder,
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
],
})
@@ -168,6 +170,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
@@ -187,6 +190,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run();
await this.marshallingDemoTrainsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,

View File

@@ -20,6 +20,7 @@ export class PaymentClientService {
private readonly baseUrl = (
// process.env.PAYMENT_API_URL ??
"https://paymentcallback.triaplc.com"
// "http://localhost:3003"
).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";

View File

@@ -289,20 +289,21 @@ export class PaymentService {
providerTxnId?: string;
paidAt?: Date;
}): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (intent.status === "success") return { alreadyFinalized: true };
// const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
// if (!intent) throw new NotFoundException("PaymentIntent not found");
// if (intent.status === "success") return { alreadyFinalized: true };
const paidAt = input.paidAt ?? new Date();
// Every booking is a real shipment now (contracts are a separate aggregate),
// so payment always settles the booking to PAID and enters allocation.
await this.datasource.transaction(async (mg) => {
await mg.update(
PaymentEntity,
{ id: intent.id },
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
);
// await mg.update(
// PaymentEntity,
// // { id: intent.id },
// {id:input.intentId},
// { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
// );
await mg.update(
Booking,
{ id: input.bookingId },
@@ -398,34 +399,46 @@ export class PaymentService {
failureCode?: string;
failureMessage?: string;
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
}
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id,
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId:event.intentId,
bookingId: event.referenceId,
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
});
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
return { processed: true, alreadyFinalized };
}
// console.log(`Received payment event: ${JSON.stringify(event)}`);
// if (event.eventType === "payment.succeeded") {
// console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`);
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
// if (!intent) {
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
// }
// console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`);
// const { alreadyFinalized } = await this.finalizePaymentSuccess({
// intentId: intent.id,
// bookingId: event.referenceId,
// providerTxnId: event.providerTxnId,
// paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
// });
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
// return { processed: true, alreadyFinalized };
// }
if (event.eventType === "payment.failed") {
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
if (!intent) {
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
}
await this.markPaymentFailed({
intentId: intent.id,
failureCode: event.failureCode,
failureMessage: event.failureMessage,
});
return { processed: true };
}
// if (event.eventType === "payment.failed") {
// const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
// if (!intent) {
// return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
// }
// await this.markPaymentFailed({
// intentId: intent.id,
// failureCode: event.failureCode,
// failureMessage: event.failureMessage,
// });
// return { processed: true };
// }
return { processed: false, reason: `Unknown event type: ${event.eventType}` };
// return { processed: false, reason: `Unknown event type: ${event.eventType}` };
}
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {

View File

@@ -32,6 +32,9 @@ export interface ImportTrainItemRow {
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
@@ -59,6 +62,9 @@ export interface ExportTrainItemRow {
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
itemType: 'CONTAINER' | 'CARGO';
itemId: string | null;
inventoryId: string | null;
@@ -265,6 +271,9 @@ export class SchedulingReadFacade {
b.reference AS "bookingReference",
b.company_id AS "customerId",
company.name AS "customerName",
w.wagon_number AS "wagonNumber",
tsw.sequence_no AS "sequenceNo",
wba.allocated_weight_tons AS "allocatedWeightTons",
(SELECT c.container_number FROM freight.containers c
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
@@ -279,11 +288,24 @@ export class SchedulingReadFacade {
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
AND wba.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM freight.train_set_wagons tsw_match
WHERE tsw_match.id = wba.train_set_wagon_id
AND tsw_match.train_set_id = ts.train_set_id
AND tsw_match.deleted_at IS NULL
)
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
ORDER BY b.reference ASC NULLS LAST`,
ORDER BY tsw.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST`,
[scheduleId],
);
return rows;
@@ -401,6 +423,9 @@ export class SchedulingReadFacade {
b.reference,
b.company_id,
company.name AS customer_name,
w.wagon_number,
tsw.sequence_no,
wba.allocated_weight_tons,
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type,
b.cargo_total_weight_vgm AS booking_weight,
oy.code AS origin,
@@ -412,6 +437,19 @@ export class SchedulingReadFacade {
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
AND wba.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM freight.train_set_wagons tsw_match
WHERE tsw_match.id = wba.train_set_wagon_id
AND tsw_match.train_set_id = ts.train_set_id
AND tsw_match.deleted_at IS NULL
)
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
@@ -423,6 +461,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
'CONTAINER' AS "itemType",
c.id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -441,6 +482,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
'CARGO' AS "itemType",
cg.id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -460,6 +504,9 @@ export class SchedulingReadFacade {
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
a.wagon_number AS "wagonNumber",
a.sequence_no AS "sequenceNo",
a.allocated_weight_tons AS "allocatedWeightTons",
CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
a.inventory_id AS "itemId",
a.inventory_id AS "inventoryId",
@@ -474,7 +521,7 @@ export class SchedulingReadFacade {
FROM assigned a
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`,
ORDER BY "sequenceNo" ASC NULLS LAST, "bookingReference" ASC NULLS LAST, "itemType" ASC`,
[scheduleId],
);
return rows;

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
@@ -273,6 +273,25 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Get(':id/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Post('bookings/:bookingId/approve-delivery')
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SignaturesService } from '../signatures/signatures.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -50,6 +51,8 @@ const normalizeWagonStatus = (status: string | null | undefined) =>
const isLoadableWagonStatus = (status: string | null | undefined) =>
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
export interface InventoryInquiryResult {
id: string;
inventoryId: string | null;
@@ -297,6 +300,9 @@ export interface ImportUnloadedRow {
pickupOption: string;
lastMileRequested: boolean;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
deliveredAt: string | null;
}
@Injectable()
@@ -316,6 +322,7 @@ export class WarehouseInventoryService {
private readonly interchangeDocuments: InterchangeDocumentsService,
private readonly lastMileService: LastMileService,
private readonly notifications: NotificationsService,
private readonly signatures: SignaturesService,
) {}
/**
@@ -1020,6 +1027,9 @@ export class WarehouseInventoryService {
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
inv.delivered_at AS "deliveredAt",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.warehouse_inventory inv
@@ -1049,7 +1059,16 @@ export class WarehouseInventoryService {
* states), with the columns the inspection screen needs. Read-only.
*/
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']);
return this.importQueueByStatuses([
'UNLOADED',
'DESTINATION_INSPECTION',
'UNDER_INSPECTION',
'ARRIVED_AT_WAREHOUSE',
'STORED',
'READY_FOR_PICKUP',
'DISPATCHED',
'DELIVERED',
]);
}
/**
@@ -2020,6 +2039,154 @@ export class WarehouseInventoryService {
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
if (!userId) {
throw new BadRequestException('Authentication is required to approve delivery');
}
const signature = await this.signatures.getForUser(userId);
if (!signature?.signatureImageUrl) {
throw new BadRequestException('Please save your signature before approving delivery');
}
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
await this.dataSource.query(
`SELECT inv.id,
inv.warehouse_id AS "warehouseId",
inv.notes
FROM freight.warehouse_inventory inv
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
WHERE inv.booking_id = $1
AND inv.deleted_at IS NULL
AND inv.inspection_status = 'PASSED'
ORDER BY inv.updated_at DESC NULLS LAST, inv.created_at DESC
LIMIT 1`,
[bookingId],
);
if (!item) {
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
}
const approvedAt = new Date();
const approval = {
approvedAt: approvedAt.toISOString(),
signerDisplayName: signature.signerDisplayName,
signatureImageUrl: signature.signatureImageUrl,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(item.id, {
notes: this.appendNote(existingNotes, approvalNote),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: `Customer approved delivery as ${signature.signerDisplayName}`,
performedBy: signature.signerDisplayName,
},
manager,
);
});
return {
bookingId,
inventoryId: item.id,
approvedAt: approval.approvedAt,
signerDisplayName: signature.signerDisplayName,
};
}
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
inv.booking_id AS "bookingId",
inv.quantity,
inv.weight,
inv.status,
inv.notes,
inv.inspection_status AS "inspectionStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
COALESCE(inv.unloaded_at, inv.arrived_at, inv.created_at) AS "handoverDate",
b.reference AS "bookingReference",
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
company.name AS "customerName",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
yard.name AS "yardName",
yard.code AS "yardCode",
zone.name AS "zoneName",
zone.code AS "zoneCode",
ts.train_number AS "trainSchedule"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[id],
);
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
if (row.inspectionStatus !== 'PASSED') {
throw new BadRequestException('Handover document is available after inspection has passed');
}
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
const html = this.buildHandoverDocumentHtml({
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
handedOverAt: new Date(row.handoverDate ?? Date.now()),
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
containerNumber: row.containerNumber ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row.status ?? null,
inspectionStatus: row.inspectionStatus ?? null,
releaseOrderReference: row.releaseOrderReference ?? null,
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
trainSchedule: row.trainSchedule ?? null,
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
});
return {
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
this.assertTransition(item.status, 'DELIVERED');
@@ -2619,6 +2786,183 @@ export class WarehouseInventoryService {
</html>`;
}
private buildHandoverDocumentHtml(data: {
reference: string;
handedOverAt: Date;
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
freightType: string | null;
tradeDirection: string | null;
containerNumber: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryStatus: string | null;
inspectionStatus: string | null;
releaseOrderReference: string | null;
releaseDate: Date | null;
trainSchedule: string | null;
customerApproval: {
approvedAt: string;
signerDisplayName: string;
signatureImageUrl: string;
} | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const fmt = (date: Date | string | null) => {
if (!date) return '-';
const parsed = date instanceof Date ? date : new Date(date);
if (Number.isNaN(parsed.getTime())) return '-';
return parsed.toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const rows = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Booking Status', data.bookingStatus],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Train Schedule', data.trainSchedule],
['Container Number', data.containerNumber],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
['Inspection Status', data.inspectionStatus],
['Release Order', data.releaseOrderReference],
['Release Date', fmt(data.releaseDate)],
];
const approval = data.customerApproval;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Import Goods Handover Document</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
h1 { margin: 8px 0 0; max-width: 380px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
.rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
.notice { width: 78%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 72px; }
.signature-img { display: block; max-width: 210px; max-height: 58px; margin: 2px 0 6px; object-fit: contain; }
.signature-meta { font-size: 11px; color: #061323; }
.seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
.seal span { position: relative; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Import Goods Handover Document</h1>
<div class="subtitle">EDR to customer warehouse handover</div>
</div>
<div class="ref">
Document No.
<strong>${esc(data.reference)}</strong>
Handover: ${esc(fmt(data.handedOverAt))}
</div>
</div>
<div class="rule"></div>
<div class="notice">
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
</div>
<div class="section-title">Handover Particulars</div>
<table>
<tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody>
</table>
<div class="section-title">Goods List</div>
<table>
<tbody>
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
</tbody>
</table>
<div class="section-title">Handover Clause</div>
<div class="clause">
The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference,
inspection status, and release records before final physical handover.
</div>
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
<div class="line">
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>
<div class="signature-meta">${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}</div>
</div>
</div>
</body>
</html>`;
}
private extractCustomerDeliveryApproval(notes?: string | null): {
approvedAt: string;
signerDisplayName: string;
signatureImageUrl: string;
} | null {
if (!notes) return null;
const line = notes
.split(/\r?\n/)
.find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
if (!line) return null;
try {
const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length));
if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null;
return {
approvedAt: String(parsed.approvedAt),
signerDisplayName: String(parsed.signerDisplayName),
signatureImageUrl: String(parsed.signatureImageUrl),
};
} catch {
return null;
}
}
private stripCustomerDeliveryApproval(notes?: string | null): string | null {
if (!notes?.trim()) return null;
const lines = notes
.split(/\r?\n/)
.filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
return lines.join('\n').trim() || null;
}
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
throw new BadRequestException(`Invalid transition ${from}${to}`);

View File

@@ -7,6 +7,7 @@ import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
@@ -73,6 +74,7 @@ import { WarehousesService } from './warehouses.service';
InterchangeDocumentsModule,
forwardRef(() => LastMileModule),
NotificationsModule,
SignaturesModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>

View File

@@ -0,0 +1,85 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
import { NestFactory } from '@nestjs/core';
import { DataSource } from 'typeorm';
config({ path: resolve(__dirname, '../../.env') });
process.env.TYPEORM_LOGGING = 'false';
import { AppModule } from '../app.module';
import { deriveTradeDirection } from '../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service';
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn'],
});
try {
const dataSource = app.get(DataSource);
const inventory = app.get(WarehouseInventoryService);
const schedules: {
id: string;
trainNumber: string | null;
originCountry: string | null;
destinationCountry: string | null;
}[] = await dataSource.query(
`SELECT ts.id,
ts.train_number AS "trainNumber",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.status = 'ARRIVED'
AND ts.deleted_at IS NULL
ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST,
ts.created_at DESC`,
);
const importSchedules = schedules.filter(
(schedule) =>
deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
) === 'IMPORT',
);
if (importSchedules.length === 0) {
console.log('No ARRIVED import trains found.');
return;
}
for (const schedule of importSchedules) {
const result = await inventory.autoUnloadArrivedBookings(
schedule.id,
'Demo Auto Unload',
);
console.log(
`${schedule.trainNumber ?? schedule.id}: ${result.unloadedCount} unloaded, ${result.skippedCount} skipped, ${result.failedCount} failed`,
);
for (const item of result.results) {
console.log(` - ${item.bookingId}: ${item.status}${item.reason ? ` (${item.reason})` : ''}`);
}
}
const queueRows = await inventory.importUnloadedQueue();
console.log(`Import Unloaded Queue rows now visible: ${queueRows.length}`);
const byStatus = queueRows.reduce<Record<string, number>>((acc, row) => {
acc[row.currentStatus] = (acc[row.currentStatus] ?? 0) + 1;
return acc;
}, {});
for (const [status, count] of Object.entries(byStatus)) {
console.log(` ${status}: ${count}`);
}
} finally {
await app.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -1,16 +1,30 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
import { WagonStatus } from '@edr/types';
config({ path: resolve(__dirname, '../../.env') });
import { AppDataSource } from '../data-source';
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 { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.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';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { Wagon } from '../modules/wagons/entities/wagon.entity';
const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01';
const BOOKING_REFS = ['NEGAD-INDODE-BKG-001', 'NEGAD-INDODE-BKG-002', 'NEGAD-INDODE-BKG-003'] as const;
function addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
@@ -25,18 +39,34 @@ async function main() {
const locomotiveRepo = manager.getRepository(Locomotive);
const trainSetRepo = manager.getRepository(TrainSet);
const scheduleRepo = manager.getRepository(TrainSchedule);
const wagonTypeRepo = manager.getRepository(WagonType);
const wagonRepo = manager.getRepository(Wagon);
const trainSetWagonRepo = manager.getRepository(TrainSetWagon);
const serviceTypeRepo = manager.getRepository(ServiceType);
const containerTypeRepo = manager.getRepository(ContainerType);
const companyRepo = manager.getRepository(Company);
const bookingRepo = manager.getRepository(Booking);
const bookingContainerRepo = manager.getRepository(BookingContainer);
const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking);
const allocationRepo = manager.getRepository(WagonBookingAllocation);
const containerItemRepo = manager.getRepository(WagonAllocationContainerItem);
const importOperationRepo = manager.getRepository(ImportDjiboutiOperation);
const negad =
(await yardRepo.findOne({ where: { code: 'NEGAD' } })) ??
(await yardRepo.save(
yardRepo.create({
code: 'NEGAD',
label: 'Negad',
label: 'Negad / Nagad',
country: 'Djibouti',
isActive: true,
displayOrder: 5,
}),
));
if (negad.label !== 'Negad / Nagad') {
negad.label = 'Negad / Nagad';
await yardRepo.save(negad);
}
const indode =
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
@@ -64,6 +94,78 @@ async function main() {
}),
));
const wagonType =
(await wagonTypeRepo.findOne({ where: { code: 'NEGAD-FLAT' } })) ??
(await wagonTypeRepo.save(
wagonTypeRepo.create({
code: 'NEGAD-FLAT',
name: 'Negad Demo Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
equatedLengthM: 14,
tareWeightTons: 20,
supportsContainer: true,
maxContainerGrossT: 70,
}),
));
const containerType =
(await containerTypeRepo.findOne({ where: { code: '40FT' } })) ??
(await containerTypeRepo.save(
containerTypeRepo.create({
code: '40FT',
label: '40FT',
sizeFt: 40,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: 2,
}),
));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.save(
serviceTypeRepo.create({
code: 'RAIL_CONTAINER',
serviceName: 'Rail Container Service',
description: 'Rail container service for demo marshalling',
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
}),
));
const company =
(await companyRepo.findOne({ where: { tin: 'NEGADIND01' } })) ??
(await companyRepo.save(
companyRepo.create({
name: 'Negad Indode Marshalling Demo Customer',
type: CompanyType.Customer,
status: CompanyStatus.Active,
tin: 'NEGADIND01',
vatNumber: 'NEGADIND01',
fanNumber: 'NEGADINDODE00001',
country: 'Ethiopia',
address: 'Indode Dry Port',
phone: '251900000202',
email: 'negad-indode-demo@edr.local',
contactPersonName: 'Marshalling Demo',
contactPersonPhone: '251900000202',
generalManagerName: 'Demo Manager',
generalManagerEmail: 'negad-indode-demo@edr.local',
generalManagerPhone: '251900000202',
}),
));
const now = new Date();
const departure = addHours(now, -12);
const arrival = now;
@@ -75,7 +177,7 @@ async function main() {
locomotiveId: locomotive.id,
totalWeightTons: 960,
totalLengthMeters: 420,
wagonCount: 18,
wagonCount: BOOKING_REFS.length,
status: 'COMPLETED',
}),
);
@@ -111,9 +213,169 @@ async function main() {
}
const saved = await scheduleRepo.save(schedule);
await trainSetRepo.update(saved.trainSetId, {
totalWeightTons: BOOKING_REFS.length * 28,
totalLengthMeters: BOOKING_REFS.length * 14,
wagonCount: BOOKING_REFS.length,
status: 'COMPLETED',
});
const existingSlots = await trainSetWagonRepo.find({ where: { trainSetId: saved.trainSetId } });
const existingAllocations = existingSlots.length
? await allocationRepo.find({
where: existingSlots.map((slot) => ({ trainSetWagonId: slot.id })),
})
: [];
if (existingAllocations.length) {
await containerItemRepo.delete(
existingAllocations.map((allocation) => ({ wagonBookingAllocationId: allocation.id })),
);
}
if (existingSlots.length) {
await allocationRepo.delete(existingSlots.map((slot) => ({ trainSetWagonId: slot.id })));
await wagonRepo.update(
existingSlots.map((slot) => ({ trainSetWagonId: slot.id })),
{
trainSetWagonId: null,
currentTrainScheduleId: null,
sequenceNumber: null,
status: WagonStatus.Available,
},
);
await trainSetWagonRepo.delete({ trainSetId: saved.trainSetId });
}
for (const [index, reference] of BOOKING_REFS.entries()) {
const sequenceNo = index + 1;
const containerNumber = `NEGADIND${String(sequenceNo).padStart(4, '0')}`;
const weightTons = 26 + sequenceNo;
let booking = await bookingRepo.findOne({ where: { reference } });
if (!booking) {
booking = bookingRepo.create({ reference });
}
Object.assign(booking, {
companyId: company.id,
originYardId: negad.id,
destinationYardId: indode.id,
serviceTypeId: serviceType.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
scheduledDate: departure,
estimatedShipmentDate: departure,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
cargoTypeId: null,
cargoFreeText: `Negad to Indode demo container ${sequenceNo}`,
cargoTotalWeightVgm: weightTons,
priorityScore: 75 - index,
trainScheduleId: saved.id,
schedulingStatus: 'SCHEDULED',
scheduledAt: now,
wagonsRequired: 1,
});
booking = await bookingRepo.save(booking);
await bookingContainerRepo.delete({ bookingId: booking.id });
const bookingContainer = await bookingContainerRepo.save(
bookingContainerRepo.create({
bookingId: booking.id,
containerTypeId: containerType.id,
containerNumber,
quantity: 1,
vgmPerUnitTons: weightTons,
totalVgmTons: weightTons,
wagonsRequired: 1,
weightLimitRuleId: null,
isOverweight: false,
overweightExcessTons: null,
}),
);
await scheduleBookingRepo.upsert(
{ trainScheduleId: saved.id, bookingId: booking.id },
{ conflictPaths: { trainScheduleId: true, bookingId: true } },
);
const wagon = await wagonRepo.save(
wagonRepo.create({
wagonNumber: `NEGAD-INDODE-WGN-${String(sequenceNo).padStart(2, '0')}`,
wagonTypeId: wagonType.id,
trainId: null,
sequenceNumber: sequenceNo,
tareWeight: 20,
maxPayloadWeight: 70,
status: WagonStatus.Assigned,
currentYardId: indode.id,
notes: 'Demo wagon for Negad to Indode marshalling',
trainSetWagonId: null,
currentTrainScheduleId: saved.id,
}),
);
const trainSetWagon = await trainSetWagonRepo.save(
trainSetWagonRepo.create({
trainSetId: saved.trainSetId,
wagonTypeId: wagonType.id,
physicalWagonId: wagon.id,
sequenceNo,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: weightTons,
status: 'LOADED',
}),
);
await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id });
const allocation = await allocationRepo.save(
allocationRepo.create({
trainSetWagonId: trainSetWagon.id,
bookingId: booking.id,
allocatedWeightTons: weightTons,
loadType: 'CONTAINER',
status: 'LOADED',
confirmedAt: now,
}),
);
await containerItemRepo.save(
containerItemRepo.create({
wagonBookingAllocationId: allocation.id,
bookingContainerId: bookingContainer.id,
containerId: null,
containerNumber,
containerTypeId: containerType.id,
positionOnWagon: 1,
sealNumber: `SEAL-${containerNumber}`,
grossWeightTons: weightTons,
}),
);
}
await importOperationRepo.upsert(
{
trainScheduleId: saved.id,
documents: {},
gatepassGrantedAt: departure,
readyForLoadingAt: departure,
loadedOnTrainAt: departure,
departedFromDjiboutiAt: departure,
loadListGeneratedAt: now,
performedBy: 'Seed Demo',
notes: 'Seeded marshalling data for Negad to Indode arrived train',
},
{ conflictPaths: { trainScheduleId: true } },
);
console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`);
console.log(`Schedule ID: ${saved.id}`);
console.log(`Route: ${negad.code} -> ${indode.code}`);
console.log(`Marshalling data: ${BOOKING_REFS.length} bookings, wagons and allocations`);
});
} finally {
await dataSource.destroy();

View File

@@ -0,0 +1,474 @@
import { Injectable, Logger } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { Wagon } from '../modules/wagons/entities/wagon.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
type DemoDirection = 'IMPORT' | 'EXPORT';
interface DemoTrain {
trainNumber: string;
direction: DemoDirection;
status: 'SCHEDULED' | 'DISPATCHED' | 'ARRIVED';
bookingPrefix: string;
departureOffsetHours: number;
}
const DEMO_TRAINS: DemoTrain[] = [
{
trainNumber: 'MSH-DEMO-IMP-01',
direction: 'IMPORT',
status: 'SCHEDULED',
bookingPrefix: 'MSH-IMP-01',
departureOffsetHours: 6,
},
{
trainNumber: 'MSH-DEMO-IMP-02',
direction: 'IMPORT',
status: 'DISPATCHED',
bookingPrefix: 'MSH-IMP-02',
departureOffsetHours: -3,
},
{
trainNumber: 'MSH-DEMO-IMP-03',
direction: 'IMPORT',
status: 'ARRIVED',
bookingPrefix: 'MSH-IMP-03',
departureOffsetHours: -14,
},
{
trainNumber: 'MSH-DEMO-EXP-01',
direction: 'EXPORT',
status: 'SCHEDULED',
bookingPrefix: 'MSH-EXP-01',
departureOffsetHours: 8,
},
{
trainNumber: 'MSH-DEMO-EXP-02',
direction: 'EXPORT',
status: 'DISPATCHED',
bookingPrefix: 'MSH-EXP-02',
departureOffsetHours: -2,
},
{
trainNumber: 'MSH-DEMO-EXP-03',
direction: 'EXPORT',
status: 'ARRIVED',
bookingPrefix: 'MSH-EXP-03',
departureOffsetHours: -12,
},
];
@Injectable()
export class MarshallingDemoTrainsSeeder {
private readonly logger = new Logger(MarshallingDemoTrainsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
try {
const yardRepo = this.dataSource.getRepository(Yard);
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
const wagonTypeRepo = this.dataSource.getRepository(WagonType);
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
const djiboutiYard =
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const ethiopiaYard =
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
const wagonType =
(await wagonTypeRepo.findOne({ where: { code: 'NW5' } })) ??
(await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ??
(await wagonTypeRepo.findOne({ where: { isActive: true } }));
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
const warehouseYard = warehouse
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
: null;
const warehouseZone = warehouseYard
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
: null;
const missing = [
!djiboutiYard ? 'Djibouti yard' : '',
!ethiopiaYard ? 'Ethiopia yard' : '',
!serviceType ? 'service type' : '',
!wagonType ? 'wagon type' : '',
!warehouse ? 'INDODE_OPEN warehouse' : '',
!warehouseYard ? 'warehouse yard' : '',
!warehouseZone ? 'warehouse zone' : '',
].filter(Boolean);
if (missing.length) {
this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`);
return;
}
let seeded = 0;
for (const demo of DEMO_TRAINS) {
const created = await this.seedTrain(demo, {
djiboutiYard: djiboutiYard!,
ethiopiaYard: ethiopiaYard!,
serviceType: serviceType!,
cargoType,
wagonType: wagonType!,
warehouse: warehouse!,
warehouseYard: warehouseYard!,
warehouseZone: warehouseZone!,
});
if (created) seeded += 1;
}
this.logger.log(`Marshalling demo trains ready: ${seeded} new train(s) seeded, 6 total expected`);
} catch (error) {
this.logger.error(
`MarshallingDemoTrainsSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async seedTrain(
demo: DemoTrain,
refs: {
djiboutiYard: Yard;
ethiopiaYard: Yard;
serviceType: ServiceType;
cargoType: CargoType | null;
wagonType: WagonType;
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
},
): Promise<boolean> {
const bookingRepo = this.dataSource.getRepository(Booking);
const trainSetRepo = this.dataSource.getRepository(TrainSet);
const trainSetWagonRepo = this.dataSource.getRepository(TrainSetWagon);
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
const allocationRepo = this.dataSource.getRepository(WagonBookingAllocation);
const containerItemRepo = this.dataSource.getRepository(WagonAllocationContainerItem);
const existing = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
if (existing) {
await this.backfillDispatchQueueInventory(demo, refs);
return false;
}
const now = new Date();
const departure = this.addHours(now, demo.departureOffsetHours);
const arrival = this.addHours(departure, demo.direction === 'IMPORT' ? 12 : 10);
const isDispatched = demo.status === 'DISPATCHED';
const isArrived = demo.status === 'ARRIVED';
const hasDeparted = isDispatched || isArrived;
const originYard = demo.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard;
const destinationYard = demo.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard;
const locomotive = await this.ensureLocomotive(originYard.id);
const bookingWeights = [22.4, 24.8, 18.6, 20.2];
const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0);
const wagonCapacity = Number(refs.wagonType.capacityTons) || 70;
const wagonLength = Number(refs.wagonType.lengthMeters) || 14;
const tareWeight = Number(refs.wagonType.tareWeightTons) || 14;
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: totalWeight,
totalLengthMeters: wagonLength * bookingWeights.length,
wagonCount: bookingWeights.length,
status: isArrived ? 'COMPLETED' : isDispatched ? 'DISPATCHED' : 'ASSIGNED',
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: originYard.id,
destinationStationId: destinationYard.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualDepartureAt: hasDeparted ? departure : null,
actualArrivalAt: isArrived ? arrival : null,
status: demo.status as TrainSchedule['status'],
trainNumber: demo.trainNumber,
direction: demo.direction,
maxWagons: 53,
bookingWindowStatus: 'CLOSED',
}),
);
for (const [index, weightTons] of bookingWeights.entries()) {
const sequence = index + 1;
const bookingReference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
const containerNumber = `${demo.direction === 'IMPORT' ? 'IMDU' : 'EXPU'}${demo.trainNumber.slice(-2)}${String(sequence).padStart(3, '0')}`;
const booking = await bookingRepo.save(
bookingRepo.create({
reference: bookingReference,
originYardId: originYard.id,
destinationYardId: destinationYard.id,
serviceTypeId: refs.serviceType.id,
status: hasDeparted ? 'IN_TRANSIT' : 'PAID',
paymentStatus: 'PAID',
scheduledDate: departure,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: demo.direction,
freightType: sequence % 2 === 0 ? 'BULK' : 'CONTAINER',
cargoTypeId: refs.cargoType?.id ?? null,
cargoFreeText: refs.cargoType ? null : `${demo.direction} marshalling demo goods ${sequence}`,
cargoTotalWeightVgm: weightTons * 1000,
trainScheduleId: schedule.id,
schedulingStatus: isArrived ? 'ARRIVED' : isDispatched ? 'DISPATCHED' : 'SCHEDULED',
scheduledAt: now,
}),
);
await this.ensureDispatchQueueInventory({
booking,
demo,
refs,
weightKg: weightTons * 1000,
now,
});
const physicalWagon = await this.ensureWagon({
wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`,
wagonTypeId: refs.wagonType.id,
yardId: originYard.id,
trainScheduleId: schedule.id,
tareWeight,
capacityTons: wagonCapacity,
dispatched: hasDeparted,
});
const trainSetWagon = await trainSetWagonRepo.save(
trainSetWagonRepo.create({
trainSetId: trainSet.id,
wagonTypeId: refs.wagonType.id,
physicalWagonId: physicalWagon.id,
sequenceNo: sequence,
capacityTons: wagonCapacity,
lengthMeters: wagonLength,
assignedWeightTons: weightTons,
status: hasDeparted ? 'DEPARTED' : 'LOADED',
}),
);
await this.dataSource.getRepository(Wagon).update(physicalWagon.id, {
trainSetWagonId: trainSetWagon.id,
});
const allocation = await allocationRepo.save(
allocationRepo.create({
trainSetWagonId: trainSetWagon.id,
bookingId: booking.id,
allocatedWeightTons: weightTons,
loadType: booking.freightType === 'CONTAINER' ? 'CONTAINER' : 'BULK',
status: hasDeparted ? 'DEPARTED' : 'LOADED',
confirmedAt: now,
}),
);
await containerItemRepo.save(
containerItemRepo.create({
wagonBookingAllocationId: allocation.id,
containerNumber,
positionOnWagon: 1,
sealNumber: `SEAL-${demo.trainNumber.slice(-2)}-${sequence}`,
chassisNumber: `CHS-${demo.trainNumber.slice(-2)}-${sequence}`,
grossWeightTons: weightTons,
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
if (demo.direction === 'IMPORT') {
await this.seedImportOperation(schedule.id, demo.trainNumber, now, departure, hasDeparted);
}
return true;
}
private async backfillDispatchQueueInventory(
demo: DemoTrain,
refs: {
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
},
): Promise<void> {
const bookingRepo = this.dataSource.getRepository(Booking);
for (let sequence = 1; sequence <= 4; sequence++) {
const reference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
const booking = await bookingRepo.findOne({ where: { reference } });
if (!booking) continue;
await this.ensureDispatchQueueInventory({
booking,
demo,
refs,
weightKg: Number(booking.cargoTotalWeightVgm) || 0,
now: new Date(),
});
}
}
private async ensureDispatchQueueInventory(input: {
booking: Booking;
demo: DemoTrain;
refs: {
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
};
weightKg: number;
now: Date;
}): Promise<void> {
const repo = this.dataSource.getRepository(WarehouseInventory);
const existing = await repo.findOne({ where: { bookingId: input.booking.id } });
if (existing) return;
const exportDispatch = input.demo.direction === 'EXPORT';
const arrivedAt = this.addHours(input.now, -8);
const inspectedAt = this.addHours(input.now, -6);
const readyAt = this.addHours(input.now, -4);
const loadedAt = this.addHours(input.now, -2);
await repo.save(
repo.create({
warehouseId: input.refs.warehouse.id,
yardId: input.refs.warehouseYard.id,
zoneId: input.refs.warehouseZone.id,
bookingId: input.booking.id,
quantity: 1,
weight: input.weightKg,
status: exportDispatch ? 'LOADED' : 'READY_FOR_PICKUP',
inspectionStatus: 'PASSED',
arrivedAt,
unloadedAt: exportDispatch ? null : arrivedAt,
inspectedAt,
readyForLoadingAt: exportDispatch ? readyAt : null,
loadedAt: exportDispatch ? loadedAt : null,
readyForPickupAt: exportDispatch ? null : readyAt,
notes: `[MSH-DEMO] ${input.demo.trainNumber} dispatch queue test item`,
}),
);
}
private async ensureLocomotive(currentYardId: string): Promise<Locomotive> {
const repo = this.dataSource.getRepository(Locomotive);
const existing = await repo.findOne({ where: { code: 'MSH-DEMO-LOCO' } });
if (existing) return existing;
return repo.save(
repo.create({
code: 'MSH-DEMO-LOCO',
name: 'Marshalling Demo Locomotive',
locomotiveType: 'DIESEL',
maxPullWeightTons: 4200,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
currentYardId,
}),
);
}
private async ensureWagon(input: {
wagonNumber: string;
wagonTypeId: string;
yardId: string;
trainScheduleId: string;
tareWeight: number;
capacityTons: number;
dispatched: boolean;
}): Promise<Wagon> {
const repo = this.dataSource.getRepository(Wagon);
const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } });
if (existing) return existing;
return repo.save(
repo.create({
wagonNumber: input.wagonNumber,
wagonTypeId: input.wagonTypeId,
currentYardId: input.yardId,
currentTrainScheduleId: input.trainScheduleId,
tareWeight: input.tareWeight,
maxPayloadWeight: input.capacityTons,
status: WagonStatus.Assigned,
notes: 'Marshalling demo seed wagon',
}),
);
}
private async seedImportOperation(
trainScheduleId: string,
trainNumber: string,
now: Date,
departure: Date,
dispatched: boolean,
): Promise<void> {
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
await repo.save(
repo.create({
trainScheduleId,
documents: {
DELIVERY_ORDER: this.documentRecord(trainNumber, 'DELIVERY_ORDER', now),
PORT_INVOICE: this.documentRecord(trainNumber, 'PORT_INVOICE', now),
DJIBOUTI_T1: this.documentRecord(trainNumber, 'DJIBOUTI_T1', now),
ETHIOPIA_T1: this.documentRecord(trainNumber, 'ETHIOPIA_T1', now),
RAILWAY_BILL: this.documentRecord(trainNumber, 'RAILWAY_BILL', now),
},
gatepassGrantedAt: now,
readyForLoadingAt: now,
loadedOnTrainAt: now,
departedFromDjiboutiAt: dispatched ? departure : null,
performedBy: 'Marshalling Demo Seeder',
notes: '[MSH-DEMO] Import train ready for marshalling document and dispatch workflow',
}),
);
}
private documentRecord(trainNumber: string, type: string, now: Date) {
return {
reference: `${type}-${trainNumber}`,
uploadedAt: now.toISOString(),
uploadedBy: 'Marshalling Demo Seeder',
notes: 'Seeded document for import marshalling workflow',
};
}
private addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
}
}