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

@@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria
# Freight org + staff (bookings / rule-engine IAM) # Freight org + staff (bookings / rule-engine IAM)
SEED_EDR_ORG=true SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true SEED_FREIGHT_STAFF=true
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
# MinIO (used by @tria-plc/iamapi-common for file storage) # MinIO (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost MINIO_ENDPOINT=localhost

View File

@@ -52,6 +52,7 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.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 { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules //New Trains, Wagons, Container and Cargo management modules
@@ -145,6 +146,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
Batch7TestDataSeeder, Batch7TestDataSeeder,
Batch8TestDataSeeder, Batch8TestDataSeeder,
WarehouseDemoSeeder, WarehouseDemoSeeder,
ExportDjiboutiInterchangeDemoSeeder,
], ],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
@@ -161,6 +163,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly batch7TestDataSeeder: Batch7TestDataSeeder, private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
private readonly batch8TestDataSeeder: Batch8TestDataSeeder, private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { } ) { }
@@ -179,6 +182,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.batch7TestDataSeeder.run(); await this.batch7TestDataSeeder.run();
await this.batch8TestDataSeeder.run(); await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run(); await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // 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. // Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,

View File

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

View File

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

View File

@@ -1261,7 +1261,7 @@ export class WarehouseInventoryService {
oy.country AS "originCountry", oy.country AS "originCountry",
dy.country AS "destinationCountry", dy.country AS "destinationCountry",
dy.code AS "destinationCode", dy.code AS "destinationCode",
dy.name AS "destinationName" dy.label AS "destinationName"
FROM freight.train_schedules ts FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_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; 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)) { if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) {
skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`); skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`);
continue; 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 { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01'; const DEMO_TRAINS = [
const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003']; {
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() { async function main() {
const app = await NestFactory.createApplicationContext(AppModule, { const app = await NestFactory.createApplicationContext(AppModule, {
@@ -44,13 +54,6 @@ async function main() {
const scheduleRepo = dataSource.getRepository(TrainSchedule); const scheduleRepo = dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking); 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 = const originYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? (await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } })); (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(', ')}`); 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 = const locomotive =
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ?? (await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
(await locomotiveRepo.save( (await locomotiveRepo.save(
@@ -97,83 +96,106 @@ async function main() {
}), }),
)); ));
const trainSet = await trainSetRepo.save( const now = Date.now();
trainSetRepo.create({ const seededSchedules: TrainSchedule[] = [];
locomotiveId: locomotive.id,
totalWeightTons: 700,
totalLengthMeters: 360,
wagonCount: 12,
status: 'COMPLETED',
}),
);
const schedule = await scheduleRepo.save( for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
scheduleRepo.create({ const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
trainSetId: trainSet.id, if (existingSchedule) {
originStationId: originYard!.id, console.log(`Export Djibouti interchange demo already seeded: ${demo.trainNumber}`);
destinationStationId: destinationYard!.id, console.log(`Schedule ID: ${existingSchedule.id}`);
scheduledDepartureDate: departure, seededSchedules.push(existingSchedule);
scheduledArrivalDate: arrival, continue;
actualArrivalAt: arrival, }
status: 'ARRIVED' as TrainSchedule['status'],
trainNumber: TRAIN_NUMBER,
}),
);
for (const [index, reference] of BOOKING_REFS.entries()) { const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
const weight = 5200 + index * 800; const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
const booking = await bookingRepo.save(
bookingRepo.create({ const trainSet = await trainSetRepo.save(
reference, trainSetRepo.create({
originYardId: originYard!.id, locomotiveId: locomotive.id,
destinationYardId: destinationYard!.id, totalWeightTons: 700 + trainIndex * 80,
serviceTypeId: serviceType!.id, totalLengthMeters: 360 + trainIndex * 20,
status: 'IN_TRANSIT', wagonCount: 12 + trainIndex,
paymentStatus: 'PAID', status: 'COMPLETED',
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,
}), }),
); );
await inventoryRepo.save( const schedule = await scheduleRepo.save(
inventoryRepo.create({ scheduleRepo.create({
warehouseId: warehouse!.id, trainSetId: trainSet.id,
yardId: warehouseYard!.id, originStationId: originYard!.id,
zoneId: warehouseZone!.id, destinationStationId: destinationYard!.id,
bookingId: booking.id, scheduledDepartureDate: departure,
quantity: 1, scheduledArrivalDate: arrival,
weight, actualArrivalAt: arrival,
status: 'DISPATCHED', status: 'ARRIVED' as TrainSchedule['status'],
inspectionStatus: 'PASSED', trainNumber: demo.trainNumber,
arrivedAt: new Date(now - 4 * 60 * 60 * 1000), direction: 'EXPORT',
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',
}), }),
); );
await scheduleBookingRepo.save( for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
scheduleBookingRepo.create({ const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
trainScheduleId: schedule.id, const booking = await bookingRepo.save(
bookingId: booking.id, 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('Export Djibouti interchange demo seeded.');
console.log(`Train number: ${TRAIN_NUMBER}`); for (const schedule of seededSchedules) {
console.log(`Schedule ID: ${schedule.id}`); 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.'); console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.');
} finally { } finally {
await app.close(); 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)}`,
);
}
}
}

View File

@@ -1,6 +1,6 @@
export const API_BASE_URL = export const API_BASE_URL =
import.meta.env.VITE_BASE_API_URL || import.meta.env.VITE_BASE_API_URL ||
import.meta.env.VITE_API_URL || import.meta.env.VITE_API_URL ||
'https://edrfreightapi.triaplc.com'; 'https://edrfreightapi.triaplc.com';
//export const API_BASE_URL = 'http://localhost:3001'; //export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -7,6 +7,7 @@ import {
Group, Group,
Loader, Loader,
Modal, Modal,
Alert,
Stack, Stack,
Table, Table,
Tabs, Tabs,
@@ -168,7 +169,7 @@ function ExportTrainDetailRows({
export default function ExportDjiboutiUnloadingQueuePage() { export default function ExportDjiboutiUnloadingQueuePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { data: trains = [], isLoading } = useExportDjiboutiArrivalQueue(); const { data: trains = [], isLoading, isError, error } = useExportDjiboutiArrivalQueue();
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' }); const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
const autoUnload = useAutoUnloadExportAtDjibouti(); const autoUnload = useAutoUnloadExportAtDjibouti();
const generateInterchange = useGenerateInterchangeDocument(); const generateInterchange = useGenerateInterchangeDocument();
@@ -272,6 +273,10 @@ export default function ExportDjiboutiUnloadingQueuePage() {
<Group justify="center" py="xl"> <Group justify="center" py="xl">
<Loader /> <Loader />
</Group> </Group>
) : isError ? (
<Alert color="red" variant="light" title="Could not load arrived export trains">
{getErrorMessage(error) ?? 'Check your API connection and sign in again.'}
</Alert>
) : trains.length === 0 ? ( ) : trains.length === 0 ? (
<VisualEmptyState <VisualEmptyState
variant="train" variant="train"

View File

@@ -12,7 +12,7 @@ import {
Text, Text,
TextInput, TextInput,
} from '@mantine/core'; } from '@mantine/core';
import { CheckCircle2, Eye, FileText, Search, XCircle } from 'lucide-react'; import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
@@ -25,6 +25,7 @@ import {
useInterchangeDocuments, useInterchangeDocuments,
} from '@/hooks/useInterchangeDocuments'; } from '@/hooks/useInterchangeDocuments';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument'; import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
const statusColor: Record<InterchangeDocumentStatus, string> = { const statusColor: Record<InterchangeDocumentStatus, string> = {
@@ -45,6 +46,116 @@ const getErrorMessage = (error: unknown) => {
return error instanceof Error ? error.message : undefined; return error instanceof Error ? error.message : undefined;
}; };
const escapeHtml = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const filenameFor = (document: InterchangeDocument) =>
`${document.documentNo || document.id}-interchange-document.html`.replace(/[\\/:*?"<>|]/g, '-');
const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
const items = document.items ?? [];
const rows = items
.map(
(item, index) => `
<tr>
<td>${index + 1}</td>
<td>${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))}</td>
<td>${escapeHtml(item.itemType)}</td>
<td>${escapeHtml(item.containerNumber)}</td>
<td>${escapeHtml(item.sealNumber)}</td>
<td>${escapeHtml(item.cargoType ?? item.cargoDescription)}</td>
<td>${escapeHtml(formatNumber(item.weight))}</td>
<td>${escapeHtml(formatNumber(item.quantity))}</td>
<td>${escapeHtml(item.wagonNumber)}</td>
<td>${escapeHtml(item.conditionStatus)}</td>
<td>${escapeHtml(item.damageDescription)}</td>
</tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(document.documentNo)} Interchange Document</title>
<style>
@page { size: A4 landscape; margin: 14mm; }
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; color: #111827; margin: 0; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid #111827; padding-bottom: 14px; }
h1 { margin: 0; font-size: 24px; }
.muted { color: #4b5563; font-size: 12px; }
.stamp { border: 2px solid #15803d; color: #15803d; border-radius: 999px; padding: 14px 18px; text-align: center; font-weight: 700; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 18px; margin: 18px 0; }
.field { border-bottom: 1px solid #d1d5db; padding-bottom: 6px; }
.label { color: #6b7280; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; }
.value { font-size: 13px; font-weight: 700; margin-top: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 11px; }
th, td { border: 1px solid #d1d5db; padding: 6px; text-align: left; vertical-align: top; }
th { background: #f3f4f6; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 34px; }
.signature { border-top: 1px solid #111827; padding-top: 8px; min-height: 48px; }
.footer { margin-top: 16px; font-size: 10px; color: #6b7280; }
</style>
</head>
<body>
<div class="top">
<div>
<h1>EDR / Djibouti Port Interchange Document</h1>
<div class="muted">Official export handover document</div>
<div class="muted">Document No: ${escapeHtml(document.documentNo)}</div>
</div>
<div class="stamp">${escapeHtml(document.status)}<br/>SIGNED HANDOVER</div>
</div>
<div class="grid">
<div class="field"><div class="label">Direction</div><div class="value">${escapeHtml(document.direction)}</div></div>
<div class="field"><div class="label">Train No</div><div class="value">${escapeHtml(document.trainNo)}</div></div>
<div class="field"><div class="label">Schedule</div><div class="value">${escapeHtml(document.scheduleId)}</div></div>
<div class="field"><div class="label">Handover Location</div><div class="value">${escapeHtml(document.handoverLocation)}</div></div>
<div class="field"><div class="label">Handover From</div><div class="value">${escapeHtml(document.handoverFrom)}</div></div>
<div class="field"><div class="label">Handover To</div><div class="value">${escapeHtml(document.handoverTo)}</div></div>
<div class="field"><div class="label">Generated At</div><div class="value">${escapeHtml(formatDate(document.generatedAt))}</div></div>
<div class="field"><div class="label">Acknowledged At</div><div class="value">${escapeHtml(formatDate(document.acknowledgedAt))}</div></div>
<div class="field"><div class="label">Signed by EDR</div><div class="value">${escapeHtml(document.generatedBy)}</div></div>
<div class="field"><div class="label">Signed by Djibouti Port</div><div class="value">${escapeHtml(document.acknowledgedBy)}</div></div>
<div class="field"><div class="label">Port Operator</div><div class="value">${escapeHtml(document.portOperatorName)}</div></div>
<div class="field"><div class="label">Manifest Ref</div><div class="value">${escapeHtml(document.manifestReference)}</div></div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Booking</th>
<th>Type</th>
<th>Container</th>
<th>Seal</th>
<th>Cargo</th>
<th>Weight</th>
<th>Qty</th>
<th>Wagon</th>
<th>Condition</th>
<th>Damage / Notes</th>
</tr>
</thead>
<tbody>${rows || '<tr><td colspan="11">No items</td></tr>'}</tbody>
</table>
<div class="signatures">
<div class="signature">EDR Representative: ${escapeHtml(document.generatedBy)}</div>
<div class="signature">Djibouti Port Operator: ${escapeHtml(document.acknowledgedBy)}</div>
</div>
<div class="footer">Generated from EDR Freight Management System. Printed on ${escapeHtml(new Date().toLocaleString())}.</div>
</body>
</html>`;
};
function DetailField({ label, value }: { label: string; value: ReactNode }) { function DetailField({ label, value }: { label: string; value: ReactNode }) {
return ( return (
<Stack gap={2}> <Stack gap={2}>
@@ -157,6 +268,37 @@ export default function InterchangeDocumentsPage() {
const dispute = useDisputeInterchangeDocument(); const dispute = useDisputeInterchangeDocument();
const cancel = useCancelInterchangeDocument(); const cancel = useCancelInterchangeDocument();
const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => {
if (interchangeDocument.items?.length) return interchangeDocument;
return interchangeDocumentsService.getById(interchangeDocument.id).then((response) => response.data);
};
const printDocument = async (interchangeDocument: InterchangeDocument) => {
const fullDocument = await getPrintableDocument(interchangeDocument);
const win = window.open('', '_blank');
if (!win) {
toast({ variant: 'destructive', title: 'Pop-up blocked', description: 'Allow pop-ups to print the document.' });
return;
}
win.document.write(buildPrintableInterchangeHtml(fullDocument));
win.document.close();
win.focus();
setTimeout(() => win.print(), 250);
};
const downloadDocument = async (interchangeDocument: InterchangeDocument) => {
const fullDocument = await getPrintableDocument(interchangeDocument);
const blob = new Blob([buildPrintableInterchangeHtml(fullDocument)], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFor(fullDocument);
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const run = async (fn: () => Promise<unknown>, title: string) => { const run = async (fn: () => Promise<unknown>, title: string) => {
try { try {
await fn(); await fn();
@@ -169,10 +311,10 @@ export default function InterchangeDocumentsPage() {
const acknowledgeDocument = (document: InterchangeDocument) => { const acknowledgeDocument = (document: InterchangeDocument) => {
const acknowledgedBy = window.prompt('Acknowledged by'); const acknowledgedBy = window.prompt('Acknowledged by');
if (!acknowledgedBy) return; if (!acknowledgedBy) return;
run( run(async () => {
() => acknowledge.mutateAsync({ id: document.id, acknowledgedBy }), const response = await acknowledge.mutateAsync({ id: document.id, acknowledgedBy });
'Interchange document acknowledged', await printDocument(response.data);
); }, 'Interchange document acknowledged');
}; };
const disputeDocument = (document: InterchangeDocument) => { const disputeDocument = (document: InterchangeDocument) => {
@@ -284,6 +426,28 @@ export default function InterchangeDocumentsPage() {
Acknowledge Acknowledge
</Button> </Button>
) : null} ) : null}
{document.status === 'ACKNOWLEDGED' ? (
<>
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<Printer size={14} />}
onClick={() => run(() => printDocument(document), 'Print view opened')}
>
Print
</Button>
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<Download size={14} />}
onClick={() => run(() => downloadDocument(document), 'Document downloaded')}
>
Download
</Button>
</>
) : null}
{document.status !== 'CANCELLED' ? ( {document.status !== 'CANCELLED' ? (
<Button <Button
size="compact-xs" size="compact-xs"

View File

@@ -1,2 +1,2 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001'; //export const API_BASE_URL = 'http://localhost:3001';