diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a8f03027a..d8413bf71 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -69,8 +69,8 @@ jobs: fi echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") - echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal") - echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice") + echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal") + echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice") echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") 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/.env.example b/apps/edr-freight-api/.env.example index 73e122a1d..73312aae3 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m TELEBIRR_PRIVATE_KEY= TELEBIRR_PUBLIC_KEY= TELEBIRR_INSECURE_TLS=false + +# Portal pages the payment provider redirects the browser to after payment. +# Point these at the freight portal's public payment result routes. +PAYMENT_RETURN_URL=http://localhost:5173/payment/success +PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure # JWT (used by @tria-plc/api-common SharedAuthModule) JWT_SECRET= JWT_ACCESS_TOKEN_SECRET= diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 4c778620b..a5ee4c293 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -24,14 +24,13 @@ import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module"; -import { CustomersModule } from "./modules/customers/customers.module"; import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; -import { OtpModule } from './modules/otp/otp.module'; +import { OtpModule } from "./modules/otp/otp.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; @@ -44,7 +43,6 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; -import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder"; import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; @@ -64,6 +62,7 @@ import { RoutesModule } from './modules/routes/routes.module'; import { WarehousesModule } from './modules/warehouses/warehouses.module'; import { FacilitiesModule } from './modules/facilities/facilities.module'; import { OverviewModule } from './modules/overview/overview.module'; +import { VehiclesModule } from './modules/vehicles/vehicles.module'; @Module({ imports: [ @@ -101,7 +100,6 @@ import { OverviewModule } from './modules/overview/overview.module'; TrainSchedulesModule, TrainSchedulingModule, SchedulingRescheduleModule, - CustomersModule, CompaniesModule, TrackingModule, BillingModule, @@ -123,12 +121,12 @@ import { OverviewModule } from './modules/overview/overview.module'; FacilitiesModule, WarehousesModule, OverviewModule, + VehiclesModule, ], providers: [ EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder, - DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, @@ -147,7 +145,6 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, - private readonly demoBookingsSeeder: DemoBookingsSeeder, private readonly pricingDataSeeder: PricingDataSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, private readonly indodeFacilitySeeder: IndodeFacilitySeeder, @@ -166,7 +163,6 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); await this.freightStaffUsersSeeder.run(); - await this.demoBookingsSeeder.run(); await this.pricingDataSeeder.run(); await this.fileUploadSettingsSeeder.run(); await this.indodeFacilitySeeder.run(); @@ -177,6 +173,11 @@ export class AppModule implements OnApplicationBootstrap { 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. + // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval + // rules are disabled inside the seeder). Kept running for the staff users. await this.demoFreightDataSeeder.run(); } } diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 676e4f4ba..41a2f3b44 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -114,7 +114,9 @@ export class ContractViewModelBuilder { tinNumber: this.valueOrDash(booking.company?.tin), vatNumber: this.valueOrDash(booking.company?.vatNumber), fanNumber: this.valueOrDash(booking.company?.fanNumber), - businessLicense: this.valueOrDash(booking.company?.businessLicense), + businessLicense: this.valueOrDash( + booking.company?.companyProfiles?.[0]?.businessLicense, + ), }, provider: { name: 'Ethio-Djibouti Standard Gauge Railway Share Company', diff --git a/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts b/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts new file mode 100644 index 000000000..a27cef3ad --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts @@ -0,0 +1,103 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, +} from "typeorm"; + +export class CreateCompanyProfiles1752000000000 implements MigrationInterface { + name = "CreateCompanyProfiles1752000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: "freight", + name: "company_profiles", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "gen_random_uuid()", + }, + { name: "company_id", type: "uuid" }, + { name: "type", type: "varchar", length: "32" }, + { name: "reference", type: "varchar", length: "20", isUnique: true }, + { + name: "status", + type: "varchar", + length: "32", + default: "'active'", + }, + { + name: "business_license", + type: "varchar", + length: "100", + isNullable: true, + }, + { name: "attributes", type: "jsonb", isNullable: true }, + { name: "created_at", type: "timestamptz", default: "now()" }, + { name: "updated_at", type: "timestamptz", default: "now()" }, + { name: "deleted_at", type: "timestamptz", isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + "freight.company_profiles", + new TableForeignKey({ + columnNames: ["company_id"], + referencedTableName: "companies", + referencedSchema: "freight", + referencedColumnNames: ["id"], + }), + ); + + await queryRunner.createIndex( + "freight.company_profiles", + new TableIndex({ columnNames: ["company_id"] }), + ); + await queryRunner.createIndex( + "freight.company_profiles", + new TableIndex({ columnNames: ["type"] }), + ); + + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("freight.company_profiles"); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts new file mode 100644 index 000000000..f15996773 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveBusinessLicenseToProfile1752000000001 + implements MigrationInterface +{ + name = 'MoveBusinessLicenseToProfile1752000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.company_profiles cp + SET business_license = c.business_license + FROM freight.companies c + WHERE cp.company_id = c.id AND c.business_license IS NOT NULL + `); + + await queryRunner.query( + `ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`, + ); + + await queryRunner.query(` + UPDATE freight.companies c + SET business_license = cp.business_license + FROM ( + SELECT DISTINCT ON (cp2.company_id) + cp2.company_id, cp2.business_license + FROM freight.company_profiles cp2 + WHERE cp2.business_license IS NOT NULL + ORDER BY cp2.company_id, cp2.created_at + ) cp + WHERE cp.company_id = c.id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts new file mode 100644 index 000000000..2cf09c9f5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateVehiclesTable1770000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN + CREATE TABLE freight.vehicles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + plate_number VARCHAR NOT NULL UNIQUE, + registration_number VARCHAR NOT NULL UNIQUE, + vehicle_type VARCHAR NOT NULL, + manufacturer VARCHAR NOT NULL, + model VARCHAR NOT NULL, + year INTEGER NOT NULL, + fuel_type VARCHAR NOT NULL, + capacity NUMERIC NOT NULL, + status VARCHAR DEFAULT 'ACTIVE' NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + deleted_at TIMESTAMP NULL + ); + + CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number); + CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number); + CREATE INDEX idx_vehicles_status ON freight.vehicles(status); + CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type); + CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`); + } +} 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/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index b2c3ffbfb..c41013ddb 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - ConflictException, forwardRef, Inject, Injectable, @@ -69,7 +68,12 @@ export class BookingTransitionService { priorityScore, } as never); - const finalBooking = await this.bookingsService.findById(updated!.id); + // Auto-consolidate now: a partial-wagon booking either pairs with a waiting + // partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one + // arrives. The returned status reflects that outcome. + const finalBooking = await this.bookingsService.runConsolidationOnSubmit( + updated!.id, + ); return { bookingId: finalBooking.id, status: finalBooking.status, @@ -143,7 +147,10 @@ export class BookingTransitionService { }, } as never); - const finalBooking = await this.bookingsService.findById(updated!.id); + // Same consolidation treatment as the direct submit path. + const finalBooking = await this.bookingsService.runConsolidationOnSubmit( + updated!.id, + ); return { bookingId: finalBooking.id, status: finalBooking.status, @@ -188,18 +195,11 @@ export class BookingTransitionService { async acceptIntake(bookingId: string, actorId: string): Promise { const booking = await this.bookingsService.findById(bookingId); + // Only SUBMITTED bookings are acceptable. A booking that still needs + // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and + // is therefore never offered for accept until a partner moves it to SUBMITTED. assertBookingStatus(booking, ['SUBMITTED']); - // Consolidation gate: a booking whose containers don't fill whole wagons - // cannot be accepted until it is paired with a complementary booking. - const gate = await this.bookingsService.resolveConsolidationGate(bookingId); - if (gate.blocked) { - throw new ConflictException( - gate.message ?? - 'Booking requires consolidation and cannot be accepted until a partner is found.', - ); - } - await this.ruleEngineService.instantiateApprovalSteps(bookingId, { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ba9fffb66..ae0f1765a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -11,6 +11,7 @@ import { Query, Request, Res, + UnauthorizedException, UploadedFiles, UseInterceptors, } from '@nestjs/common'; @@ -117,8 +118,22 @@ export class BookingsController { @Get() @ApiOperation({ summary: 'List freight bookings (paginated)' }) - findAll(@Query() filter: FilterBookingDto) { - return this.bookingsService.findAll(filter); + async findAll( + @Query() filter: FilterBookingDto, + @CurrentUser() user: TCurrentUser, + ) { + // Staff (backoffice) see every booking. Customers (portal) are always + // force-scoped to their own company, regardless of any companyId they pass. + if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + return this.bookingsService.findAll(filter); + } + const userId = user?.id; + if (!userId) throw new UnauthorizedException('Authentication required'); + const companyId = + await this.bookingsService.resolveCustomerCompanyId(userId); + // No linked company yet → no bookings to show (avoids leaking all bookings). + if (!companyId) return { items: [], total: 0 }; + return this.bookingsService.findAll(filter, companyId); } @Get('list-summary') @@ -166,18 +181,60 @@ export class BookingsController { @Get('by-reference/:reference') @ApiOperation({ summary: 'Get booking by reference' }) - async findByReference(@Param('reference') reference: string) { + async findByReference( + @Param('reference') reference: string, + @CurrentUser() user: TCurrentUser, + ) { const booking = await this.bookingsService.findByReference(reference); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } return this.transitionService.enrichBookingResponse(booking); } @Get(':id') @ApiOperation({ summary: 'Get booking by ID' }) - async findOne(@Param('id', ParseUUIDPipe) id: string) { + async findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { const booking = await this.bookingsService.findById(id); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } return this.transitionService.enrichBookingResponse(booking); } + @Get(':id/tracking') + @ApiOperation({ + summary: 'Shipment tracking timeline for a booking', + description: + "Returns the booking's consignment (once dispatched) and its ordered " + + 'tracking events. Scoped to the customer\'s own company.', + }) + async findTracking( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.bookingsService.getBookingTracking(id); + } + @Delete(':id') @HttpCode(204) @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) 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 d304e2946..b173bfe68 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository { .where('b.id != :bookingId', { bookingId: booking.id }) .andWhere('b.allowConsolidation = true') .andWhere('b.consolidationPartnerId IS NULL') + // Only pair bookings the customer has committed (SUBMITTED) or that are + // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so + // pairing never prematurely submits an unfinished/unpriced draft. .andWhere('b.status IN (:...statuses)', { - statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'], + statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], }) .andWhere('b.originYardId = :originYardId', { originYardId: booking.originYardId, @@ -686,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') @@ -703,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, }); @@ -762,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 ebf8273de..4c8ef2cab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1,12 +1,17 @@ import { BadRequestException, ConflictException, + ForbiddenException, + forwardRef, + Inject, Injectable, NotFoundException, } from '@nestjs/common'; -import { SchedulingStatus } from '@edr/types'; +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'; @@ -52,6 +57,8 @@ export class BookingsService { private readonly minioService: MinioService, // private readonly customersService: CustomersService, private readonly companiesService: CompaniesService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, @@ -146,13 +153,17 @@ export class BookingsService { /** * Enable consolidation when any container line leaves a wagon partially filled - * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out. + * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon). + * + * Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a + * half-empty wagon, so `explicit === false` is ignored when consolidation is + * actually needed. The opt-in flag only matters for cargo that already fills + * whole wagons (where consolidation is moot anyway). */ private async resolveConsolidation( containers: CreateBookingContainerDto[], explicit?: boolean, ): Promise { - if (explicit === false) return false; const needs = await this.consolidationService.needsConsolidation( containers.map((c) => ({ containerTypeId: c.containerTypeId, @@ -193,10 +204,11 @@ export class BookingsService { return { booking: paired, messages }; } - if (booking.status === 'DRAFT') { - await this.bookingsRepository.update(booking.id, { - status: 'PENDING_CONSOLIDATION', - } as never); + // No partner yet — park the booking so it waits. Applies both pre-submit + // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never + // reach this method. + if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') { + await this.bookingsRepository.parkForConsolidation(booking.id); } const pending = await this.findById(booking.id); @@ -205,45 +217,24 @@ export class BookingsService { } /** - * Consolidation gate used at staff-accept time. Returns the (possibly newly - * paired) booking plus whether it still needs a consolidation partner. - * When a booking needs consolidation and none is found, it is parked in - * PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept. + * Run consolidation right after a booking reaches SUBMITTED. If a complementary + * partner already exists, both are paired and moved (back) to SUBMITTED so staff + * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and + * waits for a later complementary booking to complete the wagon. + * + * Returns the re-fetched booking, so callers can reflect the resulting status + * (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting). */ - async resolveConsolidationGate(bookingId: string): Promise<{ - booking: Booking; - blocked: boolean; - message?: string; - }> { - let booking = await this.findById(bookingId); + async runConsolidationOnSubmit(bookingId: string): Promise { + const booking = await this.findById(bookingId); - // Already paired — passes the gate. + // Already paired (e.g. a partner submitted first) — nothing to do. if (booking.consolidationPartnerId) { - return { booking, blocked: false }; + return booking; } - const needs = - await this.consolidationService.needsConsolidationFromBooking(booking); - if (!needs) { - return { booking, blocked: false }; - } - - // A partner may have appeared since submission — try to pair now. const result = await this.tryAutoConsolidate(booking); - booking = result.booking; - if (booking.consolidationPartnerId) { - return { booking, blocked: false, message: result.messages.join(' ') }; - } - - // Still no partner — park it and block the accept. - await this.bookingsRepository.parkForConsolidation(booking.id); - booking = await this.findById(booking.id); - const slots = await this.consolidationService.slotsFromBooking(booking); - return { - booking, - blocked: true, - message: this.consolidationService.describePending(booking, slots), - }; + return result.booking; } /** Create a new freight booking. */ @@ -283,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 } }); @@ -300,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()); @@ -575,6 +582,7 @@ export class BookingsService { /** Return a paginated list of bookings matching the filter. */ async findAll( filter: FilterBookingDto, + forceCompanyId?: string, ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -587,7 +595,9 @@ export class BookingsService { ...statusFilter, ...schedulingStatusFilter, assignedToSchedule: filter.assignedToSchedule, - companyId: filter.companyId, + // A forced company scope (portal/customer) overrides any caller-provided + // companyId so a customer can only ever see their own company's bookings. + companyId: forceCompanyId ?? filter.companyId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, @@ -631,6 +641,109 @@ export class BookingsService { }); } + /** + * Resolve the company a customer user belongs to, for scoping their own + * bookings. Returns null when no profile/company is linked yet. + */ + async resolveCustomerCompanyId(userId: string): Promise { + try { + const { company } = + await this.companiesService.getCompanyInfoByUserId(userId); + return company?.id ?? null; + } catch { + return null; + } + } + + /** + * Authorize a customer's access to a single booking. Staff are scoped at the + * controller (they pass `isStaff`); for a customer, the booking must belong + * to the company the authenticated user is linked to — otherwise it is hidden + * behind a NotFound so booking IDs can't be probed. + */ + async assertCustomerCanAccessBooking( + userId: string | undefined, + booking: Booking, + ): Promise { + if (!userId) { + throw new ForbiddenException('Authentication required'); + } + const companyId = await this.resolveCustomerCompanyId(userId); + if (!companyId || booking.companyId !== companyId) { + // Don't reveal that the booking exists for another company. + throw new NotFoundException(`Booking ${booking.id} not found`); + } + } + + /** + * Build the customer-facing shipment tracking payload for a booking from the + * train schedule it is assigned to and the live checkpoint log. The caller is + * responsible for authorizing access to the booking first. + * + * When the booking has not been assigned to a train yet, returns a valid + * "no schedule" payload so the UI can show a pre-dispatch state. + */ + async getBookingTracking( + bookingId: string, + ): Promise { + const booking = await this.findById(bookingId); + + const empty: Freight.IBookingTracking = { + bookingId: booking.id, + bookingReference: booking.reference, + hasSchedule: false, + scheduleId: null, + trainNumber: null, + scheduleStatus: null, + direction: null, + origin: null, + destination: null, + stations: [], + checkpoints: [], + currentSequenceNo: -1, + actualDepartureAt: null, + actualArrivalAt: null, + scheduledDepartureAt: null, + scheduledArrivalAt: null, + }; + + if (!booking.trainScheduleId) { + return empty; + } + + // Pull the live corridor + checkpoints for the assigned schedule. If the + // schedule was removed, fall back to the pre-dispatch state rather than 500. + let track: Awaited< + ReturnType + >; + try { + track = await this.trainSchedulingService.getScheduleCheckpoints( + booking.trainScheduleId, + ); + } catch { + return empty; + } + + return { + bookingId: booking.id, + bookingReference: booking.reference, + hasSchedule: true, + scheduleId: track.scheduleId, + trainNumber: track.trainNumber, + scheduleStatus: track.status as Freight.TrainScheduleStatus, + direction: track.direction, + origin: track.origin, + destination: track.destination, + stations: track.stations, + checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[], + currentSequenceNo: track.currentSequenceNo, + actualDepartureAt: track.actualDepartureAt, + actualArrivalAt: track.actualArrivalAt, + scheduledDepartureAt: track.scheduledDepartureAt, + scheduledArrivalAt: track.scheduledArrivalAt, + }; + } + /** Aggregate metrics and tab counts for the backoffice booking list. */ async getListSummary(filter: FilterBookingDto): Promise { const page = filter.page ?? 1; 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/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 81fba19fb..ac2868ec3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -1,22 +1,38 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common'; -import { AnyFilesInterceptor } from '@nestjs/platform-express'; -import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger'; -import { CurrentUser } from '@edr/api-common'; -import { FreightAdmin } from '../../common/booking-guards'; -import { FilesService } from '../files/files.service'; -import { CompaniesService } from './companies.service'; -import { CreateCompanyDto } from './dto/create-company.dto'; -import { UpdateCompanyDto } from './dto/update-company.dto'; -import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; -import { CreateFFClientDto } from './dto/create-ff-client.dto'; -import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; -import { ResponseCompanyDto } from './dto/response-company.dto'; -import { ResponseExternalProfileDto } from './dto/response-external-profile.dto'; -import { ResponseFFClientDto } from './dto/response-ff-client.dto'; -import { CompanyInfoResponseDto } from './dto/company-info-response.dto'; -import { UpdateProfileDto } from './dto/update-profile.dto'; -import { ProfileResponseDto } from './dto/profile-response.dto'; -import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto'; +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseInterceptors, + UploadedFiles, +} from "@nestjs/common"; +import { AnyFilesInterceptor } from "@nestjs/platform-express"; +import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; +import { FreightAdmin } from "../../common/booking-guards"; +import { FilesService } from "../files/files.service"; +import { CompaniesService } from "./companies.service"; +import { CreateCompanyDto } from "./dto/create-company.dto"; +import { UpdateCompanyDto } from "./dto/update-company.dto"; +import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; +import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; +import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; +import { + ResponseCompanyDto, + ResponseCompanyProfileDto, +} from "./dto/response-company.dto"; +import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; +import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ProfileResponseDto } from "./dto/profile-response.dto"; +import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; interface CurrentIamUser { id: string; @@ -25,36 +41,47 @@ interface CurrentIamUser { phoneNumber?: string; } -@ApiTags('Companies') -@Controller('companies') +@ApiTags("Companies") +@Controller("companies") export class CompaniesController { constructor( private readonly companiesService: CompaniesService, private readonly filesService: FilesService, - ) {} + ) { } - @Get('getInfo') - @ApiOperation({ summary: 'Get company info for the current user' }) - async getInfo(@CurrentUser() user: CurrentIamUser): Promise { - const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + @Get("getInfo") + @ApiOperation({ summary: "Get company info for the current user" }) + async getInfo( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.getCompanyInfoByUserId(user.id); return new CompanyInfoResponseDto(profile, company); } - @Get('profile') - @ApiOperation({ summary: 'Get flattened profile for the settings page' }) - async getProfile(@CurrentUser() user: CurrentIamUser): Promise { - const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + @Get("profile") + @ApiOperation({ summary: "Get flattened profile for the settings page" }) + async getProfile( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.getCompanyInfoByUserId(user.id); return new ProfileResponseDto(profile, company); } - @Get('dashboard') - @ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' }) - async getDashboard(@CurrentUser() user: CurrentIamUser): Promise { + @Get("dashboard") + @ApiOperation({ + summary: + "Get portal dashboard KPIs (delivered, spend, freight volume) for the current user", + }) + async getDashboard( + @CurrentUser() user: CurrentIamUser, + ): Promise { return this.companiesService.getDashboardSummary(user.id); } - @Patch('profile') - @ApiOperation({ summary: 'Update profile (flattened settings page)' }) + @Patch("profile") + @ApiOperation({ summary: "Update profile (flattened settings page)" }) async updateProfile( @CurrentUser() user: CurrentIamUser, @Body() dto: UpdateProfileDto, @@ -62,145 +89,153 @@ export class CompaniesController { return this.companiesService.updateProfile(user.id, dto); } - @Post('create') - @ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' }) + @Post("company-profiles") + @ApiOperation({ + summary: + "Add operational profile(s) (importer/exporter/forwarder) to the current user's company", + }) + async addCompanyProfiles( + @CurrentUser() user: CurrentIamUser, + @Body() dto: AddCompanyProfilesDto, + ): Promise { + const profiles = await this.companiesService.addCompanyProfilesForUser( + user.id, + dto.types, + ); + return profiles.map((p) => new ResponseCompanyProfileDto(p)); + } + + // Used by portal + @Post("create") + @ApiOperation({ + summary: + "Create a company with its associated external profile (onboarding)", + }) async createWithProfile( @CurrentUser() user: CurrentIamUser, @Body() dto: CreateCompanyWithProfileDto, ): Promise { - const nameParts = (user.name?.en ?? '').split(' '); - const { profile, company } = await this.companiesService.createCompanyWithProfile( - { - userId: user.id, - firstName: nameParts[0] || '', - lastName: nameParts.slice(-1)[0] || '', - email: user.email ?? '', - phone: user.phoneNumber ?? '', - }, - dto, - ); + const nameParts = (user.name?.en ?? "").split(" "); + const { profile, company } = + await this.companiesService.createCompanyWithProfile( + { + userId: user.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user.email ?? "", + phone: user.phoneNumber ?? "", + }, + dto, + ); return new CompanyInfoResponseDto(profile, company); } + // Used by backoffice @Post() @FreightAdmin() - @ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' }) + @ApiOperation({ + summary: + "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", + }) async create(@Body() dto: CreateCompanyDto): Promise { const company = await this.companiesService.createCompany(dto); return new ResponseCompanyDto(company); } @Get() - @ApiOperation({ summary: 'List all companies' }) + @ApiOperation({ summary: "List all companies" }) async findAll(): Promise { const companies = await this.companiesService.findAllCompanies(); return companies.map((c) => new ResponseCompanyDto(c)); } - @Get('type/:type') - @ApiOperation({ summary: 'Find companies by type' }) - async findByType(@Param('type') type: string): Promise { + @Get("type/:type") + @ApiOperation({ summary: "Find companies by type" }) + async findByType(@Param("type") type: string): Promise { const companies = await this.companiesService.findAllCompanies(); - return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c)); + return companies + .filter((c) => c.type === type) + .map((c) => new ResponseCompanyDto(c)); } - @Get('search') - @ApiOperation({ summary: 'Search companies by name' }) - async search(@Query('name') name: string): Promise { + @Get("search") + @ApiOperation({ summary: "Search companies by name" }) + async search(@Query("name") name: string): Promise { const companies = await this.companiesService.findAllCompanies(); return companies .filter((c) => c.name.toLowerCase().includes(name.toLowerCase())) .map((c) => new ResponseCompanyDto(c)); } - @Get(':id') - @ApiOperation({ summary: 'Get company by ID' }) - async findById(@Param('id', ParseUUIDPipe) id: string): Promise { + @Get(":id") + @ApiOperation({ summary: "Get company by ID" }) + async findById( + @Param("id", ParseUUIDPipe) id: string, + ): Promise { const company = await this.companiesService.findCompanyById(id); return new ResponseCompanyDto(company); } - @Patch(':id') + @Patch(":id") @FreightAdmin() - @ApiOperation({ summary: 'Update a company' }) + @ApiOperation({ summary: "Update a company" }) async update( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCompanyDto, ): Promise { const company = await this.companiesService.updateCompany(id, dto); return new ResponseCompanyDto(company); } - @Delete(':id') + @Delete(":id") @FreightAdmin() - @ApiOperation({ summary: 'Soft-delete a company' }) + @ApiOperation({ summary: "Soft-delete a company" }) @HttpCode(HttpStatus.NO_CONTENT) - async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.companiesService.deleteCompany(id); } - @Post(':companyId/documents') + @Post(":companyId/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload documents for a company (onboarding)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload documents for a company (onboarding)" }) async uploadDocuments( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, @UploadedFiles() files: Array, ) { - return this.filesService.uploadMany(companyId, 'companies', files); + return this.filesService.uploadMany(companyId, "companies", files); } - @Post(':companyId/profiles') + @Post(":companyId/profiles") @FreightAdmin() - @ApiOperation({ summary: 'Add a profile (employee) to a company' }) + @ApiOperation({ summary: "Add a profile (employee) to a company" }) async createProfile( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, @Body() dto: CreateExternalProfileDto, ): Promise { - const profile = await this.companiesService.createProfile({ ...dto, companyId }); + const profile = await this.companiesService.createProfile({ + ...dto, + companyId, + }); return new ResponseExternalProfileDto(profile); } - @Get(':companyId/profiles') - @ApiOperation({ summary: 'List profiles for a company' }) + @Get(":companyId/profiles") + @ApiOperation({ summary: "List profiles for a company" }) async listProfiles( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, ): Promise { - const profiles = await this.companiesService.findProfilesByCompany(companyId); + const profiles = + await this.companiesService.findProfilesByCompany(companyId); return profiles.map((p) => new ResponseExternalProfileDto(p)); } - @Get('profile/user/:userId') - @ApiOperation({ summary: 'Get profile by IAM user ID' }) + @Get("profile/user/:userId") + @ApiOperation({ summary: "Get profile by IAM user ID" }) async findProfileByUser( - @Param('userId', ParseUUIDPipe) userId: string, + @Param("userId", ParseUUIDPipe) userId: string, ): Promise { const profile = await this.companiesService.findProfileByUserId(userId); return new ResponseExternalProfileDto(profile); } - - @Post('ff-clients') - @FreightAdmin() - @ApiOperation({ summary: 'Link a forwarder to a client company' }) - async createFFClient(@Body() dto: CreateFFClientDto): Promise { - const client = await this.companiesService.createFFClient(dto); - return new ResponseFFClientDto(client); - } - - @Get(':forwarderCompanyId/clients') - @ApiOperation({ summary: 'List clients of a forwarder' }) - async listFFClients( - @Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string, - ): Promise { - const clients = await this.companiesService.findForwarderClients(forwarderCompanyId); - return clients.map((c) => new ResponseFFClientDto(c)); - } - - @Delete('ff-clients/:id') - @FreightAdmin() - @ApiOperation({ summary: 'Remove a forwarder-client relationship' }) - @HttpCode(HttpStatus.NO_CONTENT) - async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise { - await this.companiesService.deleteFFClient(id); - } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 406c4f509..53d3de4c8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,21 +1,30 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { FilesModule } from '../files/files.module'; -import { CompaniesController } from './companies.controller'; -import { CompaniesService } from './companies.service'; -import { CompaniesRepository } from './companies.repository'; -import { ExternalProfileRepository } from './external-profile.repository'; -import { FFClientRepository } from './ff-client.repository'; -import { CompanyDashboardRepository } from './company-dashboard.repository'; -import { Company } from './entities/company.entity'; -import { ExternalProfile } from './entities/external-profile.entity'; -import { FFClient } from './entities/ff-client.entity'; -import { Booking } from '../bookings/entities/booking.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { FilesModule } from "../files/files.module"; +import { CompaniesController } from "./companies.controller"; +import { CompaniesService } from "./companies.service"; +import { CompaniesRepository } from "./companies.repository"; +import { ExternalProfileRepository } from "./external-profile.repository"; +import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { Company } from "./entities/company.entity"; +import { ExternalProfile } from "./entities/external-profile.entity"; +import { CompanyProfile } from "./entities/company-profile.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { CompanyProfileRepository } from "./company-profile.repository"; @Module({ - imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule], + imports: [ + TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), + FilesModule, + ], controllers: [CompaniesController], - providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository], + providers: [ + CompaniesService, + CompaniesRepository, + ExternalProfileRepository, + CompanyProfileRepository, + CompanyDashboardRepository, + ], exports: [CompaniesService], }) -export class CompaniesModule {} +export class CompaniesModule { } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 086e8d767..fe1bc5598 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1,19 +1,27 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; -import { CompaniesRepository } from './companies.repository'; -import { ExternalProfileRepository } from './external-profile.repository'; -import { FFClientRepository } from './ff-client.repository'; -import { CompanyDashboardRepository } from './company-dashboard.repository'; -import { CreateCompanyDto } from './dto/create-company.dto'; -import { UpdateCompanyDto } from './dto/update-company.dto'; -import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; -import { CreateFFClientDto } from './dto/create-ff-client.dto'; -import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; -import { UpdateProfileDto } from './dto/update-profile.dto'; -import { ProfileResponseDto } from './dto/profile-response.dto'; -import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto'; -import { Company } from './entities/company.entity'; -import { ExternalProfile } from './entities/external-profile.entity'; -import { FFClient } from './entities/ff-client.entity'; +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from "@nestjs/common"; +import { CompaniesRepository } from "./companies.repository"; +import { CompanyProfileRepository } from "./company-profile.repository"; +import { ExternalProfileRepository } from "./external-profile.repository"; +import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { CreateCompanyDto } from "./dto/create-company.dto"; +import { UpdateCompanyDto } from "./dto/update-company.dto"; +import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; +import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ProfileResponseDto } from "./dto/profile-response.dto"; +import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; +import { Company } from "./entities/company.entity"; +import { ExternalProfile } from "./entities/external-profile.entity"; +import { + CompanyProfile, + ProfileType, + ProfileStatus, +} from "./entities/company-profile.entity"; export interface UserIdentity { userId: string; @@ -27,10 +35,10 @@ export interface UserIdentity { export class CompaniesService { constructor( private readonly companiesRepo: CompaniesRepository, + private readonly companyProfilesRepo: CompanyProfileRepository, private readonly profilesRepo: ExternalProfileRepository, - private readonly ffClientsRepo: FFClientRepository, private readonly dashboardRepo: CompanyDashboardRepository, - ) {} + ) { } async createCompany(dto: CreateCompanyDto): Promise { const exists = await this.companiesRepo.existsByTin(dto.tin); @@ -40,27 +48,33 @@ export class CompaniesService { return this.companiesRepo.create(dto); } - async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> { + async createCompanyWithProfile( + identity: UserIdentity, + dto: CreateCompanyWithProfileDto, + ): Promise<{ company: Company; profile: ExternalProfile }> { if (dto.tin) { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { - throw new ConflictException(`Company with TIN ${dto.tin} already exists`); + throw new ConflictException( + `Company with TIN ${dto.tin} already exists`, + ); } } const existingProfile = await this.profilesRepo.findByEmail(identity.email); if (existingProfile) { - throw new ConflictException(`Profile with email ${identity.email} already exists`); + throw new ConflictException( + `Profile with email ${identity.email} already exists`, + ); } const company = await this.companiesRepo.create({ name: dto.companyName, type: dto.companyType, - tin: dto.tin ?? '', + tin: dto.tin ?? "", vatNumber: dto.vatNumber ?? null, - businessLicense: dto.fanNumber ?? null, fanNumber: dto.fanNumber ?? null, - country: dto.companyLocation ?? 'Ethiopia', + country: dto.companyLocation ?? "Ethiopia", address: dto.companyAddress ?? null, phone: dto.companyPhone ?? null, email: dto.companyEmail ?? null, @@ -78,11 +92,39 @@ export class CompaniesService { isPrimaryContact: dto.isPrimaryContact ?? true, }); + // Persist the operational role(s) chosen during onboarding. Types are + // already constrained to the company type on the client; any that don't + // match are skipped defensively rather than failing the whole signup. + if (dto.companyProfiles?.length) { + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + for (const input of dto.companyProfiles) { + if (!allowedTypes.includes(input.type)) continue; + const existing = await this.companyProfilesRepo.findByType( + company.id, + input.type, + ); + if (existing) continue; + const reference = await this.companyProfilesRepo.generateReference( + input.type, + ); + await this.companyProfilesRepo.create({ + companyId: company.id, + type: input.type, + reference, + businessLicense: input.businessLicense ?? null, + status: ProfileStatus.Active, + }); + } + company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( + company.id, + ); + } + return { company, profile }; } async findAllCompanies(): Promise { - return this.companiesRepo.findAll({ order: { name: 'ASC' as any } }); + return this.companiesRepo.findAll({ order: { name: "ASC" } }); } async findCompanyById(id: string): Promise { @@ -91,12 +133,21 @@ export class CompaniesService { return company; } - async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> { + async getCompanyInfoByUserId( + userId: string, + ): Promise<{ profile: ExternalProfile; company: Company }> { const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); const company = profile.company; - if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`); + if (!company) + throw new NotFoundException( + `Company for profile ${profile.id} not found`, + ); + + company.companyProfiles = + await this.companyProfilesRepo.findByCompanyId(company.id); return { profile, company }; } @@ -114,7 +165,9 @@ export class CompaniesService { * column, so "delivered YTD" counts bookings created this year that reached a * delivered/completed status. */ - async getDashboardSummary(userId: string): Promise { + async getDashboardSummary( + userId: string, + ): Promise { // A user without a company profile has no bookings — return an empty summary // rather than 404, so the portal home still renders. const profile = await this.profilesRepo.findByUserId(userId); @@ -125,7 +178,9 @@ export class CompaniesService { const yearStart = new Date(now.getFullYear(), 0, 1); const prevYearStart = new Date(now.getFullYear() - 1, 0, 1); // Same point in the previous year, so YoY compares like-for-like windows. - const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime())); + const prevYearToDate = new Date( + prevYearStart.getTime() + (now.getTime() - yearStart.getTime()), + ); const [ deliveredThis, @@ -139,20 +194,36 @@ export class CompaniesService { this.dashboardRepo.countDelivered(companyId, yearStart, now), this.dashboardRepo.countCommitted(companyId, yearStart, now), this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now), - this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate), + this.dashboardRepo.sumPaidSpendByCurrency( + companyId, + prevYearStart, + prevYearToDate, + ), this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now), - this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate), - this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now), + this.dashboardRepo.sumCommittedTonnage( + companyId, + prevYearStart, + prevYearToDate, + ), + this.dashboardRepo.monthlyCommittedTonnage( + companyId, + this.monthsAgo(now, 5), + now, + ), ]); // Spend can span currencies; report the dominant one (prefer ETB on ties). const spend = this.pickCurrencyTotal(spendThisByCcy); - const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0; + const spendPrev = + spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0; return { deliveredCount: deliveredThis, // Share of committed bookings that reached delivered/completed. - completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0, + completionRate: + committedThis > 0 + ? Math.round((deliveredThis / committedThis) * 100) + : 0, spendYtd: spend.total, spendCurrency: spend.currency, spendYtdChangePct: this.changePct(spend.total, spendPrev), @@ -172,12 +243,12 @@ export class CompaniesService { deliveredCount: 0, completionRate: 0, spendYtd: 0, - spendCurrency: 'ETB', + spendCurrency: "ETB", spendYtdChangePct: 0, freightVolume: { totalTonnes: 0, totalValue: 0, - currency: 'ETB', + currency: "ETB", ytdChangePct: 0, monthly: this.buildMonthlySeries(now, []), }, @@ -190,8 +261,11 @@ export class CompaniesService { } /** Pick the currency with the largest total, preferring ETB on ties / when empty. */ - private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } { - if (totals.length === 0) return { currency: 'ETB', total: 0 }; + private pickCurrencyTotal(totals: { currency: string; total: number }[]): { + currency: string; + total: number; + } { + if (totals.length === 0) return { currency: "ETB", total: 0 }; return totals.reduce((best, cur) => (cur.total > best.total ? cur : best)); } @@ -206,13 +280,29 @@ export class CompaniesService { now: Date, rows: { year: number; month: number; tonnes: number }[], ): { month: string; tonnes: number }[] { - const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const labels = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes])); const series: { month: string; tonnes: number }[] = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const key = `${d.getFullYear()}-${d.getMonth() + 1}`; - series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) }); + series.push({ + month: labels[d.getMonth()], + tonnes: Math.round(byKey.get(key) ?? 0), + }); } return series; } @@ -224,7 +314,10 @@ export class CompaniesService { return updated; } - async updateProfile(userId: string, dto: UpdateProfileDto): Promise { + async updateProfile( + userId: string, + dto: UpdateProfileDto, + ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); const companyUpdates: Record = {}; @@ -233,30 +326,38 @@ export class CompaniesService { if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail; if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone; - if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation; - if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; + if (dto.companyLocation !== undefined) + companyUpdates.country = dto.companyLocation; + if (dto.companyAddress !== undefined) + companyUpdates.address = dto.companyAddress; if (dto.tin !== undefined) companyUpdates.tin = dto.tin; if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; if (dto.fanNumber !== undefined) { - companyUpdates.businessLicense = dto.fanNumber; companyUpdates.fanNumber = dto.fanNumber; } - if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; - if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone; - if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; - if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail; - if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone; + if (dto.contactPersonName !== undefined) + attrUpdates.contactPersonName = dto.contactPersonName; + if (dto.contactPersonPhone !== undefined) + attrUpdates.contactPersonPhone = dto.contactPersonPhone; + if (dto.generalManagerName !== undefined) + attrUpdates.generalManagerName = dto.generalManagerName; + if (dto.generalManagerEmail !== undefined) + attrUpdates.generalManagerEmail = dto.generalManagerEmail; + if (dto.generalManagerPhone !== undefined) + attrUpdates.generalManagerPhone = dto.generalManagerPhone; if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone; if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; - if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; + if (dto.poaLocation !== undefined) + attrUpdates.poaLocation = dto.poaLocation; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; companyUpdates.attributes = attrUpdates; const updated = await this.companiesRepo.update(company.id, companyUpdates); - if (!updated) throw new NotFoundException(`Company ${company.id} not found`); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); return new ProfileResponseDto(profile, updated); } @@ -270,7 +371,9 @@ export class CompaniesService { const existing = await this.profilesRepo.findByEmail(dto.email); if (existing) { - throw new ConflictException(`Profile with email ${dto.email} already exists`); + throw new ConflictException( + `Profile with email ${dto.email} already exists`, + ); } return this.profilesRepo.create(dto); @@ -278,7 +381,8 @@ export class CompaniesService { async findProfileByUserId(userId: string): Promise { const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); return profile; } @@ -286,28 +390,119 @@ export class CompaniesService { return this.profilesRepo.findByCompanyId(companyId); } - async createFFClient(dto: CreateFFClientDto): Promise { - await this.findCompanyById(dto.forwarderCompanyId); - await this.findCompanyById(dto.clientCompanyId); + private getProfileTypeForCompanyType(companyType: string): ProfileType[] { + switch (companyType) { + case "customer": + return [ProfileType.importer, ProfileType.exporter]; + case "freight_forwarder": + return [ProfileType.freightForwarder]; + case "dj_freight_forwarder": + return [ProfileType.djFreightForwarder]; + case "transporter": + return [ProfileType.transporter]; + default: + return []; + } + } - const existing = await this.ffClientsRepo.findRelationship( - dto.forwarderCompanyId, - dto.clientCompanyId, - ); - if (existing) { - throw new ConflictException('This forwarder-client relationship already exists'); + async createCompanyProfile( + companyId: string, + profileType?: ProfileType, + ): Promise { + const company = await this.findCompanyById(companyId); + + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + + const type = profileType ?? allowedTypes[0]; + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); } - return this.ffClientsRepo.create(dto); + const existing = await this.companyProfilesRepo.findByType(companyId, type); + if (existing) { + throw new ConflictException( + `Company already has a ${type} profile (${existing.reference})`, + ); + } + + const reference = await this.companyProfilesRepo.generateReference(type); + + return this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); } - async findForwarderClients(forwarderCompanyId: string): Promise { - return this.ffClientsRepo.findByForwarder(forwarderCompanyId); + async createDefaultProfilesForCompany( + companyId: string, + ): Promise { + const company = await this.findCompanyById(companyId); + const types = this.getProfileTypeForCompanyType(company.type); + + const profiles: CompanyProfile[] = []; + for (const type of types) { + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (!existing) { + profiles.push(await this.createCompanyProfile(companyId, type)); + } + } + + if (profiles.length === 0) { + throw new BadRequestException( + `Company of type "${company.type}" must have at least one operational profile`, + ); + } + + return profiles; } - async deleteFFClient(id: string): Promise { - const client = await this.ffClientsRepo.findById(id); - if (!client) throw new NotFoundException(`FFClient ${id} not found`); - await this.ffClientsRepo.softDelete(id); + /** + * Add operational profile(s) to the current user's company (portal settings). + * Add-only and idempotent: each requested type must be allowed for the + * company's type, profiles that already exist are skipped (not re-created or + * rejected), and the full updated list is returned. + */ + async addCompanyProfilesForUser( + userId: string, + types: ProfileType[], + ): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + + const companyId = profile.company?.id ?? profile.companyId; + const company = await this.findCompanyById(companyId); + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + + for (const type of types) { + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (existing) continue; + + const reference = await this.companyProfilesRepo.generateReference(type); + await this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); + } + + return this.companyProfilesRepo.findByCompanyId(companyId); } } diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts new file mode 100644 index 000000000..db7427112 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -0,0 +1,61 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { CompanyProfile, ProfileType } from "./entities/company-profile.entity"; + +const SEQUENCE_MAP: Record = { + [ProfileType.exporter]: "seq_company_profile_ex", + [ProfileType.importer]: "seq_company_profile_im", + [ProfileType.freightForwarder]: "seq_company_profile_ffe", + [ProfileType.djFreightForwarder]: "seq_company_profile_fwj", + [ProfileType.transporter]: "seq_company_profile_tr", +}; + +const PREFIX_MAP: Record = { + [ProfileType.exporter]: "EX", + [ProfileType.importer]: "IM", + [ProfileType.freightForwarder]: "FFE", + [ProfileType.djFreightForwarder]: "FWJ", + [ProfileType.transporter]: "TR", +}; + +@Injectable() +export class CompanyProfileRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyProfile) + repo: Repository, + ) { + super(repo); + } + + async generateReference(type: ProfileType): Promise { + const seqName = SEQUENCE_MAP[type]; + const result = await this.repository.query( + `SELECT nextval('${seqName}') AS next_id`, + ); + const nextId = result[0].next_id as number; + const prefix = PREFIX_MAP[type]; + return `${prefix}-${String(nextId).padStart(5, "0")}`; + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + relations: ["company"], + }); + } + + async findByType( + companyId: string, + type: ProfileType, + ): Promise { + return this.repository.findOne({ + where: { companyId, type }, + }); + } + + async findByReference(reference: string): Promise { + return this.repository.findOne({ where: { reference } }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts new file mode 100644 index 000000000..838c42111 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -0,0 +1,9 @@ +import { IsArray, IsEnum, ArrayMinSize } from "class-validator"; +import { ProfileType } from "../entities/company-profile.entity"; + +export class AddCompanyProfilesDto { + @IsArray() + @ArrayMinSize(1) + @IsEnum(ProfileType, { each: true }) + types!: ProfileType[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index 4287676d6..aa0bb72a2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -1,5 +1,17 @@ -import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator'; +import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class CompanyProfileInputDto { + @IsEnum(ProfileType) + type!: ProfileType; + + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; +} export class CreateCompanyWithProfileDto { @IsEnum(CompanyType) @@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto { @IsOptional() attributes?: Record; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CompanyProfileInputDto) + companyProfiles?: CompanyProfileInputDto[]; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index b22334697..5718f541e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -25,11 +25,6 @@ export class CreateCompanyDto { @MaxLength(50) vatNumber?: string; - @IsOptional() - @IsString() - @MaxLength(100) - businessLicense?: string; - @IsOptional() @IsString() @MaxLength(32) diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts deleted file mode 100644 index 46375d7ea..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator'; -import { FFClientRelationship } from '../entities/ff-client.entity'; - -export class CreateFFClientDto { - @IsUUID() - @IsNotEmpty() - forwarderCompanyId!: string; - - @IsUUID() - @IsNotEmpty() - clientCompanyId!: string; - - @IsOptional() - @IsEnum(FFClientRelationship) - relationshipType?: FFClientRelationship; - - @IsOptional() - @IsBoolean() - canBookOnBehalf?: boolean; - - @IsOptional() - @IsBoolean() - canViewDocuments?: boolean; -} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index ee8ede34f..d6744e75f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,9 +1,11 @@ import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; +import { ResponseCompanyProfileDto } from './response-company.dto'; export class ProfileResponseDto { companyId: string; companyName: string; + companyType: string; companyEmail: string | null; companyPhone: string | null; companyLocation: string; @@ -12,6 +14,8 @@ export class ProfileResponseDto { vatNumber: string | null; fanNumber: string | null; + companyProfiles: ResponseCompanyProfileDto[]; + contactPersonName: string | null; contactPersonPhone: string | null; generalManagerName: string | null; @@ -29,6 +33,10 @@ export class ProfileResponseDto { constructor(profile: ExternalProfile, company: Company) { this.companyId = company.id; this.companyName = company.name; + this.companyType = company.type; + this.companyProfiles = + company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? + []; this.companyEmail = company.email ?? null; this.companyPhone = company.phone ?? null; this.companyLocation = company.country; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 1879e25d6..cb7777e8b 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -1,6 +1,29 @@ import { Company, CompanyType, CompanyStatus } from '../entities/company.entity'; +import { CompanyProfile } from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; +export class ResponseCompanyProfileDto { + id: string; + type: string; + reference: string; + status: string; + businessLicense?: string | null; + attributes?: Record | null; + createdAt: Date; + updatedAt: Date; + + constructor(profile: CompanyProfile) { + this.id = profile.id; + this.type = profile.type; + this.reference = profile.reference; + this.status = profile.status; + this.businessLicense = profile.businessLicense; + this.attributes = profile.attributes; + this.createdAt = profile.createdAt; + this.updatedAt = profile.updatedAt; + } +} + export class ResponseCompanyDto { id: string; name: string; @@ -8,7 +31,6 @@ export class ResponseCompanyDto { status: CompanyStatus; tin: string; vatNumber?: string | null; - businessLicense?: string | null; fanNumber?: string | null; country: string; address?: string | null; @@ -17,6 +39,7 @@ export class ResponseCompanyDto { website?: string | null; attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; + companyProfiles?: ResponseCompanyProfileDto[]; createdAt: Date; updatedAt: Date; @@ -27,7 +50,6 @@ export class ResponseCompanyDto { this.status = company.status; this.tin = company.tin; this.vatNumber = company.vatNumber; - this.businessLicense = company.businessLicense; this.fanNumber = company.fanNumber; this.country = company.country; this.address = company.address; @@ -36,6 +58,7 @@ export class ResponseCompanyDto { this.website = company.website; this.attributes = company.attributes; this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p)); + this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)); this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts deleted file mode 100644 index 44a48069b..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FFClient, FFClientRelationship } from '../entities/ff-client.entity'; - -export class ResponseFFClientDto { - id: string; - forwarderCompanyId: string; - clientCompanyId: string; - relationshipType: FFClientRelationship; - canBookOnBehalf: boolean; - canViewDocuments: boolean; - createdAt: Date; - updatedAt: Date; - - constructor(client: FFClient) { - this.id = client.id; - this.forwarderCompanyId = client.forwarderCompanyId; - this.clientCompanyId = client.clientCompanyId; - this.relationshipType = client.relationshipType; - this.canBookOnBehalf = client.canBookOnBehalf; - this.canViewDocuments = client.canViewDocuments; - this.createdAt = client.createdAt; - this.updatedAt = client.updatedAt; - } -} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts deleted file mode 100644 index a7ace689d..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateFFClientDto } from './create-ff-client.dto'; - -export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts new file mode 100644 index 000000000..84da76135 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Company } from "./company.entity"; + +export enum ProfileType { + importer = "importer", + exporter = "exporter", + freightForwarder = "freight_forwarder", + djFreightForwarder = "dj_freight_forwarder", + transporter = "transporter", +} + +export enum ProfileStatus { + Active = "active", + Pending = "pending", + Suspended = "suspended", + Blacklisted = "blacklisted", +} + +@Entity({ schema: "freight", name: "company_profiles" }) +@Index(["reference"], { unique: true }) +@Index(["type"]) +@Index(["companyId"]) +export class CompanyProfile extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company, (company) => company.companyProfiles) + @JoinColumn({ name: "company_id" }) + company!: Company; + + @Column({ name: "type", type: "varchar", length: 32, enum: ProfileType }) + type!: ProfileType; + + @Column({ + name: "reference", + type: "varchar", + length: 20, + nullable: false, + unique: true, + }) + reference!: string; + + @Column({ + name: "status", + type: "varchar", + length: 32, + default: ProfileStatus.Active, + }) + status!: ProfileStatus; + + @Column({ + name: "business_license", + type: "varchar", + length: 100, + nullable: true, + }) + businessLicense?: string | null; + + @Column({ name: "attributes", type: "jsonb", nullable: true }) + attributes?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 070854eb8..ec578a3b7 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -1,79 +1,110 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, OneToMany } from 'typeorm'; -import { ExternalProfile } from './external-profile.entity'; +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, OneToMany } from "typeorm"; +import { ExternalProfile } from "./external-profile.entity"; +import { CompanyProfile } from "./company-profile.entity"; export enum CompanyType { - Customer = 'customer', - Forwarder = 'forwarder', - Transporter = 'transporter', - Broker = 'broker', + Customer = "customer", + FreightForwarder = "freight_forwarder", + DJFreightForwarder = "dj_freight_forwarder", + Transporter = "transporter", } export enum CompanyStatus { - Active = 'active', - Pending = 'pending', - Suspended = 'suspended', - Blacklisted = 'blacklisted', + Active = "active", + Pending = "pending", + Suspended = "suspended", + Blacklisted = "blacklisted", } -@Entity({ schema: 'freight', name: 'companies' }) -@Index(['tin']) -@Index(['type']) +@Entity({ schema: "freight", name: "companies" }) +@Index(["tin"]) +@Index(["type"]) export class Company extends BaseEntity { - @Column({ name: 'name', type: 'varchar', length: 200 }) + @Column({ name: "name", type: "varchar", length: 200 }) name!: string; - @Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType }) + @Column({ name: "type", type: "varchar", length: 32, enum: CompanyType }) type!: CompanyType; - @Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending }) + @Column({ + name: "status", + type: "varchar", + length: 32, + default: CompanyStatus.Pending, + }) status!: CompanyStatus; - @Column({ name: 'tin', type: 'varchar', length: 10, unique: true }) + @Column({ name: "tin", type: "varchar", length: 10, unique: true }) tin!: string; - @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true }) + @Column({ name: "vat_number", type: "varchar", length: 50, nullable: true }) vatNumber?: string | null; - @Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true }) - businessLicense?: string | null; - - @Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true }) + @Column({ name: "fan_number", type: "varchar", length: 16, nullable: true }) fanNumber?: string | null; - @Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' }) + @Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" }) country!: string; - @Column({ name: 'address', type: 'text', nullable: true }) + @Column({ name: "address", type: "text", nullable: true }) address?: string | null; - @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true }) + @Column({ name: "phone", type: "varchar", length: 20, nullable: true }) phone?: string | null; - @Column({ name: 'email', type: 'varchar', length: 150, nullable: true }) + @Column({ name: "email", type: "varchar", length: 150, nullable: true }) email?: string | null; - @Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true }) + @Column({ + name: "contact_person_name", + type: "varchar", + length: 100, + nullable: true, + }) contactPersonName?: string | null; - @Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true }) + @Column({ + name: "contact_person_phone", + type: "varchar", + length: 20, + nullable: true, + }) contactPersonPhone?: string | null; - @Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true }) + @Column({ + name: "general_manager_name", + type: "varchar", + length: 100, + nullable: true, + }) generalManagerName?: string | null; - @Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true }) + @Column({ + name: "general_manager_email", + type: "varchar", + length: 150, + nullable: true, + }) generalManagerEmail?: string | null; - @Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true }) + @Column({ + name: "general_manager_phone", + type: "varchar", + length: 20, + nullable: true, + }) generalManagerPhone?: string | null; - @Column({ name: 'website', type: 'varchar', length: 200, nullable: true }) + @Column({ name: "website", type: "varchar", length: 200, nullable: true }) website?: string | null; - @Column({ name: 'attributes', type: 'jsonb', nullable: true }) + @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; @OneToMany(() => ExternalProfile, (profile) => profile.company) profiles?: ExternalProfile[]; + + @OneToMany(() => CompanyProfile, (profile) => profile.company) + companyProfiles?: CompanyProfile[]; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts deleted file mode 100644 index 136dea277..000000000 --- a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm'; -import { Company } from './company.entity'; - -export enum FFClientRelationship { - ManagedAccount = 'managed_account', - SubAgent = 'sub_agent', -} - -@Entity({ schema: 'freight', name: 'ff_clients' }) -@Unique(['forwarderCompanyId', 'clientCompanyId']) -@Index(['forwarderCompanyId']) -@Index(['clientCompanyId']) -export class FFClient extends BaseEntity { - @Column({ name: 'forwarder_company_id', type: 'uuid' }) - forwarderCompanyId!: string; - - @ManyToOne(() => Company) - @JoinColumn({ name: 'forwarder_company_id' }) - forwarderCompany!: Company; - - @Column({ name: 'client_company_id', type: 'uuid' }) - clientCompanyId!: string; - - @ManyToOne(() => Company) - @JoinColumn({ name: 'client_company_id' }) - clientCompany!: Company; - - @Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount }) - relationshipType!: FFClientRelationship; - - @Column({ name: 'can_book_on_behalf', type: 'boolean', default: true }) - canBookOnBehalf!: boolean; - - @Column({ name: 'can_view_documents', type: 'boolean', default: true }) - canViewDocuments!: boolean; -} diff --git a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts b/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts deleted file mode 100644 index b94cedec6..000000000 --- a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { BaseRepository } from '@edr/api-common'; -import { FFClient } from './entities/ff-client.entity'; - -@Injectable() -export class FFClientRepository extends BaseRepository { - constructor( - @InjectRepository(FFClient) - repo: Repository, - ) { - super(repo); - } - - async findByForwarder(forwarderCompanyId: string): Promise { - return this.repository.find({ where: { forwarderCompanyId } as any }); - } - - async findByClient(clientCompanyId: string): Promise { - return this.repository.find({ where: { clientCompanyId } as any }); - } - - async findRelationship( - forwarderCompanyId: string, - clientCompanyId: string, - ): Promise { - return this.repository.findOne({ - where: { forwarderCompanyId, clientCompanyId } as any, - }); - } -} diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts deleted file mode 100644 index 7451bd6b6..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ /dev/null @@ -1,86 +0,0 @@ -// src/modules/customers/customers.controller.ts - -import { - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseUUIDPipe, - Patch, - Post, - Body, - Query, -} from "@nestjs/common"; - -import { ApiOperation } from "@nestjs/swagger"; - -import { FreightAdmin } from "../../common/booking-guards"; -import { CustomersService } from "./customers.service"; -import { CreateCustomerDto } from "./dto/create-customer.dto"; -import { UpdateCustomerDto } from "./dto/update-customer.dto"; -import { Customer } from "./entities/customer.entity"; - -@Controller("customers") -@FreightAdmin() -export class CustomersController { - constructor(private readonly customersService: CustomersService) {} - - @Post() - create(@Body() createCustomerDto: CreateCustomerDto): Promise { - return this.customersService.create(createCustomerDto); - } - - @Get() - findAll(): Promise { - return this.customersService.findAll(); - } - - @Get("stats") - @ApiOperation({ summary: "Get customer statistics" }) - getStats(): Promise<{ total: number; withVatNumber: number }> { - return this.customersService.getStats(); - } - - @Get("search") - searchByName(@Query("name") name: string): Promise { - return this.customersService.searchByName(name); - } - - @Get("email/:email") - findByEmail(@Param("email") email: string): Promise { - return this.customersService.findByEmail(email); - } - - @Get("vat/:vatNumber") - findByVatNumber(@Param("vatNumber") vatNumber: string): Promise { - return this.customersService.findByVatNumber(vatNumber); - } - - @Get(":id") - findById(@Param("id", ParseUUIDPipe) id: string): Promise { - return this.customersService.findById(id); - } - - // @Get("user/:userId") - // findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise { - // return this.customersService.findByUserId(userId); - // } - - @Patch(":id") - @ApiOperation({ summary: "Update a customer" }) - update( - @Param("id", ParseUUIDPipe) id: string, - @Body() dto: UpdateCustomerDto, - ): Promise { - return this.customersService.update(id, dto); - } - - @Delete(":id") - @ApiOperation({ summary: "Soft-delete a customer" }) - @HttpCode(HttpStatus.NO_CONTENT) - remove(@Param("id", ParseUUIDPipe) id: string): Promise { - return this.customersService.delete(id); - } -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.module.ts b/apps/edr-freight-api/src/modules/customers/customers.module.ts deleted file mode 100644 index 28c6b7c89..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { CustomersController } from "./customers.controller"; -import { CustomersRepository } from "./customers.repository"; -import { CustomersService } from "./customers.service"; -import { Customer } from "./entities/customer.entity"; - -@Module({ - imports: [TypeOrmModule.forFeature([Customer])], - controllers: [CustomersController], - providers: [CustomersService, CustomersRepository], - exports: [CustomersService], -}) -export class CustomersModule {} diff --git a/apps/edr-freight-api/src/modules/customers/customers.repository.ts b/apps/edr-freight-api/src/modules/customers/customers.repository.ts deleted file mode 100644 index 5933cef61..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.repository.ts +++ /dev/null @@ -1,117 +0,0 @@ -// import { BaseRepository } from "@edr/api-common"; -// import { EntityRepository } from "typeorm"; - -// src/modules/customers/customers.repository.ts -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm"; -import { Customer } from "./entities/customer.entity"; -import { CreateCustomerDto } from "./dto/create-customer.dto"; -// import { UpdateCustomerDto } from "./dto/update-customer.dto"; - -@Injectable() -export class CustomersRepository { - constructor( - @InjectRepository(Customer) - private readonly repository: Repository, - ) { } - - async create(dto: CreateCustomerDto): Promise { - const customer = this.repository.create(dto); - return await this.repository.save(customer); - } - - async findAll(options?: FindManyOptions): Promise { - return await this.repository.find(options); - } - - async findById(id: string): Promise { - return await this.repository.findOne({ where: { id } as FindOptionsWhere }); - } - - async findByUserId(userId: string): Promise { - return await this.repository.findOne({ where: { userId } as FindOptionsWhere }); - } - - async findByEmail(email: string): Promise { - return await this.repository.findOne({ where: { email } as FindOptionsWhere }); - } - - async findByVatNumber(vatNumber: string): Promise { - return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere }); - } - - async findByName(name: string): Promise { - return await this.repository - .createQueryBuilder("customer") - .where("customer.companyName ILIKE :name", { name: `%${name}%` }) - .getMany(); - } - - async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise { - if (!email && !vatNumber) return null; - - const queryBuilder = this.repository.createQueryBuilder('customer'); - - if (email && vatNumber) { - queryBuilder.where('customer.email = :email', { email }) - .orWhere('customer.vatNumber = :vatNumber', { vatNumber }); - } else if (email) { - queryBuilder.where('customer.email = :email', { email }); - } else if (vatNumber) { - queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber }); - } - - return await queryBuilder.getOne(); - } - - async update(id: string, updates: Partial): Promise { - await this.repository.update(id, updates); - return this.findById(id); - } - - async delete(id: string): Promise { - const result = await this.repository.delete(id); - return (result.affected ?? 0) > 0; - } - - async count(where?: any): Promise { - if (where?.createdAt) { - const result = await this.repository - .createQueryBuilder('customer') - .where('customer.createdAt >= :date', { date: where.createdAt }) - .getCount(); - return result; - } - return await this.repository.count(); - } - - async existsByUniqueFields(email: string, vatNumber?: string): Promise { - const queryBuilder = this.repository.createQueryBuilder('customer') - .where('customer.email = :email', { email }); - - if (vatNumber) { - queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber }); - } - - const count = await queryBuilder.getCount(); - return count > 0; - } - - async countWithVatNumber(): Promise { - const count = await this.repository - .createQueryBuilder('customer') - .where('customer.vatNumber IS NOT NULL') - .andWhere("customer.vatNumber != ''") - .getCount(); - - return count; - } - - getRepository(): Repository { - return this.repository; - } - softDelete(id: string): any { - return id; - } -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.service.ts b/apps/edr-freight-api/src/modules/customers/customers.service.ts deleted file mode 100644 index e3d1f3a82..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.service.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - Injectable, - NotFoundException, - ConflictException, - BadRequestException, -} from "@nestjs/common"; - -import { CustomersRepository } from "./customers.repository"; -import { CreateCustomerDto } from "./dto/create-customer.dto"; -import { UpdateCustomerDto } from "./dto/update-customer.dto"; -import { Customer } from "./entities/customer.entity"; - -@Injectable() -export class CustomersService { - constructor(private readonly customersRepository: CustomersRepository) {} - - /** Create a new customer */ - async create(dto: CreateCustomerDto): Promise { - const exists = await this.customersRepository.existsByUniqueFields( - dto.email, - dto.vatNumber, - ); - - if (exists) { - throw new ConflictException( - "Customer with same email or VAT number already exists", - ); - } - - if (dto.vatNumber && dto.vatNumber.length !== 10) { - throw new BadRequestException("VAT number must be exactly 10 digits"); - } - - return this.customersRepository.create(dto); - } - - /** Get all customers */ - findAll(): Promise { - return this.customersRepository.findAll({ - order: { companyName: "ASC" }, - }); - } - - /** Get customer by ID */ - async findById(id: string): Promise { - const customer = await this.customersRepository.findById(id); - - if (!customer) { - throw new NotFoundException(`Customer with ID ${id} not found`); - } - - return customer; - } - - // async findByUserId(userId: string): Promise { - // const customer = await this.customersRepository.findByUserId(userId); - - // if (!customer) { - // throw new NotFoundException(`Customer with ID ${userId} not found`); - // } - - // return customer; - //} - - /** Get customer by email */ - async findByEmail(email: string): Promise { - const customer = await this.customersRepository.findByEmail(email); - - if (!customer) { - throw new NotFoundException(`Customer with email ${email} not found`); - } - - return customer; - } - - /** Get customer by VAT number */ - async findByVatNumber(vatNumber: string): Promise { - const customer = await this.customersRepository.findByVatNumber(vatNumber); - - if (!customer) { - throw new NotFoundException( - `Customer with VAT number ${vatNumber} not found`, - ); - } - - return customer; - } - - /** Search customers by name */ - searchByName(name: string): Promise { - return this.customersRepository.findByName(name); - } - - /** Update customer */ - async update(id: string, dto: UpdateCustomerDto): Promise { - await this.findById(id); - - // Validate VAT number if provided - if (dto.vatNumber && dto.vatNumber.length !== 10) { - throw new BadRequestException("VAT number must be exactly 10 digits"); - } - - // // Check email conflict - // if (dto.email) { - // const existing = await this.customersRepository.findByEmail(dto.email); - - // // if (existing && existing.userId !== id) { - // // throw new ConflictException( - // // `Customer with email "${dto.email}" already exists`, - // // ); - // // } - // } - - const updated = await this.customersRepository.update(id, dto); - - if (!updated) { - throw new NotFoundException(`Customer ${id} not found`); - } - - return updated; - } - - /** Delete customer (soft delete) */ - async remove(id: string): Promise { - await this.findById(id); - await this.customersRepository.softDelete(id); - } - - /** Get customer statistics */ - async getStats(): Promise<{ total: number; withVatNumber: number }> { - const total = await this.customersRepository.count(); - const withVatNumber = await this.customersRepository.countWithVatNumber(); - - return { total, withVatNumber }; - } - - delete(id: string): any { - return id; - } -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts deleted file mode 100644 index 39fc16414..000000000 --- a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - IsEmail, - IsEnum, - IsOptional, - IsString, - MaxLength, - IsNotEmpty, - Length, - Matches, -} from "class-validator"; - -// Enums -export enum CustomerStatusDto { - Active = "Active", - Pending = "Pending", - Inactive = "Inactive", -} - -export enum CustomerTypeDto { - Importer = "Importer", - Exporter = "Exporter", - Supplier = "Supplier", -} - -// DTO -export class CreateCustomerDto { - // Basic identity - @IsString() - @IsNotEmpty() - userId!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(100) - firstName!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(100) - lastName!: string; - - @IsEmail() - @IsNotEmpty() - email!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(20) - phone!: string; - - // Company info - @IsString() - @IsNotEmpty() - @MaxLength(200) - companyName!: string; - - @IsEmail() - @IsNotEmpty() - companyEmail!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(20) - companyPhone!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(100) - companyLocation!: string; - - @IsString() - @IsNotEmpty() - companyAddress!: string; - - // Classification - @IsOptional() - @IsEnum(CustomerTypeDto) - customerType?: CustomerTypeDto; - - @IsOptional() - @IsEnum(CustomerStatusDto) - status?: CustomerStatusDto; - - // Legal identifiers - @IsString() - @IsNotEmpty() - @Length(10, 10) - @Matches(/^\d+$/, { message: "TIN must contain only digits" }) - tinNumber!: string; - - @IsString() - @IsNotEmpty() - @Length(16, 16) - @Matches(/^\d+$/, { message: "FAN must contain only digits" }) - fanNumber!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(50) - vatNumber!: string; - - // Contact person - @IsString() - @IsNotEmpty() - @MaxLength(100) - contactPersonName!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(20) - contactPersonPhone!: string; - - // Management - @IsString() - @IsNotEmpty() - @MaxLength(100) - generalManagerName!: string; - - @IsEmail() - @IsNotEmpty() - generalManagerEmail!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(20) - generalManagerPhone!: string; - - // POA (Power of Attorney) - @IsOptional() - @IsString() - @MaxLength(100) - poaName?: string; - - @IsOptional() - @IsString() - @MaxLength(20) - poaPhone?: string; - - @IsOptional() - @IsString() - poaAddress?: string; - - @IsOptional() - @IsEmail() - poaEmail?: string; - - @IsOptional() - @IsString() - @MaxLength(100) - poaLocation?: string; - - // Extra - @IsOptional() - @IsString() - notes?: string; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts deleted file mode 100644 index 3d2a086d4..000000000 --- a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts +++ /dev/null @@ -1,60 +0,0 @@ -// src/modules/customers/dto/response-customer.dto.ts -import { Customer } from '../entities/customer.entity'; - -export class ResponseCustomerDto { - //UserId: string; - firstName: string; - lastName: string; - email: string; - phone: string; - companyName: string; - companyEmail: string; - companyPhone: string; - companyLocation: string; - companyAddress: string; - contactPersonName: string; - contactPersonPhone: string; - tinNumber: string; - vatNumber?: string; - fanNumber: string; - generalManagerName: string; - generalManagerEmail: string; - generalManagerPhone: string; - poaName?: string; - poaPhone?: string; - poaAddress?: string; - poaEmail?: string; - poaLocation?: string; - notes?: string; - createdAt: Date; - updatedAt: Date; - - constructor(customer: Customer) { - //this.UserId = customer.userId; - this.firstName = customer.firstName; - this.lastName = customer.lastName; - this.email = customer.email; - this.phone = customer.phone; - this.companyName = customer.companyName; - this.companyEmail = customer.companyEmail; - this.companyPhone = customer.companyPhone; - this.companyLocation = customer.companyLocation; - this.companyAddress = customer.companyAddress; - this.contactPersonName = customer.contactPersonName; - this.contactPersonPhone = customer.contactPersonPhone; - this.tinNumber = customer.tinNumber; - this.vatNumber = customer.vatNumber ?? undefined; - this.fanNumber = customer.fanNumber; - this.generalManagerName = customer.generalManagerName; - this.generalManagerEmail = customer.generalManagerEmail; - this.generalManagerPhone = customer.generalManagerPhone; - this.poaName = customer.poaName ?? ''; - this.poaPhone = customer.poaPhone ?? ''; - this.poaAddress = customer.poaAddress ?? ''; - this.poaEmail = customer.poaEmail ?? ''; - this.poaLocation = customer.poaLocation ?? ''; - this.notes = customer.notes ?? ''; - this.createdAt = customer.createdAt; - this.updatedAt = customer.updatedAt; - } -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts deleted file mode 100644 index f8cefe046..000000000 --- a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -// src/modules/customers/dto/update-customer.dto.ts -import { PartialType } from '@nestjs/mapped-types'; -import { CreateCustomerDto } from './create-customer.dto'; - -export class UpdateCustomerDto extends PartialType(CreateCustomerDto) { - email?: string; - vatNumber?: string; - // Add any other properties you need to access directly -} diff --git a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts deleted file mode 100644 index abccbaef8..000000000 --- a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; - -@Entity({ schema: 'freight', name: 'customers' }) -@Index(['email']) -//@Index(['userId']) -@Index(['tinNumber']) -@Index(['fanNumber']) -export class Customer extends BaseEntity { - //@Column({ name: 'user_id', type: 'uuid' }) - //userId!: string; - - @Column({ name: 'first_name', type: 'varchar', length: 100 }) - firstName!: string; - - @Column({ name: 'last_name', type: 'varchar', length: 100 }) - lastName!: string; - - @Column({ name: 'email', type: 'varchar', length: 150, unique: true }) - email!: string; - - @Column({ name: 'phone', type: 'varchar', length: 20 }) - phone!: string; - - @Column({ name: 'company_name', type: 'varchar', length: 200 }) - companyName!: string; - - @Column({ name: 'company_email', type: 'varchar', length: 150 }) - companyEmail!: string; - - @Column({ name: 'company_phone', type: 'varchar', length: 20 }) - companyPhone!: string; - - @Column({ name: 'company_location', type: 'varchar', length: 100 }) - companyLocation!: string; - - @Column({ name: 'company_address', type: 'text' }) - companyAddress!: string; - - @Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true }) - customerType?: string | null; - - @Column({ name: 'status', type: 'varchar', length: 32, nullable: true }) - status?: string | null; - - @Column({ name: 'contact_person_name', type: 'varchar', length: 100 }) - contactPersonName!: string; - - @Column({ name: 'contact_person_phone', type: 'varchar', length: 20 }) - contactPersonPhone!: string; - - @Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true }) - tinNumber!: string; - - @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true }) - vatNumber?: string | null; - - @Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true }) - fanNumber!: string; - - @Column({ name: 'general_manager_name', type: 'varchar', length: 100 }) - generalManagerName!: string; - - @Column({ name: 'general_manager_email', type: 'varchar', length: 150 }) - generalManagerEmail!: string; - - @Column({ name: 'general_manager_phone', type: 'varchar', length: 20 }) - generalManagerPhone!: string; - - @Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true }) - poaName?: string | null; - - @Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true }) - poaPhone?: string | null; - - @Column({ name: 'poa_address', type: 'text', nullable: true }) - poaAddress?: string | null; - - @Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true }) - poaEmail?: string | null; - - @Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true }) - poaLocation?: string | null; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index 50893b626..87cdc62d5 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -1,25 +1,25 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Employee } from '@tria-plc/iamapi-common'; -import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Employee } from "@tria-plc/iamapi-common"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; -import { Booking } from '../bookings/entities/booking.entity'; -import { Cargo } from '../cargoes/entities/cargoes.entity'; -import { Container } from '../container-management/entities/container.entity'; -import { Customer } from '../customers/entities/customer.entity'; -import { PaymentEntity } from '../payment/entities/payment.entity'; -import { Train } from '../trains/entities/train.entity'; -import { Wagon } from '../wagons/entities/wagon.entity'; -import { OverviewController } from './overview.controller'; -import { OverviewRepository } from './overview.repository'; -import { OverviewService } from './overview.service'; +import { Booking } from "../bookings/entities/booking.entity"; +import { Cargo } from "../cargoes/entities/cargoes.entity"; +import { Container } from "../container-management/entities/container.entity"; +import { Company } from "../companies/entities/company.entity"; +import { PaymentEntity } from "../payment/entities/payment.entity"; +import { Train } from "../trains/entities/train.entity"; +import { Wagon } from "../wagons/entities/wagon.entity"; +import { OverviewController } from "./overview.controller"; +import { OverviewRepository } from "./overview.repository"; +import { OverviewService } from "./overview.service"; @Module({ imports: [ TypeOrmModule.forFeature([ Booking, PaymentEntity, - Customer, + Company, Train, Wagon, Container, @@ -31,4 +31,4 @@ import { OverviewService } from './overview.service'; controllers: [OverviewController], providers: [OverviewService, OverviewRepository], }) -export class OverviewModule {} +export class OverviewModule { } diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 2c49ff8da..a57ba24db 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -1,24 +1,24 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; -import { Employee } from '@tria-plc/iamapi-common'; -import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; -import { Freight } from '@edr/types'; -import { Repository, ObjectLiteral } from 'typeorm'; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { Employee } from "@tria-plc/iamapi-common"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { Freight } from "@edr/types"; +import { Repository, ObjectLiteral } from "typeorm"; -import { Booking } from '../bookings/entities/booking.entity'; -import { Cargo } from '../cargoes/entities/cargoes.entity'; -import { Container } from '../container-management/entities/container.entity'; -import { Customer } from '../customers/entities/customer.entity'; -import { PaymentEntity } from '../payment/entities/payment.entity'; -import { Train } from '../trains/entities/train.entity'; -import { Wagon } from '../wagons/entities/wagon.entity'; +import { Booking } from "../bookings/entities/booking.entity"; +import { Cargo } from "../cargoes/entities/cargoes.entity"; +import { Container } from "../container-management/entities/container.entity"; +import { PaymentEntity } from "../payment/entities/payment.entity"; +import { Train } from "../trains/entities/train.entity"; +import { Wagon } from "../wagons/entities/wagon.entity"; import { OVERVIEW_CLOSED_STATUSES, OVERVIEW_IN_APPROVAL_STATUSES, OVERVIEW_NEEDS_ACTION_STATUSES, OVERVIEW_URGENT_PRIORITY_THRESHOLD, -} from './overview.constants'; +} from "./overview.constants"; +import { Company } from "../companies/entities/company.entity"; export type OverviewBookingKpisRow = { totalActive: number; @@ -46,8 +46,8 @@ export class OverviewRepository { private readonly bookingRepository: Repository, @InjectRepository(PaymentEntity) private readonly paymentRepository: Repository, - @InjectRepository(Customer) - private readonly customerRepository: Repository, + @InjectRepository(Company) + private readonly companyRepository: Repository, @InjectRepository(Train) private readonly trainRepository: Repository, @InjectRepository(Wagon) @@ -60,32 +60,32 @@ export class OverviewRepository { private readonly employeeRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, - ) {} + ) { } async getBookingKpis(): Promise { const row = await this.bookingRepository - .createQueryBuilder('booking') + .createQueryBuilder("booking") .select( `COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`, - 'totalActive', + "totalActive", ) .addSelect( `COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`, - 'needsAction', + "needsAction", ) .addSelect( `COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`, - 'urgent', + "urgent", ) .addSelect( `COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`, - 'inApproval', + "inApproval", ) .addSelect( `COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`, - 'submittedToday', + "submittedToday", ) - .where('booking.deleted_at IS NULL') + .where("booking.deleted_at IS NULL") .setParameters({ closedStatuses: [...OVERVIEW_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], @@ -112,9 +112,9 @@ export class OverviewRepository { const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = await Promise.all([ this.trainRepository - .createQueryBuilder('train') - .where('train.deleted_at IS NULL') - .andWhere('train.status IN (:...statuses)', { + .createQueryBuilder("train") + .where("train.deleted_at IS NULL") + .andWhere("train.status IN (:...statuses)", { statuses: [ Freight.TrainStatus.InService, Freight.TrainStatus.Scheduled, @@ -122,39 +122,46 @@ export class OverviewRepository { }) .getCount(), this.wagonRepository - .createQueryBuilder('wagon') - .where('wagon.deleted_at IS NULL') - .andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available }) + .createQueryBuilder("wagon") + .where("wagon.deleted_at IS NULL") + .andWhere("wagon.status = :status", { + status: Freight.WagonStatus.Available, + }) .getCount(), this.containerRepository - .createQueryBuilder('container') - .where('container.deleted_at IS NULL') - .andWhere('container.status = :status', { status: 'IN_TRANSIT' }) + .createQueryBuilder("container") + .where("container.deleted_at IS NULL") + .andWhere("container.status = :status", { status: "IN_TRANSIT" }) .getCount(), this.cargoRepository - .createQueryBuilder('cargo') - .where('cargo.deleted_at IS NULL') - .andWhere('cargo.status IN (:...statuses)', { - statuses: ['LOADED', 'IN_TRANSIT'], + .createQueryBuilder("cargo") + .where("cargo.deleted_at IS NULL") + .andWhere("cargo.status IN (:...statuses)", { + statuses: ["LOADED", "IN_TRANSIT"], }) .getCount(), ]); - return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded }; + return { + trainsActive, + wagonsAvailable, + containersInTransit, + cargoesLoaded, + }; } async getCustomerKpis(): Promise<{ totalCustomers: number; newCustomersThisMonth: number; }> { - const row = await this.customerRepository - .createQueryBuilder('customer') - .select('COUNT(*)::int', 'totalCustomers') + const row = await this.companyRepository + .createQueryBuilder("customer") + .select("COUNT(*)::int", "totalCustomers") .addSelect( `COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`, - 'newCustomersThisMonth', + "newCustomersThisMonth", ) - .where('customer.deleted_at IS NULL') + .where("customer.deleted_at IS NULL") .getRawOne>(); return { @@ -170,26 +177,26 @@ export class OverviewRepository { successfulPaymentsMtd: number; }> { const revenueRow = await this.paymentRepository - .createQueryBuilder('payment') + .createQueryBuilder("payment") .select( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, - 'revenueMtdEtb', + "revenueMtdEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, - 'revenueMtdUsd', + "revenueMtdUsd", ) - .addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd') - .where('payment.status = :status', { status: 'success' }) + .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") + .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) .getRawOne>(); const pendingPayments = await this.paymentRepository - .createQueryBuilder('payment') - .where('payment.status IN (:...statuses)', { - statuses: ['action-required', 'processing'], + .createQueryBuilder("payment") + .where("payment.status IN (:...statuses)", { + statuses: ["action-required", "processing"], }) .getCount(); @@ -201,7 +208,10 @@ export class OverviewRepository { }; } - async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> { + async getStaffKpis(): Promise<{ + activeEmployees: number; + activeUsers: number; + }> { const [activeEmployees, activeUsers] = await Promise.all([ this.employeeRepository.count({ where: { isCurrent: true }, @@ -217,15 +227,17 @@ export class OverviewRepository { return { activeEmployees, activeUsers }; } - async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> { + async getBookingTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) - .groupBy('booking.created_at::date') - .orderBy('booking.created_at::date', 'ASC') + .groupBy("booking.created_at::date") + .orderBy("booking.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ @@ -236,11 +248,11 @@ export class OverviewRepository { async getStatusCounts(): Promise> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select('booking.status', 'status') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') - .groupBy('booking.status') + .createQueryBuilder("booking") + .select("booking.status", "status") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") + .groupBy("booking.status") .getRawMany<{ status: string; count: string }>(); return Object.fromEntries( @@ -252,26 +264,26 @@ export class OverviewRepository { days: number, ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { const rows = await this.paymentRepository - .createQueryBuilder('payment') + .createQueryBuilder("payment") .select( `to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`, - 'date', + "date", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, - 'amountEtb', + "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, - 'amountUsd', + "amountUsd", ) - .where('payment.status = :status', { status: 'success' }) + .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, { days }, ) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) - .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC') + .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); return rows.map((row) => ({ @@ -283,18 +295,18 @@ export class OverviewRepository { async getRecentBookings(limit: number): Promise { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .leftJoin('booking.company', 'company') - .select('booking.id', 'id') - .addSelect('booking.reference', 'reference') - .addSelect('COALESCE(company.name, \'—\')', 'customerLabel') - .addSelect('booking.status', 'status') - .addSelect('booking.priority_score', 'priorityScore') - .addSelect('booking.total_amount', 'totalAmount') - .addSelect('booking.payment_currency', 'paymentCurrency') - .addSelect('booking.created_at', 'createdAt') - .where('booking.deleted_at IS NULL') - .orderBy('booking.created_at', 'DESC') + .createQueryBuilder("booking") + .leftJoin("booking.company", "company") + .select("booking.id", "id") + .addSelect("booking.reference", "reference") + .addSelect("COALESCE(company.name, '—')", "customerLabel") + .addSelect("booking.status", "status") + .addSelect("booking.priority_score", "priorityScore") + .addSelect("booking.total_amount", "totalAmount") + .addSelect("booking.payment_currency", "paymentCurrency") + .addSelect("booking.created_at", "createdAt") + .where("booking.deleted_at IS NULL") + .orderBy("booking.created_at", "DESC") .limit(limit) .getRawMany<{ id: string; @@ -319,15 +331,17 @@ export class OverviewRepository { })); } - async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> { + async getBookingsByFreightType(): Promise< + { label: string; count: number }[] + > { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select('booking.freight_type', 'label') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .select("booking.freight_type", "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") - .groupBy('booking.freight_type') - .orderBy('count', 'DESC') + .groupBy("booking.freight_type") + .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ @@ -338,13 +352,13 @@ export class OverviewRepository { async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select('booking.payment_currency', 'label') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .select("booking.payment_currency", "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") - .groupBy('booking.payment_currency') - .orderBy('count', 'DESC') + .groupBy("booking.payment_currency") + .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ @@ -355,11 +369,11 @@ export class OverviewRepository { async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> { const rows = await this.paymentRepository - .createQueryBuilder('payment') - .select('payment.status', 'status') - .addSelect('COUNT(*)::int', 'count') - .groupBy('payment.status') - .orderBy('count', 'DESC') + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ @@ -372,20 +386,25 @@ export class OverviewRepository { { method: string; count: number; amountEtb: number; amountUsd: number }[] > { const rows = await this.paymentRepository - .createQueryBuilder('payment') - .select('payment.method', 'method') - .addSelect('COUNT(*)::int', 'count') + .createQueryBuilder("payment") + .select("payment.method", "method") + .addSelect("COUNT(*)::int", "count") .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`, - 'amountEtb', + "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, - 'amountUsd', + "amountUsd", ) - .groupBy('payment.method') - .orderBy('count', 'DESC') - .getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>(); + .groupBy("payment.method") + .orderBy("count", "DESC") + .getRawMany<{ + method: string; + count: string; + amountEtb: string; + amountUsd: string; + }>(); return rows.map((row) => ({ method: row.method, @@ -395,16 +414,18 @@ export class OverviewRepository { })); } - async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> { + async getRevenueByCurrency(): Promise< + { currency: string; amount: number }[] + > { const rows = await this.paymentRepository - .createQueryBuilder('payment') - .select('payment.currency', 'currency') - .addSelect('COALESCE(SUM(payment.amount), 0)', 'amount') - .where('payment.status = :status', { status: 'success' }) + .createQueryBuilder("payment") + .select("payment.currency", "currency") + .addSelect("COALESCE(SUM(payment.amount), 0)", "amount") + .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) - .groupBy('payment.currency') + .groupBy("payment.currency") .getRawMany<{ currency: string; amount: string }>(); return rows.map((row) => ({ @@ -413,20 +434,28 @@ export class OverviewRepository { })); } - async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.trainRepository, 'train'); + async getTrainStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.trainRepository, "train"); } - async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.wagonRepository, 'wagon'); + async getWagonStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.wagonRepository, "wagon"); } - async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.containerRepository, 'container'); + async getContainerStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.containerRepository, "container"); } - async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.cargoRepository, 'cargo'); + async getCargoStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.cargoRepository, "cargo"); } private async statusBreakdown( @@ -435,11 +464,11 @@ export class OverviewRepository { ): Promise<{ status: string; count: number }[]> { const rows = await repository .createQueryBuilder(alias) - .select(`${alias}.status`, 'status') - .addSelect('COUNT(*)::int', 'count') + .select(`${alias}.status`, "status") + .addSelect("COUNT(*)::int", "count") .where(`${alias}.deleted_at IS NULL`) .groupBy(`${alias}.status`) - .orderBy('count', 'DESC') + .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ @@ -448,15 +477,19 @@ export class OverviewRepository { })); } - async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { - const rows = await this.customerRepository - .createQueryBuilder('customer') - .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date') - .addSelect('COUNT(*)::int', 'count') - .where('customer.deleted_at IS NULL') - .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days }) - .groupBy('customer.created_at::date') - .orderBy('customer.created_at::date', 'ASC') + async getCustomerGrowthTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { + const rows = await this.companyRepository + .createQueryBuilder("customer") + .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("customer.deleted_at IS NULL") + .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { + days, + }) + .groupBy("customer.created_at::date") + .orderBy("customer.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ @@ -466,13 +499,16 @@ export class OverviewRepository { } async getCustomersByType(): Promise<{ label: string; count: number }[]> { - const rows = await this.customerRepository - .createQueryBuilder('customer') - .select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label') - .addSelect('COUNT(*)::int', 'count') - .where('customer.deleted_at IS NULL') - .groupBy('customer.customer_type') - .orderBy('count', 'DESC') + const rows = await this.companyRepository + .createQueryBuilder("customer") + .select( + `COALESCE(NULLIF(customer.type, ''), 'Unknown')`, + "label", + ) + .addSelect("COUNT(*)::int", "count") + .where("customer.deleted_at IS NULL") + .groupBy("customer.type") + .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ @@ -481,16 +517,18 @@ export class OverviewRepository { })); } - async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> { + async getTopCustomersByBookings( + limit: number, + ): Promise<{ label: string; count: number }[]> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .leftJoin('booking.company', 'company') - .select(`COALESCE(company.name, 'Unknown')`, 'label') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .leftJoin("booking.company", "company") + .select(`COALESCE(company.name, 'Unknown')`, "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") - .groupBy('company.name') - .orderBy('count', 'DESC') + .groupBy("company.name") + .orderBy("count", "DESC") .limit(limit) .getRawMany<{ label: string; count: string }>(); @@ -502,11 +540,11 @@ export class OverviewRepository { async getUsersByStatus(): Promise<{ status: string; count: number }[]> { const rows = await this.userRepository - .createQueryBuilder('user') - .select('user.status', 'status') - .addSelect('COUNT(*)::int', 'count') - .groupBy('user.status') - .orderBy('count', 'DESC') + .createQueryBuilder("user") + .select("user.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("user.status") + .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ @@ -515,15 +553,19 @@ export class OverviewRepository { })); } - async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { + async getEmployeeGrowthTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { const rows = await this.employeeRepository - .createQueryBuilder('employee') - .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date') - .addSelect('COUNT(*)::int', 'count') - .where('employee.is_current = true') - .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days }) - .groupBy('employee.created_at::date') - .orderBy('employee.created_at::date', 'ASC') + .createQueryBuilder("employee") + .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("employee.is_current = true") + .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { + days, + }) + .groupBy("employee.created_at::date") + .orderBy("employee.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ @@ -538,16 +580,16 @@ export class OverviewRepository { where: { isActive: true, status: EUserStatus.ACCEPTED }, }), this.userRepository - .createQueryBuilder('user') - .where('user.is_active = false OR user.status != :status', { + .createQueryBuilder("user") + .where("user.is_active = false OR user.status != :status", { status: EUserStatus.ACCEPTED, }) .getCount(), ]); return [ - { label: 'Active', count: active }, - { label: 'Inactive', count: inactive }, + { label: "Active", count: active }, + { label: "Inactive", count: inactive }, ]; } } diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 9c92a036d..bcfa643b6 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -18,7 +18,8 @@ import { export class PaymentClientService { private readonly logger = new Logger(PaymentClientService.name); private readonly baseUrl = ( - process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com" + // process.env.PAYMENT_API_URL ?? + "https://paymentcallback.triaplc.com" ).replace(/\/$/, ""); private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index b24febf91..177b7475b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -145,9 +145,7 @@ export class PaymentService { .findOneBy({ id: dto.bookingId }); if (!booking) throw new NotFoundException("Booking not found"); - console.log("bookingbooking",booking) - const amountMinor = Math.round(Number(booking.totalAmount) * 100); - console.log("amountminor",amountMinor) + const amountMinor = Math.round(Number(booking.totalAmount)); const snapshot = await this.paymentClient.initiate({ service: PaymentServiceEnum.FREIGHT, @@ -159,8 +157,8 @@ export class PaymentService { provider: dto.method as unknown as ProviderMethod, platform: dto.platform, payerAccount: dto.payerAccount, - returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL, - failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL, + returnUrl:'https://edrfreight.triaplc.com/payment/success', + failureUrl: 'https://edrfreight.triaplc.com/payment/failure', }); const intent = await this.syncIntentProjection(booking.id, booking, snapshot); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index 7191a203e..dd20b100d 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -49,8 +49,17 @@ export class SchedulingRescheduleService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } + // A train can be rescheduled (with or without bookings) at any time UNLESS it + // is already on the move (DISPATCHED), has completed its run (ARRIVED), or was + // cancelled. Only DRAFT / SCHEDULED trains are reschedulable. if (schedule.status === TrainScheduleStatus.Dispatched) { - throw new BadRequestException('Cannot reschedule a dispatched train'); + throw new BadRequestException('Cannot reschedule a train that is already dispatched'); + } + if (schedule.status === TrainScheduleStatus.Arrived) { + throw new BadRequestException('Cannot reschedule a train that has already arrived'); + } + if (schedule.status === TrainScheduleStatus.Cancelled) { + throw new BadRequestException('Cannot reschedule a cancelled train'); } const currentOnSchedule = (schedule.scheduleBookings ?? []) @@ -156,10 +165,24 @@ export class SchedulingRescheduleService { } } - const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, { - bookingIds: dto.finalBookingIds, - forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT', - }); + // A train can be rescheduled even with no bookings (e.g. moved for + // maintenance). assignBookingsToSchedule requires at least one booking, so + // only call it when something is actually being (re)assigned — the new + // departure date above is the meaningful change for an empty train. The + // empty-train branch returns the same schedule-detail shape as the assign + // path so callers get a consistent response. + const assignResult = dto.finalBookingIds.length + ? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, { + bookingIds: dto.finalBookingIds, + forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT', + }) + : { + ...(await this.trainSchedulingService.getContainerTrainScheduleById( + scheduleId, + )), + warnings: [] as string[], + deferredBookings: [] as unknown[], + }; await this.schedulingRescheduleRepository.createEvent({ trainScheduleId: scheduleId, 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 17184bf97..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)) }; } @@ -721,27 +738,37 @@ export class TrainSchedulingService { : null; if (route) { - const origin = route.originYard; - const destination = route.destinationYard; + // `route.milestones` is the complete ordered corridor and already includes + // the origin (first) and destination (last) yards — `route.originYardId` + // and `route.destinationYardId` are derived from them. Use the milestones + // directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire). const milestones = [...(route.milestones ?? [])].sort( (a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo, ); + + if (milestones.length > 0) { + milestones.forEach((m, i) => + stations.push({ + sequenceNo: i, + yardId: m.yardId, + label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`, + code: m.yard?.code ?? '', + }), + ); + return stations; + } + + // Route with no milestones recorded — fall back to its origin/destination. + const origin = route.originYard; + const destination = route.destinationYard; stations.push({ sequenceNo: 0, yardId: route.originYardId, label: origin?.label ?? origin?.code ?? 'Origin', code: origin?.code ?? '', }); - milestones.forEach((m, i) => - stations.push({ - sequenceNo: i + 1, - yardId: m.yardId, - label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`, - code: m.yard?.code ?? '', - }), - ); stations.push({ - sequenceNo: milestones.length + 1, + sequenceNo: 1, yardId: route.destinationYardId, label: destination?.label ?? destination?.code ?? 'Destination', code: destination?.code ?? '', @@ -775,8 +802,16 @@ export class TrainSchedulingService { const stations = await this.buildScheduleStations(schedule); const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); + + // Resolve each checkpoint's position by its yard against the canonical + // corridor rather than the stored sequenceNo, so legacy checkpoints logged + // under an older station numbering still line up with the current stations. + const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo])); + const resolvedSeq = (e: TrainCheckpointEvent) => + seqByYard.get(e.yardId) ?? e.sequenceNo; + const currentSequenceNo = events.length - ? Math.max(...events.map((e) => e.sequenceNo)) + ? Math.max(...events.map(resolvedSeq)) : -1; return { @@ -790,13 +825,19 @@ export class TrainSchedulingService { actualArrivalAt: schedule.actualArrivalAt ? schedule.actualArrivalAt.toISOString() : null, + scheduledDepartureAt: schedule.scheduledDepartureDate + ? schedule.scheduledDepartureDate.toISOString() + : null, + scheduledArrivalAt: schedule.scheduledArrivalDate + ? schedule.scheduledArrivalDate.toISOString() + : null, origin: stations[0]?.label ?? null, destination: stations[stations.length - 1]?.label ?? null, stations, currentSequenceNo, checkpoints: events.map((e) => ({ id: e.id, - sequenceNo: e.sequenceNo, + sequenceNo: resolvedSeq(e), yardId: e.yardId, label: e.yard?.label ?? e.yard?.code ?? null, kind: e.kind, @@ -1965,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/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts new file mode 100644 index 000000000..fb5c4e92b --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -0,0 +1,32 @@ +import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator'; +import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity'; + +export class CreateVehicleDto { + @IsString() + plateNumber!: string; + + @IsEnum(VehicleType) + vehicleType!: VehicleType; + + @IsString() + manufacturer!: string; + + @IsString() + model!: string; + + @IsNumber() + year!: number; + + @IsEnum(FuelType) + fuelType!: FuelType; + + @IsNumber() + capacity!: number; + + @IsEnum(VehicleStatus) + status!: VehicleStatus; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts new file mode 100644 index 000000000..953917b2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateVehicleDto } from './create-vehicle.dto'; + +export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {} diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts new file mode 100644 index 000000000..773e8051a --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -0,0 +1,64 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +export enum VehicleType { + TRUCK = 'TRUCK', + VAN = 'VAN', + CAR = 'CAR', + BUS = 'BUS', + TRAILER = 'TRAILER', + TANKER = 'TANKER', + FLATBED = 'FLATBED', +} + +export enum FuelType { + PETROL = 'PETROL', + DIESEL = 'DIESEL', + ELECTRIC = 'ELECTRIC', + HYBRID = 'HYBRID', +} + +export enum VehicleStatus { + ACTIVE = 'ACTIVE', + MAINTENANCE = 'MAINTENANCE', + RETIRED = 'RETIRED', + OUT_OF_SERVICE = 'OUT_OF_SERVICE', +} + +@Entity({ name: 'vehicles', schema: 'freight' }) +@Index(['plateNumber']) +@Index(['registrationNumber']) +@Index(['status']) +@Index(['vehicleType']) +@Index(['manufacturer']) +export class Vehicle extends BaseEntity { + @Column({ name: 'plate_number', unique: true }) + plateNumber!: string; + + @Column({ name: 'registration_number', unique: true }) + registrationNumber!: string; + + @Column({ name: 'vehicle_type', type: 'varchar' }) + vehicleType!: VehicleType; + + @Column() + manufacturer!: string; + + @Column() + model!: string; + + @Column() + year!: number; + + @Column({ name: 'fuel_type', type: 'varchar' }) + fuelType!: FuelType; + + @Column() + capacity!: number; + + @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE }) + status!: VehicleStatus; + + @Column({ type: 'text', nullable: true }) + description!: string | null; +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts new file mode 100644 index 000000000..24ff2d022 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { VehiclesService } from './vehicles.service'; +import { CreateVehicleDto } from './dto/create-vehicle.dto'; +import { UpdateVehicleDto } from './dto/update-vehicle.dto'; + +@ApiTags('vehicles') +@ApiBearerAuth() +@Controller('vehicles') +@FleetView() +export class VehiclesController { + constructor(private readonly vehiclesService: VehiclesService) {} + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Create a new vehicle' }) + create(@Body() createVehicleDto: CreateVehicleDto) { + return this.vehiclesService.create(createVehicleDto); + } + + @Get() + @ApiOperation({ summary: 'Get all vehicles with filters' }) + findAll( + @Query('search') search?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + return this.vehiclesService.findAll({ + search, + status: status as any, + page: page ? parseInt(page) : undefined, + limit: limit ? parseInt(limit) : undefined, + sortBy, + sortOrder, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get vehicle by id' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.vehiclesService.findById(id); + } + + @Patch(':id') + @FleetManage() + @ApiOperation({ summary: 'Update a vehicle' }) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateVehicleDto: UpdateVehicleDto, + ) { + return this.vehiclesService.update(id, updateVehicleDto); + } + + @Delete(':id') + @FleetManage() + @ApiOperation({ summary: 'Delete a vehicle' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.vehiclesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts new file mode 100644 index 000000000..07aa4bd2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Vehicle } from './entities/vehicle.entity'; +import { VehiclesService } from './vehicles.service'; +import { VehiclesController } from './vehicles.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Vehicle])], + providers: [VehiclesService], + controllers: [VehiclesController], + exports: [VehiclesService], +}) +export class VehiclesModule {} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts new file mode 100644 index 000000000..9c5bad1e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Vehicle } from './entities/vehicle.entity'; + +@Injectable() +export class VehiclesRepository extends BaseRepository { + constructor( + @InjectRepository(Vehicle) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts new file mode 100644 index 000000000..12970a8a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -0,0 +1,109 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateVehicleDto } from './dto/create-vehicle.dto'; +import { UpdateVehicleDto } from './dto/update-vehicle.dto'; +import { Vehicle, VehicleStatus } from './entities/vehicle.entity'; + +@Injectable() +export class VehiclesService { + constructor( + @InjectRepository(Vehicle) + private readonly vehicleRepo: Repository, + ) {} + + async create(dto: CreateVehicleDto): Promise { + const existing = await this.vehicleRepo.findOne({ + where: { plateNumber: dto.plateNumber }, + }); + + if (existing) { + throw new ConflictException( + `Vehicle with plate number ${dto.plateNumber} already exists`, + ); + } + + const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; + const vehicle = this.vehicleRepo.create({ + ...dto, + registrationNumber, + }); + + return this.vehicleRepo.save(vehicle); + } + + async findAll(query: { + search?: string; + status?: VehicleStatus | string; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + } = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> { + const page = query.page || 1; + const limit = query.limit || 10; + const skip = (page - 1) * limit; + + const where: any = {}; + if (query.status) where.status = query.status; + + let qb = this.vehicleRepo.createQueryBuilder('v'); + + if (query.search) { + qb = qb.where( + 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search', + { search: `%${query.search}%` }, + ); + } + + if (query.status) { + qb = qb.andWhere('v.status = :status', { status: query.status }); + } + + const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes( + query.sortBy ?? '', + ) + ? query.sortBy + : 'createdAt'; + const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase(); + + const [data, total] = await qb + .orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(id: string): Promise { + const vehicle = await this.vehicleRepo.findOne({ where: { id } }); + if (!vehicle) { + throw new NotFoundException(`Vehicle ${id} not found`); + } + return vehicle; + } + + async update(id: string, dto: UpdateVehicleDto): Promise { + const vehicle = await this.findById(id); + + if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) { + const existing = await this.vehicleRepo.findOne({ + where: { plateNumber: dto.plateNumber }, + }); + if (existing) { + throw new ConflictException( + `Vehicle with plate number ${dto.plateNumber} already exists`, + ); + } + } + + Object.assign(vehicle, dto); + return this.vehicleRepo.save(vehicle); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.vehicleRepo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts index f4931f77b..b391d3f9e 100644 --- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -42,8 +42,13 @@ export class DemoFreightDataSeeder { async run() { await this.dataSource.transaction(async (manager) => { - await this.seedWagons(manager); - await this.seedApprovalRules(manager); + // Demo freight data (wagons + approval rules) disabled — keep only the + // 4 staff users. The seeders are retained for easy re-enabling; flip + // SEED_DEMO_FREIGHT_DATA=true to run them. + if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') { + await this.seedWagons(manager); + await this.seedApprovalRules(manager); + } await this.seedStaffUsers(manager); }); } diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index d680625ac..17750ddac 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": "0.0.3", "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 0c61cc445..46c8e73f3 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -44,6 +44,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; @@ -212,41 +213,13 @@ 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", - icon: , + { + title: "Administration", + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , permission: FREIGHT_PERMS.admin, }, { @@ -339,222 +312,216 @@ const App = () => { return ; } - if (!user) { - return ( - - } /> - } /> - } /> - - ); - } + if (!user) { + return ( + + } /> + } /> + } /> + + ); + } + + return ( + + } /> + } /> + } /> + }> + } /> + } /> + + } /> + + + + } + /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - return ( - - } /> - } /> - }> - } /> - } /> - - } /> - - - - } - /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - {/* iframe-based user management module */} - } /> - - {/* Legacy embedded user management routes */} - } /> - } /> - } /> - {/* } /> */} - } /> - } /> - - - - - } - /> - - - - } - /> - - } - /> - - - - } - /> - } /> - - } - /> - } /> - - } - /> - } /> - - } /> - } /> - - } - /> - } - /> - - - } /> - - ); -}; + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Legacy embedded user management routes */} + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + + + + + } + /> + + + + } + /> + + } + /> + + + + } + /> + } /> + } /> + } /> + + } + /> + } /> + + } + /> + } /> + + } /> + } /> + + } /> + } /> + + + } /> + + ); +}; export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index a2d685d0f..344e80c18 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -226,7 +226,7 @@ const FreightSidebar = ({ return (
-
+
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 62c2a3949..b1412b9a1 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -143,6 +143,7 @@ export const URL_CONSTANTS = { TRAIN_SCHEDULING: { ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings", BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules", + AVAILABLE_DAYS: "/train-scheduling/available-days", AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives", BATCH_BOARD: "/train-scheduling/batch-board", BATCH_BOARD_DETAIL: (scheduleId: string) => diff --git a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts index 8c6536c5c..823d13324 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts @@ -132,6 +132,29 @@ export const useBookableSchedules = ( enabled: Boolean(originYardId && destinationYardId), }); +/** + * Day-level pool: which days have an OPEN departure on the route. Staff pick a + * day (not a train) when creating a booking; the engine assigns the train. + */ +export const useAvailableDays = ( + originYardId?: string | null, + destinationYardId?: string | null, +) => + useQuery({ + queryKey: [ + ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, + "available-days", + originYardId ?? "", + destinationYardId ?? "", + ], + queryFn: () => + trainSchedulingService.getAvailableDays( + originYardId ?? undefined, + destinationYardId ?? undefined, + ), + enabled: Boolean(originYardId && destinationYardId), + }); + export const useTrainTrack = (id: string | undefined) => useQuery({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""), diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index 38be6dd31..042067edb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -44,7 +44,7 @@ import toast from "react-hot-toast"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { bookingsService } from "@/services/bookings.service"; -import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling"; import { api } from "@/auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; @@ -194,9 +194,9 @@ export default function NewBookingPage() { const [freightType, setFreightType] = useState("CONTAINER"); const [originYardId, setOriginYardId] = useState(null); const [destinationYardId, setDestinationYardId] = useState(null); - const [trainScheduleId, setTrainScheduleId] = useState(null); const [serviceTypeId, setServiceTypeId] = useState(null); - const [scheduledDate, setScheduledDate] = useState(""); + // Day-level pool: staff pick a DAY (yyyy-MM-dd); the engine assigns the train. + const [scheduledDay, setScheduledDay] = useState(null); const [paymentCurrency, setPaymentCurrency] = useState("ETB"); // container freight @@ -232,34 +232,37 @@ export default function NewBookingPage() { label: c.name || c.email || c.tin || c.id, })); - const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules( + // Day-level pool: fetch only the days that have a departure on the route (no + // train, no capacity). The batch engine assigns the train after booking. + const { data: availableDays, isLoading: daysLoading } = useAvailableDays( originYardId, destinationYardId, ); - const scheduleOptions = (bookableSchedules ?? []).map((s) => ({ - value: s.id, - label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date( - s.scheduleDate, - ).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`, + const dayOptions = (availableDays ?? []).map((day) => ({ + value: day, + label: new Date(`${day}T00:00:00`).toLocaleDateString(undefined, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }), })); - const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId); + const hasAvailableDays = (availableDays ?? []).length > 0; - // When a schedule is chosen its date IS the departure; otherwise fall back to the manual field. - const effectiveDepartureIso = selectedSchedule - ? new Date(selectedSchedule.scheduleDate).toISOString() - : scheduledDate - ? new Date(scheduledDate).toISOString() - : ""; + // The chosen day becomes the booking's scheduledDate (start of day, ISO). + const effectiveDepartureIso = scheduledDay + ? new Date(`${scheduledDay}T00:00:00`).toISOString() + : ""; const yardRecords = refData?.yard ?? []; const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code })); const originYard = yardRecords.find((y) => y.id === originYardId) ?? null; const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null; const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard); - const hasBookableSchedules = (bookableSchedules ?? []).length > 0; + // Reset the day when the route changes — available days depend on the route. useEffect(() => { - setTrainScheduleId(null); + setScheduledDay(null); }, [originYardId, destinationYardId]); const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code })); const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code })); @@ -302,16 +305,15 @@ export default function NewBookingPage() { const allLinesValid = lines.length > 0 && lines.every(lineValid); const sameYard = Boolean(originYardId && originYardId === destinationYardId); - const scheduleSatisfied = - hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate); - const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate); + // Day-level pool: a shipment DAY is all staff pick. The batch engine assigns + // the train afterwards (same flow as the customer portal). + const departureSatisfied = Boolean(scheduledDay); const canSubmit = Boolean(originYardId) && Boolean(destinationYardId) && !sameYard && Boolean(tradeDirection) && - scheduleSatisfied && Boolean(serviceTypeId) && departureSatisfied && (isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) && @@ -339,7 +341,7 @@ export default function NewBookingPage() { scheduledDate: effectiveDepartureIso || new Date().toISOString(), originYardId, destinationYardId, - trainScheduleId: trainScheduleId || undefined, + // Day-level pool: no trainScheduleId — the engine assigns the train. serviceTypeId, shippingLineId: shippingLineId || undefined, firstMilePickupAddress: firstMilePickupAddress.trim() || undefined, @@ -464,36 +466,30 @@ export default function NewBookingPage() { value={destinationYardId} onChange={(v) => { setDestinationYardId(v); - setTrainScheduleId(null); + setScheduledDay(null); }} searchable disabled={isLoading} error={sameYard ? "Same as origin" : undefined} /> - {hasBookableSchedules ? ( - (null); + const mountRef = useRef(null); + const rootRef = useRef(null); + const unmountTimerRef = useRef(null); - const mountBase = ( - (import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um' - ).replace(/\/$/, ''); - - const moduleOrigin = window.location.origin; - - const [iframeSrc] = useState(() => { - const sub = location.pathname.replace(/^\/(?:dashboard\/)?um(?=\/|$)/, ''); - return mountBase + (sub || '/') + location.search; - }); - - // ✅ Send token when iframe loads - const handleIframeLoad = () => { - const token = readToken(); - const refreshToken = readRefreshToken(); - const target = iframeRef.current?.contentWindow; - - if (!token) { - console.warn('⚠️ No authentication token found'); - return; - } - - if (!target) { - console.warn('⚠️ No iframe reference'); - return; - } - - target.postMessage( - { - type: 'UM_AUTH_TOKEN', - token, - refreshToken, - }, - moduleOrigin - ); - - console.log('✅ Token sent to iframe module'); - }; - - // ✅ Listen for messages from iframe useEffect(() => { - const onMessage = (event: MessageEvent) => { - // Security: Only accept from same origin - if (event.origin !== moduleOrigin) { - console.warn('🚫 Blocked message from different origin:', event.origin); - return; - } + const mountNode = mountRef.current; - const data = event.data as { type?: string; path?: string } | undefined; - if (!data) return; + if (!mountNode) { + return; + } - // Handle auth request (if module asks for token again) - if (data.type === 'UM_REQUEST_AUTH') { - const token = readToken(); - const refreshToken = readRefreshToken(); - const target = iframeRef.current?.contentWindow; + if (unmountTimerRef.current !== null) { + window.clearTimeout(unmountTimerRef.current); + unmountTimerRef.current = null; + } - if (token && target) { - target.postMessage( - { - type: 'UM_AUTH_TOKEN', - token, - refreshToken, - }, - moduleOrigin - ); - console.log('✅ Token resent to iframe (on request)'); - } - return; - } + if (!rootRef.current) { + rootRef.current = createRoot(mountNode); + } - // Handle route synchronization - if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') { - const target = '/dashboard/um' + data.path; - if (window.location.pathname + window.location.search !== target) { - navigate(target, { replace: true }); - } - } + const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, ""); + const iamApiUrl = "/um-api"; + const runtime: UserManagementRuntimeOptions = { + basename: "/um", + apiBaseUrl, + apiUrl: iamApiUrl, + recordApiUrl: iamApiUrl, + chronicleUrl: iamApiUrl, + auditApiUrl: iamApiUrl, }; - window.addEventListener('message', onMessage); - return () => window.removeEventListener('message', onMessage); - }, [moduleOrigin, navigate]); + rootRef.current.render( + , + ); - return ( -
-