diff --git a/.gitignore b/.gitignore index ba46f7fd7..3f4bed8a2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ coverage/ *~ \#*\# .\#* +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 3fcde133e..9c2a8ad78 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -35,8 +35,8 @@ "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", - "@tria-plc/api-common": "^1.4.3", - "@tria-plc/iamapi-common": "^0.6.6", + "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 7857d7c7e..a5ee4c293 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -47,6 +47,10 @@ import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; 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 @@ -129,6 +133,10 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module'; DemoFreightDataSeeder, IndodeFacilitySeeder, Batch14TestDataSeeder, + Batch5TestDataSeeder, + Batch7TestDataSeeder, + Batch8TestDataSeeder, + WarehouseDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -137,6 +145,14 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, + private readonly pricingDataSeeder: PricingDataSeeder, + private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly indodeFacilitySeeder: IndodeFacilitySeeder, + private readonly batch14TestDataSeeder: Batch14TestDataSeeder, + private readonly batch5TestDataSeeder: Batch5TestDataSeeder, + private readonly batch7TestDataSeeder: Batch7TestDataSeeder, + private readonly batch8TestDataSeeder: Batch8TestDataSeeder, + private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } @@ -147,6 +163,16 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); await this.freightStaffUsersSeeder.run(); + await this.pricingDataSeeder.run(); + await this.fileUploadSettingsSeeder.run(); + await this.indodeFacilitySeeder.run(); + await this.batch14TestDataSeeder.run(); + 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. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, // FileUploadSettingsSeeder) are intentionally disabled — they stay // registered as providers but are not run. Re-inject + call .run() to enable. diff --git a/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts b/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts new file mode 100644 index 000000000..a615fe9a4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts @@ -0,0 +1,156 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Full wagon re-seed — runs in this order: + * + * 1. DELETE all existing wagons (hard delete, not soft). + * 2. UPSERT all 10 standard wagon types so they are guaranteed to exist. + * 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across + * the 5 main operational yards (10 wagons per yard per type): + * + * KALITY — Kality Rail Terminal + * MOJO — Mojo Dry Port + * DIRE_DAWA — Dire Dawa Yard + * DJIB_PORT — Djibouti Port Terminal + * NAGAD — Nagad Terminal, Djibouti + * + * Wagon numbers follow the pattern -NNNN (e.g. NW5-0001 … NW5-0050). + * Yard IDs are fetched live from freight.yards so the migration is safe across + * all environments regardless of UUID values. + */ +export class SeedWagonsWithYardAssignment1784000000001 + implements MigrationInterface +{ + name = 'SeedWagonsWithYardAssignment1784000000001'; + + public async up(queryRunner: QueryRunner): Promise { + // ── STEP 1: Remove all wagons ────────────────────────────────────────── + await queryRunner.query(`DELETE FROM freight.wagons;`); + + // ── STEP 2: Ensure all 10 wagon types exist ──────────────────────────── + await queryRunner.query(` + INSERT INTO freight.wagon_types ( + code, + name, + capacity_tons, + length_meters, + max_wagons_per_train, + supported_load_types, + is_active, + tare_weight_tons + ) + VALUES + ('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0), + ('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0), + ('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0), + ('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0), + ('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0), + ('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0), + ('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0), + ('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0), + ('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0), + ('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + max_wagons_per_train = EXCLUDED.max_wagons_per_train, + supported_load_types = EXCLUDED.supported_load_types, + is_active = true, + tare_weight_tons = EXCLUDED.tare_weight_tons, + deleted_at = NULL, + updated_at = now(); + `); + + // ── STEP 3: Seed 50 wagons per type across 5 yards ──────────────────── + await queryRunner.query(` + DO $$ + DECLARE + wt RECORD; + yard_kality UUID; + yard_mojo UUID; + yard_dire_dawa UUID; + yard_djib_port UUID; + yard_nagad UUID; + yards UUID[]; + i INT; + yard_id UUID; + wagon_num TEXT; + v_tare NUMERIC; + v_payload NUMERIC; + BEGIN + -- Fetch yard IDs by code (safe across envs — UUIDs differ per DB) + SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1; + SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1; + SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1; + SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1; + SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1; + + IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL + OR yard_djib_port IS NULL OR yard_nagad IS NULL + THEN + RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.'; + END IF; + + yards := ARRAY[ + yard_kality, + yard_mojo, + yard_dire_dawa, + yard_djib_port, + yard_nagad + ]; + + FOR wt IN + SELECT id, code, capacity_tons, tare_weight_tons + FROM freight.wagon_types + WHERE is_active = true + ORDER BY code + LOOP + v_tare := COALESCE(wt.tare_weight_tons, 20.0); + v_payload := COALESCE(wt.capacity_tons, 60.0); + + FOR i IN 1 .. 50 LOOP + wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0'); + yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K … + + INSERT INTO freight.wagons ( + id, + wagon_number, + wagon_type_id, + tare_weight, + max_payload_weight, + status, + current_yard_id, + train_id, + sequence_number, + notes, + train_set_wagon_id, + current_train_schedule_id, + created_at, + updated_at + ) + VALUES ( + uuid_generate_v4(), + wagon_num, + wt.id, + v_tare, + v_payload, + 'Available', + yard_id, + NULL, NULL, NULL, NULL, NULL, + now(), now() + ) + ON CONFLICT (wagon_number) DO NOTHING; + END LOOP; + + RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code; + END LOOP; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove all seeded wagons (full wipe — mirrors what up() did) + await queryRunner.query(`DELETE FROM freight.wagons;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts b/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts new file mode 100644 index 000000000..544600bef --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Day-level booking pool: customers select a DAY (route + day), not a specific + * train. The batch engine's pool query filters bookings on + * (origin_yard_id, destination_yard_id, scheduled_date, status); this partial + * index backs that scan. + */ +export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_route_day + ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts similarity index 97% rename from apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts rename to apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts index 05b1259f4..fa6087faa 100644 --- a/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts @@ -2,9 +2,9 @@ import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; /** * Batch 5 — warehouse allocation rules, storage/demurrage fee rules, - * and demurrage lifecycle timestamps on inventory. + * and demurrage lifecycle timestamps on inventory. Idempotent. */ -export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface { +export class AddWarehouseAllocationAndFeeRules1791000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ diff --git a/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts b/apps/edr-freight-api/src/migrations/1791000000001-AddWarehouseFeeInvoices.ts similarity index 96% rename from apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts rename to apps/edr-freight-api/src/migrations/1791000000001-AddWarehouseFeeInvoices.ts index c34eb240a..662a35739 100644 --- a/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1791000000001-AddWarehouseFeeInvoices.ts @@ -1,7 +1,7 @@ import { MigrationInterface, QueryRunner, Table } from 'typeorm'; -/** Batch 6 — warehouse fee invoices + invoice items. */ -export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface { +/** Batch 6 — warehouse fee invoices + invoice items. Idempotent (createTable ifNotExists). */ +export class AddWarehouseFeeInvoices1791000000001 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ diff --git a/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts b/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts new file mode 100644 index 000000000..3e272c859 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Import pickup branch on warehouse_inventory: + * - release_order_reference: DO / release order number sent to the customer + * - delivered_at: when the goods were handed over (proof of delivery) + * + * Idempotent: the shared dev DB may already carry some of these columns + * (added by another checkout), so only add what is missing. + */ +export class AddImportPickupDeliveryColumns1791000000002 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'release_order_reference'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'release_order_reference', type: 'varchar', length: '100', isNullable: true }), + ); + } + if (!(await queryRunner.hasColumn(this.table, 'delivered_at'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'delivered_at', type: 'timestamptz', isNullable: true }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn(this.table, 'release_order_reference')) { + await queryRunner.dropColumn(this.table, 'release_order_reference'); + } + if (await queryRunner.hasColumn(this.table, 'delivered_at')) { + await queryRunner.dropColumn(this.table, 'delivered_at'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts b/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts new file mode 100644 index 000000000..6009d9ea4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Batch 8 — train-arrival unload landing state on warehouse_inventory: + * - unloaded_at: when the goods were unloaded off the arrived train (before storage/inspection) + * + * The `status` column is a free varchar, so the new 'UNLOADED' value needs no schema change. + * Idempotent: the shared dev DB may already carry this column (added by another checkout). + */ +export class AddInventoryUnloadedAt1791000000003 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'unloaded_at'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'unloaded_at', type: 'timestamptz', isNullable: true }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn(this.table, 'unloaded_at')) { + await queryRunner.dropColumn(this.table, 'unloaded_at'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index edda990de..b173bfe68 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -689,6 +689,11 @@ export class BookingsRepository extends BaseRepository { destinationStationId?: string; schedulingStatus?: string; trainScheduleId?: string; + /** + * EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees + * the whole (route, day) pool rather than bookings pre-targeted to one train. + */ + day?: string; }): Promise { const qb = this.repository .createQueryBuilder('booking') @@ -706,9 +711,16 @@ export class BookingsRepository extends BaseRepository { .where('booking.status = :paidStatus', { paidStatus: 'PAID' }) .andWhere('scheduleBooking.id IS NULL'); - // Mirror the automatic batch pool: a schedule only ever considers bookings that - // targeted THAT schedule (same as findBatchPool's train_schedule_id filter). - if (options.trainScheduleId) { + // Day-level pooling: customers no longer set train_schedule_id, so the wizard + // surfaces the whole (route, EAT day) pool. Fall back to the legacy + // single-schedule filter only when no day is supplied (e.g. a staff-pinned + // booking that still carries train_schedule_id). + if (options.day) { + qb.andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day: options.day }, + ); + } else if (options.trainScheduleId) { qb.andWhere('booking.train_schedule_id = :trainScheduleId', { trainScheduleId: options.trainScheduleId, }); @@ -765,6 +777,43 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** + * Day-level batch pool: ready, not-yet-allocated bookings on a route for one + * EAT calendar day, regardless of which train they end up on. Same status + * rules and ordering as {@link findBatchPool}, but keyed on + * (origin, destination, day) instead of train_schedule_id — the engine then + * distributes these across all trains departing that day. + */ + findBatchPoolByRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.origin_yard_id = :originYardId', { originYardId }) + .andWhere('booking.destination_yard_id = :destinationYardId', { + destinationYardId, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .andWhere('sb.id IS NULL') + .andWhere( + `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, + ) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.fully_executed_at', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */ findAllBySchedule(scheduleId: string): Promise { return this.repository diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index b32b16b32..4c8ef2cab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; @@ -273,8 +274,8 @@ export class BookingsService { companyId = company.id; } - // Schedule targeting: when provided, the schedule must be OPEN and on the same route. if (dto.trainScheduleId) { + // Staff manual pin: the schedule must be OPEN and on the same route. const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: dto.trainScheduleId } }); @@ -290,6 +291,22 @@ export class BookingsService { ) { throw new BadRequestException('Selected schedule is not on the booking route'); } + } else { + // Day-level pool: the customer picked a DAY — require that the route has at + // least one OPEN departure on that EAT day. The batch engine assigns the + // train later. + const day = eatDay(new Date(dto.scheduledDate)); + const hasDeparture = + await this.trainSchedulingService.existsOpenScheduleOnRouteDay( + dto.originYardId, + dto.destinationYardId, + day, + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } } const reference = dto.reference || (await this.generateReference()); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 3c7eca391..fa3bb6f4d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -91,12 +91,21 @@ export class CreateBookingDto { @IsUUID() trainId?: string; - /** Target schedule this booking is created against (required by the backoffice create form). */ - @ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' }) + /** + * Staff-only manual pin to a specific train. Customers omit this — they pick a + * DAY via {@link scheduledDate} and the batch engine assigns a train within + * that (route, day) pool. When provided, the schedule must be OPEN and on the + * booking route. + */ + @ApiPropertyOptional({ + format: 'uuid', + description: 'Staff only: pin to a specific train schedule. Customers omit this.', + }) @IsOptional() @IsUUID() trainScheduleId?: string; + /** The day the customer wants to ship (the pool day key). */ @ApiProperty({ example: '2026-06-15T00:00:00.000Z' }) @IsDateString() scheduledDate!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 961137896..c5dac1736 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -280,7 +280,15 @@ export class Booking extends BaseEntity { @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) scheduledAt?: Date | null; - /** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */ + /** + * The train this booking is assigned to. FK to train_schedules. + * + * Day-level pooling: customers no longer pick a train — they pick a DAY, and + * this stays null at creation. The batch engine sets it when it assigns the + * booking to a specific train within its (route, day) pool; staff may also + * pin it manually. The day-level pool is keyed on + * (origin_yard_id, destination_yard_id, day of scheduled_date), not this column. + */ @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) trainScheduleId?: string | null; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 38ab407bc..7316e1610 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts { }; } +/** + * The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day + * key for day-level booking pools — it must match the day the portal calendar + * renders, so always derive day keys through this (never `toISOString().slice`). + */ +export function eatDay(date: Date): string { + const { year, month, day } = eatParts(date); + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + /** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */ function eatToUtc( year: number, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index a08dd71ec..5cd06091a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => { let bookingsRepository: { findPaidUnlinkedForSchedule: jest.Mock; findBatchPool: jest.Mock; + findBatchPoolByRouteDay: jest.Mock; findReservedForSchedule: jest.Mock; update: jest.Mock; }; @@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => { }; let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; + getBookableSchedules: jest.Mock; }; let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; }; + let notifier: { + payNow: jest.Mock; + secured: jest.Mock; + expired: jest.Mock; + unplaced: jest.Mock; + }; beforeEach(() => { bookingsRepository = { findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]), findBatchPool: jest.fn().mockResolvedValue([]), + findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]), findReservedForSchedule: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; @@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => { issues: [], violations: [], }), + getBookableSchedules: jest.fn().mockResolvedValue([]), }; const bookingRepo = { findOne: jest.fn().mockResolvedValue(paidBooking), update: jest.fn().mockResolvedValue(undefined), + // WagonType.find() / global-rules find() fall back to defaults when empty. + find: jest.fn().mockResolvedValue([]), }; dataSource = { getRepository: jest.fn().mockReturnValue(bookingRepo), @@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => { }), }; + notifier = { + payNow: jest.fn(), + secured: jest.fn(), + expired: jest.fn(), + unplaced: jest.fn(), + }; + service = new BookingBatchService( dataSource as never, bookingsRepository as never, trainSchedulesRepository as never, trainScheduleBookingsRepository as never, - { payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never, + notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, ); @@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => { expect(fillOrder).toBeLessThan(reconcileOrder); expect(reconcileOrder).toBeLessThan(wagonOrder); }); + + describe('fillRouteDay — day-level distribution', () => { + const originYardId = 'yard-origin'; + const destinationYardId = 'yard-dest'; + const day = '2026-06-20'; + // 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day. + const trainA = 'train-a'; + const trainB = 'train-b'; + + // A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits. + const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 }; + + const commercial = (id: string, priority: number): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + bookingContainers: [], + }) as unknown as Booking; + + beforeEach(() => { + // Two OPEN trains on the same route + day, train A earlier than train B. + trainSchedulingService.getBookableSchedules.mockResolvedValue([ + { + id: trainA, + scheduleDate: '2026-06-20T06:00:00.000Z', + bookingWindowStatus: 'OPEN', + }, + { + id: trainB, + scheduleDate: '2026-06-20T09:00:00.000Z', + bookingWindowStatus: 'OPEN', + }, + ]); + trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) => + Promise.resolve({ + id, + maxWagons: 1, + bookingWindowStatus: 'OPEN', + trainSetId: `set-${id}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + }), + ); + }); + + it('spills overflow to the next train by priority, then reports unplaced', async () => { + // 3 commercial bookings, descending priority; only 1 fits per train (2 total). + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + commercial('hi', 30), + commercial('mid', 20), + commercial('lo', 10), + ]); + + const touched = await service.fillRouteDay(originYardId, destinationYardId, day); + + expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith( + originYardId, + destinationYardId, + day, + ); + // Both trains were processed. + expect(touched).toEqual([trainA, trainB]); + // Highest priority reserved on train A, next on train B (commercial → reserve). + const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); + expect(reservedOn).toEqual(['hi', 'mid']); + // The third booking fits no train and is reported unplaced (and only it). + expect(notifier.unplaced).toHaveBeenCalledTimes(1); + expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo'); + expect(notifier.unplaced.mock.calls[0][1]).toBe(day); + }); + + it('reserves the chosen train id on each commercial booking', async () => { + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // reserve() persists trainScheduleId so the settle lifecycle can find the train. + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'hi', + expect.objectContaining({ + trainScheduleId: trainA, + status: 'SELECTED_FOR_BATCH', + }), + ); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 771422fa2..514f8572d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; -import { groupBookingsIntoBoardWindows } from './batch-window.util'; +import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; import { BATCH_CRON, BATCH_TIMEZONE, @@ -42,6 +42,14 @@ interface Capacity { lengthMeters: number; } +/** A day-level pool key: all trains on this route departing on this EAT day. */ +interface RouteDayGroup { + originYardId: string; + destinationYardId: string; + /** EAT calendar day, `yyyy-MM-dd`. */ + day: string; +} + type WagonLengths = { container: number; bulk: number }; export type BatchBoardBookingState = @@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit { private readonly trainSchedulingService: TrainSchedulingService, ) {} - /** On boot, reconcile OPEN schedules and re-arm settle timers. */ + /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { - const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: 'OPEN' }, - }); - for (const s of open) { + const groups = await this.openRouteDayGroups(); + for (const group of groups) { try { - await this.processSchedule(s.id); + await this.processRouteDay(group); } catch (err) { - this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`); + this.logger.warn( + `Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`, + ); } } const reserved = await this.dataSource @@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit { for (const { scheduleId } of reserved) this.armSettle(scheduleId); } - /** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */ + /** + * Fire-and-forget batch pipeline for the (route, day) a schedule belongs to + * (contract sign, payment). Day-level pooling distributes across all of that + * day's trains, so a single schedule id maps to its whole route-day group. + */ enqueueScheduleProcessing(scheduleId: string): void { - void this.processSchedule(scheduleId).catch((err) => - this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`), + void this.processRouteDayForSchedule(scheduleId).catch((err) => + this.logger.error( + `processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`, + ), ); } + /** Resolve a schedule's (route, day) group and run the day-level pipeline. */ + private async processRouteDayForSchedule(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule?.scheduledDepartureDate) return; + await this.processRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }); + } + + /** + * Day-level pipeline: distribute the (route, day) pool across all its trains, + * then settle / reconcile / assign wagons per schedule (those steps stay + * schedule-scoped — only the fill is day-level). + */ + async processRouteDay(group: RouteDayGroup): Promise { + const scheduleIds = await this.fillRouteDay( + group.originYardId, + group.destinationYardId, + group.day, + ); + for (const scheduleId of scheduleIds) { + await this.settleDueReservations(scheduleId); + await this.reconcilePaidUnlinked(scheduleId); + await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); + } + } + /** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */ async processSchedule(scheduleId: string): Promise { await this.fillSchedule(scheduleId); @@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } + /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ + private async openRouteDayGroups(): Promise { + const open = await this.trainSchedulesRepository.findAll({ + where: { bookingWindowStatus: 'OPEN' }, + }); + const groups = new Map(); + for (const s of open) { + if (!s.scheduledDepartureDate) continue; + const day = eatDay(s.scheduledDepartureDate); + const key = `${s.originStationId}|${s.destinationStationId}|${day}`; + if (!groups.has(key)) { + groups.set(key, { + originYardId: s.originStationId, + destinationYardId: s.destinationStationId, + day, + }); + } + } + return [...groups.values()]; + } + + private groupLabel(group: RouteDayGroup): string { + return `${group.originYardId}→${group.destinationYardId} on ${group.day}`; + } + /** * Idempotent: link a paid batch booking to its schedule and assign wagons. * Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases. @@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit { @Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE }) async runBatchFill(): Promise { - const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: 'OPEN' }, - }); - this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`); - for (const s of open) { + const groups = await this.openRouteDayGroups(); + this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); + for (const group of groups) { try { - await this.processSchedule(s.id); + await this.processRouteDay(group); } catch (err) { - this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`); + this.logger.error( + `Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`, + ); } } } @@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit { if (booking.isGovernment) { await this.allocate(scheduleId, booking, 'gov'); } else { - await this.reserve(booking); + await this.reserve(booking, scheduleId); armed = true; } budget = this.subtract(budget, need); @@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit { void this.triggerWagonAllocation(scheduleId); } + /** + * Distribute one (route, day) pool across ALL of that day's OPEN trains, by + * priority, filling each train (earliest departure first) until it's full and + * spilling overflow to the next. Government bookings that fit no train preempt + * lower-priority commercial; bookings that fit no train at all stay pending and + * trigger a staff `unplaced` warning. Returns the schedule ids that were touched + * (or that had remaining pool work) so the caller can settle them per-schedule. + */ + async fillRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + // The day's OPEN bookable schedules on this exact corridor, earliest first. + const bookable = await this.trainSchedulingService.getBookableSchedules( + originYardId, + destinationYardId, + ); + const scheduleIds = bookable + .filter( + (s) => + s.bookingWindowStatus === 'OPEN' && + s.scheduleDate != null && + eatDay(new Date(s.scheduleDate)) === day, + ) + .sort( + (a, b) => + new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(), + ) + .map((s) => s.id); + + if (scheduleIds.length === 0) return []; + + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + + // Live per-schedule budget + arm flag, in departure order. + const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; + for (const id of scheduleIds) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !schedule.trainSetId || !locomotive) { + this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`); + continue; + } + const limits = await this.capacityLimits(locomotive, rules); + await this.syncScheduleMaxWagons(schedule, locomotive, rules); + const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + trains.push({ id, budget, armed: false }); + } + if (trains.length === 0) return []; + + const pool = await this.bookingsRepository.findBatchPoolByRouteDay( + originYardId, + destinationYardId, + day, + ); + + for (const booking of pool) { + const need = this.needFor(booking, wagonLengths); + + // First train (earliest departure) that fits this booking as-is. + let target = trains.find((t) => this.fits(need, t.budget)); + + if (!target && booking.isGovernment) { + // Government booking fits nowhere on its own — try to preempt commercial + // on each train (earliest first) until one frees enough room. + for (const t of trains) { + t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths); + if (this.fits(need, t.budget)) { + target = t; + break; + } + } + } + + if (!target) { + // Fits no train this day — stays in the pool, retried next batch. + this.notifier.unplaced(booking, day); + continue; + } + + if (booking.isGovernment) { + await this.allocate(target.id, booking, 'gov'); + } else { + await this.reserve(booking, target.id); + target.armed = true; + } + target.budget = this.subtract(target.budget, need); + } + + for (const t of trains) { + if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL'); + if (t.armed) this.armSettle(t.id); + void this.triggerWagonAllocation(t.id); + } + + return trains.map((t) => t.id); + } + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ async settleDueReservations(scheduleId: string): Promise { const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); @@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit { // ---- mutations ------------------------------------------------------------ - /** Reserve capacity for a commercial booking and open its pay window. */ - private async reserve(booking: Booking): Promise { + /** + * Reserve capacity for a commercial booking on a specific train and open its + * pay window. `scheduleId` is persisted so the settle/allocate lifecycle + * (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid), + * which is all keyed off `booking.trainScheduleId`, can find the train — with + * day-level pooling the booking arrives here with `trainScheduleId` still null, + * so the engine sets it as it picks the train. + */ + private async reserve(booking: Booking, scheduleId: string): Promise { const now = new Date(); const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); await this.bookingsRepository.update(booking.id, { + trainScheduleId: scheduleId, status: 'SELECTED_FOR_BATCH', selectedForBatchAt: now, paymentDeadline: deadline, } as never); + booking.trainScheduleId = scheduleId; await this.notifier.payNow(booking, deadline); } @@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit { void this.triggerWagonAllocation(scheduleId); } - /** Expire an unpaid reservation and free its capacity. */ + /** + * Expire an unpaid reservation and free its capacity. With day-level pooling we + * also clear `trainScheduleId` so the booking is no longer pinned to the train + * it failed to pay for — it's back in the day pool for staff to act on. + */ private async expire(booking: Booking): Promise { await this.bookingsRepository.update(booking.id, { + trainScheduleId: null, status: 'EXPIRED', schedulingStatus: 'ELIGIBLE', paymentDeadline: null, selectedForBatchAt: null, } as never); + booking.trainScheduleId = null; this.notifier.expired(booking); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e63bc1aec..e8f272123 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -67,6 +67,17 @@ export class BookingNotifierService { ); } + /** + * Staff-facing warning when a pooled booking fits no train on its chosen day. + * It stays pending and is retried next batch; staff can add capacity or pin it + * to a train manually. Mirrors {@link scheduleFull} — no customer notification. + */ + unplaced(b: Booking, day: string): void { + this.logger.warn( + `UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`, + ); + } + displaced(b: Booking): void { const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; void this.notifyContact(b, msg, 'DISPLACED'); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts new file mode 100644 index 000000000..bb55508e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class AvailableDaysQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationYardId?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 2a4c3a357..0c01dd219 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; import { RecordCheckpointDto } from "./dto/record-checkpoint.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; +import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; @@ -108,6 +109,20 @@ export class TrainSchedulingController { ); } + @Get("available-days") + // No staff guard: customers hit this while creating a booking to find which + // DAYS have a departure on their route. Day-level pooling — no capacity is + // returned, only the list of bookable days. + @ApiOperation({ + summary: "Distinct days with an OPEN same-route departure (day-level pool)", + }) + getAvailableDays(@Query() query: AvailableDaysQueryDto) { + return this.trainSchedulingService.getAvailableDays( + query.originYardId, + query.destinationYardId, + ); + } + @Get("container/eligible-bookings") @TrainSchedulingView() @ApiOperation({ summary: "List eligible container bookings" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 98b6d70c2..7bfe5811e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -87,6 +87,7 @@ import { DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, } from './booking-batch.constants'; +import { eatDay } from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; @@ -167,12 +168,28 @@ export class TrainSchedulingService { ) {} async getEligibleBookings(query: GetEligibleBookingsDto) { + // Day-level pooling: when the wizard targets a schedule, surface the whole + // (route, EAT day) pool — not just bookings pre-pinned to that train — by + // resolving the schedule's route + day and filtering on the day instead. + let day: string | undefined; + let originStationId = query.originStationId; + let destinationStationId = query.destinationStationId; + if (query.trainScheduleId) { + const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId); + if (schedule?.scheduledDepartureDate) { + day = eatDay(schedule.scheduledDepartureDate); + originStationId = originStationId ?? schedule.originStationId; + destinationStationId = destinationStationId ?? schedule.destinationStationId; + } + } + const bookings = await this.bookingsRepository.findEligibleForScheduling({ freightType: query.freightType, - originStationId: query.originStationId, - destinationStationId: query.destinationStationId, + originStationId, + destinationStationId, schedulingStatus: query.schedulingStatus, trainScheduleId: query.trainScheduleId, + day, }); return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; } @@ -1989,6 +2006,33 @@ export class TrainSchedulingService { return filteredSchedules; } + /** + * Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable + * departure on the route. Customers pick a DAY (not a train) — so this returns + * only the day strings, no capacity, counts or train info. + */ + async getAvailableDays( + originYardId?: string, + destinationYardId?: string, + ): Promise<{ days: string[] }> { + const schedules = await this.getBookableSchedules(originYardId, destinationYardId); + const days = new Set(); + for (const s of schedules) { + if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate))); + } + return { days: [...days].sort() }; + } + + /** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */ + async existsOpenScheduleOnRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + const { days } = await this.getAvailableDays(originYardId, destinationYardId); + return days.includes(day); + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts new file mode 100644 index 000000000..bfdc813f0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** Bulk-mark received inventory items as inspection PASSED. */ +export class BulkInspectDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + inventoryIds!: string[]; + + @ApiPropertyOptional({ description: 'Inspection type label (e.g. ORIGIN_INSPECTION).' }) + @IsOptional() + @IsString() + inspectionType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + inspectedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts new file mode 100644 index 000000000..9bb734512 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */ +export class BulkReceiveDto { + @ApiProperty({ enum: ['IMPORT', 'EXPORT'] }) + @IsIn(['IMPORT', 'EXPORT']) + direction!: 'IMPORT' | 'EXPORT'; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + bookingIds!: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts new file mode 100644 index 000000000..b2491472d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +/** Proof of delivery captured when import goods are handed over to the customer. */ +export class DeliverInventoryDto { + @ApiProperty({ description: 'Name of the person who received the goods' }) + @IsString() + receiverName!: string; + + @ApiPropertyOptional({ description: 'When the goods were delivered (defaults to now)' }) + @IsOptional() + @IsDateString() + deliveredAt?: string; + + @ApiPropertyOptional({ description: 'Delivery remarks / notes' }) + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts new file mode 100644 index 000000000..9d4e3eb4f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +/** Records a DO / release order being sent to the customer for import pickup. */ +export class ReleaseOrderDto { + @ApiPropertyOptional({ description: 'DO / release order reference number' }) + @IsOptional() + @IsString() + reference?: string; + + @ApiPropertyOptional({ description: 'Release date (defaults to now)' }) + @IsOptional() + @IsDateString() + releaseDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts index 4521bb808..8df9c0dd8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts @@ -3,12 +3,16 @@ import { Column, Entity, Index } from 'typeorm'; export const WAREHOUSE_ACTIVITY_TYPES = [ 'INVENTORY_RECEIVED', + 'INVENTORY_UNLOADED', 'INVENTORY_STORED', 'INVENTORY_MOVED', 'INVENTORY_RESERVED', 'READY_FOR_LOADING', 'INVENTORY_LOADED', 'INVENTORY_DISPATCHED', + 'READY_FOR_PICKUP', + 'INVENTORY_RELEASED', + 'INVENTORY_DELIVERED', ] as const; export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 6c9270987..a5d4e1eeb 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -8,26 +8,40 @@ import { Warehouse } from './warehouse.entity'; import { WarehouseYard } from './warehouse-yard.entity'; import { WarehouseZone } from './warehouse-zone.entity'; -// Batch 2 lifecycle. Supersedes the Batch 1 set +// Lifecycle. Supersedes the Batch 1 set // (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place. +// After RECEIVED + inspection (PASSED), the flow branches by booking trade direction: +// EXPORT/DOMESTIC: STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED +// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery) export const WAREHOUSE_INVENTORY_STATUSES = [ + 'UNLOADED', 'RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED', + 'READY_FOR_PICKUP', + 'DELIVERED', ] as const; export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number]; /** Allowed forward transitions for the inventory lifecycle. */ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record = { - RECEIVED: ['STORED'], + // UNLOADED = train-arrival landing state (Batch 8). Not yet stored/inspected. + // Mirrors RECEIVED so the import flow can store or go straight to pickup after inspection. + UNLOADED: ['STORED', 'READY_FOR_PICKUP'], + RECEIVED: ['STORED', 'READY_FOR_PICKUP'], STORED: ['RESERVED'], RESERVED: ['READY_FOR_LOADING'], READY_FOR_LOADING: ['LOADED'], LOADED: ['DISPATCHED'], DISPATCHED: [], + // Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched + // out by EDR (DISPATCHED) — kept separate — or be put into storage (STORED) if no one collects + // it / customs or inspection hold / operator chooses to store. + READY_FOR_PICKUP: ['DELIVERED', 'STORED', 'DISPATCHED'], + DELIVERED: [], }; @Entity({ schema: 'freight', name: 'warehouse_inventory' }) @@ -104,6 +118,10 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) arrivedAt?: Date | null; + // Batch 8: when the goods were unloaded off the arrived train (before storage/inspection). + @Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true }) + unloadedAt?: Date | null; + @Column({ name: 'stored_at', type: 'timestamptz', nullable: true }) storedAt?: Date | null; @@ -135,6 +153,14 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'release_date', type: 'timestamptz', nullable: true }) releaseDate?: Date | null; + // Import branch: reference of the DO / release order sent to the customer. + @Column({ name: 'release_order_reference', type: 'varchar', length: 100, nullable: true }) + releaseOrderReference?: string | null; + + // Import branch: when the goods were handed over to the customer (proof of delivery). + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt?: Date | null; + @Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true }) gateClearedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts index 9d27ed64a..267fcc8af 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -1,5 +1,5 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Facility } from '../../facilities/entities/facility.entity'; import { WarehouseYard } from './warehouse-yard.entity'; @@ -63,6 +63,7 @@ export class Warehouse extends BaseEntity { facilityId?: string | null; @ManyToOne(() => Facility, (facility) => facility.warehouses, { nullable: true }) + @JoinColumn({ name: 'facility_id' }) facility?: Facility | null; @OneToMany(() => WarehouseYard, (yard) => yard.warehouse) 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 de5a791c1..76e1fe892 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 @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; + /** * READ-ONLY view into the train-scheduling / wagons domain for the warehouse module. * @@ -9,6 +11,32 @@ import { DataSource } from 'typeorm'; * It is intentionally decoupled (raw SQL) so it does not import the scheduling * services/entities and cannot accidentally write to them. */ +export interface ImportTrainRow { + scheduleId: string; + trainNumber: string | null; + route: string | null; + origin: string | null; + destination: string | null; + arrivalTime: string | null; + totalBookings: number; + totalContainers: number; + totalCargoes: number; + status: string; +} + +export interface ImportTrainItemRow { + bookingId: string; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + arrivalTime: string | null; + currentStatus: string | null; + lastMileRequested: boolean; + pickupOption: string; +} export interface WagonView { id: string; wagonNumber: string; @@ -115,4 +143,81 @@ export class SchedulingReadFacade { departureStatus: schedule?.status ?? null, }; } + + /** + * ARRIVED train schedules whose route is IMPORT (origin country = Djibouti), with per-train + * booking/container/cargo counts. Direction is derived from the origin/destination station + * countries (route-based), so EXPORT/DOMESTIC trains never appear. Read-only. + */ + async importArriveQueue(): Promise { + const rows: Array< + ImportTrainRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT ts.id AS "scheduleId", + ts.train_number AS "trainNumber", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", + ts.status, + (SELECT count(*) FROM freight.train_schedule_bookings tsb + WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings", + (SELECT count(*) FROM freight.containers c + JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL + WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers", + (SELECT count(*) FROM freight.cargoes cg + JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL + WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status = 'ARRIVED' + ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`, + ); + + return rows + .filter( + (r) => + deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT', + ) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({ + ...rest, + totalBookings: Number(rest.totalBookings) || 0, + totalContainers: Number(rest.totalContainers) || 0, + totalCargoes: Number(rest.totalCargoes) || 0, + route: rest.origin || rest.destination ? `${rest.origin ?? '?'} → ${rest.destination ?? '?'}` : null, + })); + } + + /** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */ + async importTrainDetail(scheduleId: string): Promise { + const rows: ImportTrainItemRow[] = await this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + (SELECT c.container_number FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.container_number LIMIT 1) AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + b.cargo_total_weight_vgm AS "weight", + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", + COALESCE(inv.status, b.status) AS "currentStatus", + (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + CASE WHEN b.last_mile_delivery_address IS NOT NULL + THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption" + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + ORDER BY b.reference ASC NULLS LAST`, + [scheduleId], + ); + return rows; + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts index 1bb5b1289..fcc09f668 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, IsNull } from 'typeorm'; import { Warehouse } from './entities/warehouse.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; @@ -8,11 +8,18 @@ export interface WarehouseDashboard { totalWarehouses: number; totalInventory: number; receivedToday: number; + // Inspection gate + awaitingInspection: number; + inspected: number; + // Export branch stored: number; reserved: number; readyForLoading: number; loaded: number; dispatched: number; + // Import branch + readyForPickup: number; + delivered: number; } @Injectable() @@ -26,30 +33,50 @@ export class WarehouseDashboardService { const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0); - const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] = - await Promise.all([ - warehouseRepo.count(), - inventoryRepo.count(), - inventoryRepo.count({ where: { status: 'STORED' } }), - inventoryRepo.count({ where: { status: 'RESERVED' } }), - inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }), - inventoryRepo.count({ where: { status: 'LOADED' } }), - inventoryRepo.count({ where: { status: 'DISPATCHED' } }), - inventoryRepo - .createQueryBuilder('inv') - .where('inv.arrived_at >= :start', { start: startOfToday }) - .getCount(), - ]); - - return { + const [ totalWarehouses, totalInventory, - receivedToday, + awaitingInspection, + inspected, stored, reserved, readyForLoading, loaded, dispatched, + readyForPickup, + delivered, + receivedToday, + ] = await Promise.all([ + warehouseRepo.count(), + inventoryRepo.count(), + inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }), + inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }), + inventoryRepo.count({ where: { status: 'STORED' } }), + inventoryRepo.count({ where: { status: 'RESERVED' } }), + inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }), + inventoryRepo.count({ where: { status: 'LOADED' } }), + inventoryRepo.count({ where: { status: 'DISPATCHED' } }), + inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }), + inventoryRepo.count({ where: { status: 'DELIVERED' } }), + inventoryRepo + .createQueryBuilder('inv') + .where('inv.arrived_at >= :start', { start: startOfToday }) + .getCount(), + ]); + + return { + totalWarehouses, + totalInventory, + receivedToday, + awaitingInspection, + inspected, + stored, + reserved, + readyForLoading, + loaded, + dispatched, + readyForPickup, + delivered, }; } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index ce8a2c188..2699adbef 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -1,11 +1,15 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BulkReceiveDto } from './dto/bulk-receive.dto'; +import { BulkInspectDto } from './dto/bulk-inspect.dto'; +import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { SchedulingReadFacade } from './scheduling-read.facade'; @@ -56,6 +60,49 @@ export class WarehouseInventoryController { return this.inventoryService.autoLoadReady(); } + @Get('eligible-bookings') + @ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' }) + eligibleBookings(@Query('direction') direction?: string) { + const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined; + return this.inventoryService.eligibleBookings(dir); + } + + @Post('receive-bulk') + @ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) + receiveBulk(@Body() dto: BulkReceiveDto) { + return this.inventoryService.bulkReceive(dto); + } + + @Post('load-passed-export') + @ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' }) + loadPassedExport(@Body('performedBy') performedBy?: string) { + return this.inventoryService.loadPassedExport(performedBy); + } + + @Get('ready-to-load-export') + @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) + readyToLoadExport() { + return this.inventoryService.readyToLoadExport(); + } + + @Get('loaded-export') + @ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' }) + loadedExport() { + return this.inventoryService.loadedExport(); + } + + @Post('bulk-dispatch-export') + @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) + bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { + return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy); + } + + @Post('bulk-mark-inspected') + @ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' }) + bulkMarkInspected(@Body() dto: BulkInspectDto) { + return this.inventoryService.bulkMarkInspected(dto); + } + @Post('bookings/:bookingId/unload') @ApiOperation({ summary: 'Unload a single arrived booking into a location' }) unloadBooking( @@ -71,6 +118,36 @@ export class WarehouseInventoryController { return this.inventoryService.gateClearance(id, performedBy); } + @Get('import/arrive-queue') + @ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' }) + importArriveQueue() { + return this.scheduling.importArriveQueue(); + } + + @Get('import/trains/:scheduleId/items') + @ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' }) + importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { + return this.scheduling.importTrainDetail(scheduleId); + } + + @Post('import/auto-unload-arrived-bookings') + @ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' }) + autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) { + return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy); + } + + @Get('import/unloaded-queue') + @ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' }) + importUnloadedQueue() { + return this.inventoryService.importUnloadedQueue(); + } + + @Get('import/pickup-ready-queue') + @ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' }) + importPickupReadyQueue() { + return this.inventoryService.importPickupReadyQueue(); + } + @Get('loadable-wagons') @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) loadableWagons() { @@ -137,6 +214,24 @@ export class WarehouseInventoryController { return this.inventoryService.load(id, dto); } + @Post(':id/ready-for-pickup') + @ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' }) + readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.readyForPickup(id, performedBy); + } + + @Post(':id/release') + @ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' }) + release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) { + return this.inventoryService.release(id, dto); + } + + @Post(':id/deliver') + @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) + deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { + return this.inventoryService.deliver(id, dto); + } + @Patch(':id/dispatch') @ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' }) dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { 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 652be600c..31975b089 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 @@ -1,14 +1,21 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { BulkInspectDto } from './dto/bulk-inspect.dto'; +import { BulkReceiveDto } from './dto/bulk-receive.dto'; +import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseInspectionService } from './warehouse-inspection.service'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; @@ -113,6 +120,85 @@ export interface AutoLoadResult { results: { inventoryId: string; status: string; reason?: string }[]; } +// ── Receive (Import/Export bulk) shapes ────────────────────────────────────── +export interface EligibleBookingRow { + id: string; + reference: string; + customerId: string | null; + customer: string | null; + direction: string; + origin: string | null; + destination: string | null; + freightType: string | null; + cargo: string | null; + weight: string | null; + paymentStatus: string; + status: string; +} + +export interface BulkReceiveResult { + receivedCount: number; + skippedCount: number; + results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[]; +} + +export interface LoadPassedExportResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +export interface BulkInspectResult { + inspectedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +export interface ReadyToLoadRow { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + origin: string | null; + destination: string | null; + inspectionStatus: string | null; + status: string; +} + +export interface BulkDispatchResult { + dispatchedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +export interface AutoUnloadArrivedResult { + unloadedCount: number; + skippedCount: number; + failedCount: number; + results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; +} + +export interface ImportUnloadedRow { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + arrivalTime: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + trainSchedule: string | null; + inspectionStatus: string | null; + pickupOption: string; + lastMileRequested: boolean; + currentStatus: string; +} + @Injectable() export class WarehouseInventoryService { constructor( @@ -123,6 +209,7 @@ export class WarehouseInventoryService { private readonly scheduling: SchedulingReadFacade, private readonly allocation: WarehouseAllocationService, private readonly invoices: WarehouseInvoiceService, + private readonly inspectionService: WarehouseInspectionService, ) {} /** @@ -403,6 +490,542 @@ export class WarehouseInventoryService { return result; } + // ── Receive (Import/Export bulk) ─────────────────────────────────────────── + + /** + * Eligible PAID bookings that have NOT been received yet, classified IMPORT/EXPORT by route + * (origin/destination yard countries). Pass a direction to filter to one; omit it to return + * all import + export bookings in a single call (DOMESTIC routes are excluded either way). + */ + async eligibleBookings(direction?: 'IMPORT' | 'EXPORT'): Promise { + const rows: Array< + EligibleBookingRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT b.id, + b.reference AS "reference", + b.company_id AS "customerId", + company.name AS "customer", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + b.freight_type AS "freightType", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo", + b.cargo_total_weight_vgm AS "weight", + b.payment_status AS "paymentStatus", + b.status AS "status" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE b.deleted_at IS NULL + AND b.payment_status = 'PAID' + AND inv.id IS NULL + ORDER BY b.scheduled_date DESC NULLS LAST`, + ); + + // Direction is derived from the route (origin/destination yard countries), reusing deriveTradeDirection. + return rows + .map((r) => ({ + ...r, + direction: deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }), + })) + .filter((r) => + direction ? r.direction === direction : r.direction === 'IMPORT' || r.direction === 'EXPORT', + ); + } + + /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ + async bulkReceive(dto: BulkReceiveDto): Promise { + const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + + await this.dataSource.transaction(async (manager) => { + await this.validateLocation(manager, { + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + }); + + for (const bookingId of dto.bookingIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ bookingId, status: 'SKIPPED', reason }); + }; + + const [booking] = await manager.query( + `SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", + oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.bookings b + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!booking) { skip('Booking not found'); continue; } + if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; } + // Direction is derived from the route (yard countries), not the stored field. + const bookingDirection = deriveTradeDirection( + { country: booking.originCountry }, + { country: booking.destinationCountry }, + ); + if (bookingDirection !== dto.direction) { + skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); + continue; + } + + const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); + if (existing) { skip('Already received'); continue; } + + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'RECEIVED', + arrivedAt: new Date(), + notes: `Bulk received (${dto.direction})`, + }), + ); + + await this.activityLog.record( + { + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `Bulk received ${dto.direction} booking`, + performedBy: dto.performedBy, + }, + manager, + ); + + result.receivedCount += 1; + result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id }); + } + }); + + return result; + } + + /** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */ + async loadPassedExport(performedBy?: string): Promise { + const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); + const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] }; + + for (const item of ready) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason }); + }; + + if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; } + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } + const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; + if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(item.id, { + status: 'LOADED', + loadedAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_LOADED', + inventoryId: item.id, + warehouseId: item.warehouseId, + description: 'Bulk loaded (passed export)', + performedBy, + }, + manager, + ); + }); + + result.loadedCount += 1; + result.results.push({ inventoryId: item.id, status: 'LOADED' }); + } + + return result; + } + + /** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */ + private async exportInventoryByStatus( + status: WarehouseInventoryStatus, + requireInspectionPassed = false, + ): Promise { + const rows: Array< + ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT inv.id, + inv.booking_id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + ct.container_number AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + inv.weight AS "weight", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + inv.inspection_status AS "inspectionStatus", + inv.status + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.containers ct ON ct.id = inv.container_id + WHERE inv.deleted_at IS NULL + AND inv.status = $1 + ${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''} + ORDER BY inv.created_at DESC`, + [status], + ); + + return rows + .filter((r) => { + const dir = deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }); + return dir === 'EXPORT'; + }) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest); + } + + /** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */ + async readyToLoadExport(): Promise { + return this.exportInventoryByStatus('READY_FOR_LOADING', true); + } + + /** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */ + async loadedExport(): Promise { + return this.exportInventoryByStatus('LOADED'); + } + + /** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */ + private async importQueueByStatuses(statuses: string[]): Promise { + const rows: Array< + ImportUnloadedRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT inv.id, + inv.booking_id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + COALESCE(inv.unloaded_at, inv.arrived_at) AS "arrivalTime", + (SELECT c.container_number FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.container_number LIMIT 1) AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + inv.weight AS "weight", + ts.train_number AS "trainSchedule", + inv.inspection_status AS "inspectionStatus", + CASE WHEN b.last_mile_delivery_address IS NOT NULL + THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", + (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + inv.status AS "currentStatus", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL + LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + WHERE inv.deleted_at IS NULL + AND inv.status = ANY($1) + ORDER BY inv.created_at DESC`, + [statuses], + ); + + return rows + .filter( + (r) => + deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT', + ) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest); + } + + /** + * Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection + * states), with the columns the inspection screen needs. Read-only. + */ + importUnloadedQueue(): Promise { + return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']); + } + + /** + * Batch 10 — IMPORT inventory that passed inspection and is PICKUP_READY (READY_FOR_PICKUP), + * awaiting customer pickup / last mile / store / dispatch. Read-only. + */ + importPickupReadyQueue(): Promise { + return this.importQueueByStatuses(['READY_FOR_PICKUP']); + } + + /** + * Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition + * (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not + * LOADED or not EXPORT are skipped. The train/schedule flow later moves DISPATCHED → IN_TRANSIT. + */ + async bulkDispatchExport(inventoryIds: string[], performedBy?: string): Promise { + const result: BulkDispatchResult = { dispatchedCount: 0, skippedCount: 0, results: [] }; + + for (const inventoryId of inventoryIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId, status: 'SKIPPED', reason }); + }; + + const item = await this.inventoryRepository.findById(inventoryId); + if (!item) { skip('Inventory not found'); continue; } + if (item.status !== 'LOADED') { skip(`Status is ${item.status}, not LOADED`); continue; } + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } + + try { + await this.dispatch(inventoryId, performedBy); + result.dispatchedCount += 1; + result.results.push({ inventoryId, status: 'DISPATCHED' }); + } catch (error) { + skip(error instanceof Error ? error.message : String(error)); + } + } + + return result; + } + + /** Booking statuses eligible to be unloaded off an arrived import train (Batch 8). */ + private readonly IMPORT_UNLOAD_ELIGIBLE_STATUSES = [ + 'IN_TRANSIT', + 'ARRIVED_AT_INDODE', + 'ARRIVED_AT_DESTINATION', + 'ARRIVED_AT_FACILITY', + ]; + + /** + * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state. + * Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect — + * items land in UNLOADED so the operator drives store/inspect/reserve/dispatch from the queue. + */ + async autoUnloadArrivedBookings( + scheduleId: string, + performedBy?: string, + ): Promise { + const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] }; + + // 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries). + const [schedule] = await this.dataSource.query( + `SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== 'ARRIVED') { + throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`); + } + const direction = deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ); + if (direction !== 'IMPORT') { + throw new BadRequestException(`Train schedule route is ${direction}, not IMPORT`); + } + + // 2. Assigned bookings on this train. + const bookings: { + id: string; + status: string; + weight: string | null; + freightType: string | null; + tradeDirection: string | null; + cargoTypeCode: string | null; + }[] = await this.dataSource.query( + `SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight, + b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", + cgt.code AS "cargoTypeCode" + FROM freight.train_schedule_bookings tsb + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`, + [scheduleId], + ); + + const fallback = await this.pickDefaultLocation(); + const now = new Date(); + + for (const booking of bookings) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ bookingId: booking.id, status: 'SKIPPED', reason }); + }; + const fail = (reason: string) => { + result.failedCount += 1; + result.results.push({ bookingId: booking.id, status: 'FAILED', reason }); + }; + + if (!this.IMPORT_UNLOAD_ELIGIBLE_STATUSES.includes(booking.status)) { + skip(`Booking status ${booking.status} is not unload-eligible`); + continue; + } + + try { + const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0]; + + // Already unloaded or further along — leave it (do not regress the lifecycle). + if (existing && existing.status !== 'RECEIVED') { + skip(`Inventory already ${existing.status}`); + continue; + } + + if (existing) { + await this.inventoryRepository.update(existing.id, { + status: 'UNLOADED', + unloadedAt: now, + arrivedAt: existing.arrivedAt ?? now, + }); + await this.activityLog.record({ + activityType: 'INVENTORY_UNLOADED', + inventoryId: existing.id, + warehouseId: existing.warehouseId, + description: 'Unloaded from arrived import train', + performedBy, + }); + result.unloadedCount += 1; + result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' }); + continue; + } + + // No inventory yet — create it at the allocated (or default) location, in UNLOADED state. + const allocated = await this.allocation.resolveLocation({ + freightType: booking.freightType, + tradeDirection: booking.tradeDirection, + cargoTypeCode: booking.cargoTypeCode, + }); + const location = allocated ?? fallback; + if (!location) { + fail('No warehouse/yard/zone configured'); + continue; + } + + const saved = await this.inventoryRepository.create({ + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + bookingId: booking.id, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'UNLOADED', + arrivedAt: now, + unloadedAt: now, + notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', + }); + await this.activityLog.record({ + activityType: 'INVENTORY_UNLOADED', + inventoryId: saved.id, + warehouseId: saved.warehouseId, + description: 'Unloaded from arrived import train', + performedBy, + }); + result.unloadedCount += 1; + result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' }); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + } + + return result; + } + + /** + * Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal + * report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING. + * For damage / weight-loss / images, use the per-item Inspect / Report action instead. + */ + async bulkMarkInspected(dto: BulkInspectDto): Promise { + const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] }; + // UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state). + const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; + + for (const inventoryId of dto.inventoryIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId, status: 'SKIPPED', reason }); + }; + + const item = await this.inventoryRepository.findById(inventoryId); + if (!item) { skip('Inventory not found'); continue; } + if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; } + if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; } + + // Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt. + await this.inspectionService.create(inventoryId, { + reportType: 'INSPECTION', + inspectionStatus: 'PASSED', + remarks: dto.remarks ?? 'Bulk marked inspected (PASSED).', + inspectedById: dto.inspectedBy, + }); + + // A passed item advances by trade direction: + // EXPORT → Ready To Load (READY_FOR_LOADING) + // IMPORT → Pickup Ready (READY_FOR_PICKUP) — NOT ready-for-loading. + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction === 'EXPORT') { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + status: 'READY_FOR_LOADING', + readyForLoadingAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'READY_FOR_LOADING', + inventoryId, + warehouseId: item.warehouseId, + description: 'Inspection passed → ready for loading', + performedBy: dto.inspectedBy, + }, + manager, + ); + }); + result.results.push({ inventoryId, status: 'READY_FOR_LOADING' }); + } else if (direction === 'IMPORT') { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + status: 'READY_FOR_PICKUP', + readyForPickupAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'READY_FOR_PICKUP', + inventoryId, + warehouseId: item.warehouseId, + description: 'Destination inspection passed → pickup ready', + performedBy: dto.inspectedBy, + }, + manager, + ); + }); + result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' }); + } else { + result.results.push({ inventoryId, status: 'INSPECTED' }); + } + result.inspectedCount += 1; + } + + return result; + } + // ── Receive ────────────────────────────────────────────────────────────── async receive(dto: ReceiveWarehouseInventoryDto): Promise { @@ -511,6 +1134,9 @@ export class WarehouseInventoryService { if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) { throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep'); } + if (item.inspectionStatus !== 'PASSED') { + throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading'); + } return this.transition(id, 'READY_FOR_LOADING', { timestampField: 'readyForLoadingAt', activityType: 'READY_FOR_LOADING', @@ -520,6 +1146,112 @@ export class WarehouseInventoryService { }); } + // ── Import branch (READY_FOR_PICKUP → DELIVERED) ─────────────────────────── + + /** Mark inspected IMPORT inventory ready for customer pickup (RECEIVED → READY_FOR_PICKUP). */ + async readyForPickup(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + + if (item.inspectionStatus !== 'PASSED') { + throw new BadRequestException('Inventory must pass inspection before it can be marked ready for pickup'); + } + + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'IMPORT') { + throw new BadRequestException('Only IMPORT inventory can be marked ready for pickup'); + } + + return this.transition(id, 'READY_FOR_PICKUP', { + timestampField: 'readyForPickupAt', + activityType: 'READY_FOR_PICKUP', + description: 'Inventory ready for customer pickup', + performedBy, + preloaded: item, + }); + } + + /** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */ + async release(id: string, dto: ReleaseOrderDto): Promise { + const item = await this.findById(id); + if (item.status !== 'READY_FOR_PICKUP') { + throw new BadRequestException( + `Inventory must be READY_FOR_PICKUP to issue a release order (current: ${item.status})`, + ); + } + + const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date(); + const reference = dto.reference?.trim() || null; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + releaseDate, + releaseOrderReference: reference, + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_RELEASED', + inventoryId: id, + warehouseId: item.warehouseId, + description: reference + ? `Release order ${reference} sent to customer` + : 'Release order sent to customer', + performedBy: dto.performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ + async deliver(id: string, dto: DeliverInventoryDto): Promise { + const item = await this.findById(id); + this.assertTransition(item.status, 'DELIVERED'); + + if (!item.releaseDate) { + throw new BadRequestException('A release order must be issued before the goods can be delivered'); + } + + const receiverName = dto.receiverName.trim(); + const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date(); + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + status: 'DELIVERED', + deliveredAt, + }); + + // Goods physically leave the warehouse on pickup — free up capacity. + await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); + + // Proof of delivery is captured on the linked cargo. + if (item.cargoId) { + await manager.getRepository(Cargo).update(item.cargoId, { + receiverName, + deliveredAt, + deliveryRemarks: dto.remarks?.trim() ?? null, + }); + } + + await this.activityLog.record( + { + activityType: 'INVENTORY_DELIVERED', + inventoryId: id, + warehouseId: item.warehouseId, + description: `Delivered to ${receiverName}`, + performedBy: dto.performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + /** * Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record. * Reads wagon/schedule data read-only — never modifies scheduling. @@ -886,6 +1618,23 @@ export class WarehouseInventoryService { return rows?.[0]?.status ?? null; } + /** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */ + private async getBookingDirection(bookingId: string): Promise { + const rows = await this.dataSource.query( + `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.bookings b + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!rows?.[0]) return null; + return deriveTradeDirection( + { country: rows[0].originCountry }, + { country: rows[0].destinationCountry }, + ); + } + private assertCapacity( label: string, node: LocationNode, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index 3cbcc6833..f92401dfc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -1,5 +1,5 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { FindManyOptions, ILike } from 'typeorm'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindManyOptions, ILike, QueryFailedError } from 'typeorm'; import { CreateWarehouseDto } from './dto/create-warehouse.dto'; import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; @@ -49,23 +49,27 @@ export class WarehousesService { async create(dto: CreateWarehouseDto): Promise { await this.assertCodeUnique(dto.code.trim()); - return this.warehousesRepository.create({ - name: dto.name.trim(), - code: dto.code.trim(), - type: dto.type, - stationId: dto.stationId ?? null, - facilityId: dto.facilityId ?? null, - locationName: dto.locationName?.trim() ?? null, - capacityWeight: dto.capacityWeight ?? null, - capacityContainers: dto.capacityContainers ?? null, - maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, - maxVolume: dto.maxVolume ?? null, - currentWeight: 0, - currentContainers: 0, - currentVolume: 0, - status: 'ACTIVE', - isActive: true, - }); + try { + return await this.warehousesRepository.create({ + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + stationId: dto.stationId ?? null, + facilityId: dto.facilityId ?? null, + locationName: dto.locationName?.trim() ?? null, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: 'ACTIVE', + isActive: true, + }); + } catch (error) { + this.mapDbError(error); + } } async update(id: string, dto: UpdateWarehouseDto): Promise { @@ -77,20 +81,25 @@ export class WarehousesService { const status = dto.status ?? existing.status; - const updated = await this.warehousesRepository.update(id, { - name: dto.name?.trim() ?? existing.name, - code: dto.code?.trim() ?? existing.code, - type: dto.type ?? existing.type, - stationId: dto.stationId ?? existing.stationId, - facilityId: dto.facilityId ?? existing.facilityId, - locationName: dto.locationName?.trim() ?? existing.locationName, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, - maxWeight: dto.maxWeight ?? existing.maxWeight, - maxVolume: dto.maxVolume ?? existing.maxVolume, - status, - isActive: status === 'ACTIVE', - }); + let updated; + try { + updated = await this.warehousesRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + stationId: dto.stationId ?? existing.stationId, + facilityId: dto.facilityId ?? existing.facilityId, + locationName: dto.locationName?.trim() ?? existing.locationName, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, + status, + isActive: status === 'ACTIVE', + }); + } catch (error) { + this.mapDbError(error); + } if (!updated) { throw new NotFoundException(`Warehouse ${id} not found`); @@ -99,6 +108,21 @@ export class WarehousesService { return this.findById(id); } + /** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */ + private mapDbError(error: unknown): never { + if (error instanceof QueryFailedError) { + const driver = (error as QueryFailedError & { driverError?: { code?: string; detail?: string } }).driverError; + if (driver?.code === '23503') { + throw new BadRequestException('Selected facility does not exist.'); + } + if (driver?.code === '22001') { + throw new BadRequestException('A field is too long (code max 40, name max 160 characters).'); + } + throw new BadRequestException(driver?.detail ?? error.message ?? 'Invalid warehouse data.'); + } + throw error as Error; + } + private async assertCodeUnique(code: string, ignoreId?: string): Promise { const [existing] = await this.warehousesRepository.findAll({ where: { code } }); diff --git a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts new file mode 100644 index 000000000..f9e4e2af7 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts @@ -0,0 +1,139 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.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'; + +const SEED_REFS = ['SEED-B5-EXP-001', 'SEED-B5-EXP-002', 'SEED-B5-EXP-003']; + +const SEEDS = [ + { ref: 'SEED-B5-EXP-001', weight: 5000, notes: 'Electronics export cargo' }, + { ref: 'SEED-B5-EXP-002', weight: 8500, notes: 'Textile export cargo' }, + { ref: 'SEED-B5-EXP-003', weight: 3200, notes: 'Coffee export cargo' }, +]; + +/** + * Seeds 3 EXPORT+PAID bookings with READY_FOR_LOADING + inspection PASSED inventory + * so the Batch 5 "Ready To Load" tab has visible rows to test against. + * + * Origin: any Ethiopian yard (route-based direction = EXPORT when dest = Djibouti) + * Destination: any Djiboutian yard + * Uses the INDODE_OPEN warehouse created by IndodeFacilitySeeder. + */ +@Injectable() +export class Batch5TestDataSeeder { + private readonly logger = new Logger(Batch5TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + + const existing = await bookingRepo.findOne({ where: { reference: SEED_REFS[0] } }); + if (existing) { + this.logger.log('Batch 5 test data already seeded, skipping'); + return; + } + + try { + const yardRepo = this.dataSource.getRepository(Yard); + const serviceTypeRepo = this.dataSource.getRepository(ServiceType); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard); + const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone); + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + + // Find Ethiopian origin yard and Djiboutian destination yard. + const originYard = + (await yardRepo.findOne({ where: { code: 'ADDIS_ABABA' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const destYard = + (await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + + if (!originYard || !destYard) { + this.logger.warn( + `Required yards not found (origin=${originYard?.code ?? 'none'}, dest=${destYard?.code ?? 'none'}); skipping Batch 5 seed`, + ); + return; + } + + // Find any active service type (bookings require one). + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + if (!serviceType) { + this.logger.warn('No service type found; skipping Batch 5 seed'); + return; + } + + // Find INDODE warehouse. + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + if (!warehouse) { + this.logger.warn('INDODE_OPEN warehouse not found; skipping Batch 5 seed'); + return; + } + + const warehouseYard = await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }); + if (!warehouseYard) { + this.logger.warn('No warehouse yard found for INDODE_OPEN; skipping Batch 5 seed'); + return; + } + + const warehouseZone = await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }); + if (!warehouseZone) { + this.logger.warn('No warehouse zone found; skipping Batch 5 seed'); + return; + } + + const now = new Date(); + + for (const seed of SEEDS) { + const booking = await bookingRepo.save( + bookingRepo.create({ + reference: seed.ref, + originYardId: originYard.id, + destinationYardId: destYard.id, + serviceTypeId: serviceType.id, + status: 'PAID', + paymentStatus: 'PAID', + tradeDirection: 'EXPORT', + freightType: 'BULK', + cargoTotalWeightVgm: seed.weight, + cargoFreeText: seed.notes, + }), + ); + + await inventoryRepo.save( + inventoryRepo.create({ + bookingId: booking.id, + warehouseId: warehouse.id, + yardId: warehouseYard.id, + zoneId: warehouseZone.id, + status: 'READY_FOR_LOADING', + inspectionStatus: 'PASSED', + inspectedAt: new Date(now.getTime() - 3600 * 1000), + quantity: 1, + weight: seed.weight, + arrivedAt: new Date(now.getTime() - 7200 * 1000), + readyForLoadingAt: new Date(now.getTime() - 1800 * 1000), + notes: `[SEED-B5] ${seed.notes}`, + }), + ); + + this.logger.log(`Seeded ${seed.ref} → READY_FOR_LOADING + PASSED`); + } + + this.logger.log('✅ Batch 5 Ready-To-Load test data seeded successfully'); + } catch (error) { + this.logger.error( + `Batch5TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts new file mode 100644 index 000000000..dec3febe3 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts @@ -0,0 +1,103 @@ +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 { 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'; + +/** + * Seeds two ARRIVED train schedules so the Import Arrive Queue (Batch 7) is demonstrable: + * - SEED-IMP-TRAIN-01: DJIB_PORT → MOJO (IMPORT) linked to booking SEED-IMP-001 → SHOWS + * - SEED-EXP-TRAIN-01: MOJO → DJIB_PORT (EXPORT) linked to booking SEED-EXP-001 → must NOT show + * + * Read-only train-schedule SERVICE logic is untouched; this only inserts fixture rows. + * Idempotent: guards on the import train number. + */ +@Injectable() +export class Batch7TestDataSeeder { + private readonly logger = new Logger(Batch7TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + + const existing = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } }); + if (existing) { + this.logger.log('Batch 7 test data already seeded, skipping'); + return; + } + + try { + const bookingRepo = this.dataSource.getRepository(Booking); + const locoRepo = this.dataSource.getRepository(Locomotive); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const importBooking = await bookingRepo.findOne({ where: { reference: 'SEED-IMP-001' } }); + const exportBooking = await bookingRepo.findOne({ where: { reference: 'SEED-EXP-001' } }); + if (!importBooking) { + this.logger.warn('SEED-IMP-001 booking not found; skipping Batch 7 seed'); + return; + } + + // One shared locomotive is fine — train_set.locomotive_id is not unique. + const loco = + (await locoRepo.findOne({ where: { code: 'SEED-LOCO-01' } })) ?? + (await locoRepo.save( + locoRepo.create({ code: 'SEED-LOCO-01', name: 'Seed Locomotive', maxPullWeightTons: 4000 }), + )); + + const now = new Date(); + const arrival = new Date(now.getTime() - 3600 * 1000); + const departure = new Date(now.getTime() - 6 * 3600 * 1000); + + const makeArrivedTrain = async ( + trainNumber: string, + booking: Booking, + ): Promise => { + 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: booking.originYardId, + destinationStationId: booking.destinationYardId, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber, + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: booking.id }), + ); + + this.logger.log(`Seeded arrived train ${trainNumber} → booking ${booking.reference}`); + }; + + await makeArrivedTrain('SEED-IMP-TRAIN-01', importBooking); + if (exportBooking) { + await makeArrivedTrain('SEED-EXP-TRAIN-01', exportBooking); + } + + this.logger.log('✅ Batch 7 arrive-queue test data seeded successfully'); + } catch (error) { + this.logger.error( + `Batch7TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts new file mode 100644 index 000000000..c3a98c224 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; + +/** + * Makes the Batch 7 seed import train demonstrable for Batch 8: a booking riding an ARRIVED + * train is IN_TRANSIT until unloaded, so flip the seed import train's assigned bookings to + * IN_TRANSIT (an unload-eligible status). Idempotent — re-applying IN_TRANSIT is a no-op. + */ +@Injectable() +export class Batch8TestDataSeeder { + private readonly logger = new Logger(Batch8TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + const bookingRepo = this.dataSource.getRepository(Booking); + + const train = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } }); + if (!train) { + this.logger.log('SEED-IMP-TRAIN-01 not found; skipping Batch 8 seed'); + return; + } + + const links = await scheduleBookingRepo.find({ where: { trainScheduleId: train.id } }); + let updated = 0; + for (const link of links) { + const booking = await bookingRepo.findOne({ where: { id: link.bookingId } }); + if (!booking || booking.status === 'IN_TRANSIT') continue; + await bookingRepo.update(booking.id, { status: 'IN_TRANSIT' }); + updated += 1; + } + + if (updated > 0) { + this.logger.log(`✅ Batch 8: set ${updated} import train booking(s) to IN_TRANSIT (unload-eligible)`); + } else { + this.logger.log('Batch 8: import train bookings already IN_TRANSIT, skipping'); + } + } catch (error) { + this.logger.error( + `Batch8TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} 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 }), + ); + } + } +} diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index d680625ac..96dd20172 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -9,10 +9,7 @@ "preview": "vite preview --port 5183", "lint": "eslint src", "test": "vitest run", - "type-check": "tsc --noEmit", - "build:user-management": "cd user-management-config && npm run build", - "backoffice": "npm run build:user-management && nx serve @fhc-platform/backoffice", - "backoffice:no-build": "nx serve @fhc-platform/backoffice" + "type-check": "tsc --noEmit" }, "dependencies": { "@edr/types": "workspace:*", @@ -22,7 +19,7 @@ "@mantine/hooks": "^9.3.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", - "@tria-plc/iamui-common": "1.1.2", + "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4e079b26a..11e4aa8eb 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -49,6 +49,8 @@ import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources" import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; @@ -213,34 +215,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Administration", items: [ - { - label: "User management", - href: "/dashboard/user-management", - icon: , - permission: FREIGHT_PERMS.admin, - children: [ - { - label: "Users", - href: "/dashboard/user-management/users", - }, - { - label: "Employees", - href: "/dashboard/user-management/employees", - }, - { - label: "Position Types", - href: "/dashboard/user-management/position-types", - }, - { - label: "Permissions", - href: "/dashboard/user-management/permissions", - }, - { - label: "Roles", - href: "/dashboard/user-management/roles", - }, - ], - }, { label: "File settings", href: "/dashboard/file-settings", @@ -341,216 +315,210 @@ const App = () => { return ( } /> - } /> - } /> + } /> + } /> ); } return ( - } /> - } /> - }> - } /> - } /> + } /> + } /> + } /> + }> + } /> + } /> - } /> - - - - } - /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + + + + } + /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* iframe-based user management module */} - } /> + {/* Legacy embedded user management routes */} + } /> + } /> + } /> + {/* } /> */} + } /> + } /> - {/* Legacy embedded user management routes */} - } /> - } /> - } /> - {/* } /> */} - } /> - } /> + + + + } + /> + + + + } + /> - - - - } - /> - - - - } - /> + } + /> + + + + } + /> + } /> + } /> + } /> - } - /> - - - - } - /> - } /> + } + /> + } /> - } - /> - } /> + } + /> + } /> - } - /> - } /> + } /> + } /> - } /> - } /> + } /> + } /> + - } - /> - } - /> - - - } /> + } /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx new file mode 100644 index 000000000..4b5485815 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core'; +import { Info } from 'lucide-react'; + +import { useToast } from '@/hooks/use-toast'; +import { useDeliverInventory } from '@/hooks/useWarehouses'; +import type { WarehouseInventoryItem } from '@/types/warehouse'; +import { extractErrorMessage } from './options'; + +interface DeliverInventoryModalProps { + opened: boolean; + onClose: () => void; + item: WarehouseInventoryItem | null; +} + +export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) { + const { toast } = useToast(); + const deliverMutation = useDeliverInventory(); + const [receiverName, setReceiverName] = useState(''); + const [remarks, setRemarks] = useState(''); + + useEffect(() => { + if (opened) { + setReceiverName(''); + setRemarks(''); + } + }, [opened, item]); + + const handleSubmit = async () => { + if (!item) return; + if (!receiverName.trim()) { + toast({ variant: 'destructive', title: 'Receiver name is required' }); + return; + } + try { + await deliverMutation.mutateAsync({ + id: item.id, + payload: { receiverName: receiverName.trim(), remarks: remarks.trim() || undefined }, + }); + toast({ title: 'Delivered — proof of delivery captured' }); + onClose(); + } catch (error) { + toast({ variant: 'destructive', title: 'Delivery failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + } color="green" variant="light"> + + A release order must already be issued. Capturing the receiver marks the goods DELIVERED. + + + setReceiverName(e.currentTarget.value)} + /> +