diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d450ebfc8..4c778620b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -52,6 +52,7 @@ import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; 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 { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; //New Trains, Wagons, Container and Cargo management modules @@ -137,6 +138,7 @@ import { OverviewModule } from './modules/overview/overview.module'; Batch5TestDataSeeder, Batch7TestDataSeeder, Batch8TestDataSeeder, + WarehouseDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -153,6 +155,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly batch5TestDataSeeder: Batch5TestDataSeeder, private readonly batch7TestDataSeeder: Batch7TestDataSeeder, private readonly batch8TestDataSeeder: Batch8TestDataSeeder, + private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } @@ -171,6 +174,7 @@ export class AppModule implements OnApplicationBootstrap { await this.batch5TestDataSeeder.run(); await this.batch7TestDataSeeder.run(); await this.batch8TestDataSeeder.run(); + await this.warehouseDemoSeeder.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. await this.demoFreightDataSeeder.run(); diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts new file mode 100644 index 000000000..e00544dc0 --- /dev/null +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -0,0 +1,258 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.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 { Warehouse } from '../modules/warehouses/entities/warehouse.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'; + +/** + * One coherent warehouse dataset so EVERY queue/tab shows representative data: + * Export → Receive Queue : PAID export bookings, not yet received + * Export → Ready To Load : EXPORT inventory READY_FOR_LOADING + inspection PASSED + * Export → Loaded/Dispatch : EXPORT inventory LOADED + * Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory) + * Import → Unloaded Queue : UNLOADED import inventory + * Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED) + * + * Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it + * never collides with other seeders. To repopulate after items are walked through their lifecycle, + * delete the WH-DEMO-* bookings (cascades) and reboot. + */ +@Injectable() +export class WarehouseDemoSeeder { + private readonly logger = new Logger(WarehouseDemoSeeder.name); + private readonly SENTINEL = 'WH-DEMO-RCV-1'; + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + if (await bookingRepo.findOne({ where: { reference: this.SENTINEL } })) { + this.logger.log('Warehouse demo data already seeded, skipping'); + 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 whYardRepo = this.dataSource.getRepository(WarehouseYard); + const whZoneRepo = this.dataSource.getRepository(WarehouseZone); + const invRepo = this.dataSource.getRepository(WarehouseInventory); + + const djibYard = + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const ethYard = + (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 } }); + + if (!djibYard || !ethYard || !serviceType) { + this.logger.warn( + `Missing yards/service type (djib=${djibYard?.code}, eth=${ethYard?.code}, svc=${serviceType?.code}); skipping`, + ); + return; + } + + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + const whYard = warehouse ? await whYardRepo.findOne({ where: { warehouseId: warehouse.id } }) : null; + const whZone = whYard ? await whZoneRepo.findOne({ where: { yardId: whYard.id } }) : null; + if (!warehouse || !whYard || !whZone) { + this.logger.warn('INDODE_OPEN warehouse/yard/zone missing; skipping warehouse demo seed'); + return; + } + + const now = Date.now(); + const ago = (mins: number) => new Date(now - mins * 60_000); + + // EXPORT booking = Ethiopia → Djibouti; IMPORT booking = Djibouti → Ethiopia. + const makeBooking = async ( + reference: string, + direction: 'EXPORT' | 'IMPORT', + status: string, + weight: number, + idx: number, + ): Promise => + bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id, + destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id, + serviceTypeId: serviceType.id, + status, + paymentStatus: 'PAID', + tradeDirection: direction, + freightType: idx % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType ? null : `${direction} demo cargo ${idx}`, + cargoTotalWeightVgm: weight, + }), + ); + + const makeInventory = async ( + booking: Booking, + status: string, + weight: number, + extra: Partial, + ): Promise => { + await invRepo.save( + invRepo.create({ + warehouseId: warehouse.id, + yardId: whYard.id, + zoneId: whZone.id, + bookingId: booking.id, + quantity: 1, + weight, + status: status as WarehouseInventory['status'], + notes: '[WH-DEMO]', + ...extra, + }), + ); + }; + + let created = 0; + + // 1) Export Receive Queue — 3 PAID export bookings, NO inventory. + for (let i = 1; i <= 3; i++) { + await makeBooking(`WH-DEMO-RCV-${i}`, 'EXPORT', 'PAID', 4000 + i * 500, i); + created++; + } + + // 2) Export Ready To Load — EXPORT inventory READY_FOR_LOADING + PASSED. + for (let i = 1; i <= 3; i++) { + const b = await makeBooking(`WH-DEMO-RTL-${i}`, 'EXPORT', 'PAID', 6000 + i * 500, i); + await makeInventory(b, 'READY_FOR_LOADING', 6000 + i * 500, { + inspectionStatus: 'PASSED', + arrivedAt: ago(180), + inspectedAt: ago(120), + readyForLoadingAt: ago(60), + }); + created++; + } + + // 3) Export Loaded / Dispatch Queue — EXPORT inventory LOADED. + for (let i = 1; i <= 2; i++) { + const b = await makeBooking(`WH-DEMO-LOAD-${i}`, 'EXPORT', 'PAID', 7000 + i * 500, i); + await makeInventory(b, 'LOADED', 7000 + i * 500, { + inspectionStatus: 'PASSED', + arrivedAt: ago(240), + inspectedAt: ago(180), + readyForLoadingAt: ago(120), + loadedAt: ago(30), + }); + created++; + } + + // 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored). + for (let i = 1; i <= 3; i++) { + const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i); + await makeInventory(b, 'UNLOADED', 5000 + i * 500, { + arrivedAt: ago(90), + unloadedAt: ago(45), + }); + created++; + } + + // 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED). + for (let i = 1; i <= 3; i++) { + const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i); + await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, { + inspectionStatus: 'PASSED', + arrivedAt: ago(200), + unloadedAt: ago(160), + inspectedAt: ago(120), + readyForPickupAt: ago(60), + }); + created++; + } + + // 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet. + await this.seedArrivedImportTrain(djibYard, ethYard, serviceType, cargoType, ago(60), ago(360)); + created += 1; + + this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`); + } catch (error) { + this.logger.error( + `WarehouseDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** An ARRIVED Djibouti→Ethiopia train with 3 IN_TRANSIT bookings (no inventory) for the Arrive Queue. */ + private async seedArrivedImportTrain( + djibYard: Yard, + ethYard: Yard, + serviceType: ServiceType, + cargoType: CargoType | null, + arrival: Date, + departure: Date, + ): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + const locoRepo = this.dataSource.getRepository(Locomotive); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const loco = + (await locoRepo.findOne({ where: { code: 'WH-DEMO-LOCO' } })) ?? + (await locoRepo.save(locoRepo.create({ code: 'WH-DEMO-LOCO', name: 'Demo Locomotive', maxPullWeightTons: 4000 }))); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: loco.id, + totalWeightTons: 500, + totalLengthMeters: 300, + wagonCount: 10, + status: 'COMPLETED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: djibYard.id, + destinationStationId: ethYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber: 'WH-DEMO-IMP-TRAIN', + }), + ); + + for (let i = 1; i <= 3; i++) { + const b = await bookingRepo.save( + bookingRepo.create({ + reference: `WH-DEMO-ARR-${i}`, + originYardId: djibYard.id, + destinationYardId: ethYard.id, + serviceTypeId: serviceType.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + tradeDirection: 'IMPORT', + freightType: i % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType ? null : `IMPORT arrive demo cargo ${i}`, + cargoTotalWeightVgm: 5000 + i * 400, + }), + ); + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: b.id }), + ); + } + } +}