diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 4ccffb6a4..e02391ccd 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria # Freight org + staff (bookings / rule-engine IAM) SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true +SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false # MinIO (used by @tria-plc/iamapi-common for file storage) MINIO_ENDPOINT=localhost diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3b69d2812..02183f390 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts index 667f22659..fa84f2d94 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts @@ -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", diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index 7d73dc6ef..ca16bcaa5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -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", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index e13d77de0..e25286252 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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; diff --git a/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts b/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts index a91c0293b..c6ec67041 100644 --- a/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts @@ -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(); diff --git a/apps/edr-freight-api/src/seed/export-djibouti-interchange-demo.seeder.ts b/apps/edr-freight-api/src/seed/export-djibouti-interchange-demo.seeder.ts new file mode 100644 index 000000000..f4b258718 --- /dev/null +++ b/apps/edr-freight-api/src/seed/export-djibouti-interchange-demo.seeder.ts @@ -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 { + 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)}`, + ); + } + } +} diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 061305b11..30cbc6440 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ 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 || - 'https://edrfreightapi.triaplc.com'; + 'https://edrfreightapi.triaplc.com'; //export const API_BASE_URL = 'http://localhost:3001'; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx index 105bbdcf4..1aeeb7cb8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx @@ -7,6 +7,7 @@ import { Group, Loader, Modal, + Alert, Stack, Table, Tabs, @@ -168,7 +169,7 @@ function ExportTrainDetailRows({ export default function ExportDjiboutiUnloadingQueuePage() { const navigate = useNavigate(); const { toast } = useToast(); - const { data: trains = [], isLoading } = useExportDjiboutiArrivalQueue(); + const { data: trains = [], isLoading, isError, error } = useExportDjiboutiArrivalQueue(); const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' }); const autoUnload = useAutoUnloadExportAtDjibouti(); const generateInterchange = useGenerateInterchangeDocument(); @@ -272,6 +273,10 @@ export default function ExportDjiboutiUnloadingQueuePage() { + ) : isError ? ( + + {getErrorMessage(error) ?? 'Check your API connection and sign in again.'} + ) : trains.length === 0 ? ( = { @@ -45,6 +46,116 @@ const getErrorMessage = (error: unknown) => { return error instanceof Error ? error.message : undefined; }; +const escapeHtml = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +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) => ` + + ${index + 1} + ${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))} + ${escapeHtml(item.itemType)} + ${escapeHtml(item.containerNumber)} + ${escapeHtml(item.sealNumber)} + ${escapeHtml(item.cargoType ?? item.cargoDescription)} + ${escapeHtml(formatNumber(item.weight))} + ${escapeHtml(formatNumber(item.quantity))} + ${escapeHtml(item.wagonNumber)} + ${escapeHtml(item.conditionStatus)} + ${escapeHtml(item.damageDescription)} + `, + ) + .join(''); + + return ` + + + + ${escapeHtml(document.documentNo)} Interchange Document + + + +
+
+

EDR / Djibouti Port Interchange Document

+
Official export handover document
+
Document No: ${escapeHtml(document.documentNo)}
+
+
${escapeHtml(document.status)}
SIGNED HANDOVER
+
+ +
+
Direction
${escapeHtml(document.direction)}
+
Train No
${escapeHtml(document.trainNo)}
+
Schedule
${escapeHtml(document.scheduleId)}
+
Handover Location
${escapeHtml(document.handoverLocation)}
+
Handover From
${escapeHtml(document.handoverFrom)}
+
Handover To
${escapeHtml(document.handoverTo)}
+
Generated At
${escapeHtml(formatDate(document.generatedAt))}
+
Acknowledged At
${escapeHtml(formatDate(document.acknowledgedAt))}
+
Signed by EDR
${escapeHtml(document.generatedBy)}
+
Signed by Djibouti Port
${escapeHtml(document.acknowledgedBy)}
+
Port Operator
${escapeHtml(document.portOperatorName)}
+
Manifest Ref
${escapeHtml(document.manifestReference)}
+
+ + + + + + + + + + + + + + + + + + ${rows || ''} +
#BookingTypeContainerSealCargoWeightQtyWagonConditionDamage / Notes
No items
+ +
+
EDR Representative: ${escapeHtml(document.generatedBy)}
+
Djibouti Port Operator: ${escapeHtml(document.acknowledgedBy)}
+
+ + +`; +}; + function DetailField({ label, value }: { label: string; value: ReactNode }) { return ( @@ -157,6 +268,37 @@ export default function InterchangeDocumentsPage() { const dispute = useDisputeInterchangeDocument(); 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, title: string) => { try { await fn(); @@ -169,10 +311,10 @@ export default function InterchangeDocumentsPage() { const acknowledgeDocument = (document: InterchangeDocument) => { const acknowledgedBy = window.prompt('Acknowledged by'); if (!acknowledgedBy) return; - run( - () => acknowledge.mutateAsync({ id: document.id, acknowledgedBy }), - 'Interchange document acknowledged', - ); + run(async () => { + const response = await acknowledge.mutateAsync({ id: document.id, acknowledgedBy }); + await printDocument(response.data); + }, 'Interchange document acknowledged'); }; const disputeDocument = (document: InterchangeDocument) => { @@ -284,6 +426,28 @@ export default function InterchangeDocumentsPage() { Acknowledge ) : null} + {document.status === 'ACKNOWLEDGED' ? ( + <> + + + + ) : null} {document.status !== 'CANCELLED' ? (