interchange document generation and acknowledgement

This commit is contained in:
hagiye
2026-06-27 13:39:42 +03:00
parent d726927453
commit 3decc9aeea
11 changed files with 520 additions and 108 deletions

View File

@@ -52,6 +52,7 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
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 { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules
@@ -145,6 +146,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
Batch7TestDataSeeder,
Batch8TestDataSeeder,
WarehouseDemoSeeder,
ExportDjiboutiInterchangeDemoSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -161,6 +163,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
@@ -179,6 +182,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.batch7TestDataSeeder.run();
await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.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

@@ -265,12 +265,12 @@ export class InterchangeDocumentsService {
)
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CONTAINER' AS "itemType",
'CONTAINER'::varchar AS "itemType",
COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId",
NULL AS "bookingCargoId",
NULL::uuid AS "bookingCargoId",
COALESCE(c.container_number, bc.container_number) AS "containerNumber",
c.seal_number AS "sealNumber",
NULL AS "cargoId",
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
@@ -288,12 +288,12 @@ export class InterchangeDocumentsService {
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CONTAINER' AS "itemType",
'CONTAINER'::varchar AS "itemType",
bc.id AS "bookingContainerId",
NULL AS "bookingCargoId",
NULL::uuid AS "bookingCargoId",
bc.container_number AS "containerNumber",
NULL AS "sealNumber",
NULL AS "cargoId",
NULL::varchar AS "sealNumber",
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight",
@@ -314,11 +314,11 @@ export class InterchangeDocumentsService {
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CARGO' AS "itemType",
NULL AS "bookingContainerId",
'CARGO'::varchar AS "itemType",
NULL::uuid AS "bookingContainerId",
cg.id AS "bookingCargoId",
NULL AS "containerNumber",
NULL AS "sealNumber",
NULL::varchar AS "containerNumber",
NULL::varchar AS "sealNumber",
cg.id AS "cargoId",
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
@@ -337,12 +337,12 @@ export class InterchangeDocumentsService {
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
NULL AS "bookingContainerId",
NULL AS "bookingCargoId",
NULL AS "containerNumber",
NULL AS "sealNumber",
NULL AS "cargoId",
(CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType",
NULL::uuid AS "bookingContainerId",
NULL::uuid AS "bookingCargoId",
NULL::varchar AS "containerNumber",
NULL::varchar AS "sealNumber",
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
a.cargo_total_weight_vgm AS "weight",

View File

@@ -329,7 +329,7 @@ export class SchedulingReadFacade {
}
if (filter.destination) {
params.push(`%${filter.destination}%`);
where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`);
where.push(`(dy.code ILIKE $${params.length} OR dy.label ILIKE $${params.length})`);
}
if (filter.dateFrom) {
params.push(filter.dateFrom);
@@ -351,7 +351,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
dy.name AS "destinationName",
dy.label AS "destinationName",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
ts.scheduled_departure_date AS "departureTime",

View File

@@ -1261,7 +1261,7 @@ export class WarehouseInventoryService {
oy.country AS "originCountry",
dy.country AS "destinationCountry",
dy.code AS "destinationCode",
dy.name AS "destinationName"
dy.label AS "destinationName"
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
@@ -1402,6 +1402,20 @@ export class WarehouseInventoryService {
}
const currentStatus = item.inventoryStatus ?? item.bookingStatus;
if (currentStatus === 'UNLOADED_AT_DJIBOUTI_PORT') {
seenInventory.add(item.inventoryId);
result.unloadedCount += 1;
result.results.push({
bookingId: item.bookingId,
itemType: item.itemType,
itemId: item.itemId,
inventoryId: item.inventoryId,
containerNumber: item.containerNumber,
status: 'UNLOADED_AT_DJIBOUTI_PORT',
message: 'Already unloaded at Djibouti Port',
});
continue;
}
if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) {
skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`);
continue;

View File

@@ -21,8 +21,18 @@ import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.ent
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01';
const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'];
const DEMO_TRAINS = [
{
trainNumber: 'ICD-DEMO-EXP-DJ-01',
bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'],
arrivalOffsetHours: 1,
},
{
trainNumber: 'ICD-DEMO-EXP-DJ-02',
bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'],
arrivalOffsetHours: 2,
},
];
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
@@ -44,13 +54,6 @@ async function main() {
const scheduleRepo = dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking);
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } });
if (existingSchedule) {
console.log(`Export Djibouti interchange demo already seeded: ${TRAIN_NUMBER}`);
console.log(`Schedule ID: ${existingSchedule.id}`);
return;
}
const originYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
@@ -83,10 +86,6 @@ async function main() {
throw new Error(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
}
const now = Date.now();
const departure = new Date(now - 6 * 60 * 60 * 1000);
const arrival = new Date(now - 60 * 60 * 1000);
const locomotive =
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
(await locomotiveRepo.save(
@@ -97,83 +96,106 @@ async function main() {
}),
));
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: 700,
totalLengthMeters: 360,
wagonCount: 12,
status: 'COMPLETED',
}),
);
const now = Date.now();
const seededSchedules: TrainSchedule[] = [];
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: originYard!.id,
destinationStationId: destinationYard!.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualArrivalAt: arrival,
status: 'ARRIVED' as TrainSchedule['status'],
trainNumber: TRAIN_NUMBER,
}),
);
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
if (existingSchedule) {
console.log(`Export Djibouti interchange demo already seeded: ${demo.trainNumber}`);
console.log(`Schedule ID: ${existingSchedule.id}`);
seededSchedules.push(existingSchedule);
continue;
}
for (const [index, reference] of BOOKING_REFS.entries()) {
const weight = 5200 + index * 800;
const booking = await bookingRepo.save(
bookingRepo.create({
reference,
originYardId: originYard!.id,
destinationYardId: destinationYard!.id,
serviceTypeId: serviceType!.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: index % 2 === 0 ? 'CONTAINER' : 'BULK',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType ? null : `Export Djibouti interchange demo cargo ${index + 1}`,
cargoTotalWeightVgm: weight,
const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: 700 + trainIndex * 80,
totalLengthMeters: 360 + trainIndex * 20,
wagonCount: 12 + trainIndex,
status: 'COMPLETED',
}),
);
await inventoryRepo.save(
inventoryRepo.create({
warehouseId: warehouse!.id,
yardId: warehouseYard!.id,
zoneId: warehouseZone!.id,
bookingId: booking.id,
quantity: 1,
weight,
status: 'DISPATCHED',
inspectionStatus: 'PASSED',
arrivedAt: new Date(now - 4 * 60 * 60 * 1000),
inspectedAt: new Date(now - 3 * 60 * 60 * 1000),
readyForLoadingAt: new Date(now - 2 * 60 * 60 * 1000),
loadedAt: new Date(now - 90 * 60 * 1000),
dispatchedAt: new Date(now - 70 * 60 * 1000),
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: originYard!.id,
destinationStationId: destinationYard!.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualArrivalAt: arrival,
status: 'ARRIVED' as TrainSchedule['status'],
trainNumber: demo.trainNumber,
direction: 'EXPORT',
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
const booking = await bookingRepo.save(
bookingRepo.create({
reference,
originYardId: originYard!.id,
destinationYardId: destinationYard!.id,
serviceTypeId: serviceType!.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType
? null
: `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
cargoTotalWeightVgm: weight,
}),
);
await inventoryRepo.save(
inventoryRepo.create({
warehouseId: warehouse!.id,
yardId: warehouseYard!.id,
zoneId: warehouseZone!.id,
bookingId: booking.id,
quantity: 1,
weight,
status: 'DISPATCHED',
inspectionStatus: 'PASSED',
arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000),
inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000),
readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000),
loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000),
dispatchedAt: departure,
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
seededSchedules.push(schedule);
}
console.log('Export Djibouti interchange demo seeded.');
console.log(`Train number: ${TRAIN_NUMBER}`);
console.log(`Schedule ID: ${schedule.id}`);
for (const schedule of seededSchedules) {
console.log(`Train number: ${schedule.trainNumber}`);
console.log(`Schedule ID: ${schedule.id}`);
}
console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.');
} finally {
await app.close();

View File

@@ -0,0 +1,202 @@
import { Injectable, Logger } from '@nestjs/common';
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 { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.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';
const SEED_FLAG = 'SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO';
const DEMO_TRAINS = [
{
trainNumber: 'ICD-DEMO-EXP-DJ-01',
bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'],
arrivalOffsetHours: 1,
},
{
trainNumber: 'ICD-DEMO-EXP-DJ-02',
bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'],
arrivalOffsetHours: 2,
},
];
@Injectable()
export class ExportDjiboutiInterchangeDemoSeeder {
private readonly logger = new Logger(ExportDjiboutiInterchangeDemoSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
this.logger.log(`Skipping export Djibouti interchange demo seed because ${SEED_FLAG} is not enabled`);
return;
}
try {
const yardRepo = this.dataSource.getRepository(Yard);
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
const bookingRepo = this.dataSource.getRepository(Booking);
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
const locomotiveRepo = this.dataSource.getRepository(Locomotive);
const trainSetRepo = this.dataSource.getRepository(TrainSet);
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
const originYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const destinationYard =
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
const cargoType = await cargoTypeRepo.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 = [
!originYard ? 'Ethiopian origin yard' : '',
!destinationYard ? 'Djibouti destination yard' : '',
!serviceType ? 'service type' : '',
!warehouse ? 'INDODE_OPEN warehouse' : '',
!warehouseYard ? 'warehouse yard' : '',
!warehouseZone ? 'warehouse zone' : '',
].filter(Boolean);
if (missing.length) {
this.logger.warn(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
return;
}
const locomotive =
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
(await locomotiveRepo.save(
locomotiveRepo.create({
code: 'ICD-DEMO-LOCO',
name: 'Interchange Demo Locomotive',
maxPullWeightTons: 4000,
}),
));
const now = Date.now();
let seeded = 0;
let skipped = 0;
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
if (existingSchedule) {
skipped += 1;
continue;
}
const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: 700 + trainIndex * 80,
totalLengthMeters: 360 + trainIndex * 20,
wagonCount: 12 + trainIndex,
status: 'COMPLETED',
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: originYard!.id,
destinationStationId: destinationYard!.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualArrivalAt: arrival,
status: 'ARRIVED' as TrainSchedule['status'],
trainNumber: demo.trainNumber,
direction: 'EXPORT',
}),
);
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
const booking = await bookingRepo.save(
bookingRepo.create({
reference,
originYardId: originYard!.id,
destinationYardId: destinationYard!.id,
serviceTypeId: serviceType!.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType
? null
: `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
cargoTotalWeightVgm: weight,
}),
);
await inventoryRepo.save(
inventoryRepo.create({
warehouseId: warehouse!.id,
yardId: warehouseYard!.id,
zoneId: warehouseZone!.id,
bookingId: booking.id,
quantity: 1,
weight,
status: 'DISPATCHED',
inspectionStatus: 'PASSED',
arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000),
inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000),
readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000),
loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000),
dispatchedAt: departure,
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
seeded += 1;
}
this.logger.log(`Export Djibouti interchange demo seed complete: ${seeded} train(s) seeded, ${skipped} skipped`);
} catch (error) {
this.logger.error(
`ExportDjiboutiInterchangeDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}