diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 1ed8ff9fc..5bce9de95 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -61,4 +61,25 @@ REDIS_PORT=6379 RABBITMQ_ENABLED=false RABBITMQ_URL=amqp://localhost:5672 SMS_QUEUE=sms_queue + +# ── VeriFayda 2.0 (eSignet OIDC) identity verification ────────────────────── +# Disabled by default; /fayda/verification/start returns 503 until enabled. +FAYDA_ENABLED=false +FAYDA_CLIENT_ID= +FAYDA_AUTHORIZATION_ENDPOINT= +FAYDA_TOKEN_ENDPOINT= +FAYDA_USERINFO_ENDPOINT= +# Base64-encoded RSA private JWK used for the private_key_jwt client assertion +FAYDA_PRIVATE_KEY_BASE64= +# OAuth redirect_uri for MOBILE clients (must be registered with eSignet) +FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete +# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. +FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +FAYDA_SCOPE=openid profile email phone address +FAYDA_ACR_VALUES=mosip:idp:acr:generated-code +FAYDA_CLAIMS_LOCALES=en am +FAYDA_SESSION_TTL_MINUTES=10 +EXPIRATION_TIME=15 +ALGORITHM=RS256 EMAIL_QUEUE=email_queue diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index a0a119861..c148dd226 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -63,6 +63,7 @@ "dotenv": "^17.4.2", "dotenv-cli": "^11.0.0", "handlebars": "^4.7.9", + "jose": "^5.10.0", "libphonenumber-js": "^1.13.6", "minio": "7.1.3", "pg": "^8.13.0", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index f40e4f40f..b090624ff 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,4 +1,8 @@ -import { Module, OnApplicationBootstrap } from "@nestjs/common"; +import { + MiddlewareConsumer, + Module, + OnApplicationBootstrap, +} from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; @@ -12,6 +16,7 @@ import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; +import faydaConfig from "./config/fayda.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -61,26 +66,29 @@ import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { WagonsModule } from './modules/wagons/wagons.module'; -import { ContainersModule } from './modules/container-management/containers.module'; -import { CargoesModule } from './modules/cargoes/cargoes.module'; -import { RoutesModule } from './modules/routes/routes.module'; -import { WarehousesModule } from './modules/warehouses/warehouses.module'; -import { OverviewModule } from './modules/overview/overview.module'; -import { VehiclesModule } from './modules/vehicles/vehicles.module'; -import { DriversModule } from './modules/drivers/drivers.module'; -import { FuelModule } from './modules/fuel/fuel.module'; -import { MaintenanceModule } from './modules/maintenance/maintenance.module'; -import { FirstMileModule } from './modules/first-mile/first-mile.module'; -import { LastMileModule } from './modules/last-mile/last-mile.module'; -import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; -import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; +import { VerifaydaModule } from './modules/verifayda/verifayda.module'; +import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module'; +import { WagonsModule } from "./modules/wagons/wagons.module"; +import { ContainersModule } from "./modules/container-management/containers.module"; +import { CargoesModule } from "./modules/cargoes/cargoes.module"; +import { RoutesModule } from "./modules/routes/routes.module"; +import { WarehousesModule } from "./modules/warehouses/warehouses.module"; +import { OverviewModule } from "./modules/overview/overview.module"; +import { VehiclesModule } from "./modules/vehicles/vehicles.module"; +import { DriversModule } from "./modules/drivers/drivers.module"; +import { FuelModule } from "./modules/fuel/fuel.module"; +import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; +import { FirstMileModule } from "./modules/first-mile/first-mile.module"; +import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; +import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; +import { LoggerMiddleware } from "./logger.middleware"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig], + load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig], }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), @@ -141,6 +149,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera LastMileModule, InterchangeDocumentsModule, ImportOperationsModule, + VerifaydaModule, + FleetHistoryModule, ], providers: [ EdrOrgSeeder, @@ -211,4 +221,8 @@ export class AppModule implements OnApplicationBootstrap { // bookings bill to. Idempotent — keyed by fixed IDs. await this.govCompaniesSeeder.run(); } + + configure(consumer: MiddlewareConsumer) { + consumer.apply(LoggerMiddleware).forRoutes("*"); + } } diff --git a/apps/edr-freight-api/src/config/fayda.config.ts b/apps/edr-freight-api/src/config/fayda.config.ts new file mode 100644 index 000000000..a25289159 --- /dev/null +++ b/apps/edr-freight-api/src/config/fayda.config.ts @@ -0,0 +1,126 @@ +import { registerAs } from '@nestjs/config'; + +export interface FaydaJwk { + kty: 'RSA'; + use?: string; + kid?: string; + alg?: string; + n: string; + e: string; + d: string; + p?: string; + q?: string; + dp?: string; + dq?: string; + qi?: string; +} + +export type FaydaPlatform = 'WEB' | 'MOBILE'; + +export interface FaydaConfig { + enabled: boolean; + clientId: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userInfoEndpoint: string; + /** OAuth redirect_uri sent to eSignet for MOBILE clients. */ + redirectUri: string; + /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */ + webRedirectUri: string; + privateJwk: FaydaJwk; + scope: string; + acrValues: string; + claimsLocales: string; + sessionTtlMinutes: number; +} + +const REQUIRED_VARS = [ + 'FAYDA_CLIENT_ID', + 'FAYDA_AUTHORIZATION_ENDPOINT', + 'FAYDA_TOKEN_ENDPOINT', + 'FAYDA_USERINFO_ENDPOINT', + 'FAYDA_PRIVATE_KEY_BASE64', +] as const; + +function decodePrivateJwk(base64: string): FaydaJwk { + let jwk: unknown; + try { + const json = Buffer.from(base64, 'base64').toString('utf8'); + jwk = JSON.parse(json); + } catch (err) { + throw new Error( + `FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`, + ); + } + if (!jwk || typeof jwk !== 'object') { + throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object'); + } + const candidate = jwk as Partial; + if (candidate.kty !== 'RSA') { + throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"'); + } + if (!candidate.n || !candidate.e || !candidate.d) { + throw new Error( + 'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)', + ); + } + return candidate as FaydaJwk; +} + +export default registerAs('fayda', (): FaydaConfig => { + const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true'; + // `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address` + // are needed so the matching essential claims aren't rejected as out-of-scope. + const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address'; + const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code'; + const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am'; + const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); + const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; + const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri; + if (!enabled) { + return { + enabled: false, + clientId: process.env.FAYDA_CLIENT_ID ?? '', + authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '', + tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '', + userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', + redirectUri, + webRedirectUri, + privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, + scope, + acrValues, + claimsLocales, + sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl, + }; + } + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`, + ); + } + if (!redirectUri) { + throw new Error( + 'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI', + ); + } + if (Number.isNaN(sessionTtl) || sessionTtl <= 0) { + throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer'); + } + + return { + enabled: true, + clientId: process.env.FAYDA_CLIENT_ID!, + authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!, + tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!, + userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, + redirectUri, + webRedirectUri, + privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), + scope, + acrValues, + claimsLocales, + sessionTtlMinutes: sessionTtl, + }; +}); diff --git a/apps/edr-freight-api/src/logger.middleware.ts b/apps/edr-freight-api/src/logger.middleware.ts new file mode 100644 index 000000000..dd7532ec8 --- /dev/null +++ b/apps/edr-freight-api/src/logger.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, Response, NextFunction } from "express"; + +@Injectable() +export class LoggerMiddleware implements NestMiddleware { + private readonly logger = new Logger("HTTP"); + + use(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + + res.on("finish", () => { + const duration = Date.now() - start; + + this.logger.log( + `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`, + ); + }); + + next(); + } +} diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 0107956e4..0fa1056dd 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -38,7 +38,8 @@ async function bootstrap() { maxAge: 86400, // cache preflight for 24h to cut chatter in dev }); - app.setGlobalPrefix("api"); + // /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint. + app.setGlobalPrefix("api", { exclude: ["callback"] }); // enableImplicitConversion is OFF: class-transformer's implicit boolean // coercion turns any non-empty multipart/form-data string (including the // literal "false") into `true`, silently corrupting flags like isHazardous diff --git a/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts b/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts new file mode 100644 index 000000000..32f1e6f0c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts @@ -0,0 +1,269 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Repairs schema drift on databases that were originally built by TypeORM + * `synchronize` (at an older entity snapshot) and never had their migration + * history recorded. Such databases have `freight.migrations` empty while most + * of the schema already exists, so a from-scratch migration run aborts on the + * first non-idempotent statement and never reaches the columns/tables added + * after synchronize was last used. + * + * The deployment procedure for those databases is: + * 1. Baseline every pre-existing migration into `freight.migrations`. + * 2. Run migrations — this file is the only pending one and back-fills the + * objects the drift scan found missing. + * + * Every statement is idempotent (IF NOT EXISTS / guarded CREATE TYPE), so it is + * also safe on a clean database where the earlier migrations already created + * these objects — it simply no-ops. + */ +export class RepairSynchronizeDrift1870000000000 + implements MigrationInterface +{ + name = 'RepairSynchronizeDrift1870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // --- enum types (derived from entities that never had a source migration) --- + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.consignments_cargo_type_enum AS ENUM ( + 'CONTAINER', 'BULK_LIQUID', 'BULK_DRY', 'GENERAL', 'REFRIGERATED', 'HAZARDOUS' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.consignments_status_enum AS ENUM ( + 'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.tracking_events_status_enum AS ENUM ( + 'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + + // --- missing tables --- + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.consignments ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL, + tracking_number varchar(64) NOT NULL, + cargo_type freight.consignments_cargo_type_enum NOT NULL, + weight_kg numeric(12, 2) NOT NULL, + status freight.consignments_status_enum NOT NULL DEFAULT 'PENDING', + origin_station varchar(128) NOT NULL, + destination_station varchar(128) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_consignments PRIMARY KEY (id), + CONSTRAINT uq_consignments_tracking_number UNIQUE (tracking_number) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.tracking_events ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + consignment_id uuid NOT NULL, + location varchar(256) NOT NULL, + status freight.tracking_events_status_enum NOT NULL, + occurred_at timestamptz NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_tracking_events PRIMARY KEY (id) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_purchases ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + purchase_date timestamptz NOT NULL, + liters numeric(10, 2) NOT NULL, + cost_per_liter numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + fuel_station varchar(255) NULL, + payment_method varchar(50) DEFAULT 'CASH', + odometer_reading numeric(10, 2) NULL, + driver_id uuid NULL, + receipt_number varchar(255) NULL, + notes text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_purchases PRIMARY KEY (id), + CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_consumption ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + month date NOT NULL, + total_liters numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + total_distance_km numeric(10, 2) NOT NULL, + fuel_efficiency_km_per_l numeric(10, 2) NULL, + number_of_purchases integer DEFAULT 0, + average_cost_per_liter numeric(10, 2) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_consumption PRIMARY KEY (id), + CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE, + CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month) + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_schedules ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + maintenance_type varchar NOT NULL, + description varchar NOT NULL, + scheduled_date timestamptz NOT NULL, + completed_date timestamptz, + estimated_cost numeric(14,2), + actual_cost numeric(14,2), + status varchar NOT NULL DEFAULT 'SCHEDULED', + odometer_reading numeric, + service_provider varchar, + notes text, + next_due_km numeric, + next_due_date timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + PRIMARY KEY (id) + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_vehicle_date ON freight.maintenance_schedules (vehicle_id, scheduled_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_costs ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + maintenance_schedule_id uuid, + incurred_date timestamptz NOT NULL, + cost_amount numeric(14,2) NOT NULL, + cost_type varchar NOT NULL, + description varchar NOT NULL, + service_provider varchar, + invoice_number varchar, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + PRIMARY KEY (id), + CONSTRAINT fk_maintenance_schedule FOREIGN KEY (maintenance_schedule_id) + REFERENCES freight.maintenance_schedules (id) ON DELETE SET NULL + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_costs_vehicle_date ON freight.maintenance_costs (vehicle_id, incurred_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.otp_verifications ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + phone varchar NOT NULL, + otp varchar NOT NULL, + verified boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_otp_verifications PRIMARY KEY (id), + CONSTRAINT uq_otp_verifications_phone UNIQUE (phone) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.booking_batch_offers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + offered_wagons integer NOT NULL, + total_wagons integer NOT NULL, + offered_lines jsonb NULL, + offered_weight_tons numeric(12, 3) NOT NULL, + offered_amount numeric(14, 2) NOT NULL, + offered_pricing_breakdown jsonb NULL, + invoice_id uuid NULL, + payment_deadline timestamptz NOT NULL, + status varchar(10) NOT NULL DEFAULT 'OFFERED', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`); + + // --- missing columns on existing tables --- + await queryRunner.query(`ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz;`); + + await queryRunner.query(`ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS booking_type varchar(20) NOT NULL DEFAULT 'ONE_TIME', + ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32), + ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120), + ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60), + ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16), + ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz, + ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz, + ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS bulk_reefer_quantity numeric(12,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS clearance_current_phase varchar(40), + ADD COLUMN IF NOT EXISTS duty_required boolean, + ADD COLUMN IF NOT EXISTS vessel_departure_date date, + ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz, + ADD COLUMN IF NOT EXISTS ro_hold_reason text, + ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`); + + await queryRunner.query(`ALTER TABLE freight.cargoes + ADD COLUMN IF NOT EXISTS receiver_name varchar, + ADD COLUMN IF NOT EXISTS delivered_at timestamp, + ADD COLUMN IF NOT EXISTS delivery_remarks text;`); + + await queryRunner.query(`ALTER TABLE freight.contract_clearance_cycles + ADD COLUMN IF NOT EXISTS duty_required boolean, + ADD COLUMN IF NOT EXISTS vessel_departure_date date, + ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz, + ADD COLUMN IF NOT EXISTS ro_hold_reason text, + ADD COLUMN IF NOT EXISTS current_phase varchar(40), + ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`); + + await queryRunner.query(`ALTER TABLE freight.first_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`); + await queryRunner.query(`ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`); + + await queryRunner.query(`ALTER TABLE freight.route_milestones + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2);`); + + await queryRunner.query(`ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE';`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status);`); + + await queryRunner.query(`ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS window_phase varchar(20) NULL, + ADD COLUMN IF NOT EXISTS window_opens_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS window_closes_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS doc_review_ends_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS doc_review_completed_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS payment_phase_ends_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS booking_cycle_no integer NOT NULL DEFAULT 0;`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_train_schedules_window_phase + ON freight.train_schedules (window_phase) WHERE window_phase IS NOT NULL;`); + + await queryRunner.query(`ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS import_window_lead_days integer NOT NULL DEFAULT 3, + ADD COLUMN IF NOT EXISTS export_booking_lead_hours integer NOT NULL DEFAULT 24, + ADD COLUMN IF NOT EXISTS window_open_hour integer NOT NULL DEFAULT 8, + ADD COLUMN IF NOT EXISTS window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3, + ADD COLUMN IF NOT EXISTS doc_review_minutes integer NOT NULL DEFAULT 30, + ADD COLUMN IF NOT EXISTS payment_window_minutes integer NOT NULL DEFAULT 60, + ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;`); + } + + public async down(): Promise { + // No-op: this migration only repairs drift by additively creating objects + // that other migrations own. Rolling it back would drop objects those + // migrations legitimately created. Revert individual feature migrations + // instead if needed. + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts b/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts new file mode 100644 index 000000000..10b358eea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Session store for the VeriFayda 2.0 OIDC verification flow (ported from + * passenger-api). One row per started verification; `state` is the + * single-use CSRF token linking the eSignet redirect back to the session. + */ +export class AddFaydaVerificationSessions1890000000002 implements MigrationInterface { + name = "AddFaydaVerificationSessions1890000000002"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.fayda_verification_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + state varchar NOT NULL UNIQUE, + code_verifier varchar NOT NULL, + purpose varchar NOT NULL DEFAULT 'VERIFY', + platform varchar NOT NULL DEFAULT 'WEB', + save_to_account boolean NOT NULL DEFAULT false, + status varchar NOT NULL DEFAULT 'PENDING', + error_code varchar, + error_description text, + iam_user_id uuid, + expires_at timestamptz NOT NULL, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_EXPIRES_AT" + ON freight.fayda_verification_sessions (expires_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_IAM_USER_ID" + ON freight.fayda_verification_sessions (iam_user_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts b/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts new file mode 100644 index 000000000..ba8003143 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Track Fayda identity verification on drivers: whether the driver's + * identity was verified through VeriFayda and the OIDC subject it was + * verified against. + */ +export class AddDriverFaydaVerification1890000000003 implements MigrationInterface { + name = "AddDriverFaydaVerification1890000000003"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD COLUMN IF NOT EXISTS fayda_verified boolean DEFAULT false, + ADD COLUMN IF NOT EXISTS fayda_sub varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP COLUMN IF EXISTS fayda_verified, + DROP COLUMN IF EXISTS fayda_sub + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts b/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts new file mode 100644 index 000000000..7c1413617 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Allow more than one vehicle per last-mile delivery. Junction table joins + * last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as + * the first assignment so nothing is lost. + */ +export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface { + name = "AddLastMileVehicleAssignments1890000000004"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE, + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE" + ON freight.last_mile_vehicle_assignments (vehicle_id) + `); + // Backfill: existing single-vehicle assignments become the first row + await queryRunner.query(` + INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id) + SELECT id, vehicle_id FROM freight.last_mile + WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL + ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts b/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts new file mode 100644 index 000000000..2ec26e034 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Store the driver's gender. Prefilled from the Fayda VERIFY response + * (Male/Female) but editable; nullable so existing rows and manual, + * non-Fayda driver records stay valid. + */ +export class AddDriverGender1890000000005 implements MigrationInterface { + name = "AddDriverGender1890000000005"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD COLUMN IF NOT EXISTS gender varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP COLUMN IF EXISTS gender + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts b/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts new file mode 100644 index 000000000..04c9a7000 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Enforce one driver record per verified Fayda identity. A unique index on + * fayda_sub blocks a second driver from being created against the same Fayda + * OIDC subject; NULLs stay distinct so legacy/unverified rows are unaffected. + */ +export class AddDriverFaydaSubUnique1890000000006 implements MigrationInterface { + name = "AddDriverFaydaSubUnique1890000000006"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB" + ON freight.drivers (fayda_sub) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts b/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts new file mode 100644 index 000000000..77d0a3043 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Make driver uniqueness soft-delete aware. The original table used plain + * column UNIQUE constraints (drivers_email_key, etc.) which count soft-deleted + * rows, so deleting a driver then re-adding the same email/phone/license/Fayda + * identity failed at the DB with a raw 500 — even though the service's own + * (deleted_at-excluding) duplicate check saw nothing. Replace them with partial + * unique indexes scoped to live rows (deleted_at IS NULL) so uniqueness matches + * what the service enforces and freed values become reusable after deletion. + */ +export class DriverUniquePartialSoftDelete1890000000007 implements MigrationInterface { + name = "DriverUniquePartialSoftDelete1890000000007"; + + public async up(queryRunner: QueryRunner): Promise { + // Drop the full-table unique constraints from CreateDriversTable... + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP CONSTRAINT IF EXISTS drivers_email_key, + DROP CONSTRAINT IF EXISTS drivers_phone_number_key, + DROP CONSTRAINT IF EXISTS drivers_license_number_key + `); + // ...and the plain fayda_sub unique index from 1890000000006. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"`); + + // Re-add each as a partial unique index scoped to non-deleted rows. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_EMAIL_ACTIVE" + ON freight.drivers (email) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_PHONE_ACTIVE" + ON freight.drivers (phone_number) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_LICENSE_ACTIVE" + ON freight.drivers (license_number) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB_ACTIVE" + ON freight.drivers (fayda_sub) WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_EMAIL_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_PHONE_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_LICENSE_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB_ACTIVE"`); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB" + ON freight.drivers (fayda_sub) + `); + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD CONSTRAINT drivers_email_key UNIQUE (email), + ADD CONSTRAINT drivers_phone_number_key UNIQUE (phone_number), + ADD CONSTRAINT drivers_license_number_key UNIQUE (license_number) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts new file mode 100644 index 000000000..8fbb688d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle + * status/availability transitions, first/last-mile vehicle assignments + mile + * status changes). Queried by vehicle_id or driver_id to build a per-record + * timeline. Populated going forward — existing records have no back-history. + */ +export class AddFleetEvents1890000000008 implements MigrationInterface { + name = "AddFleetEvents1890000000008"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.fleet_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_type varchar NOT NULL, + vehicle_id uuid, + driver_id uuid, + first_mile_id uuid, + last_mile_id uuid, + from_value varchar, + to_value varchar, + label varchar, + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_VEHICLE" + ON freight.fleet_events (vehicle_id, created_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER" + ON freight.fleet_events (driver_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts b/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts new file mode 100644 index 000000000..d8bc1c5c0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Container number carried by each vehicle on a last-mile delivery. Auto-filled + * from the booking's container number when present, else entered by the operator + * at assignment time. + */ +export class AddLastMileAssignmentContainerNumber1890000000009 + implements MigrationInterface +{ + name = "AddLastMileAssignmentContainerNumber1890000000009"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS container_number varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS container_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts b/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts new file mode 100644 index 000000000..fc2d30552 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Per-vehicle actual distance on a last-mile delivery. A booking served by + * several trucks records each truck's km; the record's total (last_mile.exact_km) + * is their sum and drives the invoice. + */ +export class AddLastMileAssignmentDistance1890000000010 + implements MigrationInterface +{ + name = "AddLastMileAssignmentDistance1890000000010"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS distance_km + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts new file mode 100644 index 000000000..6c7235e3b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings. + * Tracking only — does not gate dispatch. + */ +export class AddLoadingStatusToTrainScheduleBookings1900000000000 + implements MigrationInterface +{ + name = "AddLoadingStatusToTrainScheduleBookings1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + DROP COLUMN IF EXISTS loading_status + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts new file mode 100644 index 000000000..7a661bc7c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts @@ -0,0 +1,126 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Simplify the rate + weight-limit configuration model: + * + * 1. Drop the effective_from / effective_to validity window from both + * `rates` and `weight_limit_rules`. Rates are now activated purely by + * the approval workflow (status = LIVE) and weight limits are always + * active for their container + direction. No time-travel scheduling. + * + * 2. Enforce "one rate per pattern" with partial unique indexes so the same + * configuration (e.g. FIRST_MILE for a given container type) cannot be + * duplicated. NULL scope columns are COALESCE-normalised because Postgres + * treats NULLs as distinct in a plain unique index. + * + * This migration is destructive on the date columns — existing effective_* + * values are dropped. + */ +export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface { + name = 'SimplifyRatesAndWeightLimitRules1900000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. De-duplicate existing data so the unique indexes can be created ── + // Keep the most recently-created row per pattern, soft-delete the rest. + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.rates + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED' + ) + UPDATE freight.rates r + SET deleted_at = now() + FROM ranked + WHERE r.id = ranked.id AND ranked.rn > 1; + `); + + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY container_type_id, trade_direction + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.weight_limit_rules + WHERE deleted_at IS NULL + ) + UPDATE freight.weight_limit_rules w + SET deleted_at = now() + FROM ranked + WHERE w.id = ranked.id AND ranked.rn > 1; + `); + + // ── 2. Drop the effective-date indexes + columns ─────────────────────── + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`); + // Indexes created by TypeORM's @Index carry generated hashed names — drop + // any index that references the effective_from column defensively. + await queryRunner.query(` + DO $$ + DECLARE idx record; + BEGIN + FOR idx IN + SELECT indexname FROM pg_indexes + WHERE schemaname = 'freight' + AND tablename IN ('rates', 'weight_limit_rules') + AND indexdef ILIKE '%effective_from%' + LOOP + EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname); + END LOOP; + END $$; + `); + + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`); + + // ── 3. One-rate-per-pattern partial unique indexes ───────────────────── + // The unit is part of the identity so a surcharge can legitimately carry two + // rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON), + // while still blocking a true duplicate (same rateType + scope + unit). + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern" + ON freight.weight_limit_rules (container_type_id, trade_direction) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`); + + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`); + await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`); + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts new file mode 100644 index 000000000..194c0d056 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Widen train_scheduling_global_rules.window_duration_hours from numeric(4,2) + * to numeric(6,4). The UI now lets staff enter the booking-window duration in + * minutes / hours / days and converts to the column's native hours unit; a + * 4-minute window is 0.0667h, which numeric(4,2) rounds to 0.07 (≈3.96 min). + * Four decimals store sub-minute durations exactly (0.0667h → 4.00 min). + */ +export class WidenWindowDurationHoursPrecision1910000000000 + implements MigrationInterface +{ + name = "WidenWindowDurationHoursPrecision1910000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(6, 4); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(4, 2); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts b/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts new file mode 100644 index 000000000..d48e127c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Snapshot the booking-window rule onto each train schedule. + * + * A schedule's window (open time + reopen cycles) must be frozen to the rule it + * was created with: a later global-rules edit applies only to FUTURE schedules, + * while an already-open schedule keeps its base rule. Previously the batch board + * recomputed windows from the LIVE global config, so editing the rule redrew the + * board for open schedules (a synthetic grid that no longer matched the window + * the customer was shown). These columns give the board a per-schedule rule to + * derive its display windows from. + * + * Existing rows are backfilled from the current global-rules singleton — the best + * available base, since they never stored one. Their stamped windowOpensAt/ + * windowClosesAt are still real, so only projected reopen cycles rely on the + * backfill. + */ +export class AddScheduleWindowRuleSnapshot1920000000000 + implements MigrationInterface +{ + name = "AddScheduleWindowRuleSnapshot1920000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS rule_window_open_hour integer, + ADD COLUMN IF NOT EXISTS rule_window_duration_hours numeric(6, 4), + ADD COLUMN IF NOT EXISTS rule_reopen_delay_minutes integer, + ADD COLUMN IF NOT EXISTS rule_import_window_lead_days integer, + ADD COLUMN IF NOT EXISTS rule_export_booking_lead_hours integer; + `); + + // Backfill from the global-rules singleton so pre-existing schedules render. + await queryRunner.query(` + UPDATE freight.train_schedules ts + SET + rule_window_open_hour = COALESCE(ts.rule_window_open_hour, r.window_open_hour), + rule_window_duration_hours = COALESCE(ts.rule_window_duration_hours, r.window_duration_hours), + rule_reopen_delay_minutes = COALESCE(ts.rule_reopen_delay_minutes, r.reopen_delay_minutes), + rule_import_window_lead_days = COALESCE(ts.rule_import_window_lead_days, r.import_window_lead_days), + rule_export_booking_lead_hours = COALESCE(ts.rule_export_booking_lead_hours, r.export_booking_lead_hours) + FROM freight.train_scheduling_global_rules r + WHERE ts.rule_window_open_hour IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS rule_window_open_hour, + DROP COLUMN IF EXISTS rule_window_duration_hours, + DROP COLUMN IF EXISTS rule_reopen_delay_minutes, + DROP COLUMN IF EXISTS rule_import_window_lead_days, + DROP COLUMN IF EXISTS rule_export_booking_lead_hours; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts new file mode 100644 index 000000000..b1218a9b3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a hard per-unit weight ceiling to weight limit rules. + * + * maxVgmTons stays the soft "overweight" threshold (surcharge + warning); + * max_capacity_tons is the absolute ceiling above which a booking cannot be + * created at all. Null means no ceiling (existing behavior). + */ +export class AddMaxCapacityToWeightLimitRules1930000000000 + implements MigrationInterface +{ + name = "AddMaxCapacityToWeightLimitRules1930000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + DROP COLUMN IF EXISTS max_capacity_tons; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts b/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts new file mode 100644 index 000000000..42f2c4eba --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Allow more than one vehicle per first-mile pickup. Junction table joins + * first_mile ⇄ vehicles, with each truck's container number + actual distance; + * existing single vehicle_id values are backfilled as the first assignment so + * nothing is lost. Mirrors the last-mile vehicle-assignment schema. + */ +export class AddFirstMileVehicleAssignments1940000000000 implements MigrationInterface { + name = "AddFirstMileVehicleAssignments1940000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.first_mile_vehicle_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + first_mile_id uuid NOT NULL REFERENCES freight.first_mile(id) ON DELETE CASCADE, + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id), + container_number varchar, + distance_km numeric(10,2), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "UQ_FIRST_MILE_VEHICLE" UNIQUE (first_mile_id, vehicle_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FM_VEHICLE_ASSIGNMENTS_VEHICLE" + ON freight.first_mile_vehicle_assignments (vehicle_id) + `); + // Backfill: existing single-vehicle assignments become the first row + await queryRunner.query(` + INSERT INTO freight.first_mile_vehicle_assignments (first_mile_id, vehicle_id) + SELECT id, vehicle_id FROM freight.first_mile + WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL + ON CONFLICT (first_mile_id, vehicle_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.first_mile_vehicle_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts b/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts new file mode 100644 index 000000000..c7ab60577 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts @@ -0,0 +1,133 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Replace load-type string matching with a real wagon-type foreign key. + * + * Before this migration, train scheduling picked a wagon type by matching + * strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …) + * and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on + * `cargo_types` and `container_types` so scheduling resolves the wagon type + * through the relation instead. + * + * The columns are NULLABLE: cargo grouping rows and container/legacy cargo that + * never ship in bulk have no wagon type, and forcing one onto them is + * meaningless. Scheduling enforces the requirement at run time (it throws when a + * scheduled bulk cargo type or a container type in the batch has no wagon type). + * + * Backfill reproduces the old hardcoded resolution one final time so existing + * bulk cargo + container rows are not left unset. After this, the runtime map is + * removed — the FK is the single source of truth. + */ +export class AddWagonTypeFkToCargoAndContainerTypes1940000000000 + implements MigrationInterface +{ + name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // ── Columns + FKs ──────────────────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS wagon_type_id uuid; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagon_type_id uuid; + `); + + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD CONSTRAINT fk_cargo_types_wagon_type + FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types(id) + ON DELETE RESTRICT; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD CONSTRAINT fk_container_types_wagon_type + FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types(id) + ON DELETE RESTRICT; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id + ON freight.cargo_types (wagon_type_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id + ON freight.container_types (wagon_type_id); + `); + + // ── Backfill: old cargo-code → wagon-code map (one last time) ───────────── + // COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2, + // COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default). + const cargoCodeToWagon: Record = { + COFFEE: "KW2", + GRAIN: "KW2", + WHEAT: "KW2", + SORGHUM: "KW2", + CORN: "KW2", + FERTILIZER: "PW2", + SUGAR: "PW2", + COAL: "KW3", + STEEL: "CW3", + ORE: "CW3", + }; + + for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) { + await queryRunner.query( + ` + UPDATE freight.cargo_types ct + SET wagon_type_id = wt.id + FROM freight.wagon_types wt + WHERE wt.code = $1 + AND UPPER(TRIM(ct.code)) = $2 + AND ct.wagon_type_id IS NULL; + `, + [wagonCode, cargoCode], + ); + } + + // Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3. + await queryRunner.query(` + UPDATE freight.cargo_types ct + SET wagon_type_id = wt.id + FROM freight.wagon_types wt + WHERE wt.code = 'CW3' + AND ct.wagon_type_id IS NULL + AND ct.unit_of_measure = 'PER_TON'; + `); + + // All container types → the old container default wagon NW5. + await queryRunner.query(` + UPDATE freight.container_types ct + SET wagon_type_id = wt.id + FROM freight.wagon_types wt + WHERE wt.code = 'NW5' + AND ct.wagon_type_id IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id; + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type; + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 536129122..fdd230d33 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -307,6 +307,16 @@ export class BillingService { }); } + /** Invoices for a batch of source records (e.g. many last-mile legs), so a + * list can show which records already have an invoice without N+1 queries. */ + findBySourceIds(source: string, sourceIds: string[]): Promise { + if (!sourceIds.length) return Promise.resolve([]); + return this.invoices.findAll({ + where: { source, sourceId: In(sourceIds) }, + order: { createdAt: "DESC" }, + }); + } + /** Invoices for the signed-in customer; empty when they have no company. */ async findForUser( userId: string, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index e8469e627..d63106c2b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -431,7 +431,7 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); - const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); @@ -571,6 +571,23 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** + * Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate; + * an unsaved preview booking (no id) sums the wagonsRequired already computed + * on its in-memory container lines — same math, no DB row needed. + */ + private async resolveWagonCount(booking: Booking): Promise { + if (!booking.id) { + return Math.ceil( + (booking.bookingContainers ?? []).reduce( + (sum, bc) => sum + Number(bc.wagonsRequired ?? 0), + 0, + ), + ); + } + return this.bookingsRepository.calculateWagonCount(booking.id); + } + /** Friendly container-type label for the per-unit card; degrades to "Container". */ private async containerTypeLabel(containerTypeId: string): Promise { try { 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 3e1ea3cd4..35fc7fd54 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 @@ -1051,17 +1051,27 @@ export class BookingTransitionService { // paid → auto-allocated by the settle/paid pipeline. Consolidated bookings // only reserve once both partners are FULLY_EXECUTED (handled inside). const fresh = await this.bookingsService.findById(booking.id); - await this.bookingBatchService.acceptExportBooking(fresh); - } else if (booking.tradeDirection === "IMPORT") { - // Import bookings wait for their booking-day window cycle — the batch runs - // after staff document review, never at accept time. - } else if (booking.scheduledDate) { - this.bookingBatchService.enqueueRouteDayProcessing( - booking.originYardId, - booking.destinationYardId, - eatDay(new Date(booking.scheduledDate)), - ); + try { + await this.bookingBatchService.acceptExportBooking(fresh); + } catch (err) { + // The status update above already committed. Without compensation the + // client gets an error for a booking that reads as accepted after a + // refresh — half-applied state. Put the request back so staff can retry. + await this.bookingsRepository.update(booking.id, { + status: "OPERATION_REQUEST_PENDING", + fullyExecutedAt: null, + lockedAt: booking.lockedAt ?? null, + } as never); + this.logger.warn( + `Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`, + ); + throw err; + } } + // IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the + // batch runs after the window closes + staff document review, never at accept + // time. (Legacy pre-migration schedules with no window phase are still served + // by the periodic legacy fill.) return this.bookingsService.findById(booking.id); } @@ -1078,23 +1088,59 @@ export class BookingTransitionService { } | null; } > { - const note = await this.bookingsRepository.findLatestReviewNote( - booking.id, - "CHANGES_REQUESTED", - ); - const summary = - booking.contractSummary ?? - this.contractService.buildContractSummary(booking); - const nextPending = - booking.status === "PENDING_APPROVAL" || - booking.status === "APPROVED_PENDING_SIGNATURE" - ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) - : null; - const nextStep = computeNextStep(booking, nextPending); - const activeBatchOffer = - booking.status === "SELECTED_FOR_BATCH" - ? await this.bookingBatchService.getOpenOfferSummary(booking.id) - : null; + // This enrichment runs AFTER the transition has committed. A failure here + // must never 500 the response — the client would report "failed" for a + // transition that actually succeeded (visible only after a refresh). + // Degrade each fragile field to null instead. + let note: Awaited< + ReturnType + > = null; + try { + note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + "CHANGES_REQUESTED", + ); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let summary: string | null = booking.contractSummary ?? null; + try { + summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let nextStep: BookingNextStep | null = null; + try { + const nextPending = + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" + ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) + : null; + nextStep = computeNextStep(booking, nextPending); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let activeBatchOffer: Awaited< + ReturnType + > = null; + try { + activeBatchOffer = + booking.status === "SELECTED_FOR_BATCH" + ? await this.bookingBatchService.getOpenOfferSummary(booking.id) + : null; + } catch (err) { + this.logger.warn( + `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } return { ...booking, latestChangeRequestNote: note?.note ?? null, 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 3f696bc6b..6dfe2b8b9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { Contract } from '../contracts/entities/contract.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -35,6 +36,7 @@ export interface BookingListFilterOptions { serviceTypeId?: string; cargoTypeId?: string; freightType?: string; + bookingType?: string; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -89,6 +91,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('bc.units', 'bcu') .leftJoinAndSelect('booking.company', 'company') // .leftJoinAndSelect('booking.customer', 'customer') .leftJoinAndSelect('booking.train', 'train') @@ -103,6 +106,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') .where('booking.id = :id', { id }) + .addOrderBy('bcu.sort_order', 'ASC') .leftJoinAndMapMany( 'booking.files', FileRecord, @@ -584,6 +588,12 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.serviceType', 'serviceType') .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') + // Contract reference for the list column + search (no entity relation on + // Booking → contract, so join the entity by id and select just the + // reference — a schema-qualified table string is parsed as alias.relation + // by TypeORM and crashes). + .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id') + .addSelect('contract.reference', 'contract_reference') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); @@ -602,10 +612,24 @@ export class BookingsRepository extends BaseRepository { qb.orderBy(sortField, options.sortOrder ?? 'DESC'); } - const [items, total] = await qb + const total = await qb.getCount(); + const { entities: items, raw } = await qb .skip((page - 1) * pageSize) .take(pageSize) - .getManyAndCount(); + .getRawAndEntities(); + + // The joined contract.reference comes back on the raw rows only (entity has no + // contract relation) — map it onto each booking by position. + const contractRefByBooking = new Map(); + for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) { + if (row.booking_id && !contractRefByBooking.has(row.booking_id)) { + contractRefByBooking.set(row.booking_id, row.contract_reference ?? null); + } + } + for (const item of items) { + (item as Booking & { contractReference?: string | null }).contractReference = + contractRefByBooking.get(item.id) ?? null; + } if (items.length) { const links = await this.dataSource.getRepository(TrainScheduleBooking).find({ @@ -737,6 +761,11 @@ export class BookingsRepository extends BaseRepository { freightType: options.freightType, }); } + if (options.bookingType) { + qb.andWhere('booking.bookingType = :bookingType', { + bookingType: options.bookingType, + }); + } if (options.createdFrom) { qb.andWhere('booking.created_at >= :createdFrom', { createdFrom: options.createdFrom, @@ -1051,8 +1080,12 @@ export class BookingsRepository extends BaseRepository { company: true, originYard: true, destinationYard: true, - bookingContainers: { containerType: true }, - cargoType: true, + // units carry the real per-container numbers entered at booking time — + // the wagon plan shows those instead of generated placeholders. + // containerType.wagonType + cargoType.wagonType drive wagon-type + // resolution during scheduling (FK, not the old load-type string map). + bookingContainers: { containerType: { wagonType: true }, units: true }, + cargoType: { wagonType: true }, }, order: { priorityScore: 'DESC', createdAt: 'ASC' }, }); 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 ac639a4bd..e3d882baa 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1001,6 +1001,7 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts index db9746c09..182ff153d 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { ContainerType } from '../../rule-engine/entities/container-type.entity'; import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity'; import { Booking } from './booking.entity'; +import { BookingContainerUnit } from './booking-container-unit.entity'; @Entity({ schema: 'freight', name: 'booking_container' }) @Index(['bookingId']) @@ -61,4 +62,8 @@ export class BookingContainer extends BaseEntity { @Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) overweightExcessTons?: number | null; + + /** The physical containers under this line — each with its own number + VGM. */ + @OneToMany(() => BookingContainerUnit, (u) => u.bookingContainer) + units?: BookingContainerUnit[]; } 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 4b033bab5..cf0ca76f6 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 @@ -157,6 +157,10 @@ export class Booking extends BaseEntity { @Column({ name: 'contract_route_id', type: 'uuid', nullable: true }) contractRouteId?: string | null; + /** Booking origin: ONE_TIME (single-shipment) or GENERAL_CONTRACT (drawdown). */ + @Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' }) + bookingType!: string; + /** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */ @Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true }) contractKind?: 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 4bcc3252a..b1761b35f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -334,15 +334,19 @@ export class CompaniesController { @Param("companyId", ParseUUIDPipe) companyId: string, ) { const files = await this.filesService.findByResource(companyId, "companies"); - return files.map((f) => ({ - id: f.id, - name: f.name, - code: f.code, - mimeType: f.mimeType, - size: f.size, - uploadedAt: f.createdAt, - url: f.url, - })); + return Promise.all( + files.map(async (f) => ({ + id: f.id, + name: f.name, + code: f.code, + mimeType: f.mimeType, + size: f.size, + uploadedAt: f.createdAt, + // Raw `f.url` is an un-signed MinIO path the browser can't open — sign + // it so the file previews/downloads in the client. + url: f.url ? await this.filesService.signUrl(f.url) : f.url, + })), + ); } @Post(":companyId/documents") diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index ef36ea5eb..125cf5f3d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1,9 +1,11 @@ import { BadRequestException, ForbiddenException, + Inject, Injectable, Logger, NotFoundException, + forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; @@ -12,9 +14,11 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity' import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -62,6 +66,8 @@ export class ContractBookingService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, ) {} async createUnderContract( @@ -113,6 +119,32 @@ export class ContractBookingService { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // Booking-window gate (config-driven): an operations booking may only be + // created while the route's booking window is open — import: the day's window + // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); + // export: within exportBookingLeadHours of departure. Customs Path B bookings + // enter clearance first and are scheduled later, so they are not gated here. + if (!generalCustoms) { + await this.trainSchedulingService.assertBookingWindowOpen({ + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + scheduledDate: dto.scheduledDate ?? null, + direction: contract.tradeDirection ?? null, + }); + } + + // Hard capacity gate: a container line whose total weight exceeds the + // container type's max capacity can never be booked — no surcharge path, + // no override. Checked before any row is written. + if (freightType === 'CONTAINER') { + await this.assertWithinMaxCapacity(contract, dto); + // 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ + // ≤ the cap, and drawdown bookings never pass through submit — so this is + // their only chance to hard-block an unbalanceable set. Entry order is + // irrelevant (the check sorts by weight before pairing). + await this.assert20ftPairableAtCreate(dto); + } + // Denormalize route/direction/freight onto the booking for the scheduling engine. const booking = await this.bookingsRepository.create({ reference, @@ -566,11 +598,14 @@ export class ContractBookingService { } /** - * Pre-create validation for the shipment form: run the overweight rule + the - * 20ft weight-pairing rule against the entered containers WITHOUT persisting a - * booking. The portal calls this from the price-confirm modal so the customer - * sees the overweight warning (+ surcharge basis) and is blocked on an - * un-pairable 20ft set before the booking is created. + * Pre-create validation + authoritative price preview for the shipment form: + * build an UNSAVED booking shaped exactly like {@link createUnderContract} + * would persist it and run the same BookingPricingService compute over it — + * base rail freight, first/last-mile trucking, and every rule-engine surcharge + * (overweight, hazard, reefer, consolidation, …). The portal and the GL + * backoffice form call this from the price-confirm modal, so the breakdown the + * user confirms is line-for-line what the booking will be charged. Also runs + * the 20ft weight-pairing rule, which hard-blocks creation. */ async validateShipment( contractId: string, @@ -582,16 +617,31 @@ export class ContractBookingService { maxAllowedTons: number; excessTons: number; }>; + overweightSurchargeAmount: number; + currency: string | null; pairingErrors: string[]; + capacityErrors: string[]; + lineItems: PriceLineItemDto[]; + totalAmount: number; }> { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); const lines = dto.containers ?? []; - if (!lines.length) return { overweightLines: [], pairingErrors: [] }; + if (contract.freightType === 'CONTAINER' && !lines.length) { + return { + overweightLines: [], + overweightSurchargeAmount: 0, + currency: null, + pairingErrors: [], + capacityErrors: [], + lineItems: [], + totalAmount: 0, + }; + } - // Resolve each line's container type + total VGM (sum of unit weights) so the - // rule engine can flag overweight per line (maxVgmTons × quantity vs total). + // Resolve each container line's type + total VGM (sum of unit weights) — + // mirrors persistContainers so the preview lines match the persisted ones. const resolved = await Promise.all( lines.map(async (line) => { const ct = await this.resolveContainerTypeForSize( @@ -606,46 +656,44 @@ export class ContractBookingService { }), ); - const ruleResult = await this.ruleEngineService.evaluate({ - freightType: 'CONTAINER', - cargoTypeId: null, - serviceTypeId: contract.serviceTypeId, - paymentCurrency: contract.paymentCurrency, + // The unsaved twin of the booking createUnderContract would write: same + // denormalized contract fields, same container-line math. No id → the + // pricing service derives wagon counts from the in-memory lines. + const route = await this.resolveRoute(contract, dto.contractRouteId); + const previewBooking = Object.assign(new Booking(), { + freightType: contract.freightType, tradeDirection: contract.tradeDirection, - isHazardous: false, - isReefer: contract.isReefer ?? false, - isGovernment: false, - allowConsolidation: false, + paymentCurrency: contract.paymentCurrency, + serviceTypeId: contract.serviceTypeId, + cargoTypeId: this.resolveCargoTypeId(contract, dto), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + isGovernment: contract.isGovernment, shippingLineId: null, - totalWagons: 0, - bulkTons: 0, - containers: resolved.map((r) => ({ - containerTypeId: r.ct.id, - quantity: r.line.quantity, - vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0, - totalVgmTons: r.totalVgmTons, - isReefer: r.ct.isReefer, - })), - } as never); + contractRouteId: route?.id ?? null, + cargoTotalWeightVgm: this.resolveBulkTons(dto), + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => + Object.assign(new BookingContainer(), { + containerTypeId: ct.id, + containerSize: line.containerSize, + quantity: line.quantity, + hazardousQuantity: line.hazardousQuantity ?? 0, + reeferQuantity: line.reeferQuantity ?? 0, + vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, + totalVgmTons, + wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + }), + ), + }) as Booking; - const overweightLines: Array<{ - containerTypeCode: string; - totalVgmTons: number; - maxAllowedTons: number; - excessTons: number; - }> = []; - for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { - const wr = ruleResult.containerWeightResults[i]; - if (!wr?.isOverweight) continue; - const r = resolved[i]; - const excessTons = Number(wr.overweightExcessTons ?? 0); - overweightLines.push({ - containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '', - totalVgmTons: r?.totalVgmTons ?? 0, - maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons), - excessTons, - }); - } + const computed = await this.bookingPricingService.computePriceForBooking(previewBooking); + + // The overweight surcharge line is already currency-converted; surface its + // amount separately so the warning alert can reference the exact charge. + const overweightSurchargeAmount = + computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0; // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. const twentyFtUnits = resolved @@ -661,7 +709,90 @@ export class ContractBookingService { (v) => v.message, ); - return { overweightLines, pairingErrors }; + // Hard capacity ceiling — a non-empty result means the create call will be + // rejected, so the form can block submit up front. + const capacityErrors = await this.ruleEngineService.capacityViolations( + resolved.map(({ line, ct, totalVgmTons }) => ({ + containerTypeId: ct.id, + quantity: line.quantity, + totalVgmTons, + })), + contract.tradeDirection, + ); + + return { + overweightLines: computed.overweightLines, + overweightSurchargeAmount, + currency: computed.currency, + pairingErrors, + capacityErrors, + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + }; + } + + /** + * Throws when any container line's total weight exceeds the hard capacity + * ceiling of its weight limit rule. Mirrors validateShipment's line + * resolution so the gate matches what the form preview reported. + */ + private async assertWithinMaxCapacity( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): Promise { + const lines = dto.containers ?? []; + if (!lines.length) return; + + const containers = await Promise.all( + lines.map(async (line) => { + const ct = await this.resolveContainerTypeForSize( + line.containerSize, + contract.isReefer || (line.reeferQuantity ?? 0) > 0, + ); + const totalVgmTons = (line.units ?? []).reduce( + (s, u) => s + Number(u.vgmTons ?? 0), + 0, + ); + return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons }; + }), + ); + + const violations = await this.ruleEngineService.capacityViolations( + containers, + contract.tradeDirection, + ); + if (violations.length) { + throw new BadRequestException(violations.join('; ')); + } + } + + /** + * Hard-block booking creation when the 20ft container weights cannot be + * balanced onto wagons (pair diff over the global cap). Same rule the + * shipment-form preview reports as `pairingErrors`, enforced server-side. + */ + private async assert20ftPairableAtCreate( + dto: CreateBookingUnderContractDto, + ): Promise { + const twentyFtUnits = (dto.containers ?? []) + .filter((line) => (line.containerSize ?? '').includes('20')) + .flatMap((line, lineIdx) => + (line.units ?? []).map((u, idx) => ({ + label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + })), + ); + if (twentyFtUnits.length < 2) return; + + const maxDiff = await this.max20ftPairDiffTons(); + const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff); + if (violations.length) { + throw new BadRequestException( + `Cannot create booking — 20ft containers cannot be paired on wagons: ${violations + .map((v) => v.message) + .join(' ')}`, + ); + } } private async max20ftPairDiffTons(): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 73a4fe117..532d26359 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -77,6 +77,9 @@ export interface ContractClearanceView { /** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */ exportClearanceFinalized?: boolean; linkedBookingId?: string | null; + /** Reference + status of the GL-created shipment booking, once it exists. */ + linkedBookingReference?: string | null; + linkedBookingStatus?: string | null; dutyAdvice?: { amount: number; currency: string; @@ -284,13 +287,22 @@ export class ContractClearanceService { ); let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); - if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { + // Once GL creates the shipment booking, surface its reference + status so the + // customer sees the concrete booking instead of a stale "will be created + // shortly" message. Reuse the export booking load; fetch for import too. + let linkedBookingReference: string | null = null; + let linkedBookingStatus: string | null = null; + if (cycle?.bookingId) { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { - nextAction = this.workflowService.computeNextActionForBooking( - booking, - bookingMilestones, - ); + linkedBookingReference = booking.reference ?? null; + linkedBookingStatus = booking.status ?? null; + if (contract.tradeDirection === 'EXPORT') { + nextAction = this.workflowService.computeNextActionForBooking( + booking, + bookingMilestones, + ); + } } } @@ -326,6 +338,8 @@ export class ContractClearanceService { preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), exportClearanceFinalized: Boolean(cycle?.completedAt), linkedBookingId: cycle?.bookingId ?? null, + linkedBookingReference, + linkedBookingStatus, dutyAdvice, workflowFiles, t1, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 09e4a7ffd..06ac31d68 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -791,7 +791,7 @@ export class ContractsController { @Post(':id/validate-shipment') @ApiOperation({ summary: - 'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).', + 'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).', }) validateShipment( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 5bf6ddb4f..a9f9dcf5d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -13,6 +13,7 @@ import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.m import { SignaturesModule } from '../signatures/signatures.module'; import { OtpModule } from '../otp/otp.module'; import { BookingsModule } from '../bookings/bookings.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; @@ -78,6 +79,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), + // TrainSchedulingModule provides the config-driven booking-window gate used + // by ContractBookingService.createUnderContract. forwardRef because + // TrainSchedulingModule already imports ContractsModule. + forwardRef(() => TrainSchedulingModule), ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 9e464db51..34a958fd2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -135,6 +135,7 @@ export class ContractsRepository extends BaseRepository { // Attach the generated contract PDF to each row so list/home can offer a // direct download. Loaded separately to keep pagination counts correct. await this.attachContractFiles(items); + await this.attachClearancePhases(items); const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; return { @@ -173,6 +174,30 @@ export class ContractsRepository extends BaseRepository { } } + /** + * Attach each contract's persisted clearance phase (latest cycle's + * current_phase) so list consumers can show step-accurate customer actions + * ("Pay duty & upload slip" vs generic "Update clearance") without a + * per-contract clearance-view request. One query per page, like + * `attachContractFiles`. + */ + private async attachClearancePhases(contracts: Contract[]): Promise { + if (contracts.length === 0) return; + const ids = contracts.map((c) => c.id); + const rows: Array<{ contract_id: string; current_phase: string | null }> = + await this.dataSource.query( + `SELECT DISTINCT ON (contract_id) contract_id, current_phase + FROM freight.contract_clearance_cycles + WHERE contract_id = ANY($1) + ORDER BY contract_id, cycle_number DESC`, + [ids], + ); + const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase])); + for (const contract of contracts) { + contract.clearancePhase = byContract.get(contract.id) ?? null; + } + } + async getStatusCounts(): Promise> { const rows = await this.repository .createQueryBuilder('contract') diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 0461d3736..07d08d3c0 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -254,4 +254,10 @@ export class Contract extends BaseEntity { createForeignKeyConstraints: false, }) files?: FileRecord[]; + + /** + * Latest clearance cycle's current_phase, attached by + * ContractsRepository.attachClearancePhases for list responses. Not a column. + */ + clearancePhase?: string | null; } diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index eb0628b96..b4da558e2 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards'; import { DriversService } from './drivers.service'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('drivers') @ApiBearerAuth() @Controller('drivers') @FleetView() export class DriversController { - constructor(private readonly driversService: DriversService) {} + constructor( + private readonly driversService: DriversService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() @FleetManage() @@ -55,6 +59,12 @@ export class DriversController { return this.driversService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get driver assignment & activity history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getDriverHistory(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a driver' }) diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index 2464a26ac..6e7c1f69c 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -1,18 +1,27 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; import { Driver, DriverStatus } from './entities/driver.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class DriversService { constructor( @InjectRepository(Driver) private readonly driverRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateDriverDto): Promise { + if (dto.faydaVerified !== true) { + throw new BadRequestException( + 'Driver identity must be verified with Fayda before saving', + ); + } + const existing = await this.driverRepo.findOne({ where: [ { licenseNumber: dto.licenseNumber }, @@ -33,8 +42,28 @@ export class DriversService { } } + if (dto.faydaSub) { + const dupe = await this.driverRepo.findOne({ + where: { faydaSub: dto.faydaSub }, + }); + if (dupe) { + throw new ConflictException( + 'A driver is already registered for this Fayda identity', + ); + } + } + const driver = this.driverRepo.create(dto); - return this.driverRepo.save(driver); + const saved = await this.driverRepo.save(driver); + + await this.history.record({ + eventType: FleetEventType.DRIVER_REGISTERED, + driverId: saved.id, + label: `${saved.firstName ?? ''} ${saved.lastName ?? ''}`.trim() || null, + toValue: saved.status ?? null, + }); + + return saved; } async findAll(query: { @@ -106,7 +135,25 @@ export class DriversService { } } + if (dto.faydaSub && dto.faydaSub !== driver.faydaSub) { + const dupe = await this.driverRepo.findOne({ + where: { faydaSub: dto.faydaSub }, + }); + if (dupe) { + throw new ConflictException( + 'A driver is already registered for this Fayda identity', + ); + } + } + Object.assign(driver, dto); + + if (driver.faydaVerified !== true) { + throw new BadRequestException( + 'Driver identity must be verified with Fayda before saving', + ); + } + return this.driverRepo.save(driver); } diff --git a/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts b/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts index d8e2aec4a..c2f0bca81 100644 --- a/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts +++ b/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts @@ -1,5 +1,5 @@ -import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator'; -import { DriverStatus } from '../entities/driver.entity'; +import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator'; +import { DriverStatus, DriverGender } from '../entities/driver.entity'; export class CreateDriverDto { @IsString() @@ -20,6 +20,10 @@ export class CreateDriverDto { @IsDateString() dateOfBirth!: string; + @IsOptional() + @IsEnum(DriverGender) + gender?: DriverGender; + @IsDateString() licenseExpiryDate!: string; @@ -42,4 +46,12 @@ export class CreateDriverDto { @IsOptional() @IsString() notes?: string; + + @IsOptional() + @IsBoolean() + faydaVerified?: boolean; + + @IsOptional() + @IsString() + faydaSub?: string; } diff --git a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts index b3defe2db..58a08b849 100644 --- a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts +++ b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts @@ -8,6 +8,12 @@ export enum DriverStatus { ON_LEAVE = 'ON_LEAVE', } +export enum DriverGender { + MALE = 'MALE', + FEMALE = 'FEMALE', + OTHER = 'OTHER', +} + @Entity({ name: 'drivers', schema: 'freight' }) export class Driver extends BaseEntity { @Column({ name: 'license_number', unique: true, nullable: true }) @@ -28,6 +34,9 @@ export class Driver extends BaseEntity { @Column({ name: 'date_of_birth', type: 'date', nullable: true }) dateOfBirth?: Date; + @Column({ type: 'varchar', nullable: true }) + gender?: DriverGender | null; + @Column({ name: 'license_expiry_date', type: 'date', nullable: true }) licenseExpiryDate?: Date; @@ -51,4 +60,12 @@ export class Driver extends BaseEntity { @Column({ type: 'numeric', precision: 3, scale: 2, nullable: true }) rating?: number | null; + + @Column({ name: 'fayda_verified', type: 'boolean', default: false, nullable: true }) + faydaVerified?: boolean; + + /** Fayda OIDC subject the identity was verified against. Unique — one driver + * record per verified Fayda identity (NULLs allowed for legacy/unverified). */ + @Column({ name: 'fayda_sub', type: 'varchar', unique: true, nullable: true }) + faydaSub?: string | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index ec1fb6fa9..a5c641dd7 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -123,6 +123,16 @@ export class FilesService { return this.filesRepository.findByResource(resourceId, resource); } + /** + * Short-lived signed URL for a stored file's raw MinIO URL. The persisted + * `url` is an un-signed object path that a browser cannot fetch directly; + * callers that expose files for preview/download must sign them first. + */ + async signUrl(rawUrl: string, expirySeconds = 300): Promise { + const objectName = this.minioService.getObjectNameFromUrl(rawUrl); + return this.minioService.getSignedUrl(objectName, expirySeconds); + } + async findByCode( resourceId: string, resource: string, diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts deleted file mode 100644 index b750f1147..000000000 --- a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class FirstMileContainerAllocationDto { - containerId!: string; - vehicleId!: string; -} - -export class AllocateFirstMileContainersDto { - allocations!: FirstMileContainerAllocationDto[]; -} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts new file mode 100644 index 000000000..84247708b --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class VehicleDistanceInput { + @IsUUID() + vehicleId!: string; + + @IsNumber() + @Min(0) + distanceKm!: number; +} + +/** Per-vehicle actual distances for a first-mile pickup (multi-truck). */ +export class SetDistancesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VehicleDistanceInput) + distances!: VehicleDistanceInput[]; + + /** Recomputed remaining payment (total km × rate), from the client. */ + @IsOptional() + @IsNumber() + remainingPayment?: number; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts new file mode 100644 index 000000000..8656b2109 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts @@ -0,0 +1,19 @@ +import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class FirstMileVehicleInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsString() + containerNumber?: string; +} + +/** Replace the full set of vehicles (with their container numbers) on a pickup. */ +export class SetVehiclesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FirstMileVehicleInput) + vehicles!: FirstMileVehicleInput[]; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts new file mode 100644 index 000000000..39bf51a50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { FirstMile } from './first-mile.entity'; + +/** + * One row per vehicle assigned to a first-mile pickup. A pickup can be served + * by several vehicles at once (multi-truck bookings); the legacy + * `first_mile.vehicle_id` column keeps pointing at the first assignment for + * backward compatibility. + */ +@Entity({ name: 'first_mile_vehicle_assignments', schema: 'freight' }) +@Unique(['firstMileId', 'vehicleId']) +@Index(['vehicleId']) +export class FirstMileVehicleAssignment extends BaseEntity { + @Column({ name: 'first_mile_id', type: 'uuid' }) + firstMileId!: string; + + @ManyToOne(() => FirstMile, (fm) => fm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'first_mile_id' }) + firstMile?: FirstMile; + + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { nullable: false, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; + + /** Container this truck carries — auto-filled from the booking's container + * number when known, else entered manually at assignment time. */ + @Column({ name: 'container_number', type: 'varchar', nullable: true }) + containerNumber?: string | null; + + /** Actual distance driven by this truck (km), entered per vehicle. */ + @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 27dcfef87..45a051028 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity'; +import { FirstMileVehicleAssignment } from './first-mile-vehicle-assignment.entity'; export const FIRST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -61,4 +62,7 @@ export class FirstMile extends BaseEntity { { eager: false }, ) containerAllocations!: FirstMileContainerAllocation[]; + + @OneToMany(() => FirstMileVehicleAssignment, (va) => va.firstMile) + vehicleAssignments?: FirstMileVehicleAssignment[]; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index 22520aac1..f4935a87b 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -57,7 +57,8 @@ export class FirstMileInvoiceService { return null; } - const totalAmount = record.remainingPayment || 0; + // numeric columns come back as strings — coerce before the finite/>0 check. + const totalAmount = Number(record.remainingPayment) || 0; if (!Number.isFinite(totalAmount) || totalAmount <= 0) { this.logger.warn( `Skipping invoice for first-mile record ${record.id}: no remaining payment.`, @@ -71,7 +72,7 @@ export class FirstMileInvoiceService { type: 'DELIVERY_FEE', companyId: fm.booking!.companyId, companyProfileId: fm.booking!.companyProfileId || '', - currency: 'ETB', + currency: fm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index d5f53ae0c..e43fbcae8 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -17,13 +18,11 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; -import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; +import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDistancesDto } from './dto/set-distances.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; -import { BillingService } from '../billing/billing.service'; -import { BookingsService } from '../bookings/bookings.service'; -import { Freight } from '@edr/types'; @ApiTags('first-mile') @ApiBearerAuth() @@ -33,8 +32,6 @@ export class FirstMileController { constructor( private readonly firstMileService: FirstMileService, private readonly firstMileInvoiceService: FirstMileInvoiceService, - private readonly billingService: BillingService, - private readonly bookingsService: BookingsService ) { } @Get() @@ -89,39 +86,43 @@ export class FirstMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Update a first-mile leg' }) async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { - const record = await this.firstMileService.update(id, dto); - // Auto-generate invoice if distance or payment was updated - const booking = await this.bookingsService.findById(record.bookingId); - if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { - await this.billingService.generateInvoice({ - source: Freight.InvoiceSource.FirstMile, - sourceId: record.id, - type: "FIRST_MILE", - companyId: booking.companyId, - companyProfileId: booking.companyProfileId, - currency: "ETB", + // No invoice side-effects — invoices are generated only via the explicit + // POST :id/invoice endpoint (the "Generate Invoice" action). + return this.firstMileService.update(id, dto); + } - lines: [ - { - chargeType: "FIRST_MILE", - description: "First Mile Transportation Service", - quantity: 1, - unitRate: record.remainingPayment, - amount: record.remainingPayment, - currency: "ETB", - }, - ], - - subtotalAmount: record.remainingPayment, - taxAmount: 0, // Replace if VAT/tax applies - totalAmount: record.remainingPayment, - - dueInDays: 7, - status: Freight.InvoiceStatus.Pending, - }); - await this.firstMileInvoiceService.ensureInvoiceFor(record); + @Post(':id/invoice') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' }) + async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { + const record = await this.firstMileService.findById(id); + const invoice = await this.firstMileInvoiceService.ensureInvoiceFor(record); + if (!invoice) { + throw new BadRequestException( + 'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a FIRST_MILE rate is configured, and the booking has a company.', + ); } - return record; + return invoice; + } + + @Post(':id/vehicles') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' }) + async setVehicles( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetVehiclesDto, + ) { + return this.firstMileService.setVehicles(id, dto.vehicles); + } + + @Post(':id/distances') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' }) + async setDistances( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDistancesDto, + ) { + return this.firstMileService.setDistances(id, dto.distances, dto.remainingPayment); } @Delete(':id') @@ -131,14 +132,4 @@ export class FirstMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.firstMileService.remove(id); } - - @Post(':firstMileId/allocate-containers') - @TrainSchedulingManage() - @ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' }) - allocateContainers( - @Param('firstMileId', ParseUUIDPipe) firstMileId: string, - @Body() dto: AllocateFirstMileContainersDto, - ) { - return this.firstMileService.allocateContainers(firstMileId, dto.allocations); - } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index 799ae14e6..51f5ccf4e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; +import { FirstMileVehicleAssignment } from './entities/first-mile-vehicle-assignment.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileRepository } from './first-mile.repository'; @@ -15,7 +16,7 @@ import { FirstMileService } from './first-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), + TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation, FirstMileVehicleAssignment]), forwardRef(() => BillingModule), forwardRef(() => BookingsModule), VehiclesModule, diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 40d163220..5c12d94ee 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, In } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -11,9 +11,12 @@ import { CreateFirstMileDto } from "./dto/create-first-mile.dto"; import { UpdateFirstMileDto } from "./dto/update-first-mile.dto"; import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; +import { FirstMileVehicleAssignment } from "./entities/first-mile-vehicle-assignment.entity"; import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; -import { InvoiceEventPayload } from "../billing/billing.service"; +import { BillingService, InvoiceEventPayload } from "../billing/billing.service"; +import { FleetHistoryService } from "../fleet-history/fleet-history.service"; +import { FleetEventType } from "../fleet-history/entities/fleet-event.entity"; type FirstMileListFilter = { status?: FirstMileStatus; @@ -43,8 +46,78 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, + private readonly history: FleetHistoryService, + private readonly billing: BillingService, ) { } + /** Attach real invoice info so the UI shows an invoice link only when one + * exists — not merely because distance was entered. Batched (no N+1). */ + private async attachInvoices(records: FirstMile[]): Promise { + const invoices = await this.billing.findBySourceIds( + 'first_mile', + records.map((r) => r.id), + ); + const byId = new Map(); + for (const inv of invoices) { + if (!byId.has(inv.sourceId)) { + byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) }); + } + } + for (const r of records) { + (r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; + } + } + + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; + try { + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; + } catch { + return { driverId: null, plate: null, driverName: null }; + } + } + + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const [junction, allocations] = await Promise.all([ + this.dataSource.manager.count(FirstMileVehicleAssignment, { + where: { firstMileId: recordId }, + }), + this.dataSource.manager.count(FirstMileContainerAllocation, { + where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, + }), + ]); + return junction > 0 || allocations > 0; + } + + /** Human booking reference for a first-mile record, for the history timeline. */ + private async resolveBookingRef(record: FirstMile): Promise { + const loaded = (record as FirstMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const b = await this.bookingsRepository.findById(record.bookingId); + return (b as { reference?: string } | null)?.reference ?? null; + } catch { + return null; + } + } + /** * Look up a booking by its human-readable reference and confirm it has been * paid before any first-mile work proceeds. Throws if the reference is @@ -135,14 +208,18 @@ export class FirstMileService { originYard: true, destinationYard: true, cargoType: true, + bookingContainers: { containerType: true, units: true }, }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, }); + await this.attachInvoices(data); + return { data, meta: { @@ -179,8 +256,10 @@ export class FirstMileService { originYard: true, destinationYard: true, cargoType: true, + bookingContainers: { containerType: true, units: true }, }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, }); @@ -188,6 +267,8 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + await this.attachInvoices([record]); + return record; } @@ -210,6 +291,20 @@ export class FirstMileService { if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: record.id, + driverId: info.driverId, + label: record.status, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); } return record; @@ -248,6 +343,18 @@ export class FirstMileService { async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -278,6 +385,39 @@ export class FirstMileService { if (existing.vehicleId) { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); + if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: id, + driverId: info.driverId, + label: updated.status, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } } // Notify assigned driver on every explicit vehicle assignment or reassignment @@ -285,6 +425,25 @@ export class FirstMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: info.driverId, + fromValue: existing.status, + toValue: dto.status, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + // Trip finished — release the vehicles it was holding if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); @@ -295,12 +454,40 @@ export class FirstMileService { async updateStatus(id: string, status: FirstMileStatus): Promise { const existing = await this.findById(id); + + if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); } + if (status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: info.driverId, + fromValue: existing.status, + toValue: status, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); } @@ -313,18 +500,154 @@ export class FirstMileService { * allocations), unless still in use by another active trip. */ private async releaseVehicles(record: FirstMile): Promise { - const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { - where: { firstMileId: record.id }, - }); - const vehicleIds = recordAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - if (record.vehicleId) { - vehicleIds.push(record.vehicleId); - } + const [assignments, recordAllocations] = await Promise.all([ + this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: record.id }, + }), + this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: record.id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...recordAllocations.map((a) => a.vehicleId), + record.vehicleId ?? null, + ].filter((id): id is string => Boolean(id)), + ), + ]; await this.vehiclesService.releaseIfUnused(vehicleIds); } + /** + * Replace the full set of vehicles serving a first-mile pickup (multi-truck). + * Diffs against the current junction rows, syncing availability + audit history + * for each added/removed vehicle. The first vehicle is mirrored onto the legacy + * `vehicleId` column for back-compat with single-vehicle readers. + */ + async setVehicles( + id: string, + inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + ): Promise { + const existing = await this.findById(id); + // Dedupe by vehicleId, keeping the container number; preserve order. + const desiredMap = new Map(); + for (const inp of inputs) { + if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + } + const desired = [...desiredMap.keys()]; + const desiredSet = new Set(desired); + + const manager = this.dataSource.manager; + const current = await manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + }); + const junctionSet = new Set(current.map((a) => a.vehicleId)); + // Fold the legacy vehicleId into the release set — a vehicle assigned via the + // old single-vehicle path has no junction row but must still be freed. + const releaseIds = [...new Set( + current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []), + )]; + const added = desired.filter((v) => !junctionSet.has(v)); + const removed = releaseIds.filter((v) => !desiredSet.has(v)); + // Vehicles that stay but whose container number changed. + const changed = current.filter( + (a) => + desiredMap.has(a.vehicleId) && + (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), + ); + + await this.dataSource.transaction(async (tx) => { + if (removed.length) { + await tx.delete(FirstMileVehicleAssignment, { + firstMileId: id, + vehicleId: In(removed), + }); + } + for (const vehicleId of added) { + await tx.insert(FirstMileVehicleAssignment, { + firstMileId: id, + vehicleId, + containerNumber: desiredMap.get(vehicleId) ?? null, + }); + } + for (const row of changed) { + await tx.update( + FirstMileVehicleAssignment, + { firstMileId: id, vehicleId: row.vehicleId }, + { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + ); + } + }); + + // Legacy primary vehicle = first of the set (null when cleared). + await this.firstMileRepository.update(id, { vehicleId: desired[0] ?? null } as any); + + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of added) { + await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY); + void this.notifyDriverAssignment(vehicleId, existing); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + label: existing.status, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of removed) { + await this.vehiclesService.releaseIfUnused([vehicleId]); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + + return this.findById(id); + } + + /** + * Record each truck's actual distance. The pickup total (exact_km) is their + * sum and drives billing; `remainingPayment` (total km × rate) is recomputed + * client-side. Does NOT generate an invoice — that's a separate explicit step. + */ + async setDistances( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ): Promise { + await this.findById(id); + + // Distances are locked once the invoice exists. + const invoices = await this.billing.findBySourceIds('first_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Distances cannot be changed after the invoice is generated', + ); + } + + for (const d of distances) { + await this.dataSource.manager.update( + FirstMileVehicleAssignment, + { firstMileId: id, vehicleId: d.vehicleId }, + { distanceKm: d.distanceKm }, + ); + } + const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + await this.firstMileRepository.update(id, { + exactKm: total, + ...(remainingPayment != null ? { remainingPayment } : {}), + } as any); + return this.findById(id); + } + private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -383,56 +706,54 @@ export class FirstMileService { } async remove(id: string): Promise { - await this.findById(id); - await this.firstMileRepository.softDelete(id); - } + const existing = await this.findById(id); - async allocateContainers( - firstMileId: string, - allocations: Array<{ containerId: string; vehicleId: string }>, - ) { - const firstMile = await this.findById(firstMileId); - if (!firstMile) { - throw new NotFoundException(`First-mile record ${firstMileId} not found`); + // Can't delete once billed. + const invoices = await this.billing.findBySourceIds('first_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Cannot delete a first-mile leg after its invoice is generated', + ); } - const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { - where: { - firstMileId, - containerId: In(allocations.map((a) => a.containerId)), - }, - }); - const previousVehicleIds = previousAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); + // Every vehicle this pickup holds — junction + legacy + container rows. + const [assignments, allocations] = await Promise.all([ + this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + }), + this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...allocations.map((a) => a.vehicleId), + existing.vehicleId ?? null, + ].filter((v): v is string => Boolean(v)), + ), + ]; - await this.dataSource.transaction(async (manager) => { - for (const allocation of allocations) { - await manager.delete(FirstMileContainerAllocation, { - firstMileId, - containerId: allocation.containerId, - }); - await manager.insert(FirstMileContainerAllocation, { - firstMileId, - containerId: allocation.containerId, - vehicleId: allocation.vehicleId, - containerType: "CONTAINER", - quantity: 1, + await this.firstMileRepository.softDelete(id); + if (assignments.length) { + await this.dataSource.manager.softDelete(FirstMileVehicleAssignment, { firstMileId: id }); + } + + // Free every vehicle no longer held by another active trip and audit release. + if (vehicleIds.length) { + await this.vehiclesService.releaseIfUnused(vehicleIds); + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of vehicleIds) { + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } - }); - - const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); - await Promise.all( - [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)), - ); - await this.vehiclesService.releaseIfUnused( - previousVehicleIds.filter((id) => !vehicleIds.has(id)), - ); - - return { - success: true, - allocated: allocations.length, - }; + } } } diff --git a/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts new file mode 100644 index 000000000..5096adbc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts @@ -0,0 +1,55 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * Append-only audit log for fleet activity. One row per transition. Queried by + * `vehicleId` (vehicle timeline) or `driverId` (driver timeline); an event may + * carry both so a driver↔vehicle assignment or a mile assignment shows on both. + * `createdAt` (from BaseEntity) is the event time. + */ +export enum FleetEventType { + DRIVER_REGISTERED = 'DRIVER_REGISTERED', + VEHICLE_REGISTERED = 'VEHICLE_REGISTERED', + DRIVER_ASSIGNED = 'DRIVER_ASSIGNED', + DRIVER_UNASSIGNED = 'DRIVER_UNASSIGNED', + VEHICLE_STATUS_CHANGED = 'VEHICLE_STATUS_CHANGED', + VEHICLE_AVAILABILITY_CHANGED = 'VEHICLE_AVAILABILITY_CHANGED', + MILE_VEHICLE_ASSIGNED = 'MILE_VEHICLE_ASSIGNED', + MILE_VEHICLE_RELEASED = 'MILE_VEHICLE_RELEASED', + MILE_STATUS_CHANGED = 'MILE_STATUS_CHANGED', +} + +@Entity({ name: 'fleet_events', schema: 'freight' }) +export class FleetEvent extends BaseEntity { + @Column({ name: 'event_type', type: 'varchar' }) + eventType!: FleetEventType; + + @Index() + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @Index() + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string | null; + + @Column({ name: 'first_mile_id', type: 'uuid', nullable: true }) + firstMileId?: string | null; + + @Column({ name: 'last_mile_id', type: 'uuid', nullable: true }) + lastMileId?: string | null; + + /** Previous value for a transition (e.g. old status/availability). */ + @Column({ name: 'from_value', type: 'varchar', nullable: true }) + fromValue?: string | null; + + /** New value for a transition (e.g. new status/availability). */ + @Column({ name: 'to_value', type: 'varchar', nullable: true }) + toValue?: string | null; + + /** Human-readable summary token (driver name, plate, booking ref, mile). */ + @Column({ name: 'label', type: 'varchar', nullable: true }) + label?: string | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts new file mode 100644 index 000000000..14828e0a9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FleetEvent } from './entities/fleet-event.entity'; +import { FleetHistoryService } from './fleet-history.service'; + +/** + * Global so any fleet-touching service (vehicles, drivers, first/last-mile) can + * inject FleetHistoryService to append audit events without each module having + * to import this one. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([FleetEvent])], + providers: [FleetHistoryService], + exports: [FleetHistoryService], +}) +export class FleetHistoryModule {} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts new file mode 100644 index 000000000..9c61119b9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FleetEvent, FleetEventType } from './entities/fleet-event.entity'; + +export interface FleetEventInput { + eventType: FleetEventType; + vehicleId?: string | null; + driverId?: string | null; + firstMileId?: string | null; + lastMileId?: string | null; + fromValue?: string | null; + toValue?: string | null; + label?: string | null; + metadata?: Record | null; +} + +@Injectable() +export class FleetHistoryService { + private readonly logger = new Logger(FleetHistoryService.name); + + constructor( + @InjectRepository(FleetEvent) + private readonly eventRepo: Repository, + ) {} + + /** + * Append an audit event. Best-effort: recording history must never break the + * business operation that triggered it, so failures are logged and swallowed. + */ + async record(input: FleetEventInput): Promise { + try { + await this.eventRepo.save(this.eventRepo.create(input)); + } catch (err) { + this.logger.error( + `Failed to record fleet event ${input.eventType}: ${String(err)}`, + ); + } + } + + getVehicleHistory(vehicleId: string): Promise { + return this.eventRepo.find({ + where: { vehicleId }, + order: { createdAt: 'DESC' }, + }); + } + + getDriverHistory(driverId: string): Promise { + return this.eventRepo.find({ + where: { driverId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts deleted file mode 100644 index de86ac883..000000000 --- a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class LastMileContainerAllocationDto { - containerId!: string; - vehicleId!: string; -} - -export class AllocateLastMileContainersDto { - allocations!: LastMileContainerAllocationDto[]; -} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts new file mode 100644 index 000000000..3b8b26bfe --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class VehicleDistanceInput { + @IsUUID() + vehicleId!: string; + + @IsNumber() + @Min(0) + distanceKm!: number; +} + +/** Per-vehicle actual distances for a last-mile delivery (multi-truck). */ +export class SetDistancesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VehicleDistanceInput) + distances!: VehicleDistanceInput[]; + + /** Recomputed remaining payment (total km × rate), from the client. */ + @IsOptional() + @IsNumber() + remainingPayment?: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts new file mode 100644 index 000000000..e07eec0b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts @@ -0,0 +1,19 @@ +import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class LastMileVehicleInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsString() + containerNumber?: string; +} + +/** Replace the full set of vehicles (with their container numbers) on a delivery. */ +export class SetVehiclesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LastMileVehicleInput) + vehicles!: LastMileVehicleInput[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts index 8a61c73bf..187d9aea1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts @@ -24,7 +24,7 @@ export class LastMileContainerAllocation extends BaseEntity { @Column('uuid', { name: 'vehicle_id', nullable: true }) vehicleId?: string | null; - @Column('text') + @Column('text', { name: 'container_type' }) containerType!: string; @Column('integer', { default: 1 }) diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts new file mode 100644 index 000000000..eee414275 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { LastMile } from './last-mile.entity'; + +/** + * One row per vehicle assigned to a last-mile delivery. A delivery can be + * served by several vehicles at once (multi-truck bookings); the legacy + * `last_mile.vehicle_id` column keeps pointing at the first assignment for + * backward compatibility. + */ +@Entity({ name: 'last_mile_vehicle_assignments', schema: 'freight' }) +@Unique(['lastMileId', 'vehicleId']) +@Index(['vehicleId']) +export class LastMileVehicleAssignment extends BaseEntity { + @Column({ name: 'last_mile_id', type: 'uuid' }) + lastMileId!: string; + + @ManyToOne(() => LastMile, (lm) => lm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'last_mile_id' }) + lastMile?: LastMile; + + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { nullable: false, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; + + /** Container this truck carries — auto-filled from the booking's container + * number when known, else entered manually at assignment time. */ + @Column({ name: 'container_number', type: 'varchar', nullable: true }) + containerNumber?: string | null; + + /** Actual distance driven by this truck (km), entered per vehicle. */ + @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 85d01b7f0..1f8bda8fc 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { LastMileContainerAllocation } from './last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity'; export const LAST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -57,4 +58,7 @@ export class LastMile extends BaseEntity { @OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile) containerAllocations?: LastMileContainerAllocation[]; + + @OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile) + vehicleAssignments?: LastMileVehicleAssignment[]; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index c304a89e8..7b14f6887 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -54,6 +54,15 @@ export class LastMileInvoiceService { return null; } + // numeric columns come back as strings — coerce before billing. + const totalAmount = Number(record.remainingPayment) || 0; + if (!Number.isFinite(totalAmount) || totalAmount <= 0) { + this.logger.warn( + `Skipping invoice for last-mile record ${record.id}: no remaining payment.`, + ); + return null; + } + // Generate invoice with remainingPayment as totalAmount const input: GenerateInvoiceInput = { source: 'last_mile' as Freight.InvoiceSource, @@ -61,17 +70,17 @@ export class LastMileInvoiceService { type: 'DELIVERY_FEE', companyId: lm.booking!.companyId, companyProfileId: lm.booking!.companyProfileId || '', - currency: 'ETB', + currency: lm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', description: 'Last-mile delivery', quantity: 1, - unitRate: record.remainingPayment || 0, - amount: record.remainingPayment || 0, + unitRate: totalAmount, + amount: totalAmount, }, ], - totalAmount: record.remainingPayment || 0, + totalAmount, }; return this.billing.generateInvoice(input); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 88a96e6c2..2931a1f85 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -17,13 +18,11 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; -import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; +import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDistancesDto } from './dto/set-distances.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; import { LastMileInvoiceService } from './last-mile-invoice.service'; -import { Freight } from '@edr/types'; -import { BillingService } from '../billing/billing.service'; -import { BookingsService } from '../bookings/bookings.service'; @ApiTags('last-mile') @ApiBearerAuth() @@ -33,8 +32,6 @@ export class LastMileController { constructor( private readonly lastMileService: LastMileService, private readonly lastMileInvoiceService: LastMileInvoiceService, - private readonly billingService: BillingService, - private readonly bookingsService: BookingsService ) {} @Get() @@ -83,39 +80,9 @@ export class LastMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Update a last-mile leg' }) async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { - const record = await this.lastMileService.update(id, dto); - // Auto-generate invoice if distance or payment was updated - const booking = await this.bookingsService.findById(record.bookingId); - if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { - await this.billingService.generateInvoice({ - source: Freight.InvoiceSource.LastMile, - sourceId: record.id, - type: "LAST_MILE", - companyId: booking.companyId, - companyProfileId: booking.companyProfileId, - currency: "ETB", - - lines: [ - { - chargeType: "LAST_MILE", - description: "Last Mile Transportation Service", - quantity: 1, - unitRate: record.remainingPayment, - amount: record.remainingPayment, - currency: "ETB", - }, - ], - - subtotalAmount: record.remainingPayment, - taxAmount: 0, // Replace if VAT/tax applies - totalAmount: record.remainingPayment, - - dueInDays: 7, - status: Freight.InvoiceStatus.Pending, - }); - await this.lastMileInvoiceService.ensureInvoiceFor(record); - } - return record; + // No invoice side-effects here — invoices are generated only via the + // explicit POST :id/invoice endpoint (the "Generate Invoice" action). + return this.lastMileService.update(id, dto); } @Delete(':id') @@ -126,13 +93,38 @@ export class LastMileController { return this.lastMileService.remove(id); } - @Post(':id/allocate-containers') + + @Post(':id/vehicles') @TrainSchedulingManage() - @ApiOperation({ summary: 'Allocate containers to vehicles' }) - async allocateContainers( + @ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' }) + async setVehicles( @Param('id', ParseUUIDPipe) id: string, - @Body() dto: AllocateLastMileContainersDto, + @Body() dto: SetVehiclesDto, ) { - return this.lastMileService.allocateContainers(id, dto.allocations); + return this.lastMileService.setVehicles(id, dto.vehicles); + } + + @Post(':id/distances') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' }) + async setDistances( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDistancesDto, + ) { + return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); + } + + @Post(':id/invoice') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' }) + async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { + const record = await this.lastMileService.findById(id); + const invoice = await this.lastMileInvoiceService.ensureInvoiceFor(record); + if (!invoice) { + throw new BadRequestException( + 'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a LAST_MILE rate is configured, and the booking has a company.', + ); + } + return invoice; } } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index 32b688069..e639e4dfd 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileRepository } from './last-mile.repository'; @@ -15,7 +16,7 @@ import { LastMileService } from './last-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), + TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]), BillingModule, forwardRef(() => BookingsModule), VehiclesModule, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 69eec29ae..22f25a6aa 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,17 +1,21 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; import { LastMileRepository } from './last-mile.repository'; -import { InvoiceEventPayload } from '../billing/billing.service'; +import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; import { OnEvent } from '@nestjs/event-emitter'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; type LastMileListFilter = { status?: LastMileStatus; @@ -41,8 +45,77 @@ export class LastMileService { private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly dataSource: DataSource, + private readonly history: FleetHistoryService, + private readonly billing: BillingService, ) {} + /** Attach real invoice info (number/status) to records so the UI can show an + * invoice link only when one actually exists — NOT merely because distance + * was entered. Batched to avoid N+1. */ + private async attachInvoices(records: LastMile[]): Promise { + const invoices = await this.billing.findBySourceIds( + 'last_mile', + records.map((r) => r.id), + ); + const byId = new Map(); + for (const inv of invoices) { + if (!byId.has(inv.sourceId)) { + byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) }); + } + } + for (const r of records) { + (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; + } + } + + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; + try { + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; + } catch { + return { driverId: null, plate: null, driverName: null }; + } + } + + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(LastMileContainerAllocation, { + where: { lastMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + + /** Human booking reference for a last-mile record, for the history timeline. + * Uses the already-loaded relation when present, else looks it up. */ + private async resolveBookingRef( + record: LastMile, + ): Promise { + const loaded = (record as LastMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const booking = await this.bookingsRepository.findById(record.bookingId); + return (booking as { reference?: string } | null)?.reference ?? null; + } catch { + return null; + } + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -97,14 +170,17 @@ export class LastMileService { const [data, total] = await this.lastMileRepository.findAndCount({ where, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, }); + await this.attachInvoices(data); + return { data, meta: { @@ -119,8 +195,9 @@ export class LastMileService { async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, }); @@ -128,11 +205,13 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${id} not found`); } + await this.attachInvoices([record]); + return record; } async create(dto: CreateLastMileDto): Promise { - return this.lastMileRepository.create({ + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, @@ -142,16 +221,38 @@ export class LastMileService { vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, }); + + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: record.id, + driverId: info.driverId, + label: record.status, + metadata: { + mile: 'LAST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + + return record; } - @OnEvent("lastmile.invoice.paid") + @OnEvent("last_mile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { - await this.lastMileRepository.update(payload.sourceId, { paid: true } as any); - this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + // Invoice paid → the delivery is complete. Route through update() so it + // also frees the trucks + records history (same as "Mark Delivered"). + await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto); + this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`); } catch (err) { this.logger.error( - `Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`, + `Failed to deliver last-mile ${payload.sourceId} on payment: ${String(err)}`, ); } } @@ -159,6 +260,18 @@ export class LastMileService { async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this last-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -180,9 +293,231 @@ export class LastMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); + if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + // Keep vehicle availability in sync: new vehicle goes BUSY, replaced one + // is freed if no other active trip still holds it. + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + if (existing.vehicleId) { + await this.vehiclesService.releaseIfUnused([existing.vehicleId]); + } + if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + lastMileId: id, + driverId: info.driverId, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: id, + driverId: info.driverId, + label: updated.status, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + } + + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + lastMileId: id, + vehicleId, + driverId: info.driverId, + fromValue: existing.status, + toValue: dto.status, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + + // Delivery finished — free the vehicles this trip was holding. + if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') { + await this.releaseVehicles(updated); + } + return updated; } + /** + * Free every vehicle held by this record — junction assignments, the legacy + * direct vehicle, and container allocations — unless still used by another + * active trip. + */ + private async releaseVehicles(record: LastMile): Promise { + const [assignments, recordAllocations] = await Promise.all([ + this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: record.id }, + }), + this.dataSource.manager.find(LastMileContainerAllocation, { + where: { lastMileId: record.id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...recordAllocations.map((a) => a.vehicleId), + record.vehicleId ?? null, + ].filter((id): id is string => Boolean(id)), + ), + ]; + await this.vehiclesService.releaseIfUnused(vehicleIds); + } + + /** + * Replace the full set of vehicles serving a last-mile delivery (multi-truck). + * Diffs against the current junction rows, syncing availability + audit history + * for each added/removed vehicle. The first vehicle is mirrored onto the legacy + * `vehicleId` column for back-compat with single-vehicle readers. + */ + async setVehicles( + id: string, + inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + ): Promise { + const existing = await this.findById(id); + // Dedupe by vehicleId, keeping the container number; preserve order. + const desiredMap = new Map(); + for (const inp of inputs) { + if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + } + const desired = [...desiredMap.keys()]; + const desiredSet = new Set(desired); + + const manager = this.dataSource.manager; + const current = await manager.find(LastMileVehicleAssignment, { + where: { lastMileId: id }, + }); + const junctionSet = new Set(current.map((a) => a.vehicleId)); + // Fold the legacy vehicleId into the release set — a vehicle assigned via the + // old single-vehicle path has no junction row but must still be freed. + const releaseIds = [...new Set( + current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []), + )]; + const added = desired.filter((v) => !junctionSet.has(v)); + const removed = releaseIds.filter((v) => !desiredSet.has(v)); + // Vehicles that stay but whose container number changed. + const changed = current.filter( + (a) => + desiredMap.has(a.vehicleId) && + (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), + ); + + await this.dataSource.transaction(async (tx) => { + if (removed.length) { + await tx.delete(LastMileVehicleAssignment, { + lastMileId: id, + vehicleId: In(removed), + }); + } + for (const vehicleId of added) { + await tx.insert(LastMileVehicleAssignment, { + lastMileId: id, + vehicleId, + containerNumber: desiredMap.get(vehicleId) ?? null, + }); + } + for (const row of changed) { + await tx.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: row.vehicleId }, + { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + ); + } + }); + + // Legacy primary vehicle = first of the set (null when cleared). + await this.lastMileRepository.update(id, { vehicleId: desired[0] ?? null } as any); + + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of added) { + await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY); + void this.notifyDriverAssignment(vehicleId, existing); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + lastMileId: id, + driverId: info.driverId, + label: existing.status, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of removed) { + await this.vehiclesService.releaseIfUnused([vehicleId]); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId: id, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + + return this.findById(id); + } + + /** + * Record each truck's actual distance. The delivery total (exact_km) is their + * sum and drives billing; `remainingPayment` (total km × rate) is recomputed + * client-side. Does NOT generate an invoice — that's a separate explicit step. + */ + async setDistances( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ): Promise { + await this.findById(id); + + // Distances are locked once the invoice exists. + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Distances cannot be changed after the invoice is generated', + ); + } + + for (const d of distances) { + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: d.vehicleId }, + { distanceKm: d.distanceKm }, + ); + } + const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + await this.lastMileRepository.update(id, { + exactKm: total, + ...(remainingPayment != null ? { remainingPayment } : {}), + } as any); + return this.findById(id); + } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -223,38 +558,54 @@ export class LastMileService { } async remove(id: string): Promise { - await this.findById(id); - await this.lastMileRepository.softDelete(id); - } + const existing = await this.findById(id); - async allocateContainers( - lastMileId: string, - allocations: Array<{ containerId: string; vehicleId: string }>, - ) { - const lastMile = await this.findById(lastMileId); - if (!lastMile) { - throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + // Can't delete once billed. + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Cannot delete a last-mile delivery after its invoice is generated', + ); } - await this.dataSource.transaction(async (manager) => { - for (const allocation of allocations) { - await manager.delete(LastMileContainerAllocation, { - lastMileId, - containerId: allocation.containerId, - }); - await manager.insert(LastMileContainerAllocation, { - lastMileId, - containerId: allocation.containerId, - vehicleId: allocation.vehicleId, - containerType: 'CONTAINER', - quantity: 1, + // Every vehicle this delivery holds — junction + legacy + container rows. + const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: id }, + }); + const allocations = await this.dataSource.manager.find(LastMileContainerAllocation, { + where: { lastMileId: id }, + }); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...allocations.map((a) => a.vehicleId), + existing.vehicleId ?? null, + ].filter((v): v is string => Boolean(v)), + ), + ]; + + await this.lastMileRepository.softDelete(id); + if (assignments.length) { + await this.dataSource.manager.softDelete(LastMileVehicleAssignment, { lastMileId: id }); + } + + // Free every vehicle no longer held by another active trip (releaseIfUnused + // ignores this now soft-deleted record) and audit the release. + if (vehicleIds.length) { + await this.vehiclesService.releaseIfUnused(vehicleIds); + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of vehicleIds) { + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId: id, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } - }); - - return { - success: true, - allocated: allocations.length, - }; + } } + } diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 4e56c8b70..16d598eb0 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -8,6 +8,11 @@ import { EmailClientService } from "./email-client.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; +// Fall back to a sane broker URL so an unset RABBITMQ_URL can't produce +// `urls: [undefined]` (which crashes amqp-connection-manager on 'heartbeat'). +const RABBITMQ_URL = + process.env.RABBITMQ_URL ?? process.env.PAYMENT_RABBITMQ_URL ?? "amqp://localhost:5672"; + @Module({ imports: [ ConfigModule, @@ -16,7 +21,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" name: "SMS_SERVICE", transport: Transport.RMQ, options: { - urls: [process.env.RABBITMQ_URL as string], + urls: [RABBITMQ_URL], queue: process.env.SMS_QUEUE ?? "sms_queue", queueOptions: { durable: true }, }, @@ -25,7 +30,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" name: "EMAIL_SERVICE", transport: Transport.RMQ, options: { - urls: [process.env.RABBITMQ_URL as string], + urls: [RABBITMQ_URL], queue: process.env.EMAIL_QUEUE ?? "email_queue", queueOptions: { durable: true }, }, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index f30ebd501..76db7fa78 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -21,6 +21,14 @@ export class CreateCargoTypeDto { @IsUUID() parentGroupId?: string; + @ApiPropertyOptional({ + description: + 'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.', + }) + @IsOptional() + @IsUUID('4') + wagonTypeId?: string | null; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index 52cfe274b..e0baf7251 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -30,6 +30,14 @@ export class CreateContainerTypeDto { @IsBoolean() isOpenTop?: boolean; + @ApiPropertyOptional({ + description: + 'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.', + }) + @IsOptional() + @IsUUID('4') + wagonTypeId?: string | null; + @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 995718135..9a4cd642b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; import { RATE_APPLIES_TO, RATE_TRIGGERS, @@ -51,15 +51,6 @@ export class CreateRateDto { @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) @IsIn([...RATE_UNITS]) rateUnit!: string; - - @ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index 6be37214b..d60a56944 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; @@ -22,12 +22,14 @@ export class CreateWeightLimitRuleDto { @Transform(({ value }) => Number(value)) maxVgmTons!: number; - @ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' }) + @ApiPropertyOptional({ + description: + 'Hard per-unit weight ceiling in tons — above this the booking cannot be created. Null/omitted = no ceiling.', + minimum: 0, + }) @IsOptional() - @IsDateString() - effectiveTo?: string; + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? null : Number(value))) + maxCapacityTons?: number | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index c8ac35f25..ac8a2ea24 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -1,11 +1,13 @@ import { BaseEntity } from '@edr/api-common'; import { CargoUnitOfMeasure } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ schema: 'freight', name: 'cargo_types' }) @Index(['isActive']) @Index(['displayOrder']) @Index(['parentGroupId']) +@Index(['wagonTypeId']) @Index(['code']) export class CargoType extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) @@ -25,6 +27,19 @@ export class CargoType extends BaseEntity { @Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true }) unitOfMeasure?: CargoUnitOfMeasure | null; + /** + * Wagon type that carries this (bulk) cargo. Replaces the former hardcoded + * cargo-code → wagon-code map: train scheduling resolves the bulk wagon type + * through this FK. Nullable — grouping rows and container/legacy cargo never + * carry it; scheduling throws if a scheduled bulk cargo type leaves it unset. + */ + @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true }) + wagonTypeId?: string | null; + + @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index e03078c19..f7cbeed99 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -1,10 +1,12 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { WeightLimitRule } from './weight-limit-rule.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ schema: 'freight', name: 'container_types' }) @Index(['code']) @Index(['isActive']) +@Index(['wagonTypeId']) export class ContainerType extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) code!: string; @@ -24,6 +26,19 @@ export class ContainerType extends BaseEntity { @Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true }) isOpenTop!: boolean; + /** + * Wagon type that carries this container. Replaces the former hardcoded + * container wagon-code default (NW5): train scheduling resolves the container + * wagon type through this FK. Nullable; scheduling throws if a scheduled + * container type leaves it unset. + */ + @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true }) + wagonTypeId?: string | null; + + @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType | null; + @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts new file mode 100644 index 000000000..cef613412 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -0,0 +1,71 @@ +import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; + +/** + * Which rate units make sense for a given rate shape. The weighting basis is + * driven by the *type* of thing being billed — a container leg bills per + * container, bulk freight per ton, an intercity move can be per-km, a + * cancellation is a flat/per-invoice fee, and overweight is always per excess + * ton. This keeps the rate table dynamic yet non-conflicting: the admin can + * only pick a unit the pricing engine knows how to apply. + * + * Returned lists are ordered with the most natural/default unit first. + */ +export function allowedRateUnits(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; +}): RateUnit[] { + const { appliesTo, trigger } = input; + + // Surcharges (Applies to = Other) are governed by their trigger. + if (appliesTo === 'OTHER') { + switch (trigger) { + case 'OVERWEIGHT': + // Overweight always bills the excess tonnage — per ton, nothing else. + return ['PER_TON']; + case 'REEFER': + case 'HAZARDOUS': + // Scale with the freight shape: per container for boxes, per ton for bulk. + return ['PER_CONTAINER', 'PER_TON']; + case 'DEMURRAGE': + return ['PER_CONTAINER', 'PER_TON']; + case 'CANCELLATION': + return ['FLAT', 'PER_INVOICE']; + case 'CONSOLIDATION': + return ['PER_CONTAINER', 'FLAT']; + case 'SHIPPING_LINE': + case 'PIL_EXTRA_FEE': + return ['PER_CONTAINER', 'FLAT']; + default: + return ['FLAT', 'PER_TON', 'PER_CONTAINER']; + } + } + + // Base freight + first/last mile scale with the cargo type. + switch (appliesTo) { + case 'CONTAINER': + return ['PER_CONTAINER', 'PER_WAGON']; + case 'BULK': + return ['PER_TON', 'PER_WAGON']; + case 'INTERCITY': + return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM']; + case 'FIRST_MILE': + case 'LAST_MILE': + return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT']; + default: + return ['FLAT']; + } +} + +/** The default (first / most natural) unit for a rate shape. */ +export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit { + return allowedRateUnits(input)[0]; +} + +/** True when `unit` is a valid weighting basis for the given rate shape. */ +export function isRateUnitAllowed(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; + unit: RateUnit; +}): boolean { + return allowedRateUnits(input).includes(input.unit); +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index b57b48cd8..50f8b3b99 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -81,7 +81,6 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; @Entity({ schema: 'freight', name: 'rates' }) @Index(['rateType']) @Index(['status']) -@Index(['effectiveFrom']) @Index(['containerTypeId']) @Index(['trigger']) export class Rate extends BaseEntity { @@ -131,10 +130,4 @@ export class Rate extends BaseEntity { @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) approvedAt?: Date | null; - - @Column({ name: 'effective_from', type: 'date' }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts index 39557eec9..7a30cc20d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -5,7 +5,6 @@ import { ContainerType } from './container-type.entity'; @Entity({ schema: 'freight', name: 'weight_limit_rules' }) @Index(['containerTypeId']) @Index(['tradeDirection']) -@Index(['effectiveFrom']) export class WeightLimitRule extends BaseEntity { @Column({ name: 'container_type_id', type: 'uuid' }) containerTypeId!: string; @@ -20,9 +19,11 @@ export class WeightLimitRule extends BaseEntity { @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) maxVgmTons!: number; - @Column({ name: 'effective_from', type: 'date', nullable: true }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; + /** + * Absolute per-unit weight ceiling in tons. Weight above maxVgmTons but at or + * below this is "overweight" (surcharge + warning); weight above this hard- + * blocks booking creation entirely. Null = no ceiling (overweight only). + */ + @Column({ name: 'max_capacity_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) + maxCapacityTons!: number | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 52b991155..96db214c4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -4,6 +4,13 @@ import { Rate } from '../entities/rate.entity'; export interface IRatesRepository { findById(id: string): Promise; findLiveRates(): Promise; + findByPattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts index cedbd1eee..3c175df4e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise; + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts index 496c2ce7b..5fba70fe1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -33,7 +33,7 @@ export class CargoTypesRepository implements ICargoTypesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts index fe0a8f41e..0e4fb2716 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts @@ -33,7 +33,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 0d49a0bf3..a7b8e69ab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -16,15 +16,50 @@ export class RatesRepository implements IRatesRepository { } findLiveRates(): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rate') .where('rate.status = :status', { status: 'LIVE' }) - .andWhere('rate.effective_from <= :now', { now }) - .andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now }) .getMany(); } + /** + * Find a non-superseded rate matching an identity pattern — the same tuple the + * `UQ_rates_pattern` unique index enforces. Used to reject duplicates before + * insert so the admin gets a friendly error instead of a raw constraint fault. + * NULL scope columns are matched with IS NULL, mirroring the COALESCE index. + */ + findByPattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise { + const qb = this.repo + .createQueryBuilder('rate') + .where('rate.rate_type = :rateType', { rateType: pattern.rateType }) + .andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }) + .andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' }); + + if (pattern.containerTypeId) { + qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); + } else { + qb.andWhere('rate.container_type_id IS NULL'); + } + if (pattern.cargoTypeId) { + qb.andWhere('rate.cargo_type_id = :cargoTypeId', { cargoTypeId: pattern.cargoTypeId }); + } else { + qb.andWhere('rate.cargo_type_id IS NULL'); + } + if (pattern.tradeDirection) { + qb.andWhere('rate.trade_direction = :tradeDirection', { tradeDirection: pattern.tradeDirection }); + } else { + qb.andWhere('rate.trade_direction IS NULL'); + } + + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } @@ -39,7 +74,7 @@ export class RatesRepository implements IRatesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts index 0d151c561..7432dfc34 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rule') .innerJoinAndSelect('rule.containerType', 'ct') @@ -31,11 +30,27 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { dir: tradeDirection, both: 'BOTH', }) - .andWhere('rule.effective_from <= :now', { now }) - .andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now }) .getMany(); } + /** + * Find a rule matching the (containerType, tradeDirection) identity — the + * tuple enforced by `UQ_weight_limit_rules_pattern`. Used to reject duplicates + * before insert. Optionally excludes a row by id so updates don't self-collide. + */ + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise { + const qb = this.repo + .createQueryBuilder('rule') + .where('rule.container_type_id = :containerTypeId', { containerTypeId }) + .andWhere('rule.trade_direction = :tradeDirection', { tradeDirection }); + if (excludeId) qb.andWhere('rule.id <> :excludeId', { excludeId }); + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } @@ -50,7 +65,7 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 4b082e5fd..0fee8e75a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -136,6 +136,10 @@ export class RuleEngineService { } } + hardBlocked.push( + ...(await this.capacityViolations(input.containers, input.tradeDirection)), + ); + for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, @@ -296,6 +300,40 @@ export class RuleEngineService { }; } + /** + * Messages for container lines whose total weight exceeds the hard capacity + * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking + * must not be created at all. Overweight (above maxVgmTons but within + * capacity) is NOT reported here — that is a surcharge, not a block. + */ + async capacityViolations( + containers: Array<{ + containerTypeId: string; + quantity: number; + totalVgmTons: number; + }>, + tradeDirection: string, + ): Promise { + const violations: string[] = []; + for (const container of containers) { + const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( + container.containerTypeId, + tradeDirection, + ); + const rule = rules[0]; + if (!rule || rule.maxCapacityTons == null) continue; + const perUnit = Number(rule.maxCapacityTons); + const maxTotal = perUnit * container.quantity; + if (container.totalVgmTons > maxTotal) { + const label = rule.containerType?.code ?? container.containerTypeId; + violations.push( + `${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`, + ); + } + } + return violations; + } + /** * Ensure ITMLS default approval chains exist (container + bulk). Idempotent. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index f80ada585..5470094a5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -82,6 +82,7 @@ export class CargoTypesService { requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, + wagonTypeId: dto.wagonTypeId ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 38407f36a..629bf3023 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -64,6 +64,7 @@ export class ContainerTypesService { isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, + wagonTypeId: dto.wagonTypeId ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index c3ab6bab0..0027e18c1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -1,8 +1,15 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CreateRateDto } from '../dto/create-rate.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; +import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; @Injectable() @@ -27,7 +34,7 @@ export class RatesService { const [data, total] = await this.repository.findAndCount({ where, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -46,6 +53,50 @@ export class RatesService { return entity; } + /** + * Normalise + validate the weighting unit for a rate shape. Overweight is + * always billed per excess ton, so its unit is forced to PER_TON regardless + * of what the client sent. Every other shape must pick a unit the pricing + * engine can actually apply (see `allowedRateUnits`). + */ + private resolveRateUnit( + appliesTo: Rate['appliesTo'], + trigger: Rate['trigger'], + requestedUnit: Rate['rateUnit'], + ): Rate['rateUnit'] { + // Overweight is per-ton, full stop. + if (trigger === 'OVERWEIGHT') return 'PER_TON'; + + if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); + throw new BadRequestException( + `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, + ); + } + return requestedUnit; + } + + /** + * Reject a second rate with the same identity pattern (rateType + scope). With + * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would + * make pricing ambiguous — so we allow exactly one per pattern. + */ + private async assertNoDuplicatePattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId: string | null; + cargoTypeId: string | null; + tradeDirection: string | null; + ignoreId?: string; + }): Promise { + const existing = await this.repository.findByPattern(pattern); + if (existing && existing.id !== pattern.ignoreId) { + throw new ConflictException( + 'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.', + ); + } + } + /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { const appliesTo = dto.appliesTo as Rate['appliesTo']; @@ -57,25 +108,28 @@ export class RatesService { const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null); + const rateType = deriveRateType({ + appliesTo, + trigger, + tradeDirection, + isBulk: Boolean(cargoTypeId), + }); + const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); + + await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection }); + return this.repository.create({ appliesTo, trigger, - rateType: deriveRateType({ - appliesTo, - trigger, - tradeDirection, - isBulk: Boolean(cargoTypeId), - }), + rateType, containerTypeId, cargoTypeId, tradeDirection, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, - rateUnit: dto.rateUnit as Rate['rateUnit'], + rateUnit, status: 'DRAFT', proposedByStaffId, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined, }); } @@ -110,22 +164,35 @@ export class RatesService { ? dto.tradeDirection : existing.tradeDirection; - updates.containerTypeId = containerTypeId; - updates.cargoTypeId = cargoTypeId; - updates.tradeDirection = tradeDirection; + updates.containerTypeId = containerTypeId ?? null; + updates.cargoTypeId = cargoTypeId ?? null; + updates.tradeDirection = tradeDirection ?? null; // Keep the derived rateType in sync with whatever changed. - updates.rateType = deriveRateType({ + const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, isBulk: Boolean(cargoTypeId), }); + updates.rateType = rateType; + + // Re-validate the unit against the (possibly changed) shape; overweight is + // forced to PER_TON. + const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; + updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit); + + // Guard the pattern uniqueness for the new identity, ignoring this row. + await this.assertNoDuplicatePattern({ + rateType, + rateUnit: updates.rateUnit, + containerTypeId: updates.containerTypeId, + cargoTypeId: updates.cargoTypeId, + tradeDirection: updates.tradeDirection, + ignoreId: id, + }); updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; - if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; - if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo); const updated = await this.repository.update(id, updates); if (!updated) throw new NotFoundException(`Rate ${id} not found`); return updated; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts index d171f55aa..44f5332f2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -1,4 +1,10 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; @@ -30,7 +36,7 @@ export class WeightLimitRulesService { const [data, total] = await this.repository.findAndCount({ where, relations: { containerType: true }, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -44,26 +50,75 @@ export class WeightLimitRulesService { return entity; } + /** + * Reject a second rule for the same container + direction. One VGM limit per + * (container, direction) — otherwise the booking engine can't tell which + * applies. + */ + private async assertNoDuplicate( + containerTypeId: string, + tradeDirection: string, + ignoreId?: string, + ): Promise { + const existing = await this.repository.findByPattern(containerTypeId, tradeDirection, ignoreId); + if (existing) { + throw new ConflictException( + 'A weight limit rule for this container type and trade direction already exists. Edit the existing rule instead.', + ); + } + } + + /** + * Capacity is the hard ceiling; the VGM limit is the soft overweight + * threshold. A ceiling below the threshold would make every overweight + * booking impossible to create, which is never what the operator means. + */ + private assertCapacityAboveVgmLimit( + maxVgmTons: number, + maxCapacityTons: number | null | undefined, + ): void { + if (maxCapacityTons != null && Number(maxCapacityTons) < Number(maxVgmTons)) { + throw new BadRequestException( + 'Max capacity must be greater than or equal to the max VGM limit.', + ); + } + } + /** Create a new weight limit rule. */ async create(dto: CreateWeightLimitRuleDto): Promise { + await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection); + this.assertCapacityAboveVgmLimit(dto.maxVgmTons, dto.maxCapacityTons); return this.repository.create({ containerTypeId: dto.containerTypeId, tradeDirection: dto.tradeDirection, maxVgmTons: dto.maxVgmTons, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null, + maxCapacityTons: dto.maxCapacityTons ?? null, }); } /** Update an existing weight limit rule. */ async update(id: string, dto: UpdateWeightLimitRuleDto): Promise { - await this.findById(id); + const existing = await this.findById(id); const patch: Partial = {}; if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; - if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo); + if (dto.maxCapacityTons !== undefined) patch.maxCapacityTons = dto.maxCapacityTons; + + this.assertCapacityAboveVgmLimit( + patch.maxVgmTons ?? Number(existing.maxVgmTons), + patch.maxCapacityTons !== undefined ? patch.maxCapacityTons : existing.maxCapacityTons, + ); + + // Re-check uniqueness when the identity (container/direction) changes. + if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) { + await this.assertNoDuplicate( + patch.containerTypeId ?? existing.containerTypeId, + patch.tradeDirection ?? existing.tradeDirection, + id, + ); + } + const updated = await this.repository.update(id, patch); if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`); return updated; diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts index 4ffecea26..352704eea 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -1,9 +1,15 @@ import { BaseEntity } from '@edr/api-common'; +import { LoadingStatus } from '@edr/types'; import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { TrainSchedule } from './train-schedule.entity'; +export const TRAIN_SCHEDULE_BOOKING_LOADING_STATUSES = [ + LoadingStatus.Unloaded, + LoadingStatus.Loaded, +] as const; + @Entity({ schema: 'freight', name: 'train_schedule_bookings' }) @Index(['trainScheduleId', 'bookingId'], { unique: true }) @Index(['bookingId'], { unique: true }) @@ -23,4 +29,7 @@ export class TrainScheduleBooking extends BaseEntity { @ManyToOne(() => Booking) @JoinColumn({ name: 'booking_id' }) booking?: Booking; + + @Column({ name: 'loading_status', type: 'varchar', length: 20, default: 'UNLOADED' }) + loadingStatus!: string; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index d67d58811..0cc07dacc 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -111,6 +111,27 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'booking_cycle_no', type: 'int', default: 0 }) bookingCycleNo!: number; + // ── Booking-window rule snapshot ────────────────────────────────────────── + // The scheduling rule this train was created with, frozen at creation. A later + // global-rules edit applies only to FUTURE schedules — an already-open schedule + // keeps its base rule. The batch board derives its display windows (open time + + // reopen cycles) from THIS snapshot, never from the live global config. NULL on + // legacy rows created before the snapshot existed (board falls back to live cfg). + @Column({ name: 'rule_window_open_hour', type: 'int', nullable: true }) + ruleWindowOpenHour?: number | null; + + @Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true }) + ruleWindowDurationHours?: number | null; + + @Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true }) + ruleReopenDelayMinutes?: number | null; + + @Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true }) + ruleImportWindowLeadDays?: number | null; + + @Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true }) + ruleExportBookingLeadHours?: number | null; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts index 4ccfcd469..64607ecb8 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository { + return this.repo(manager).find({ + where: { trainScheduleId }, + select: { id: true, bookingId: true, trainScheduleId: true, loadingStatus: true }, + }); + } + + async updateLoadingStatusMany( + trainScheduleId: string, + bookingIds: string[], + loadingStatus: string, + manager?: EntityManager, + ): Promise { + if (!bookingIds.length) return; + await this.repo(manager).update( + { trainScheduleId, bookingId: In(bookingIds) }, + { loadingStatus }, + ); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index e765e5694..764589b73 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -3,9 +3,9 @@ import { listBatchWindowsForDate, listBatchWindowsForBookings, BATCH_WINDOW_START_HOURS, - boardWindowForTimestamp, - listBoardWindowsForRange, + listConfigBookingWindows, groupBookingsIntoBoardWindows, + type BoardWindowConfig, } from './batch-window.util'; describe('batch-window.util', () => { @@ -54,83 +54,87 @@ describe('batch-window.util', () => { }); }); -describe('batch-window board windows (midnight-based 3h slots)', () => { - it('maps 04:00 EAT to the 03:00–06:00 slot', () => { - // 01:00 UTC = 04:00 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z')); - expect(w.label).toContain('03:00'); - expect(w.label).toContain('06:00'); - expect(w.date).toBe('2026-06-11'); - expect(w.dateLabel).toContain('11 Jun'); - }); +describe('batch-window board windows (config-driven booking cycles)', () => { + // Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later. + const cfg: BoardWindowConfig = { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowDurationHours: 3, + reopenDelayMinutes: 90, + exportBookingLeadHours: 24, + }; - it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => { - // 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z')); - expect(w.label).toContain('00:00'); - expect(w.label).toContain('03:00'); - expect(w.date).toBe('2026-06-11'); - }); - - it('maps 23:00 EAT to the final 21:00–24:00 slot', () => { - // 20:00 UTC = 23:00 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z')); - expect(w.label).toContain('21:00'); - expect(w.label).toContain('24:00'); - expect(w.date).toBe('2026-06-11'); - }); - - it('lists a continuous range open→departure clamped at both ends', () => { - // open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC) - const open = new Date('2026-06-05T05:00:00.000Z'); + it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => { + // departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC) const departure = new Date('2026-06-08T11:00:00.000Z'); - const windows = listBoardWindowsForRange(open, departure); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); - // Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5 - expect(windows).toHaveLength(6 + 8 + 8 + 5); expect(windows[0].date).toBe('2026-06-05'); - expect(windows[0].label).toContain('06:00'); - expect(windows[0].label).toContain('09:00'); - const last = windows[windows.length - 1]; - expect(last.date).toBe('2026-06-08'); - expect(last.label).toContain('12:00'); - expect(last.label).toContain('15:00'); - // chronological + unique keys - const keys = windows.map((w) => w.key); - expect(new Set(keys).size).toBe(keys.length); + expect(windows[0].label).toContain('08:00'); + expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z'); + // end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC) + expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z'); }); - it('handles a same-day open→departure range', () => { - const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot) - const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot) - const windows = listBoardWindowsForRange(open, departure); - // 06,09,12 = 3 slots - expect(windows).toHaveLength(3); + it('import: reopens reopenDelayMinutes after close, same booking day', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); + // cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT + expect(windows.length).toBeGreaterThanOrEqual(2); + expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT + // all cycles stay on the same EAT booking day expect(windows.every((w) => w.date === '2026-06-05')).toBe(true); }); - it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => { - const open = new Date('2026-06-05T05:00:00.000Z'); - const departure = new Date('2026-06-06T11:00:00.000Z'); + it('export: single FCFS window exportBookingLeadHours before departure', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('EXPORT', departure, cfg); + expect(windows).toHaveLength(1); + // 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun + expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z'); + expect(windows[0].end.toISOString()).toBe(departure.toISOString()); + }); + + it('buckets bookings into config cycles and keeps empty + pending windows', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); const items = [ - { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th + { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1 { id: 'b', ts: null }, // pending ]; const map = groupBookingsIntoBoardWindows( items, (i) => i.ts, - open, + 'IMPORT', departure, + cfg, 'pending-contract', ); const pending = map.get('pending-contract'); expect(pending?.items.map((i) => i.id)).toEqual(['b']); const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a')); expect(withA?.window?.date).toBe('2026-06-05'); - // empty slots are retained for the UI + // empty cycles are retained for the UI const emptyCount = [...map.values()].filter( (b) => b.window && b.items.length === 0, ).length; expect(emptyCount).toBeGreaterThan(0); }); + + it('attaches a booking made before the window opened to the first cycle', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }]; + const map = groupBookingsIntoBoardWindows( + items, + (i) => i.ts, + 'IMPORT', + departure, + cfg, + 'pending-contract', + ); + const withEarly = [...map.values()].find((b) => + b.items.some((i) => i.id === 'early'), + ); + expect(withEarly?.window?.date).toBe('2026-06-05'); + expect(withEarly?.window?.label).toContain('08:00'); + }); }); 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 650da3adc..b30b3f454 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 @@ -181,6 +181,26 @@ export function computeExportWindowTimes( }; } +/** + * Earliest departure a train may be scheduled for — staff cannot schedule inside + * the lead window. IMPORT/DOMESTIC lead is in whole EAT days: with lead 3 and + * today the 11th, the 12th and 13th are blocked and the 14th is the first + * allowed departure day (00:00 EAT). EXPORT lead is in hours: earliest departure + * is `now + exportBookingLeadHours` (24h = 1 day). Mirrors the booking-window + * math so a schedulable date always has a real booking window before it. + */ +export function earliestSchedulableDeparture( + direction: string | null | undefined, + cfg: { importWindowLeadDays: number; exportBookingLeadHours: number }, + now: Date, +): Date { + if (direction === 'EXPORT') { + return new Date(now.getTime() + cfg.exportBookingLeadHours * 3_600_000); + } + const earliestDay = shiftEatDay(eatDay(now), cfg.importWindowLeadDays); + return eatDayToUtc(earliestDay, 0); +} + /** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */ export function getBatchWindowForTimestamp(date: Date): BatchWindow { const { year, month, day, hour } = eatParts(date); @@ -230,14 +250,13 @@ export function listBatchWindowsForBookings( } // --------------------------------------------------------------------------- -// Board-display windows: full-day, midnight-based 3h slots over a date range. -// These are used ONLY for the batch-board UI grouping (not persisted, and -// independent of the cron intake hours above). +// Board-display windows: the REAL booking-window cycles derived from the +// train_scheduling_global_rules config (window open hour, lead days, duration, +// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle +// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after +// reopenDelayMinutes until departure). Export shows the single FCFS lead window. // --------------------------------------------------------------------------- -/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */ -export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const; - /** A board window carries an EAT calendar date in addition to the slot times. */ export interface BoardWindow extends BatchWindow { /** EAT calendar day as ISO `YYYY-MM-DD`. */ @@ -246,6 +265,15 @@ export interface BoardWindow extends BatchWindow { dateLabel: string; } +/** Config fields the board needs to reconstruct booking-window cycles. */ +export interface BoardWindowConfig { + importWindowLeadDays: number; + windowOpenHour: number; + windowDurationHours: number; + reopenDelayMinutes: number; + exportBookingLeadHours: number; +} + const dayLabelFmt = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: '2-digit', @@ -257,119 +285,133 @@ function pad2(n: number): string { return String(n).padStart(2, '0'); } -/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */ -function boardWindowFromEatStart( - year: number, - month: number, - day: number, - startHour: number, -): BoardWindow { - const start = eatToUtc(year, month, day, startHour); - const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over) - const end = eatToUtc(year, month, day, endHour); - const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`; +/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */ +function boardWindowFromInterval(start: Date, end: Date): BoardWindow { + const { year, month, day } = eatParts(start); return { key: start.toISOString(), start, end, - label: formatWindowLabel(start, end, endLabel), + label: formatWindowLabel(start, end), date: `${year}-${pad2(month)}-${pad2(day)}`, dateLabel: dayLabelFmt.format(start), }; } -/** Which midnight-based 3h EAT slot a timestamp falls in. */ -export function boardWindowForTimestamp(date: Date): BoardWindow { - const { year, month, day, hour } = eatParts(date); - let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0; - for (const h of BOARD_WINDOW_HOURS) { - if (hour >= h) startHour = h; - } - return boardWindowFromEatStart(year, month, day, startHour); -} - /** - * Continuous list of board windows from `openDate` to `departureDate` (inclusive), - * clamped to the slot containing `openDate` on the first day and the slot - * containing `departureDate` on the last day. Returned in chronological order. + * The real booking-window cycles for a schedule, straight from config. + * + * IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays` + * for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes` + * after each close, on the same booking day, until departure. This mirrors + * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the + * exact windows the engine runs. + * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure. + * + * `anchorOpensAt` pins the FIRST window's open time to the schedule's stored + * `windowOpensAt` instead of recomputing it from config. Pass it so the board + * shows the real frozen window (and reopen cycles projected from it) even after + * the global rule changed — the recomputed open time would otherwise drift. */ -export function listBoardWindowsForRange( - openDate: Date, - departureDate: Date, +export function listConfigBookingWindows( + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, + anchorOpensAt?: Date | null, ): BoardWindow[] { - const startWin = boardWindowForTimestamp(openDate); - const endWin = boardWindowForTimestamp(departureDate); - // Guard against an inverted range (departure before open). - if (endWin.start.getTime() < startWin.start.getTime()) { - return [startWin]; + if (direction === 'EXPORT') { + const start = + anchorOpensAt ?? + new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000); + return [boardWindowFromInterval(start, departure)]; } const windows: BoardWindow[] = []; - const seen = new Set(); - // Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to - // avoid any boundary ambiguity, then filter to [startWin.start, endWin.start]. - let cursor = new Date(eatToUtc( - Number(startWin.date.slice(0, 4)), - Number(startWin.date.slice(5, 7)), - Number(startWin.date.slice(8, 10)), - 12, - )); - const lastDayMs = eatToUtc( - Number(endWin.date.slice(0, 4)), - Number(endWin.date.slice(5, 7)), - Number(endWin.date.slice(8, 10)), - 12, - ).getTime(); + const durationMs = cfg.windowDurationHours * 3_600_000; + const reopenMs = cfg.reopenDelayMinutes * 60_000; + const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); - while (cursor.getTime() <= lastDayMs) { - const { year, month, day } = eatParts(cursor); - for (const h of BOARD_WINDOW_HOURS) { - const w = boardWindowFromEatStart(year, month, day, h); - if ( - w.start.getTime() >= startWin.start.getTime() && - w.start.getTime() <= endWin.start.getTime() && - !seen.has(w.key) - ) { - seen.add(w.key); - windows.push(w); - } + let opensAt = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour); + // Reopen stays on the same EAT booking day and before departure; cap at 12 cycles. + for (let cycle = 0; cycle < 12; cycle += 1) { + if (opensAt.getTime() >= departure.getTime()) break; + let closesAt = new Date(opensAt.getTime() + durationMs); + if (closesAt.getTime() > departure.getTime()) closesAt = departure; + windows.push(boardWindowFromInterval(opensAt, closesAt)); + + const nextOpensAt = new Date(closesAt.getTime() + reopenMs); + if ( + nextOpensAt.getTime() >= departure.getTime() || + eatDay(nextOpensAt) !== eatDay(opensAt) + ) { + break; } - cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000); + opensAt = nextOpensAt; } - windows.sort(compareBatchWindows); + // Degenerate config (no window before departure) — surface a single window + // clamped to departure so the board still renders something meaningful. + if (windows.length === 0) { + windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure)); + } return windows; } +/** Which config booking-window a timestamp falls in; null if before/after all of them. */ +function configWindowForTimestamp( + windows: BoardWindow[], + date: Date, +): BoardWindow | null { + const ms = date.getTime(); + for (const w of windows) { + if (ms >= w.start.getTime() && ms < w.end.getTime()) return w; + } + return null; +} + /** - * Group items into board windows spanning [openDate, departureDate]. Empty - * windows are kept so the UI shows every slot. Items whose timestamp falls - * outside the range still get their own window (nothing hidden). Items without - * a timestamp go to `pendingKey`. + * Group items into the real config booking-window cycles for a schedule. Empty + * windows are kept so the UI shows every cycle. Items whose timestamp falls + * outside every window (e.g. a booking created before the window opened) are + * attached to the nearest window by start time so nothing is hidden. Items + * without a timestamp go to `pendingKey`. */ export function groupBookingsIntoBoardWindows( items: T[], getTimestamp: (item: T) => Date | null | undefined, - openDate: Date, - departureDate: Date, + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, pendingKey = 'pending-contract', + anchorOpensAt?: Date | null, ): Map { + const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt); const map = new Map(); - - for (const w of listBoardWindowsForRange(openDate, departureDate)) { + for (const w of windows) { map.set(w.key, { window: w, items: [] }); } map.set(pendingKey, { window: null, items: [] }); + const firstWindow = windows[0] ?? null; + const lastWindow = windows[windows.length - 1] ?? null; + for (const item of items) { const ts = getTimestamp(item); if (!ts) { map.get(pendingKey)!.items.push(item); continue; } - const w = boardWindowForTimestamp(ts); - if (!map.has(w.key)) { - map.set(w.key, { window: w, items: [] }); + let w = configWindowForTimestamp(windows, ts); + if (!w) { + // Booked before the window opened → first cycle; after it closed → last cycle. + w = + firstWindow && ts.getTime() < firstWindow.start.getTime() + ? firstWindow + : lastWindow; + } + if (!w) { + map.get(pendingKey)!.items.push(item); + continue; } map.get(w.key)!.items.push(item); } 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 2c30a5964..46175c7ef 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 @@ -590,6 +590,9 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; for (const s of schedules) { if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; + // Batch board is IMPORT-only: export is FCFS with no batch/priority calc, + // and domestic/legacy schedules run the legacy fill, not the window batch. + if (s.direction !== "IMPORT") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -630,6 +633,12 @@ export class BookingBatchService implements OnModuleInit { if (s.status === "ARRIVED" || s.status === "CANCELLED") { throw new BadRequestException("Schedule is no longer active"); } + // Batch board is IMPORT-only (export is FCFS, no batch/priority calc). + if (s.direction !== "IMPORT") { + throw new BadRequestException( + "The batch board only covers import schedules", + ); + } const wagonLengths = await this.loadWagonLengths(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); @@ -710,15 +719,43 @@ export class BookingBatchService implements OnModuleInit { const loco = s.trainSet?.locomotive ?? null; - // Display windows span the whole booking window: from when it opened - // (schedule creation) through the scheduled departure, in 3-hour EAT slots. - const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date(); + // Display windows are the REAL booking-window cycles this schedule was FROZEN + // with at creation (import: opens at its stored window time, lasts its rule's + // duration, reopens per its rule's delay; export: single FCFS lead window) — + // NOT the live global config. A later global-rules edit only re-derives + // not-yet-open schedules (restampPendingWindows), so an already-open schedule + // must keep drawing from its own snapshot, anchored on its stored open time. + // Legacy rows with no snapshot fall back to the live config. + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + const num = (v: unknown, fallback: number) => { + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : fallback; + }; + const windowCfg = { + windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour), + windowDurationHours: num( + s.ruleWindowDurationHours, + liveCfg.windowDurationHours, + ), + reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes), + importWindowLeadDays: num( + s.ruleImportWindowLeadDays, + liveCfg.importWindowLeadDays, + ), + exportBookingLeadHours: num( + s.ruleExportBookingLeadHours, + liveCfg.exportBookingLeadHours, + ), + }; const departureDate = s.scheduledDepartureDate ?? new Date(); const windowBuckets = groupBookingsIntoBoardWindows( items, (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), - openDate, + s.direction ?? null, departureDate, + windowCfg, + undefined, + s.windowOpensAt ?? null, ); const emptyCounts = () => ({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index f2fe5db07..da235c562 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -7,6 +7,7 @@ import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { NotificationsService } from '../notifications/notifications.service'; import { BookingBatchService } from './booking-batch.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @@ -19,12 +20,13 @@ import { type BookingWindowConfig } from './booking-window.config'; * schedule row, so every transition is derived purely from the clock — a restart * resumes mid-phase with no loss (onModuleInit runs one tick immediately). * - * Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept - * documents) → PAYMENT (batch reserves in priority order, customers pay) → - * reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized). + * Import & domestic phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW + * (staff accept documents) → PAYMENT (batch reserves in priority order, customers + * pay) → reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized). * Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority). - * Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy - * fill (runBatchFill), which this tick invokes every 5th minute. + * Only PRE-MIGRATION rows have windowPhase NULL; those are served by the legacy + * fill (runBatchFill), which this tick invokes every 5th minute. New schedules of + * every direction get a window phase. */ @Injectable() export class BookingWindowService implements OnModuleInit { @@ -37,6 +39,7 @@ export class BookingWindowService implements OnModuleInit { private readonly trainSchedulesRepository: TrainSchedulesRepository, private readonly bookingBatchService: BookingBatchService, private readonly trainSchedulingService: TrainSchedulingService, + private readonly notifications: NotificationsService, ) {} async onModuleInit(): Promise { @@ -156,6 +159,7 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); schedule.bookingWindowStatus = 'OPEN'; } + await this.notifyWindowOpened(schedule); this.logger.log(`Export booking window opened for schedule ${schedule.id}`); return true; } @@ -193,6 +197,8 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); schedule.bookingWindowStatus = 'OPEN'; } + // Only announce the first opening of the day; reopen cycles don't re-notify. + if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule); this.logger.log( `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, ); @@ -325,6 +331,67 @@ export class BookingWindowService implements OnModuleInit { } } + /** + * SMS + email every active-contract customer on this schedule's route when its + * booking window opens, so they can book from the portal home before it closes. + * Fire-and-forget; a failed notification never blocks the window transition. + */ + private async notifyWindowOpened(schedule: TrainSchedule): Promise { + try { + const rows: Array<{ phone: string | null; email: string | null }> = + await this.dataSource.query( + `SELECT DISTINCT + COALESCE(co.contact_person_phone, co.phone) AS phone, + COALESCE(co.email, co.general_manager_email) AS email + FROM freight.contract_routes cr + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + JOIN freight.companies co ON co.id = c.company_id + WHERE cr.origin_yard_id = $1 + AND cr.destination_yard_id = $2 + AND cr.deleted_at IS NULL`, + [schedule.originStationId, schedule.destinationStationId], + ); + if (!rows.length) return; + + const closes = schedule.windowClosesAt + ? schedule.windowClosesAt.toLocaleString('en-GB', { timeZone: BATCH_TIMEZONE }) + : 'later today'; + const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { + timeZone: BATCH_TIMEZONE, + }); + const msg = + `Booking is now open for the train departing ${depart}. ` + + `Book your shipment from the portal home page before ${closes} EAT.`; + + const seenPhone = new Set(); + const seenEmail = new Set(); + for (const r of rows) { + if (r.phone && !seenPhone.has(r.phone)) { + seenPhone.add(r.phone); + await this.notifications + .directSend('sms', r.phone, msg) + .catch((e) => this.logger.warn(`Window-open SMS failed: ${(e as Error).message}`)); + } + if (r.email && !seenEmail.has(r.email)) { + seenEmail.add(r.email); + await this.notifications + .directSend('email', r.email, msg) + .catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`)); + } + } + this.logger.log( + `Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`, + ); + } catch (err) { + this.logger.warn( + `notifyWindowOpened failed for ${schedule.id}: ${(err as Error).message}`, + ); + } + } + private async setPhase( schedule: TrainSchedule, patch: Partial< diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts new file mode 100644 index 000000000..25943050c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts @@ -0,0 +1,15 @@ +import { LoadingStatus } from '@edr/types'; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsEnum, IsUUID } from 'class-validator'; + +export class UpdateImportLoadingStatusDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ enum: LoadingStatus }) + @IsEnum(LoadingStatus) + loadingStatus!: LoadingStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 1171b0c90..2e82feb6a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -60,11 +60,13 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Max(23) windowOpenHour?: number; + // Stored in hours. The UI enters this in minutes/hours/days and converts to + // hours before sending, so the floor is 1 minute (0.0166h) — not 15 min. @ApiPropertyOptional({ example: 3 }) @IsOptional() @Type(() => Number) @IsNumber() - @Min(0.25) + @Min(0.0166) @Max(12) windowDurationHours?: number; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 7b8d9b26a..1a67bb791 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -54,11 +54,13 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'window_open_hour', type: 'int', default: 8 }) windowOpenHour!: number; + // Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h) + // are exact. See WidenWindowDurationHoursPrecision migration. @Column({ name: 'window_duration_hours', type: 'numeric', - precision: 4, - scale: 2, + precision: 6, + scale: 4, default: 3, }) windowDurationHours!: number; 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 8887fb4c6..773e4738a 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 @@ -28,6 +28,7 @@ import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; +import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; @@ -60,16 +61,38 @@ export class TrainSchedulingController { @Get("my-booking-windows") @ApiOperation({ summary: - "Upcoming/open booking windows on the signed-in customer's active contract lanes", + "Upcoming/open booking windows announced to the signed-in customer (all window-engine schedules; their own contract lanes carry a Book-now target)", }) async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) { + // Every customer sees announced windows; companyId (when resolvable) just + // enriches lanes they hold a contract on so "Book now" can target it. const companyId = await this.billingService.resolveCompanyId( resolveAuthUserId(user), ); - if (!companyId) return []; return this.trainSchedulingService.getBookingWindowsForCompany(companyId); } + @Get("contracts/:contractId/booking-windows") + @ApiOperation({ + summary: + "Upcoming/open booking windows on a contract's routes — gates the booking form for customer + Ethiopian GL", + }) + getContractBookingWindows( + @Param("contractId", ParseUUIDPipe) contractId: string, + ) { + return this.trainSchedulingService.getBookingWindowsForContract(contractId); + } + + @Get("booking-windows") + @TrainSchedulingView() + @ApiOperation({ + summary: + "All announced booking windows across lanes (import cycle + export FCFS), for staff dashboards", + }) + listBookingWindows() { + return this.trainSchedulingService.listAllBookingWindows(); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) @@ -325,6 +348,28 @@ export class TrainSchedulingController { return this.trainSchedulingService.getCompositionRemovals(id); } + @Get("schedules/:id/import-loading-bookings") + @TrainSchedulingView() + @ApiOperation({ + summary: "List import bookings eligible for loading confirmation on this schedule", + }) + getImportLoadingBookings(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getImportLoadingBookings(id); + } + + @Patch("schedules/:id/import-loading-status") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", + }) + updateImportLoadingStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateImportLoadingStatusDto, + ) { + return this.trainSchedulingService.updateImportLoadingStatus(id, dto); + } + @Post("schedules/:id/pin-wagons") @TrainSchedulingManage() @ApiOperation({ summary: "Pin physical wagons to train set slots" }) 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 ce835e1e4..32a046356 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 @@ -1,5 +1,6 @@ import { AllocationLoadType, + LoadingStatus, SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, @@ -9,11 +10,12 @@ import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In, Not } from 'typeorm'; +import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -36,6 +38,8 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; @@ -45,6 +49,7 @@ import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; +import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -84,10 +89,6 @@ import { type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; -import { - getDefaultContainerWagonTypeCode, - pickBulkWagonType, -} from './wagon-type-resolver.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { @@ -102,6 +103,7 @@ import { import { computeExportWindowTimes, computeImportWindowTimes, + earliestSchedulableDeparture, eatDay, } from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; @@ -168,8 +170,30 @@ const DEFAULT_TRAIN_LIMITS: Required = { max20ftPairWeightDiffTons: 10, }; +/** Raw row shape for the booking-window queries (company- and contract-scoped). */ +interface BookingWindowRow { + schedule_id: string; + contract_id: string | null; + contract_kind: string | null; + direction: string | null; + window_phase: string | null; + window_opens_at: Date | null; + window_closes_at: Date | null; + doc_review_ends_at: Date | null; + payment_phase_ends_at: Date | null; + booking_window_status: string; + booking_cycle_no: number; + scheduled_departure_date: Date; + origin_label: string | null; + origin_code: string | null; + destination_label: string | null; + destination_code: string | null; +} + @Injectable() export class TrainSchedulingService { + private readonly logger = new Logger(TrainSchedulingService.name); + constructor( @InjectDataSource() private readonly dataSource: DataSource, @@ -248,7 +272,76 @@ export class TrainSchedulingService { if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes; - return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row); + + // Fields that change the STAMPED open/close times of a schedule. docReview/ + // payment/reopen are read live by the cron each tick, so they need no + // re-stamp; only the four below feed computeImport/ExportWindowTimes. + const windowTimingChanged = + dto.importWindowLeadDays != null || + dto.windowOpenHour != null || + dto.windowDurationHours != null || + dto.exportBookingLeadHours != null; + + const saved = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .save(row); + + // The cron reads config fresh every tick, so derived timings (doc review, + // payment, reopen) take effect on the next tick with no restart. But each + // schedule's initial open/close times were FROZEN at creation — re-stamp the + // ones whose window has not opened yet so a config edit applies to them too. + if (windowTimingChanged) { + await this.restampPendingWindows(); + } + + return saved; + } + + /** + * Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has + * not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure + * in the future) using the CURRENT global-rules config. Schedules already OPEN or + * past their window are left untouched — customers may have booked against the + * times they were shown, so those stay frozen. Returns the count re-stamped. + */ + async restampPendingWindows(): Promise { + const cfg = await this.getWindowConfig(); + const now = new Date(); + const schedules = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' }, + { status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' }, + ], + }); + + const repo = this.dataSource.getRepository(TrainSchedule); + let restamped = 0; + for (const s of schedules) { + if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue; + const times = + s.direction === 'EXPORT' + ? computeExportWindowTimes(s.scheduledDepartureDate, cfg) + : computeImportWindowTimes(s.scheduledDepartureDate, cfg, now); + // A not-yet-open schedule legitimately adopts the new rule, so refresh its + // snapshot alongside the re-stamped times — the board then draws the new + // window from this same rule. + await repo.update(s.id, { + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + ruleWindowOpenHour: cfg.windowOpenHour, + ruleWindowDurationHours: cfg.windowDurationHours, + ruleReopenDelayMinutes: cfg.reopenDelayMinutes, + ruleImportWindowLeadDays: cfg.importWindowLeadDays, + ruleExportBookingLeadHours: cfg.exportBookingLeadHours, + }); + restamped += 1; + } + if (restamped > 0) { + this.logger.log( + `Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`, + ); + } + return restamped; } /** @@ -383,24 +476,54 @@ export class TrainSchedulingService { // Effective capacity is capped by the weakest locomotive in the set. const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; const departure = new Date(dto.scheduleDate); - // IMPORT/EXPORT trains start with a CLOSED customer window; the window engine - // opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead). - // DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL). + // Every schedule starts with a CLOSED customer window; the window engine opens + // it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT + // (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens + // 24h before departure (FCFS). No schedule is ever always-open now. const windowCfg = await this.getWindowConfig(); + + // Staff cannot schedule inside the lead window — there must be room for a + // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT + // days (lead 3, today 11th → first allowed departure is the 14th); EXPORT + // lead is in hours (24h = 1 day ahead). + const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date()); + if (departure.getTime() < earliest.getTime()) { + const detail = + direction === 'EXPORT' + ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` + : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; + throw new BadRequestException( + `Departure ${departure.toISOString()} is inside the booking lead window; ` + + `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + + `(earliest ${earliest.toISOString()})`, + ); + } + // Freeze the rule this schedule is born with. A later global-rules edit + // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an + // already-open schedule keeps this snapshot, and the batch board draws its + // windows from it rather than the live config. + const ruleSnapshot = { + ruleWindowOpenHour: windowCfg.windowOpenHour, + ruleWindowDurationHours: windowCfg.windowDurationHours, + ruleReopenDelayMinutes: windowCfg.reopenDelayMinutes, + ruleImportWindowLeadDays: windowCfg.importWindowLeadDays, + ruleExportBookingLeadHours: windowCfg.exportBookingLeadHours, + }; const windowFields = - direction === 'IMPORT' + direction === 'EXPORT' ? { bookingWindowStatus: 'CLOSED', windowPhase: 'PRE_WINDOW', - ...computeImportWindowTimes(departure, windowCfg, new Date()), + ...ruleSnapshot, + ...computeExportWindowTimes(departure, windowCfg), } - : direction === 'EXPORT' - ? { - bookingWindowStatus: 'CLOSED', - windowPhase: 'PRE_WINDOW', - ...computeExportWindowTimes(departure, windowCfg), - } - : {}; + : { + // IMPORT and DOMESTIC share the import booking-day window cycle. + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...ruleSnapshot, + ...computeImportWindowTimes(departure, windowCfg, new Date()), + }; const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, routeId: route.id, @@ -480,16 +603,22 @@ export class TrainSchedulingService { ); if (!validation.valid) { + // Put the violation detail in the message itself — global exception + // filters flatten the body, and "Booking validation failed" alone tells + // staff nothing (e.g. which wagon type is missing at the yard). throw new BadRequestException({ - message: 'Booking validation failed', + message: `Booking validation failed: ${validation.violations.join('; ')}`, violations: validation.violations, warnings: validation.warnings, }); } if (!validation.bookings.length) { + const shortfall = validation.deferredBookings + .map((d) => `${d.reference}: ${d.reason}`) + .join('; '); throw new BadRequestException({ - message: 'No bookings fit on available fleet wagons', + message: `No wagons available for the selected bookings${shortfall ? ` — ${shortfall}` : ''}`, violations: ['Insufficient fleet wagons for the selected bookings'], warnings: validation.warnings, deferredBookings: validation.deferredBookings, @@ -503,12 +632,14 @@ export class TrainSchedulingService { if (!limitLoco) { throw new BadRequestException('Schedule train set has no locomotives'); } - if (limitLoco.maxPullWeightTons < totalWeightTons) { + // forceAssign lets staff overload the locomotive set knowingly — the + // validator has already surfaced it as a warning in that case. + if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) { throw new BadRequestException( `Train set locomotives cannot pull ${totalWeightTons}T`, ); } - if (limitLoco.maxTrainLengthMeters < totalLengthMeters) { + if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) { throw new BadRequestException( `Train set locomotives cannot support ${totalLengthMeters}m`, ); @@ -744,6 +875,87 @@ export class TrainSchedulingService { ); } + async getImportLoadingBookings(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + ]); + if (!scheduleBookings.length) { + return { count: 0, items: [] }; + } + + const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); + const statusByBookingId = new Map( + scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]), + ); + const candidateIds = scheduleBookings + .map((sb) => sb.bookingId) + .filter((id) => allocatedBookingIds.has(id)); + if (!candidateIds.length) { + return { count: 0, items: [] }; + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds); + const items = bookings + .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID') + .map((b) => ({ + id: b.id, + reference: b.reference ?? null, + customer: b.company?.name ?? null, + weightTons: b.cargoTotalWeightVgm, + loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded, + })); + return { count: items.length, items }; + } + + async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations, bookings] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + this.bookingsRepository.findByIdsForScheduling(dto.bookingIds), + ]); + + const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId)); + const allocatedIds = new Set(allocations.map((a) => a.bookingId)); + const bookingById = new Map(bookings.map((b) => [b.id, b])); + + const invalid: string[] = []; + for (const id of dto.bookingIds) { + const booking = bookingById.get(id); + if ( + !scheduledIds.has(id) || + !allocatedIds.has(id) || + !booking || + booking.tradeDirection !== 'IMPORT' || + booking.paymentStatus !== 'PAID' + ) { + invalid.push(id); + } + } + if (invalid.length) { + throw new BadRequestException( + `Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`, + ); + } + + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + dto.bookingIds, + dto.loadingStatus, + ); + return this.getImportLoadingBookings(scheduleId); + } + async pinWagons(scheduleId: string, dto: PinWagonsDto) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -2045,9 +2257,16 @@ export class TrainSchedulingService { max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, }; + // With forceAssign, capacity-shaped rules (train limits, total weight, + // locomotive capability) become warnings — staff owns the override. Physical + // impossibilities (no wagon of the required type at the yard, wrong route, + // wrong status) can never be forced and stay violations. + const pushLimit = (issues: string[]) => + forceAssign ? warnings.push(...issues) : violations.push(...issues); + if (resolvedMode === 'MIXED') { - violations.push( - ...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), + pushLimit( + validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), ); if (requireContainerPlacements) { const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); @@ -2064,7 +2283,7 @@ export class TrainSchedulingService { ); } } else { - violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits)); + pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits)); if (requireContainerPlacements && resolvedMode === 'CONTAINER') { violations.push( @@ -2087,8 +2306,8 @@ export class TrainSchedulingService { ); if (totalWeightTons > trainLimits.maxWeightTons) { const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; - if (!violations.includes(message)) { - violations.push(message); + if (!violations.includes(message) && !warnings.includes(message)) { + pushLimit([message]); } } @@ -2115,9 +2334,9 @@ export class TrainSchedulingService { (setLimits.maxPullWeightTons < totalWeightTons || setLimits.maxTrainLengthMeters < totalLengthMeters) ) { - violations.push( + pushLimit([ 'Assigned locomotives cannot support the total train weight and length', - ); + ]); } } else { const inServiceLocomotives = await this.locomotivesRepository.findAll({ @@ -2135,7 +2354,7 @@ export class TrainSchedulingService { Number(l.maxTrainLengthMeters) >= totalLengthMeters, ) ) { - violations.push('No locomotive can support the total train weight and length'); + pushLimit(['No locomotive can support the total train weight and length']); } } @@ -2504,28 +2723,98 @@ export class TrainSchedulingService { return violations; } + /** + * Resolve the wagon type for a batch through the cargo-type / container-type + * `wagon_type_id` FK (replaces the former load-type string matching). Throws + * when the relevant type has no wagon type configured — scheduling is blocked + * until an admin assigns one on the cargo-type / container-type config screen. + */ private async resolveWagonType( freightType: 'CONTAINER' | 'BULK', bookingIds: string[], ): Promise { + const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); + if (freightType === 'CONTAINER') { - const [wagonType] = await this.wagonTypesRepository.findAll({ - where: { code: getDefaultContainerWagonTypeCode(), isActive: true }, - }); - if (!wagonType) { - throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`); + // First container type present on the batch drives the container wagon + // type (matches the prior single-wagon-type-per-consist behavior). + const containerType = bookings + .flatMap((b) => b.bookingContainers ?? []) + .map((line) => line.containerType) + .find((ct): ct is NonNullable => Boolean(ct)); + if (!containerType) { + throw new BadRequestException('No container type found on the container booking(s)'); } + const wagonType = await this.loadWagonTypeForType( + containerType.wagonTypeId ?? null, + `Container type "${containerType.label ?? containerType.code}"`, + ); return wagonType; } - const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); - const cargoCode = bookings[0]?.cargoType?.code ?? null; - const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } }); - const picked = pickBulkWagonType(wagonTypes, cargoCode); - if (!picked) { - throw new NotFoundException('No suitable bulk wagon type found'); + const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct)); + if (!cargoType) { + throw new BadRequestException('No cargo type found on the bulk booking(s)'); } - return picked; + return this.loadWagonTypeForType( + cargoType.wagonTypeId ?? null, + `Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`, + ); + } + + /** + * Load an active wagon type by FK id, throwing a clear error when the id is + * unset (type not configured) or points at a missing/inactive wagon type. + */ + private async loadWagonTypeForType( + wagonTypeId: string | null, + typeLabel: string, + ): Promise { + if (!wagonTypeId) { + throw new BadRequestException( + `${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`, + ); + } + const [wagonType] = await this.wagonTypesRepository.findAll({ + where: { id: wagonTypeId, isActive: true }, + }); + if (!wagonType) { + throw new NotFoundException( + `${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`, + ); + } + return wagonType; + } + + /** + * Soft wagon-type resolution for the customer-facing availability preview + * (getAvailableDaysForCargo). Reads the configured FK by cargo/container type; + * returns null (→ "no days") instead of throwing when nothing is configured, + * since this only estimates which days have wagons and creates no booking. + */ + private async resolveWagonTypeForPreview( + freightType: 'CONTAINER' | 'BULK', + cargoTypeCode: string | null, + ): Promise { + if (freightType === 'BULK') { + if (!cargoTypeCode) return null; + const cargoType = await this.dataSource.getRepository(CargoType).findOne({ + where: { code: cargoTypeCode }, + relations: { wagonType: true }, + }); + return cargoType?.wagonType?.isActive ? cargoType.wagonType : null; + } + + // Container preview: the input carries no specific container type, so use the + // wagon type of the first configured (active) container type. + const containerType = await this.dataSource + .getRepository(ContainerType) + .findOne({ + where: { isActive: true, wagonTypeId: Not(IsNull()) }, + relations: { wagonType: true }, + order: { displayOrder: 'ASC' }, + }); + return containerType?.wagonType?.isActive ? containerType.wagonType : null; } private async persistTrainSetWagons( @@ -2915,42 +3204,39 @@ export class TrainSchedulingService { } /** - * Upcoming/open booking windows for a customer's active-contract lanes — - * powers the portal home "booking windows" section. Only window-engine - * schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are - * always open and need no announcement. + * Upcoming/open booking windows announced on the portal home "booking + * windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead) + * are listed so every customer sees what is opening — not just those on their + * contract lanes; DOMESTIC trains are always open and need no announcement. + * + * When `companyId` is given, a matching active contract on the lane is + * LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling + * "Book now"); customers with no covering contract still see the window with a + * null contract, and the portal routes them to the contract list to get one. */ - async getBookingWindowsForCompany(companyId: string) { - const rows: Array<{ - schedule_id: string; - direction: string | null; - window_phase: string | null; - window_opens_at: Date | null; - window_closes_at: Date | null; - booking_window_status: string; - booking_cycle_no: number; - scheduled_departure_date: Date; - origin_label: string | null; - origin_code: string | null; - destination_label: string | null; - destination_code: string | null; - }> = await this.dataSource.query( - `SELECT DISTINCT ts.id AS schedule_id, + async getBookingWindowsForCompany(companyId: string | null) { + const rows: Array = await this.dataSource.query( + `SELECT DISTINCT ON (ts.id) + ts.id AS schedule_id, + cr.contract_id AS contract_id, + c.contract_kind AS contract_kind, ts.direction, ts.window_phase, ts.window_opens_at, ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, ts.booking_window_status, ts.booking_cycle_no, ts.scheduled_departure_date, oy.label AS origin_label, oy.code AS origin_code, dy.label AS destination_label, dy.code AS destination_code FROM freight.train_schedules ts - JOIN freight.contract_routes cr + LEFT JOIN freight.contract_routes cr ON cr.origin_yard_id = ts.origin_station_id AND cr.destination_yard_id = ts.destination_station_id AND cr.deleted_at IS NULL - JOIN freight.contracts c + LEFT JOIN freight.contracts c ON c.id = cr.contract_id AND c.company_id = $1 AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') @@ -2962,22 +3248,123 @@ export class TrainSchedulingService { AND ts.window_phase IS NOT NULL AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.scheduled_departure_date >= now() - ORDER BY ts.window_opens_at ASC NULLS LAST`, + ORDER BY ts.id, c.id NULLS LAST, ts.window_opens_at ASC NULLS LAST`, [companyId], ); + return rows + .map((r) => this.mapBookingWindowRow(r)) + .sort((a, b) => { + const ta = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const tb = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return ta - tb; + }); + } + + /** + * Upcoming/open booking windows on a single contract's routes. Used to gate the + * booking form for the customer AND Ethiopian GL (who books on the customer's + * behalf): no window row with isOpenNow=true → booking entry is hidden. + */ + async getBookingWindowsForContract(contractId: string) { + const rows: Array = await this.dataSource.query( + `SELECT DISTINCT ts.id AS schedule_id, + cr.contract_id AS contract_id, + c.contract_kind AS contract_kind, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + JOIN freight.contract_routes cr + ON cr.origin_yard_id = ts.origin_station_id + AND cr.destination_yard_id = ts.destination_station_id + AND cr.contract_id = $1 + AND cr.deleted_at IS NULL + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.window_opens_at ASC NULLS LAST`, + [contractId], + ); + return rows.map((r) => this.mapBookingWindowRow(r)); + } + + /** + * All announced booking windows across every lane — import window cycles AND + * export FCFS lead windows — for staff dashboards (GL clearance queue). Same + * phase filter as the customer-facing lists, no contract scoping. + */ + async listAllBookingWindows() { + const rows: Array< + Omit & { + train_number: string | null; + } + > = await this.dataSource.query( + `SELECT ts.id AS schedule_id, + ts.train_number, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.window_opens_at ASC NULLS LAST`, + ); return rows.map((r) => ({ + ...this.mapBookingWindowRow({ + ...r, + contract_id: null, + contract_kind: null, + }), + trainNumber: r.train_number, + })); + } + + private mapBookingWindowRow(r: BookingWindowRow) { + return { scheduleId: r.schedule_id, + contractId: r.contract_id, + contractKind: r.contract_kind, direction: r.direction, windowPhase: r.window_phase, isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN', windowOpensAt: r.window_opens_at, windowClosesAt: r.window_closes_at, + docReviewEndsAt: r.doc_review_ends_at, + paymentPhaseEndsAt: r.payment_phase_ends_at, bookingWindowStatus: r.booking_window_status, bookingCycleNo: r.booking_cycle_no, departureDate: r.scheduled_departure_date, origin: r.origin_label ?? r.origin_code ?? null, destination: r.destination_label ?? r.destination_code ?? null, - })); + }; } /** OPEN schedules a new booking may target (with rough remaining capacity). @@ -3100,15 +3487,12 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; - const wagonTypes = await this.dataSource.getRepository(WagonType).find(); - - // Resolve the wagon type this cargo needs. - const requiredType = - input.freightType === 'BULK' - ? pickBulkWagonType(wagonTypes, input.cargoTypeCode) - : wagonTypes.find( - (wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive, - ); + // Resolve the wagon type this cargo needs via the cargo/container-type FK. + // Soft (customer availability preview): no days if unresolved, never throws. + const requiredType = await this.resolveWagonTypeForPreview( + input.freightType, + input.cargoTypeCode ?? null, + ); if (!requiredType) return { days: [] }; // How many wagons of that type the cargo needs. @@ -3174,6 +3558,53 @@ export class TrainSchedulingService { return days.includes(day); } + /** + * Enforce the config-driven booking window at booking-create time. + * + * A booking is only allowed when the route has an OPEN departure the customer + * can join for the requested day — which, because the window engine keeps + * `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means: + * - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT, + * `importWindowLeadDays` before departure, for `windowDurationHours`). + * - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS). + * + * `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so + * both gates are satisfied by checking that route for open departures. When a + * specific day is requested, require an open departure on that EAT day; when no + * day is given, require at least one open departure on the route at all. + * Throws `BadRequestException` when the window is closed. No-ops when the route + * yards are unknown (nothing to gate against). + */ + async assertBookingWindowOpen(input: { + originYardId?: string | null; + destinationYardId?: string | null; + scheduledDate?: Date | string | null; + direction?: string | null; + }): Promise { + const { originYardId, destinationYardId } = input; + if (!originYardId || !destinationYardId) return; + + const { days } = await this.getAvailableDays(originYardId, destinationYardId); + if (days.length === 0) { + throw new BadRequestException( + input.direction === 'EXPORT' + ? 'The export booking window for this route is not open yet' + : 'The import booking window for this route is closed right now', + ); + } + + if (input.scheduledDate) { + const day = eatDay(new Date(input.scheduledDate)); + if (!days.includes(day)) { + throw new BadRequestException( + input.direction === 'EXPORT' + ? 'No departure is within the export booking window on the selected day' + : 'The import booking window is not open for the selected day', + ); + } + } + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { @@ -3212,6 +3643,21 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, direction: schedule.direction ?? null, + // Booking-window phase + phase deadlines drive the countdown timers in the + // operations workspace (display only — the window engine enforces them). + windowPhase: schedule.windowPhase ?? null, + windowOpensAt: schedule.windowOpensAt + ? schedule.windowOpensAt.toISOString() + : null, + windowClosesAt: schedule.windowClosesAt + ? schedule.windowClosesAt.toISOString() + : null, + docReviewEndsAt: schedule.docReviewEndsAt + ? schedule.docReviewEndsAt.toISOString() + : null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt + ? schedule.paymentPhaseEndsAt.toISOString() + : null, route: schedule.route ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } : null, @@ -3374,7 +3820,7 @@ export class TrainSchedulingService { if (!validation.valid) { throw new BadRequestException({ - message: 'Booking validation failed', + message: `Booking validation failed: ${validation.violations.join('; ')}`, violations: validation.violations, warnings: validation.warnings, }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 8c3199461..35d5ce185 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -210,7 +210,14 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); const perWagon = containersPerWagonFromType(wagonsPerUnit); const teuSlots = teuSlotsForSizeFt(sizeFt); + // The REAL per-container numbers/weights entered at booking time. Unit i of + // the line maps to units[i] (sortOrder order); the line-level number is only + // a legacy fallback — never invent numbers here. + const units = [...(line.units ?? [])].sort( + (a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0), + ); for (let i = 0; i < qty; i += 1) { + const unit = units[i]; rows.push({ bookingId: booking.id, bookingReference: booking.reference, @@ -219,12 +226,13 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR containerTypeId: line.containerTypeId ?? '', containerTypeCode: code, label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, - grossWeightTons: Number(line.vgmPerUnitTons), + grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), sizeFt, wagonsPerUnit, containersPerWagon: perWagon, teuSlots, - containerNumber: line.containerNumber ?? null, + containerNumber: + unit?.containerNumber?.trim() || line.containerNumber || null, }); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts deleted file mode 100644 index bac0330f2..000000000 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; - -const CARGO_CODE_TO_WAGON_TYPE: Record = { - COFFEE: 'KW2', - GRAIN: 'KW2', - WHEAT: 'KW2', - SORGHUM: 'KW2', - CORN: 'KW2', - FERTILIZER: 'PW2', - SUGAR: 'PW2', - COAL: 'KW3', - STEEL: 'CW3', - ORE: 'CW3', -}; - -const DEFAULT_BULK_WAGON_TYPE = 'CW3'; -const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5'; - -/** - * Resolve wagon type code from cargo type code for bulk freight. - */ -export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string { - if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE; - const normalized = cargoTypeCode.trim().toUpperCase(); - return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE; -} - -/** - * Pick the best matching wagon type entity for bulk cargo. - */ -export function pickBulkWagonType( - wagonTypes: WagonType[], - cargoTypeCode?: string | null, -): WagonType | undefined { - const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode); - const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive); - if (direct) return direct; - - return wagonTypes.find( - (wt) => - wt.isActive && - !wt.supportsContainer && - wt.code !== DEFAULT_CONTAINER_WAGON_TYPE, - ); -} - -export function getDefaultContainerWagonTypeCode(): string { - return DEFAULT_CONTAINER_WAGON_TYPE; -} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 8e6d8a0a8..f0a77791a 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -14,13 +14,17 @@ 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'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('vehicles') @ApiBearerAuth() @Controller('vehicles') @FleetView() export class VehiclesController { - constructor(private readonly vehiclesService: VehiclesService) {} + constructor( + private readonly vehiclesService: VehiclesService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() @FleetManage() @@ -57,6 +61,12 @@ export class VehiclesController { return this.vehiclesService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get vehicle assignment, status & mile history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getVehicleHistory(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a vehicle' }) diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 69568e483..25260e86f 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -9,12 +9,15 @@ import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile- import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity'; import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class VehiclesService { constructor( @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateVehicleDto): Promise { @@ -34,7 +37,28 @@ export class VehiclesService { registrationNumber, }); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + await this.history.record({ + eventType: FleetEventType.VEHICLE_REGISTERED, + vehicleId: saved.id, + label: saved.plateNumber ?? saved.code ?? null, + toValue: saved.availability ?? null, + }); + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: saved.id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + metadata: { + vehiclePlate: saved.plateNumber ?? saved.code ?? null, + driverName: saved.assignedDriverName ?? null, + }, + }); + } + + return saved; } async findAll(query: { @@ -97,12 +121,76 @@ export class VehiclesService { } } + const prev = { + assignedDriverId: vehicle.assignedDriverId, + assignedDriverName: vehicle.assignedDriverName, + status: vehicle.status, + availability: vehicle.availability, + }; + Object.assign(vehicle, dto); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + // Driver (re)assignment — emit an unassign for the old driver and/or an + // assign for the new one so both drivers' timelines and the vehicle's line up. + if ( + dto.assignedDriverId !== undefined && + dto.assignedDriverId !== prev.assignedDriverId + ) { + const vehiclePlate = saved.plateNumber ?? saved.code ?? null; + if (prev.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_UNASSIGNED, + vehicleId: id, + driverId: prev.assignedDriverId, + label: prev.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: prev.assignedDriverName ?? null }, + }); + } + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: saved.assignedDriverName ?? null }, + }); + } + } + if (dto.status !== undefined && dto.status !== prev.status) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_STATUS_CHANGED, + vehicleId: id, + fromValue: prev.status ?? null, + toValue: saved.status ?? null, + }); + } + if (dto.availability !== undefined && dto.availability !== prev.availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: prev.availability ?? null, + toValue: saved.availability ?? null, + }); + } + + return saved; } async setAvailability(id: string, availability: VehicleAvailability): Promise { + // Read the current value so the audit event records an accurate from→to and + // we skip logging no-op writes (setAvailability is called in release loops). + const vehicle = await this.vehicleRepo.findOne({ where: { id } }); + const previous = vehicle?.availability; await this.vehicleRepo.update(id, { availability }); + if (previous !== availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: previous ?? null, + toValue: availability, + }); + } } /** diff --git a/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts b/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts new file mode 100644 index 000000000..3435acabc --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts @@ -0,0 +1,49 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * One row per started Fayda verification. Mirrors the passenger-api Prisma + * model `FaydaVerificationSession`, but stored in the freight schema via + * TypeORM. `state` is the single-use CSRF token that links the eSignet + * redirect back to this session. + */ +@Entity({ name: 'fayda_verification_sessions', schema: 'freight' }) +@Index(['expiresAt']) +@Index(['iamUserId']) +export class FaydaVerificationSession extends BaseEntity { + @Column({ name: 'state', unique: true }) + state!: string; + + @Column({ name: 'code_verifier' }) + codeVerifier!: string; + + /** VERIFY | LOGIN */ + @Column({ name: 'purpose', default: 'VERIFY' }) + purpose!: string; + + /** WEB | MOBILE — recorded for audit */ + @Column({ name: 'platform', default: 'WEB' }) + platform!: string; + + @Column({ name: 'save_to_account', type: 'boolean', default: false }) + saveToAccount!: boolean; + + /** PENDING | COMPLETED | FAILED */ + @Column({ name: 'status', default: 'PENDING' }) + status!: string; + + @Column({ name: 'error_code', type: 'varchar', nullable: true }) + errorCode?: string | null; + + @Column({ name: 'error_description', type: 'text', nullable: true }) + errorDescription?: string | null; + + @Column({ name: 'iam_user_id', type: 'uuid', nullable: true }) + iamUserId?: string | null; + + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt!: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts new file mode 100644 index 000000000..1c5569750 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { VerifaydaCallbackDto } from './verifayda.dto'; + +/** + * Plain acknowledgement endpoint for the Fayda redirect_uri when it points at + * the API instead of the web app (e.g. MOBILE clients or connectivity checks). + * Registered at /callback (excluded from the global /api prefix in main.ts). + * It does NOT consume the verification session — the client must still call + * GET /api/fayda/verification/complete with the echoed code+state. + */ +@ApiTags('Fayda Verification') +@Controller('callback') +export class FaydaCallbackController { + @Get() + @IsPublic() + @ApiOperation({ summary: 'Acknowledge a Fayda redirect (returns OK, echoes code/state)' }) + @ApiOkResponse({ + schema: { example: { status: 'ok', code: '...', state: '...' } }, + }) + ok(@Query() query: VerifaydaCallbackDto) { + return { + status: 'ok', + ...(query.code ? { code: query.code } : {}), + ...(query.state ? { state: query.state } : {}), + ...(query.error ? { error: query.error } : {}), + ...(query.error_description ? { error_description: query.error_description } : {}), + }; + } +} + +// return res.redirect(url.toString()); diff --git a/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts b/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts new file mode 100644 index 000000000..8673aa60e --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts @@ -0,0 +1,30 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { DataSource } from 'typeorm'; + +/** + * Like the IAM JwtGuard, but never rejects the request. + * + * When a valid IAM bearer token is present, `request.user` is populated with + * the package `TCurrentUser`. Missing or invalid tokens continue as guests. + */ +@Injectable() +export class OptionalJwtGuard extends IamJwtGuard implements CanActivate { + constructor( + reflector: Reflector, + @InjectDataSource() dataSource: DataSource, + ) { + super(reflector, dataSource); + } + + async canActivate(context: ExecutionContext): Promise { + try { + await super.canActivate(context); + } catch { + context.switchToHttp().getRequest().user = undefined; + } + return true; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts new file mode 100644 index 000000000..9b4316fc7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts @@ -0,0 +1,71 @@ +import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose'; +import { generateClientAssertion } from './client-assertion.util'; + +describe('generateClientAssertion', () => { + let privateJwk: JWK; + let publicJwk: JWK; + + beforeAll(async () => { + const kp = await generateKeyPair('RS256', { extractable: true }); + privateJwk = await exportJWK(kp.privateKey); + publicJwk = await exportJWK(kp.publicKey); + }); + + it('produces a JWT verifiable with the matching public key', async () => { + const jwt = await generateClientAssertion({ + clientId: 'edr-passenger-test', + audience: 'https://esignet.example.com/token', + privateJwk, + }); + + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload, protectedHeader } = await jwtVerify(jwt, verifier, { + issuer: 'edr-passenger-test', + subject: 'edr-passenger-test', + audience: 'https://esignet.example.com/token', + }); + + expect(protectedHeader.alg).toBe('RS256'); + expect(protectedHeader.typ).toBe('JWT'); + expect(payload.iss).toBe('edr-passenger-test'); + expect(payload.sub).toBe('edr-passenger-test'); + expect(payload.aud).toBe('https://esignet.example.com/token'); + expect(typeof payload.iat).toBe('number'); + expect(typeof payload.exp).toBe('number'); + }); + + it('defaults exp to 120 seconds after iat', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + }); + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload } = await jwtVerify(jwt, verifier); + expect(payload.exp! - payload.iat!).toBe(120); + }); + + it('honors a custom expiresIn', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + expiresIn: '5m', + }); + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload } = await jwtVerify(jwt, verifier); + expect(payload.exp! - payload.iat!).toBe(300); + }); + + it('fails verification against a wrong audience', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + }); + const verifier = await importJWK(publicJwk, 'RS256'); + await expect( + jwtVerify(jwt, verifier, { audience: 'https://other/token' }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts new file mode 100644 index 000000000..dc3558ccc --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -0,0 +1,22 @@ +import { SignJWT, importJWK, type JWK } from 'jose'; + +export interface GenerateClientAssertionInput { + clientId: string; + audience: string; + privateJwk: JWK; + expiresIn?: string; +} + +export async function generateClientAssertion( + input: GenerateClientAssertionInput, +): Promise { + const privateKey = await importJWK(input.privateJwk, 'RS256'); + return new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(input.clientId) + .setSubject(input.clientId) + .setAudience(input.audience) + .setIssuedAt() + .setExpirationTime(input.expiresIn ?? '2m') + .sign(privateKey); +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts new file mode 100644 index 000000000..d359a07f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts @@ -0,0 +1,65 @@ +import { createHash } from 'crypto'; +import { + base64Url, + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './pkce.util'; + +describe('pkce.util', () => { + describe('base64Url', () => { + it('strips padding and replaces + and / with - and _', () => { + const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]); + const out = base64Url(input); + expect(out).not.toMatch(/[+/=]/); + }); + }); + + describe('generateCodeVerifier', () => { + it('returns a base64url-safe string', () => { + expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces unique values across calls', () => { + const a = generateCodeVerifier(); + const b = generateCodeVerifier(); + expect(a).not.toEqual(b); + }); + + it('produces at least 43 characters (RFC 7636 minimum)', () => { + expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43); + }); + }); + + describe('generateCodeChallenge', () => { + it('equals base64url(sha256(verifier))', () => { + const verifier = 'fixed-test-verifier'; + const expected = createHash('sha256') + .update(verifier) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + expect(generateCodeChallenge(verifier)).toBe(expected); + }); + + it('is deterministic for the same verifier', () => { + const verifier = generateCodeVerifier(); + expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier)); + }); + + it('differs for different verifiers', () => { + expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b')); + }); + }); + + describe('generateState', () => { + it('returns a base64url-safe string', () => { + expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces unique values across calls', () => { + expect(generateState()).not.toEqual(generateState()); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts new file mode 100644 index 000000000..89e9437d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts @@ -0,0 +1,21 @@ +import { createHash, randomBytes } from 'crypto'; + +export function base64Url(buffer: Buffer): string { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +export function generateCodeVerifier(): string { + return base64Url(randomBytes(64)); +} + +export function generateCodeChallenge(codeVerifier: string): string { + return base64Url(createHash('sha256').update(codeVerifier).digest()); +} + +export function generateState(): string { + return base64Url(randomBytes(32)); +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts new file mode 100644 index 000000000..977e677f8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { OptionalJwtGuard } from './optional-jwt.guard'; +import { + CompleteVerificationResultDto, + StartVerificationDto, + VerifaydaCallbackDto, + VerificationStatusDto, +} from './verifayda.dto'; +import { VerifaydaService } from './verifayda.service'; + +/** Minimal slices of the Express req we touch (avoids a hard dependency on + * `@types/express`, which isn't resolved in this package). */ +interface RequestWithOptionalUser { + user?: TCurrentUser; +} +interface RequestWithUser { + user: TCurrentUser; +} + +@ApiTags('Fayda Verification') +@Controller('fayda/verification') +export class VerifaydaController { + constructor(private readonly service: VerifaydaService) {} + + @Post('start') + @IsPublic() + @HttpCode(HttpStatus.OK) + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Start a VeriFayda 2.0 verification session', + description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to. + +- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user. +- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender). +- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT. +- The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`, + }) + @ApiOkResponse({ + description: 'Authorize URL the frontend should redirect the user to.', + schema: { + example: { + authorizationUrl: + 'https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...', + }, + }, + }) + async start( + @Body() dto: StartVerificationDto, + @Req() req: RequestWithOptionalUser, + ): Promise<{ authorizationUrl: string }> { + const authorizationUrl = await this.service.startVerification({ + purpose: dto.purpose ?? 'VERIFY', + platform: dto.platform ?? 'WEB', + userId: req.user?.id, + wantsPasswordSetup: dto.wantsPasswordSetup ?? false, + }); + return { authorizationUrl }; + } + + @Get('complete') + @IsPublic() + @ApiOperation({ + summary: 'Complete a verification (Fayda redirect / client callback lands here)', + description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``, + }) + @ApiOkResponse({ type: CompleteVerificationResultDto }) + async complete( + @Query() dto: VerifaydaCallbackDto, + ): Promise { + return this.service.completeVerification(dto); + } + + @Get('status') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: "Get the current user's Fayda verification status", + description: + 'Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.', + }) + @ApiOkResponse({ type: VerificationStatusDto }) + async status( + @Req() req: RequestWithUser, + ): Promise { + return this.service.getVerificationStatus(req.user.id); + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts new file mode 100644 index 000000000..1885b11e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts @@ -0,0 +1,105 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export class StartVerificationDto { + @ApiPropertyOptional({ + enum: ['LOGIN', 'VERIFY'], + default: 'VERIFY', + description: + 'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.', + }) + @IsOptional() + @IsIn(['LOGIN', 'VERIFY']) + purpose?: 'LOGIN' | 'VERIFY'; + + @ApiPropertyOptional({ + enum: ['WEB', 'MOBILE'], + default: 'WEB', + description: + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', + }) + @IsOptional() + @IsIn(['WEB', 'MOBILE']) + platform?: 'WEB' | 'MOBILE'; + + @ApiPropertyOptional({ + type: Boolean, + default: false, + description: + 'Set to true when the user opts in to full account registration (checkbox). ' + + 'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' + + 'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.', + }) + @IsOptional() + wantsPasswordSetup?: boolean; +} + +export class CompleteVerificationResultDto { + @ApiProperty({ enum: ['LOGIN', 'VERIFY'] }) + purpose!: 'LOGIN' | 'VERIFY'; + + @ApiProperty() verified!: boolean; + + @ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' }) + token?: string; + + @ApiPropertyOptional() + refreshToken?: string; + + @ApiPropertyOptional({ + description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).', + }) + user?: { + id: string; + email: string; + role: string; + passengerId?: string; + agentId?: string; + }; + + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) + fullName?: string; + + @ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' }) + email?: string; + + @ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' }) + phoneNumber?: string; + + @ApiPropertyOptional({ + description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).', + }) + birthdate?: string; + + @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) + gender?: string; + + @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) + userDataSaved?: boolean; + + @ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' }) + iamUserId?: string; + + @ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' }) + requiresPassword?: boolean; + + @ApiPropertyOptional({ + description: + 'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' + + 'AND they have not yet set a password. Frontend should navigate to the set-password screen.', + }) + promptPasswordSetup?: boolean; +} + +export class VerifaydaCallbackDto { + @ApiPropertyOptional() @IsOptional() @IsString() code?: string; + @ApiPropertyOptional() @IsOptional() @IsString() state?: string; + @ApiPropertyOptional() @IsOptional() @IsString() error?: string; + @ApiPropertyOptional() @IsOptional() @IsString() error_description?: string; +} + +export class VerificationStatusDto { + @ApiProperty() verified!: boolean; + @ApiPropertyOptional() verifiedAt?: Date; + @ApiPropertyOptional() fullName?: string; +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts new file mode 100644 index 000000000..a7d531102 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts @@ -0,0 +1,19 @@ +import { BadGatewayException, ConflictException } from '@nestjs/common'; + +export class FaydaTokenExchangeException extends BadGatewayException { + constructor(message = 'Fayda token exchange failed') { + super({ code: 'FAYDA_TOKEN_EXCHANGE_FAILED', message }); + } +} + +export class FaydaUserInfoException extends BadGatewayException { + constructor(message = 'Fayda userinfo fetch failed') { + super({ code: 'FAYDA_USERINFO_FAILED', message }); + } +} + +export class FaydaIdentityConflictException extends ConflictException { + constructor(message = 'This Fayda identity is already linked to another account') { + super({ code: 'FAYDA_IDENTITY_CONFLICT', message }); + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts new file mode 100644 index 000000000..82fb9435c --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { VerifaydaController } from './verifayda.controller'; +import { FaydaCallbackController } from './fayda-callback.controller'; +import { VerifaydaService } from './verifayda.service'; +import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([FaydaVerificationSession])], + controllers: [VerifaydaController, FaydaCallbackController], + providers: [VerifaydaService], + exports: [VerifaydaService], +}) +export class VerifaydaModule {} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts new file mode 100644 index 000000000..6e3e2e095 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -0,0 +1,597 @@ +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token'; +import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; +import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; +import { + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './utils/pkce.util'; +import { generateClientAssertion } from './utils/client-assertion.util'; +import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto'; +import { + FaydaTokenExchangeException, + FaydaUserInfoException, +} from './verifayda.errors'; +import { + FaydaTokenResponse, + FaydaUserInfo, + NormalizedFaydaUserInfo, + VerifaydaPurpose, +} from './verifayda.types'; + +export interface StartVerificationInput { + purpose: VerifaydaPurpose; + platform?: FaydaPlatform; + userId?: string; // iamUserId of the authenticated user, if any + wantsPasswordSetup?: boolean; +} + +export interface FaydaUserSummary { + id: string; + email: string; + role: string; +} + +/** + * Result of completing a verification. `verified` is always true on success. + * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity + * attributes (name, email, phone, dob, gender) for the caller to consume. + */ +export interface CompleteVerificationResult { + purpose: VerifaydaPurpose; + verified: boolean; + token?: string; + refreshToken?: string; + requiresPassword?: boolean; + promptPasswordSetup?: boolean; + iamUserId?: string; + user?: FaydaUserSummary; + fullName?: string; + email?: string; + phoneNumber?: string; + birthdate?: string; + gender?: string; + userDataSaved?: boolean; +} + +@Injectable() +export class VerifaydaService { + private readonly logger = new Logger(VerifaydaService.name); + + private readonly faydaConfig: FaydaConfig; + + constructor( + private readonly config: ConfigService, + @InjectRepository(FaydaVerificationSession) + private readonly sessionRepo: Repository, + @InjectDataSource() private readonly dataSource: DataSource, + ) { + const fayda = this.config.get('fayda'); + if (!fayda) { + throw new Error('Fayda config namespace not registered'); + } + this.faydaConfig = fayda; + } + + // ========================================================================== + // OIDC flow + // ========================================================================== + + async startVerification(input: StartVerificationInput): Promise { + if (!this.faydaConfig.enabled) { + throw new ServiceUnavailableException({ + code: 'FAYDA_DISABLED', + message: 'Fayda integration is not enabled', + }); + } + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const expiresAt = new Date( + Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000, + ); + + await this.sessionRepo.save( + this.sessionRepo.create({ + state, + codeVerifier, + purpose: input.purpose, + platform: input.platform ?? 'WEB', + saveToAccount: input.wantsPasswordSetup ?? false, + iamUserId: input.userId ?? null, + expiresAt, + }), + ); + + this.logger.log( + `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, + ); + + return this.buildAuthorizationUrl({ + state, + codeChallenge, + redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'), + }); + } + + /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + private redirectUriForPlatform(platform?: FaydaPlatform): string { + return platform === 'MOBILE' + ? this.faydaConfig.redirectUri + : this.faydaConfig.webRedirectUri; + } + + async completeVerification( + query: VerifaydaCallbackDto, + ): Promise { + if (query.error) { + this.logger.warn(`Fayda callback returned error: ${query.error}`); + if (query.state) { + await this.markSessionFailed( + query.state, + query.error, + query.error_description, + ); + } + throw new BadRequestException({ + code: 'FAYDA_AUTH_ERROR', + message: query.error, + description: query.error_description, + }); + } + + if (!query.code || !query.state) { + throw new BadRequestException({ + code: 'FAYDA_MISSING_PARAMETERS', + message: 'code and state are required', + }); + } + + const session = await this.sessionRepo.findOne({ + where: { state: query.state }, + }); + if (!session || session.status !== 'PENDING') { + this.logger.warn('Fayda complete with unknown or non-pending state'); + throw new BadRequestException({ + code: 'FAYDA_INVALID_STATE', + message: 'Verification session is invalid or already used', + }); + } + if (session.expiresAt.getTime() < Date.now()) { + await this.markSessionFailed(query.state, 'session_expired'); + throw new BadRequestException({ + code: 'FAYDA_SESSION_EXPIRED', + message: 'Verification session has expired; start again', + }); + } + + try { + const tokens = await this.exchangeCodeForTokens( + query.code, + session.codeVerifier, + this.redirectUriForPlatform(session.platform as FaydaPlatform), + ); + const userInfo = await this.fetchUserInfo(tokens.access_token); + const normalized = this.normalizeUserInfo(userInfo); + + if (!normalized.sub) { + throw new FaydaUserInfoException('Fayda userinfo missing required sub'); + } + + let result: CompleteVerificationResult; + if (session.purpose === 'LOGIN') { + const { userId } = await this.handleLoginSuccess(normalized); + const login = await this.issueLoginToken(userId); + result = { purpose: 'LOGIN', verified: true, ...login }; + } else { + // VERIFY — prove identity, save to IAM, return verified attributes + short-lived token. + const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized); + + let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined; + if (iamUserId) { + try { + sessionToken = await this.createFaydaSession(iamUserId); + } catch (err) { + this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`); + } + } + + result = { + purpose: 'VERIFY', + verified: true, + fullName: normalized.fullName, + email: normalized.email, + phoneNumber: normalized.phoneNumber, + birthdate: normalized.birthdate, + gender: normalized.gender, + userDataSaved, + iamUserId: iamUserId ?? undefined, + token: sessionToken?.token, + refreshToken: sessionToken?.refreshToken, + requiresPassword: sessionToken?.requiresPassword, + promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false), + }; + } + + await this.sessionRepo.update(session.id, { + status: 'COMPLETED', + completedAt: new Date(), + codeVerifier: '', + }); + + this.logger.log( + `Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`, + ); + return result; + } catch (err) { + const reason = this.classifyFailureReason(err); + this.logger.error( + `Fayda verification failed: reason=${reason} message=${(err as Error).message}`, + ); + await this.markSessionFailed( + query.state, + reason, + (err as Error).message, + ); + throw err; + } + } + + private async issueLoginToken( + _userId: string, + ): Promise<{ token: string; user: FaydaUserSummary }> { + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package auth endpoints.', + }); + } + + async getVerificationStatus(iamUserId: string): Promise { + const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>( + `SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + const iam = rows[0] ?? null; + const faydaVerified = iam?.verified_by === 'fayda'; + const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined; + const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined; + return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName }; + } + + // ========================================================================== + // OIDC internals + // ========================================================================== + + private buildAuthorizationUrl(args: { + state: string; + codeChallenge: string; + redirectUri: string; + }): string { + const params = new URLSearchParams({ + client_id: this.faydaConfig.clientId, + response_type: 'code', + redirect_uri: args.redirectUri, + scope: this.faydaConfig.scope, + state: args.state, + code_challenge: args.codeChallenge, + code_challenge_method: 'S256', + acr_values: this.faydaConfig.acrValues, + claims_locales: this.faydaConfig.claimsLocales, + }); + + // Every claim is marked essential so eSignet shows them locked/pre-checked + // on the consent screen — the user cannot toggle any off; they either + // consent to all of them or the whole flow is cancelled (?error=...). + const claims = { + userinfo: { + name: { essential: true }, + phone_number: { essential: true }, + email: { essential: true }, + birthdate: { essential: true }, + gender: { essential: true }, + address: { essential: true }, + nationality: { essential: true }, + picture: { essential: true }, + }, + id_token: {}, + }; + params.set('claims', JSON.stringify(claims)); + + return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`; + } + + private async exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string, + ): Promise { + const clientAssertion = await generateClientAssertion({ + clientId: this.faydaConfig.clientId, + audience: this.faydaConfig.tokenEndpoint, + privateJwk: this.faydaConfig.privateJwk, + }); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: this.faydaConfig.clientId, + client_assertion_type: + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: clientAssertion, + code_verifier: codeVerifier, + }); + + const response = await fetch(this.faydaConfig.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!response.ok) { + let detail = ''; + try { + detail = await response.text(); + } catch { + // ignore + } + throw new FaydaTokenExchangeException( + `Fayda token endpoint returned ${response.status}${detail ? `: ${detail}` : ''}`, + ); + } + + return (await response.json()) as FaydaTokenResponse; + } + + private async fetchUserInfo(accessToken: string): Promise { + const response = await fetch(this.faydaConfig.userInfoEndpoint, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + throw new FaydaUserInfoException( + `Fayda userinfo endpoint returned ${response.status}`, + ); + } + + const contentType = response.headers.get('content-type') ?? ''; + const raw = await response.text(); + + if (contentType.includes('application/json')) { + return JSON.parse(raw) as FaydaUserInfo; + } + + // Signed JWT response — decode payload (signature verification = production TODO) + if (raw.split('.').length === 3) { + const payloadB64 = raw.split('.')[1]; + const normalizedB64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/'); + const json = Buffer.from(normalizedB64, 'base64').toString('utf8'); + return JSON.parse(json) as FaydaUserInfo; + } + + throw new FaydaUserInfoException( + 'Unsupported Fayda userinfo response format', + ); + } + + private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo { + const nameEn = raw['name#en'] as string | undefined; + const nameAm = raw['name#am'] as string | undefined; + const genderEn = raw['gender#en'] as string | undefined; + const genderAm = raw['gender#am'] as string | undefined; + const addressEn = raw['address#en'] as string | undefined; + const addressAm = raw['address#am'] as string | undefined; + const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined; + + return { + sub: raw.sub, + fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm, + phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined, + rawPhoneNumber: rawPhone, + email: raw.email as string | undefined, + gender: genderEn ?? genderAm ?? (raw.gender as string | undefined), + birthdate: raw.birthdate as string | undefined, + picture: raw.picture as string | undefined, + nameEn, + nameAm, + genderEn, + genderAm, + addressEn, + addressAm, + }; + } + + private standardizePhoneNumber(phone: string): string { + const digits = phone.replace(/\D/g, ''); + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + return `+${digits}`; + } + + // LOGIN via Fayda is handled entirely by the IAM package's own OIDC flow. + // This method is kept as a stub so completeVerification() still compiles; + // it throws immediately without touching the database. + private async handleLoginSuccess( + _normalized: NormalizedFaydaUserInfo, + ): Promise<{ userId: string }> { + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.', + }); + } + + private async upsertIamUser( + normalized: NormalizedFaydaUserInfo, + ): Promise<{ iamUserId: string | null; userDataSaved: boolean }> { + try { + const iamMetadata = { + sub: normalized.sub, + address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' }, + email: normalized.email ?? '', + gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' }, + name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }, + phoneNumber: normalized.rawPhoneNumber ?? '', + }; + + // Step 1 — already linked to this Fayda sub; ensure verified_by is set + const bySub = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`, + [normalized.sub], + ); + if (bySub.length > 0) { + await this.dataSource.query( + `UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`, + [bySub[0].id], + ); + return { iamUserId: bySub[0].id, userDataSaved: true }; + } + + // Step 2 — existing user by phone or email, not yet Fayda-verified + const conditions: string[] = []; + const params: unknown[] = []; + if (normalized.phoneNumber) { + params.push(normalized.phoneNumber); + conditions.push(`phone_number = $${params.length}`); + } + if (normalized.email) { + params.push(normalized.email); + conditions.push(`email = $${params.length}`); + } + if (conditions.length > 0) { + const byContact = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`, + params, + ); + if (byContact.length > 0) { + const existingId = byContact[0].id; + await this.dataSource.query( + `UPDATE iam.users + SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb, + verified_by = 'fayda', + updated_at = NOW() + WHERE id = $2`, + [JSON.stringify(iamMetadata), existingId], + ); + return { iamUserId: existingId, userDataSaved: true }; + } + } + + // Step 3 — new user + const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }; + const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub; + const inserted = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.users ( + id, name, username, email, phone_number, metadata, + user_type, status, is_active, has_set_password, + is_phone_number_verified, verified_by, + created_at, updated_at + ) VALUES ( + gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb, + 'individual', 'submitted', true, false, + false, 'fayda', + NOW(), NOW() + ) RETURNING id`, + [ + JSON.stringify(name), + username, + normalized.email ?? null, + normalized.phoneNumber ?? null, + JSON.stringify(iamMetadata), + ], + ); + return { iamUserId: inserted[0].id, userDataSaved: true }; + } catch (err) { + this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`); + return { iamUserId: null, userDataSaved: false }; + } + } + + private async createFaydaSession( + iamUserId: string, + ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> { + const rows = await this.dataSource.query<{ + id: string; + email: string; + name: { en: string; am: string } | null; + username: string; + phone_number: string | null; + has_set_password: boolean; + status: string; + }[]>( + `SELECT id, email, name, username, phone_number, has_set_password, status + FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`); + const u = rows[0]; + + const userInfo = { + id: u.id, + email: u.email ?? '', + name: u.name ?? { en: '', am: '' }, + userType: 'individual', + status: u.status, + hasSetPassword: u.has_set_password, + isPhoneNumberVerified: false, + hasFinishedRegistration: false, + hasFinishedDMSOnboarding: false, + username: u.username, + phoneNumber: u.phone_number ?? '', + roles: [], + permissions: [], + employee: [], + }; + + const sessions = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.sessions + (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) + VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3) + ON CONFLICT (user_id, device) DO UPDATE + SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo", + expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW() + RETURNING id`, + [u.email ?? '', JSON.stringify(userInfo), iamUserId], + ); + + const sessionId = sessions[0].id; + const token = generateToken({ id: sessionId }); + const refreshToken = generateRefreshToken({ id: sessionId }); + + return { token, refreshToken, requiresPassword: !u.has_set_password }; + } + + private async markSessionFailed( + state: string, + errorCode: string, + errorDescription?: string, + ): Promise { + await this.sessionRepo.update( + { state, status: 'PENDING' }, + { + status: 'FAILED', + errorCode, + errorDescription: errorDescription ?? null, + completedAt: new Date(), + codeVerifier: '', + }, + ); + } + + private classifyFailureReason(err: unknown): string { + if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed'; + if (err instanceof FaydaUserInfoException) return 'userinfo_failed'; + return 'verification_failed'; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts new file mode 100644 index 000000000..442a22d2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts @@ -0,0 +1,45 @@ +export type VerifaydaPurpose = 'LOGIN' | 'VERIFY'; + +export interface FaydaTokenResponse { + access_token: string; + id_token?: string; + token_type: string; + expires_in?: number; + scope?: string; +} + +export interface FaydaUserInfo { + sub: string; + name?: string; + 'name#en'?: string; + 'name#am'?: string; + phone_number?: string; + 'phone_number#en'?: string; + 'phone_number#am'?: string; + phone?: string; + email?: string; + gender?: string; + birthdate?: string; + picture?: string; + address?: Record; + [key: string]: unknown; +} + +export interface NormalizedFaydaUserInfo { + sub: string; + // Convenience / display fields + fullName?: string; + phoneNumber?: string; // standardized e.g. +251911234567 + email?: string; + gender?: string; + birthdate?: string; + picture?: string; + // Raw localized fields — preserved for IAM-identical writes + nameEn?: string; + nameAm?: string; + genderEn?: string; + genderAm?: string; + addressEn?: string; + addressAm?: string; + rawPhoneNumber?: string; // unstandardized, stored in IAM metadata +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts index 43cd6f61a..8d18d2a1f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts @@ -1,9 +1,10 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, Matches } from 'class-validator'; export class CreateAllocationRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiPropertyOptional({ default: 100 }) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 788c798bf..5a8025948 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; @@ -7,6 +7,7 @@ export class CreateWarehouseDto { @ApiProperty() @IsString() @MaxLength(160) + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index e2c20901d..22e9f9491 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator'; import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; @@ -25,6 +25,7 @@ export class FeeRuleTierDto { export class CreateFeeRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty({ enum: FEE_RULE_TYPES }) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 9f89e7c2c..49fb22fde 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -52,6 +52,11 @@ export class FilterWarehouseInventoryDto { @IsEnum(WAREHOUSE_INVENTORY_STATUSES) status?: WarehouseInventoryStatus; + @ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT'] }) + @IsOptional() + @IsEnum(['IMPORT', 'EXPORT']) + direction?: 'IMPORT' | 'EXPORT'; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 11f34c892..ac6b71c89 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -430,6 +430,7 @@ export class WarehouseInventoryService { ...(filter.status ? { status: filter.status } : {}), ...(createdAt ? { createdAt } : {}), ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), + ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; const search = filter.search?.trim(); @@ -2032,6 +2033,22 @@ export class WarehouseInventoryService { const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); + + if (item.bookingId) { + const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = + await this.dataSource.query( + `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { + throw new BadRequestException( + 'Customer must approve delivery (sign the handover) before the exit paper can be generated', + ); + } + } } const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() diff --git a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts index 2d3c47c26..53d6c9eec 100644 --- a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts +++ b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts @@ -3,6 +3,7 @@ import { WagonStatus } from '@edr/types'; import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Company } from '../modules/companies/entities/company.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; @@ -116,6 +117,11 @@ export class MarshallingDemoTrainsSeeder { ? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }) : null; + // bookings.company_id is NOT NULL — reuse any seeded company for the demo. + const company = await this.dataSource + .getRepository(Company) + .findOne({ where: {}, order: { createdAt: 'ASC' } }); + const missing = [ !djiboutiYard ? 'Djibouti yard' : '', !ethiopiaYard ? 'Ethiopia yard' : '', @@ -124,6 +130,7 @@ export class MarshallingDemoTrainsSeeder { !warehouse ? 'INDODE_OPEN warehouse' : '', !warehouseYard ? 'warehouse yard' : '', !warehouseZone ? 'warehouse zone' : '', + !company ? 'company' : '', ].filter(Boolean); if (missing.length) { this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`); @@ -141,6 +148,7 @@ export class MarshallingDemoTrainsSeeder { warehouse: warehouse!, warehouseYard: warehouseYard!, warehouseZone: warehouseZone!, + company: company!, }); if (created) seeded += 1; } @@ -164,6 +172,7 @@ export class MarshallingDemoTrainsSeeder { warehouse: Warehouse; warehouseYard: WarehouseYard; warehouseZone: WarehouseZone; + company: Company; }, ): Promise { const bookingRepo = this.dataSource.getRepository(Booking); @@ -231,6 +240,7 @@ export class MarshallingDemoTrainsSeeder { const booking = await bookingRepo.save( bookingRepo.create({ reference: bookingReference, + companyId: refs.company.id, originYardId: originYard.id, destinationYardId: destinationYard.id, serviceTypeId: refs.serviceType.id, diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 02a05602e..aa3f2c9bb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -319,37 +319,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); - const base = new Date("2026-01-01"); - const rules = [ - { - containerTypeId: twenty.id, - tradeDirection: "IMPORT", - maxVgmTons: 26, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: twenty.id, - tradeDirection: "EXPORT", - maxVgmTons: 26, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: forty.id, - tradeDirection: "IMPORT", - maxVgmTons: 28, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: forty.id, - tradeDirection: "EXPORT", - maxVgmTons: 28, - effectiveFrom: base, - isActive: true, - }, + { containerTypeId: twenty.id, tradeDirection: "IMPORT", maxVgmTons: 26 }, + { containerTypeId: twenty.id, tradeDirection: "EXPORT", maxVgmTons: 26 }, + { containerTypeId: forty.id, tradeDirection: "IMPORT", maxVgmTons: 28 }, + { containerTypeId: forty.id, tradeDirection: "EXPORT", maxVgmTons: 28 }, ]; for (const rule of rules) { @@ -361,10 +335,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { }); if (existing) { - await wlRepo.update(existing.id, { - maxVgmTons: rule.maxVgmTons, - effectiveFrom: rule.effectiveFrom, - }); + await wlRepo.update(existing.id, { maxVgmTons: rule.maxVgmTons }); } else { await wlRepo.insert(rule); } @@ -409,7 +380,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { ctByCode: Map, cargoByCode: Map, ): Promise { - const effectiveFrom = new Date("2026-01-01"); const now = new Date(); // Each rate is self-describing: `appliesTo` + `trigger` decide how the @@ -445,6 +415,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, + // ── First/last-mile road haulage (per km) — drives the mile invoices ── + { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, + { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, ]; // Idempotent: insert each canonical rate only if no row with the same @@ -479,7 +452,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { proposedByStaffId: STAFF_USER_ID, approvedByCeoId: CEO_USER_ID, approvedAt: now, - effectiveFrom, })) .filter((d) => !existingBySignature.has(signature(d))); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 53d636145..92784cd38 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -52,8 +52,9 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; -import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; -import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; +// Hidden for now — Shipment Requests pages disabled (imports kept commented). +// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; +// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; @@ -83,6 +84,8 @@ import UsersPage from "./pages/dashboard/user-management/UsersPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; +import DriverDetailPage from "./pages/fleet/DriverDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; import FuelStatsPage from "./pages/fleet/FuelStatsPage"; @@ -120,6 +123,7 @@ import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; import { HealthCheck } from "./features/health/HealthCheck"; +import FaydaCallbackPage from "./pages/FaydaCallbackPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -178,6 +182,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, + // Hidden for now — Shipment Requests nav item disabled. // { // label: "Shipment Requests", // href: "/dashboard/shipment-requests", @@ -328,7 +333,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , }, { @@ -375,7 +380,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , }, ], @@ -567,6 +572,7 @@ const App = () => { } /> } /> + } /> } /> ); @@ -576,6 +582,7 @@ const App = () => { } /> } /> + } /> } /> { } /> + {/* Hidden for now — Shipment Requests pages disabled. { } /> + */} {/* GL (Path B) contract clearance review hub */} { } /> + + + + } + /> { } /> + + + + } + /> vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), + queryFn: async () => { + const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }); + return res.data ?? []; + }, }); const vehicleOptions = useMemo( @@ -85,13 +87,12 @@ export function ContainerAllocationTable({ }); const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; if (vehiclesLoading) { return ( - + - + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx deleted file mode 100644 index 78c5160ca..000000000 --- a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState, useMemo } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Box, - Button, - Group, - Loader, - Select, - Stack, - Table, - Text, - Alert, -} from "@mantine/core"; -import { AlertCircle } from "lucide-react"; -import toast from "react-hot-toast"; - -import { vehiclesService } from "@/services/vehicles.service"; - -export interface ContainerAllocationRow { - id: string; - type: string; - qty: number; -} - -export interface FirstMileContainerAllocationTableProps { - firstMileId: string; - containers: ContainerAllocationRow[]; - onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; -} - -/** - * Manual container-to-vehicle allocation table for first-mile pickups. - * Displays containers with type/qty, vehicle dropdown per row, and save action. - */ -export function FirstMileContainerAllocationTable({ - firstMileId, - containers, - onSave, -}: FirstMileContainerAllocationTableProps) { - const [allocations, setAllocations] = useState>( - () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - - const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), - }); - - const vehicleOptions = useMemo( - () => - vehicles.map((v) => ({ - value: v.id, - label: `${v.plateNumber} (${v.vehicleType})`, - description: `${v.model} · ${v.manufacturer}`, - })), - [vehicles], - ); - - const saveAllocation = useMutation({ - mutationFn: async () => { - const mappings = containers - .filter((c) => allocations[c.id]) - .map((c) => ({ - containerId: c.id, - vehicleId: allocations[c.id]!, - })); - - if (mappings.length === 0) { - throw new Error("No containers allocated to vehicles"); - } - - await onSave(mappings); - }, - onSuccess: () => { - toast.success("Container allocations saved"); - setAllocations( - containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : "Failed to save allocations", - ); - }, - }); - - const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; - - if (vehiclesLoading) { - return ( - - - - ); - } - - return ( - - {vehicles.length === 0 && ( - } color="yellow"> - No free vehicles available. Free up or add vehicles before allocating containers. - - )} - - - - - - Container ID - Type - Qty - Assigned Vehicle - - - - {containers.map((container) => ( - - - - {container.id} - - - {container.type} - {container.qty} - -
-
- - - - {allocatedCount} of {containers.length} containers allocated - - - -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx deleted file mode 100644 index cc619cb9a..000000000 --- a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState, useMemo } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Box, - Button, - Group, - Loader, - Select, - Stack, - Table, - Text, - Alert, -} from "@mantine/core"; -import { AlertCircle } from "lucide-react"; -import toast from "react-hot-toast"; - -import { vehiclesService } from "@/services/vehicles.service"; - -export interface LastMileContainerRow { - id: string; - type: string; - qty: number; -} - -export interface LastMileContainerAllocationTableProps { - lastMileId: string; - containers: LastMileContainerRow[]; - onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; -} - -/** - * Manual container-to-vehicle allocation table for last-mile deliveries. - * Displays containers with type/qty, vehicle dropdown per row, and save action. - */ -export function LastMileContainerAllocationTable({ - lastMileId, - containers, - onSave, -}: LastMileContainerAllocationTableProps) { - const [allocations, setAllocations] = useState>( - () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - - const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), - }); - - const vehicleOptions = useMemo( - () => - vehicles.map((v) => ({ - value: v.id, - label: `${v.plateNumber} (${v.vehicleType})`, - description: `${v.model} · ${v.manufacturer}`, - })), - [vehicles], - ); - - const saveAllocation = useMutation({ - mutationFn: async () => { - const mappings = containers - .filter((c) => allocations[c.id]) - .map((c) => ({ - containerId: c.id, - vehicleId: allocations[c.id]!, - })); - - if (mappings.length === 0) { - throw new Error("No containers allocated to vehicles"); - } - - await onSave(mappings); - }, - onSuccess: () => { - toast.success("Container allocations saved"); - setAllocations( - containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : "Failed to save allocations", - ); - }, - }); - - const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; - - if (vehiclesLoading) { - return ( - - - - ); - } - - return ( - - {vehicles.length === 0 && ( - } color="yellow"> - No free vehicles available. Free up or add vehicles before allocating containers. - - )} - - - - - - Container ID - Type - Qty - Assigned Vehicle - - - - {containers.map((container) => ( - - - - {container.id} - - - {container.type} - {container.qty} - -
-
- - - - {allocatedCount} of {containers.length} containers allocated - - - -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index e451f2d2c..349d68f32 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -1,5 +1,5 @@ -import { Download, Zap, FileText, Clock } from "lucide-react"; -import { Stack, Text, Button } from "@mantine/core"; +import { Zap, Clock } from "lucide-react"; +import { Stack, Text } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; import { BookingActionsMenu } from "./BookingActionsMenu"; @@ -14,21 +14,11 @@ interface BookingActionsToolbarProps { mutations: Mutations; } -/** Detail-page actions: primary toolbar + downloads. */ -export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { +/** Detail-page actions: primary staff-action toolbar. */ +export function BookingActionsToolbar({ booking }: BookingActionsToolbarProps) { const row = toBookingListRow(booking); const { status } = booking; - const downloadBlob = async (fn: () => Promise, filename: string) => { - const blob = await fn(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }; - if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") { return null; } @@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool - - {status === "CONTRACT_READY" && ( - - - - )} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx new file mode 100644 index 000000000..cd925c03c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx @@ -0,0 +1,202 @@ +import { useMemo } from "react"; +import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react"; +import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core"; + +import type { BookingDetail } from "@/types/booking"; +import { SectionCard } from "./SectionCard"; + +export interface BookingContainerUnitsCardProps { + booking: BookingDetail; +} + +interface FlatUnit { + id: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number; + isHazardous?: boolean; + isReefer?: boolean; + typeLabel: string; + sizeFt?: number; +} + +/** + * The physical container manifest: one row per container with its number, type, + * seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown + * bookings — when a line has no units the card falls back to the aggregate + * type/qty/weight so it still renders something for plain bookings. + */ +export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) { + const lines = booking.bookingContainers ?? []; + + const units: FlatUnit[] = useMemo( + () => + lines.flatMap((line) => + (line.units ?? []).map((u) => ({ + id: u.id, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber, + vgmTons: Number(u.vgmTons) || 0, + isHazardous: u.isHazardous, + isReefer: u.isReefer, + typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—", + sizeFt: line.containerType?.sizeFt, + })), + ), + [lines], + ); + + // Container bookings only — bulk has no container manifest. + if (booking.freightType === "BULK" || lines.length === 0) return null; + + const totalUnits = units.length; + const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0); + + return ( + 0 + ? "Each physical container with its number and weight" + : "Per-container numbers were not captured for this booking" + } + accent="teal" + extra={ + totalUnits > 0 ? ( + + {totalUnits} container{totalUnits === 1 ? "" : "s"} + + ) : ( + + {lines.length} line{lines.length === 1 ? "" : "s"} + + ) + } + > + {totalUnits > 0 ? ( + + + + + + # + Container No. + Type + Seal + Weight (VGM) + + + + {units.map((u, i) => ( + + + + {i + 1} + + + + + + + + + {u.containerNumber} + + {u.isReefer ? ( + + + + ) : null} + {u.isHazardous ? ( + + + + ) : null} + + + + + {u.typeLabel} + {u.sizeFt ? ( + + {u.sizeFt}FT + + ) : null} + + + + + {u.sealNumber || "—"} + + + + + {u.vgmTons.toFixed(3)} t + + + + ))} + +
+
+ + + + Total weight (VGM) + + + {totalVgm.toFixed(3)} t + + +
+ ) : ( + // Fallback: no per-unit numbers — show the aggregate lines. + + + + + Type + Qty + VGM / unit + Total VGM + + + + {lines.map((line) => { + const perUnit = Number(line.vgmPerUnitTons) || 0; + return ( + + + + + {line.containerType?.label ?? line.containerType?.code ?? "—"} + + {line.containerType?.sizeFt ? ( + + {line.containerType.sizeFt}FT + + ) : null} + + + {line.quantity} + {perUnit.toFixed(3)} t + + + {(line.quantity * perUnit).toFixed(3)} t + + + + ); + })} + +
+
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index a023fafda..003f3d4de 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -8,6 +8,7 @@ export * from "./BookingDetailHeader"; export * from "./BookingLifecycleStepper"; export * from "./BookingRouteCard"; export * from "./BookingContainersCard"; +export * from "./BookingContainerUnitsCard"; export * from "./BookingApprovalCard"; export * from "./BookingReviewNotesCard"; export * from "./BookingPaymentCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index f9bd7683d..6f52d287f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -4,9 +4,10 @@ import { useParams, useSearchParams, } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, + Badge, Box, Button, Center, @@ -18,6 +19,7 @@ import { Paper, Select, Stack, + Switch, Text, Textarea, TextInput, @@ -25,6 +27,7 @@ import { } from "@mantine/core"; import { AlertCircle, + AlertTriangle, CalendarDays, CheckCircle2, ChevronLeft, @@ -58,16 +61,36 @@ import { StepLabel, } from "./gl-booking-form/form-ui"; +/** All booking-window times are communicated in East Africa Time. */ +const EAT_TZ = "Africa/Addis_Ababa"; + +function fmtWindowOpensAt(iso: string): string { + const date = new Date(iso).toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: EAT_TZ, + }); + const time = new Date(iso).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: EAT_TZ, + }); + return `${date} · ${time}`; +} + interface UnitDraft { containerNumber: string; sealNumber: string; vgmTons: number | string; + /** Per-unit flags — the line's hazardous/reefer counts are derived from these. */ + hazardous: boolean; + reefer: boolean; } interface ContainerLineDraft { containerSize: string; - hazardousQuantity: number | string; - reeferQuantity: number | string; units: UnitDraft[]; } @@ -80,7 +103,13 @@ interface BulkLineDraft { } function emptyUnit(): UnitDraft { - return { containerNumber: "", sealNumber: "", vgmTons: "" }; + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + hazardous: false, + reefer: false, + }; } function bulkUnitOfMeasure( @@ -106,6 +135,33 @@ export default function GlCreateBookingForm() { enabled: Boolean(requestId), }); + // Same window-gating the customer sees: GL may only create a booking while a + // booking window is OPEN for one of the contract's routes. + const contractId = contract?.id ?? id; + const { data: bookingWindows, isLoading: windowsLoading } = useQuery({ + ...api.trainScheduling.contractBookingWindows.queryOptions({ + input: { contractId: contractId ?? "" }, + }), + enabled: Boolean(contractId), + }); + + const windowOpen = useMemo( + () => (bookingWindows ?? []).some((w) => w.isOpenNow), + [bookingWindows], + ); + + // Soonest future window across all routes, used for the "next window" notice. + const nextWindow = useMemo(() => { + const now = Date.now(); + return (bookingWindows ?? []) + .filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now) + .sort( + (a, b) => + new Date(a.windowOpensAt!).getTime() - + new Date(b.windowOpensAt!).getTime(), + )[0]; + }, [bookingWindows]); + const [scheduledDate, setScheduledDate] = useState(""); const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); @@ -152,11 +208,13 @@ export default function GlCreateBookingForm() { setContainerLines( lines.containers.map((c) => ({ containerSize: c.containerSize, - hazardousQuantity: c.hazardousQuantity ?? "0", - reeferQuantity: c.reeferQuantity ?? "", - units: Array.from({ length: Math.max(1, c.quantity) }, () => - emptyUnit(), - ), + // The request carries counts; pre-toggle the first N units so GL sees + // the customer's declared hazardous/reefer split and can adjust it. + units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({ + ...emptyUnit(), + hazardous: i < Number(c.hazardousQuantity ?? 0), + reefer: i < Number(c.reeferQuantity ?? 0), + })), })), ); } else if (lines.bulk) { @@ -182,8 +240,6 @@ export default function GlCreateBookingForm() { setContainerLines( containerSizes.map((size) => ({ containerSize: size, - hazardousQuantity: "0", - reeferQuantity: "0", units: [emptyUnit()], })), ); @@ -214,8 +270,8 @@ export default function GlCreateBookingForm() { containers: containerLines.map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, - hazardousQuantity: Number(l.hazardousQuantity || 0), - reeferQuantity: Number(l.reeferQuantity || 0), + hazardousQuantity: l.units.filter((u) => u.hazardous).length, + reeferQuantity: l.units.filter((u) => u.reefer).length, })), bulkQuantity: bulkLines.reduce( (s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0), @@ -314,12 +370,16 @@ export default function GlCreateBookingForm() { ); const canSubmit = + windowOpen && Boolean(scheduledDate) && (!needsRouteSelect || Boolean(contractRouteId)) && (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); - const handleSubmit = () => { - if (!scheduledDate || !contract) return; + /** The create-booking DTO from the current form state — shared by the + * authoritative price preview and the actual submit so what GL confirms is + * exactly what gets booked. */ + const buildPayload = (): Freight.CreateBookingUnderContractDto | null => { + if (!scheduledDate || !contract) return null; const payload: Freight.CreateBookingUnderContractDto = { scheduledDate, @@ -333,12 +393,10 @@ export default function GlCreateBookingForm() { .map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - ...(l.reeferQuantity !== "" - ? { reeferQuantity: Number(l.reeferQuantity) } - : {}), + // Counts are derived from the per-unit toggles — they can never + // exceed the line quantity. + hazardousQuantity: l.units.filter((u) => u.hazardous).length, + reeferQuantity: l.units.filter((u) => u.reefer).length, units: l.units.map((u) => ({ containerNumber: u.containerNumber, ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), @@ -361,6 +419,59 @@ export default function GlCreateBookingForm() { })); } + return payload; + }; + + // Authoritative price preview (same pricing pass the booking persists at + // create): rail freight + first/last mile + overweight + every surcharge. + // Fired when the price modal opens; the modal falls back to the contract + // unit-rate estimate while it loads. + const validateShipmentMutation = useMutation({ + mutationFn: (dto: Freight.CreateBookingUnderContractDto) => + contractsService.validateShipment(id ?? "", dto), + }); + const validation = validateShipmentMutation.data ?? null; + + const serverTotal = useMemo(() => { + const items = validation?.lineItems; + if (!items?.length) return null; + return { + currency: validation?.currency ?? priceTotal?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [validation, priceTotal]); + + const displayTotal = serverTotal ?? priceTotal; + const pairingErrors = validation?.pairingErrors ?? []; + const capacityErrors = validation?.capacityErrors ?? []; + const overweightLines = validation?.overweightLines ?? []; + + const openPriceModal = () => { + setPriceOpen(true); + const payload = buildPayload(); + if (payload) { + validateShipmentMutation.reset(); + validateShipmentMutation.mutate(payload); + } + }; + + const handleSubmit = () => { + if (!contract || !windowOpen) return; + // Never book past unresolved 20ft pairing hard-blocks. + if (pairingErrors.length > 0) return; + // A line above the container type's max capacity can never book. + if (capacityErrors.length > 0) return; + const payload = buildPayload(); + if (!payload) return; + mutations.createBooking.mutate(payload, { onSuccess: async (booking) => { if (requestId) { @@ -451,6 +562,33 @@ export default function GlCreateBookingForm() { ) : null} + {!windowsLoading && !windowOpen ? ( + } + title="Booking window is closed" + mb="lg" + > + GL can create a booking only while a window is open.{" "} + {nextWindow?.windowOpensAt ? ( + <> + Next window: {fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT{" "} + for{" "} + + {nextWindow.origin ?? "Origin"} → {nextWindow.destination ?? "Destination"} + + . + + ) : ( + <>No upcoming booking window scheduled. + )} + + ) : null} + + {windowsLoading || windowOpen ? ( + <> {line.containerSize} containers - + syncUnits(lineIdx, Number(v) || 0)} radius={10} styles={fieldStyles} + w={160} /> {contract.isHazardous ? ( - - patchLine(lineIdx, { hazardousQuantity: v }) - } - radius={10} - styles={fieldStyles} - /> + + {line.units.filter((u) => u.hazardous).length} hazardous + ) : null} {contract.isReefer ? ( - - patchLine(lineIdx, { reeferQuantity: v }) - } - radius={10} - styles={fieldStyles} - /> + + {line.units.filter((u) => u.reefer).length} refrigerated + ) : null} Per-container details {line.units.map((unit, unitIdx) => ( - + + {/* Per-unit flags: toggle exactly the containers that are + hazardous / refrigerated; line counts derive from these. */} + {contract.isHazardous ? ( + + {unitIdx === 0 ? ( + + Hazardous + + ) : null} + + patchUnit(lineIdx, unitIdx, { + hazardous: e.currentTarget.checked, + }) + } + /> + + ) : null} + {contract.isReefer ? ( + + {unitIdx === 0 ? ( + + Reefer + + ) : null} + + patchUnit(lineIdx, unitIdx, { + reefer: e.currentTarget.checked, + }) + } + /> + + ) : null} ))} @@ -745,7 +917,7 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} disabled={!canSubmit} - onClick={() => setPriceOpen(true)} + onClick={openPriceModal} > Review price & book @@ -776,11 +948,89 @@ export default function GlCreateBookingForm() { } > - {priceTotal ? ( + {displayTotal ? ( + {validateShipmentMutation.isPending && ( + + + + Computing the final price breakdown and checking container + weights… + + + )} + + {pairingErrors.length > 0 && ( + } + title="Cannot create booking — 20ft wagon pairing" + > + + {pairingErrors.map((msg, i) => ( + + {msg} + + ))} + + Adjust the 20ft container weights or quantities so pairs + differ by no more than 10 tons. + + + + )} + + {capacityErrors.length > 0 && ( + } + title="Cannot create booking — over maximum capacity" + > + + {capacityErrors.map((msg, i) => ( + + {msg} + + ))} + + Reduce the cargo weight or split it across more containers + to book this shipment. + + + + )} + + {overweightLines.length > 0 && ( + } + title="Overweight containers" + > + + {overweightLines.map((line, i) => ( + + {line.containerTypeCode}: {line.totalVgmTons}t exceeds + limit {line.maxAllowedTons}t (+{line.excessTons}t + overweight) + + ))} + + An overweight surcharge applies (included in the total + below). + + + + )} + - {priceTotal.lines.map((line, i) => ( + {displayTotal.lines.map((line, i) => ( @@ -788,16 +1038,16 @@ export default function GlCreateBookingForm() { {line.quantity.toLocaleString()} ×{" "} - {line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "} + {line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "} {formatRateUnit(line.unit)} - {line.amount.toLocaleString()} {priceTotal.currency} + {line.amount.toLocaleString()} {displayTotal.currency} ))} - {priceTotal.lines.length === 0 && ( + {displayTotal.lines.length === 0 && ( No priced lines — check the cargo details. @@ -815,9 +1065,9 @@ export default function GlCreateBookingForm() { Total - {priceTotal.total.toLocaleString()}{" "} + {displayTotal.total.toLocaleString()}{" "} - {priceTotal.currency} + {displayTotal.currency} @@ -838,6 +1088,11 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} loading={mutations.createBooking.isPending} + disabled={ + validateShipmentMutation.isPending || + pairingErrors.length > 0 || + capacityErrors.length > 0 + } onClick={handleSubmit} > Confirm & book @@ -846,6 +1101,8 @@ export default function GlCreateBookingForm() { ) : null} + + ) : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx new file mode 100644 index 000000000..e5e998721 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -0,0 +1,332 @@ +import { useMemo, useState } from "react"; +import { + ActionIcon, + Badge, + Box, + Card, + Group, + SimpleGrid, + Skeleton, + Stack, + Text, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + ArrowRight, + CalendarClock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; +import { CountdownTimer } from "@edr/ui-common"; + +import { api } from "@/services/api"; +import type { StaffBookingWindow } from "@/types/trainScheduling"; + +/** All window times are communicated in East Africa Time. */ +const TZ = "Africa/Addis_Ababa"; +/** Cards visible per carousel page. */ +const PER_PAGE = 3; + +function fmtDay(iso: string): string { + return new Date(iso).toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: TZ, + }); +} + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: TZ, + }); +} + +function windowLabel(w: StaffBookingWindow): string { + if (w.windowOpensAt && w.windowClosesAt) { + return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime( + w.windowClosesAt, + )} EAT`; + } + if (w.windowOpensAt) { + return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`; + } + return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " "); +} + +/** + * The countdown for whichever phase the window is currently in, mirroring the + * customer portal. `expiredText` names the NEXT step so a deadline that lapses + * between refetches announces what comes next rather than the bare "Expired". + */ +function phaseCountdown( + w: StaffBookingWindow, +): { label: string; deadline: string; expiredText: string } | null { + switch (w.windowPhase) { + case "PRE_WINDOW": + return w.windowOpensAt + ? { + label: "Opens in", + deadline: w.windowOpensAt, + expiredText: "Opening now…", + } + : null; + case "OPEN": + return w.windowClosesAt + ? { + label: "Closes in", + deadline: w.windowClosesAt, + expiredText: "Review starting…", + } + : null; + case "DOC_REVIEW": + return w.docReviewEndsAt + ? { + label: "Doc review ends in", + deadline: w.docReviewEndsAt, + expiredText: "Payment starting…", + } + : null; + case "PAYMENT": + return w.paymentPhaseEndsAt + ? { + label: "Payment ends in", + deadline: w.paymentPhaseEndsAt, + expiredText: "Closing…", + } + : null; + default: + return null; + } +} + +/** Drop windows whose booking window (or the train itself) has already passed. */ +function isPast(w: StaffBookingWindow): boolean { + const now = Date.now(); + const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null; + const departs = w.departureDate ? new Date(w.departureDate).getTime() : null; + // Still live while in a post-close staff phase (doc review / payment). + if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false; + if (departs != null && departs <= now) return true; + if (closes != null && closes <= now) return true; + return false; +} + +function WindowCard({ w }: { w: StaffBookingWindow }) { + const cd = phaseCountdown(w); + const open = w.isOpenNow; + const isImport = w.direction === "IMPORT"; + + return ( + + + + + {w.direction ? ( + + {isImport ? "Import" : "Export"} + + ) : ( + + )} + + {open + ? "Open now" + : (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")} + + + + + + {w.origin ?? "—"} + + + + {w.destination ?? "—"} + + + {w.trainNumber ? ( + + Train {w.trainNumber} + + ) : null} + + + + + {windowLabel(w)} + + + {w.departureDate ? ( + + Departs {fmtDay(w.departureDate)} + + ) : null} + + + {cd ? ( + + + + ) : null} + + + ); +} + +/** + * All announced booking windows (import cycles + export FCFS) across every lane, + * shown to GL ET on the clearance queue as a paged carousel — three lanes per + * page, arrows to flip. Mirrors the customer's portal "Booking Windows" card. + * Hidden when nothing is pending. + */ +export function GlUpcomingWindowsSection() { + const { data, isLoading } = useQuery( + api.trainScheduling.allBookingWindows.queryOptions({ + refetchInterval: 60_000, + }), + ); + const [page, setPage] = useState(0); + + const windows = useMemo(() => { + const rows = (data ?? []).filter( + (w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w), + ); + // Open lanes first, then by opening time. + return rows.sort((a, b) => { + const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); + if (openDiff !== 0) return openDiff; + const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return at - bt; + }); + }, [data]); + + const pageCount = Math.max(1, Math.ceil(windows.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = windows.slice( + safePage * PER_PAGE, + safePage * PER_PAGE + PER_PAGE, + ); + + if (!isLoading && windows.length === 0) return null; + + return ( + + + + + + + Booking windows + + + Import and export booking windows across all lanes (EAT) + + + + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: + i === safePage + ? "var(--mantine-color-edr-green-6)" + : "var(--mantine-color-gray-3)", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} + + + {isLoading ? ( + + {[1, 2, 3].map((i) => ( + + ))} + + ) : ( + + {visible.map((w) => ( + + ))} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index 44ece975d..de8ff1953 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -208,6 +208,10 @@ function codeLabel(code?: string | null): string | null { export interface ContractDocumentsCardProps { files: ContractFile[]; + /** Card heading. Defaults to "Documents". */ + title?: string; + /** Message shown when there are no files. */ + emptyText?: string; /** Open the file inline in a viewer modal. */ onView?: (file: ContractFile) => void; /** Download the file to disk. */ @@ -217,13 +221,15 @@ export interface ContractDocumentsCardProps { /** Rich list of the contract's attached documents: type, size, view + download. */ export function ContractDocumentsCard({ files, + title = "Documents", + emptyText = "No documents attached to this contract.", onView, onDownload, }: ContractDocumentsCardProps) { return ( @@ -233,7 +239,7 @@ export function ContractDocumentsCard({ > {files.length === 0 ? ( - No documents attached to this contract. + {emptyText} ) : ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx index 52bcfef98..1ef5d1139 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/TransportDocumentCard.tsx @@ -32,7 +32,7 @@ export function TransportDocumentCard({ bookingId }: { bookingId: string }) { if (!file) return; setLoading(true); try { - await contractsService.uploadTransportDocument(bookingId, file); + await contractsService.uploadTransportDocument(bookingId, { transportDocument: file }); toast.success("Transport document uploaded"); } catch (e) { toast.error(e instanceof Error ? e.message : "Upload failed"); diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index e1d1d4977..f314ab35e 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useState } from "react"; -import { Loader2, Calendar } from "lucide-react"; +import { Loader2, ShieldCheck } from "lucide-react"; import { + Alert, + Badge, Button, Group, Modal, @@ -12,7 +14,6 @@ import { Text, Textarea, TextInput, - ActionIcon, } from "@mantine/core"; import { @@ -20,6 +21,10 @@ import { type FleetFormFieldDef, } from "@/pages/fleet/config/resources"; import type { FleetRecord } from "@/services/fleet/fleet.service"; +import { + verifaydaService, + type FaydaCallbackMessage, +} from "@/services/verifayda.service"; export interface FleetFormDialogProps { open: boolean; @@ -31,8 +36,32 @@ export interface FleetFormDialogProps { isSubmitting: boolean; selectOptionsLoading?: boolean; onSubmit: (values: Record) => void; + /** + * Show a "Verify with Fayda" step: opens the eSignet popup and prefills + * firstName/lastName/email/phoneNumber/dateOfBirth from the verified + * identity, stamping faydaVerified + faydaSub on the payload. + */ + verifyWithFayda?: boolean; } +// Fayda returns gender as "Male"/"Female"; snap it onto the form's uppercase +// option values (MALE/FEMALE/OTHER) so the Select prefills instead of rendering +// blank. Unknown/empty values fall through to undefined (field left untouched). +const normalizeGender = (raw?: string): string | undefined => { + const up = (raw ?? "").trim().toUpperCase(); + if (up === "MALE" || up === "M") return "MALE"; + if (up === "FEMALE" || up === "F") return "FEMALE"; + return up ? "OTHER" : undefined; +}; + +// Fayda may return the birthdate as "2001/12/01" (slashes), but the date input +// and validator expect ISO "2001-12-01". Normalize separators + trim to 10 chars +// so the DOB field prefills instead of silently staying blank. +const normalizeBirthdate = (raw?: string): string | undefined => { + const iso = (raw ?? "").trim().replace(/\//g, "-").slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : undefined; +}; + const buildInitialValues = ( fields: FleetFormFieldDef[], emptyValues: Record, @@ -72,9 +101,12 @@ const FleetFormDialog = ({ isSubmitting, selectOptionsLoading, onSubmit, + verifyWithFayda, }: FleetFormDialogProps) => { const [values, setValues] = useState>({}); const [errors, setErrors] = useState>({}); + const [faydaLoading, setFaydaLoading] = useState(false); + const [faydaError, setFaydaError] = useState(null); // Seed the form ONLY when the dialog opens or the edited record changes — NOT // when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the @@ -87,10 +119,88 @@ const FleetFormDialog = ({ if (open) { setValues(buildInitialValues(fields, emptyValues, initialRecord)); setErrors({}); + setFaydaError(null); + setFaydaLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, recordId]); + // Receive the ?code&state relayed by the /callback popup, exchange it for + // the verified identity, and prefill the matching form fields. + useEffect(() => { + if (!open || !verifyWithFayda) return; + const onMessage = async (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== "fayda-callback") return; + + if (event.data.error) { + setFaydaLoading(false); + setFaydaError(event.data.errorDescription ?? event.data.error); + return; + } + if (!event.data.code || !event.data.state) return; + + try { + const result = await verifaydaService.complete(event.data.code, event.data.state); + if (!result.verified) { + setFaydaError("Identity could not be verified"); + return; + } + const nameParts = (result.fullName ?? "").trim().split(/\s+/).filter(Boolean); + const [firstName, ...rest] = nameParts; + const gender = normalizeGender(result.gender); + const dateOfBirth = normalizeBirthdate(result.birthdate); + setValues((current) => ({ + ...current, + ...(firstName ? { firstName } : {}), + ...(rest.length ? { lastName: rest.join(" ") } : {}), + ...(result.email ? { email: result.email } : {}), + ...(result.phoneNumber ? { phoneNumber: result.phoneNumber } : {}), + ...(dateOfBirth ? { dateOfBirth } : {}), + ...(gender ? { gender } : {}), + faydaVerified: true, + ...(result.iamUserId ? { faydaSub: result.iamUserId } : {}), + })); + setFaydaError(null); + } catch (err) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + (err instanceof Error ? err.message : "Verification failed"); + setFaydaError(message); + } finally { + setFaydaLoading(false); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [open, verifyWithFayda]); + + const handleFaydaVerify = async () => { + setFaydaError(null); + setFaydaLoading(true); + try { + const { authorizationUrl } = await verifaydaService.start(); + const popup = window.open( + authorizationUrl, + "fayda-verify", + "width=480,height=760,noopener=no", + ); + if (!popup) { + setFaydaLoading(false); + setFaydaError("Pop-up blocked — allow pop-ups for this site and retry."); + } + // Loading stays on until the popup posts back; reopening the dialog resets it. + } catch (err) { + setFaydaLoading(false); + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + (err instanceof Error ? err.message : "Could not start verification"); + setFaydaError(message); + } + }; + + const faydaVerified = values.faydaVerified === true; + const shortFields = useMemo( () => fields.filter((f) => f.type !== "textarea"), [fields], @@ -127,6 +237,12 @@ const FleetFormDialog = ({ const date = new Date(stringValue + "T00:00:00Z"); if (isNaN(date.getTime())) { next[field.name] = `${field.label} is not a valid date`; + } else if (field.dateBound === "future") { + const startOfToday = new Date(); + startOfToday.setUTCHours(0, 0, 0, 0); + if (date <= startOfToday) { + next[field.name] = `${field.label} must be in the future`; + } } else if (date > new Date()) { next[field.name] = `${field.label} cannot be in the future`; } @@ -147,6 +263,12 @@ const FleetFormDialog = ({ }, [fields]); const handleSubmit = () => { + // Hard gate: a driver record cannot be saved until its identity is verified + // with Fayda. Mirrored server-side in DriversService. + if (verifyWithFayda && !faydaVerified) { + setFaydaError("Verify the driver's identity with Fayda before saving."); + return; + } if (!validate()) return; const payload = Object.fromEntries( Object.entries(values) @@ -167,6 +289,9 @@ const FleetFormDialog = ({ const renderField = (field: FleetFormFieldDef) => { const value = values[field.name]; const error = errors[field.name]; + // Fayda-owned identity fields (name/email/phone/DOB/gender) are populated + // only by verification and never hand-edited. + const isDisabled = Boolean(field.disabled || field.faydaLocked); if (field.type === "select") { return ( @@ -186,7 +311,7 @@ const FleetFormDialog = ({ } error={error} searchable - disabled={selectOptionsLoading} + disabled={selectOptionsLoading || isDisabled} rightSection={ selectOptionsLoading ? ( @@ -216,7 +341,7 @@ const FleetFormDialog = ({ error={error} searchable clearable - disabled={selectOptionsLoading} + disabled={selectOptionsLoading || isDisabled} rightSection={ selectOptionsLoading ? ( @@ -240,7 +365,7 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} /> ); } @@ -260,7 +385,7 @@ const FleetFormDialog = ({ } error={error} minRows={3} - disabled={field.disabled} + disabled={isDisabled} /> ); } @@ -280,13 +405,8 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} description={field.description || "Select a date"} - rightSection={ - - - - } size="sm" radius="md" styles={{ @@ -318,7 +438,7 @@ const FleetFormDialog = ({ })) } error={error} - disabled={field.disabled} + disabled={isDisabled} /> ); }; @@ -333,6 +453,40 @@ const FleetFormDialog = ({ centered > + {verifyWithFayda && ( + + {faydaVerified ? ( + } + > + Identity verified with Fayda + + ) : ( + + Identity must be verified with Fayda before this driver can be + saved. + + )} + + + )} + {verifyWithFayda && faydaError && ( + + {faydaError} + + )} {shortFields.map(renderField)} @@ -345,6 +499,7 @@ const FleetFormDialog = ({ color="edr-green" loading={isSubmitting} onClick={handleSubmit} + disabled={verifyWithFayda && !faydaVerified} > Save diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx new file mode 100644 index 000000000..43f2bce4f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -0,0 +1,207 @@ +import { Center, Loader, Modal, Text, Timeline } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + CircleDot, + Route, + Truck, + UserCheck, + UserMinus, + UserPlus, +} from "lucide-react"; + +import { + fleetHistoryService, + type FleetHistoryEvent, +} from "@/services/fleet-history.service"; +import type { FleetRecord } from "@/services/fleet/fleet.service"; + +export interface FleetHistoryModalProps { + opened: boolean; + onClose: () => void; + entity: "driver" | "vehicle"; + record: FleetRecord | null; +} + +const asObj = (r: FleetRecord | null) => (r ?? {}) as Record; + +const titleFor = (entity: "driver" | "vehicle", record: FleetRecord | null) => { + const r = asObj(record); + if (entity === "vehicle") { + return `Vehicle history — ${r.plateNumber ?? r.code ?? ""}`.trim(); + } + return `Driver history — ${[r.firstName, r.lastName] + .filter(Boolean) + .join(" ")}`.trim(); +}; + +const mileLabel = (e: FleetHistoryEvent) => + e.metadata?.mile === "LAST" ? "Last-mile" : "First-mile"; + +const arrow = (from?: string | null, to?: string | null) => + `${from ?? "—"} → ${to ?? "—"}`; + +const metaStr = (e: FleetHistoryEvent, key: string) => { + const v = e.metadata?.[key]; + return typeof v === "string" && v ? v : null; +}; + +function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { + const vehiclePlate = metaStr(e, "vehiclePlate"); + const driverName = metaStr(e, "driverName") ?? (e.label || null); + const bookingRef = metaStr(e, "bookingRef"); + + // Compose the detail line with whatever the current view doesn't already + // know: on a driver's timeline show which vehicle; always show the booking. + const detail = (extra?: string) => + [ + entity === "driver" && vehiclePlate ? `Vehicle ${vehiclePlate}` : "", + bookingRef ? `Booking ${bookingRef}` : "", + extra ?? "", + ] + .filter(Boolean) + .join(" · "); + + switch (e.eventType) { + case "DRIVER_REGISTERED": + return { + icon: , + title: "Driver registered", + text: e.toValue ? `Status: ${e.toValue}` : "", + }; + case "VEHICLE_REGISTERED": + return { + icon: , + title: "Vehicle registered", + text: e.toValue ? `Availability: ${e.toValue}` : "", + }; + case "DRIVER_ASSIGNED": + return { + icon: , + title: entity === "vehicle" ? "Driver assigned" : "Assigned to vehicle", + text: + entity === "vehicle" + ? driverName + ? `Driver ${driverName}` + : "" + : vehiclePlate + ? `Vehicle ${vehiclePlate}` + : "", + }; + case "DRIVER_UNASSIGNED": + return { + icon: , + title: + entity === "vehicle" + ? "Driver unassigned" + : "Unassigned from vehicle", + text: + entity === "vehicle" + ? driverName + ? `Driver ${driverName}` + : "" + : vehiclePlate + ? `Vehicle ${vehiclePlate}` + : "", + }; + case "VEHICLE_STATUS_CHANGED": + return { + icon: , + title: "Status changed", + text: arrow(e.fromValue, e.toValue), + }; + case "VEHICLE_AVAILABILITY_CHANGED": + return { + icon: , + title: `Marked ${e.toValue ?? ""}`.trim(), + text: e.fromValue ? arrow(e.fromValue, e.toValue) : "", + }; + case "MILE_VEHICLE_ASSIGNED": + return { + icon: , + title: `${mileLabel(e)}: vehicle assigned`, + text: detail(e.label ? `Status: ${e.label}` : ""), + }; + case "MILE_VEHICLE_RELEASED": + return { + icon: , + title: `${mileLabel(e)}: vehicle released`, + text: detail(), + }; + case "MILE_STATUS_CHANGED": + return { + icon: , + title: `${mileLabel(e)} status`, + text: detail(arrow(e.fromValue, e.toValue)), + }; + default: + return { icon: , title: e.eventType, text: "" }; + } +} + +const fmt = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +const FleetHistoryModal = ({ + opened, + onClose, + entity, + record, +}: FleetHistoryModalProps) => { + const id = asObj(record).id ? String(asObj(record).id) : ""; + + const { data, isLoading } = useQuery({ + queryKey: ["fleet-history", entity, id], + queryFn: () => + entity === "vehicle" + ? fleetHistoryService.vehicle(id) + : fleetHistoryService.driver(id), + enabled: opened && Boolean(id), + }); + + const events = data ?? []; + + return ( + {titleFor(entity, record)}} + radius="lg" + size="lg" + centered + > + {isLoading ? ( +
+ +
+ ) : events.length === 0 ? ( + + No history recorded yet. Activity appears here as this{" "} + {entity} is assigned, reassigned, or its status changes. + + ) : ( + + {events.map((e) => { + const d = describe(e, entity); + return ( + + {d.text && ( + + {d.text} + + )} + + {fmt(e.createdAt)} + + + ); + })} + + )} +
+ ); +}; + +export default FleetHistoryModal; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 9096f52a1..de010e380 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -1,4 +1,4 @@ -import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react"; +import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react"; import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core"; import { useNavigate } from "react-router-dom"; @@ -11,6 +11,8 @@ export interface FleetRecordActionsProps { onEdit: (record: FleetRecord) => void; onRemove: (record: FleetRecord) => void; onAssignDriver?: (record: FleetRecord) => void; + onHistory?: (record: FleetRecord) => void; + onViewDetail?: (record: FleetRecord) => void; layout?: "row" | "compact"; } @@ -20,12 +22,18 @@ const FleetRecordActions = ({ onEdit, onRemove, onAssignDriver, + onHistory, + onViewDetail, layout = "row", }: FleetRecordActionsProps) => { const navigate = useNavigate(); const removeLabel = config.removeActionLabel ?? "Delete"; const showDetail = Boolean(config.detailPath && "id" in record); + const showViewDetail = Boolean(onViewDetail); const isVehicle = config.slug === "vehicles"; + const showHistory = + Boolean(onHistory) && + (config.slug === "drivers" || config.slug === "vehicles"); const handleDetail = () => { if (!config.detailPath || !("id" in record)) return; @@ -57,6 +65,22 @@ const FleetRecordActions = ({ > Edit + {showViewDetail ? ( + onViewDetail?.(record)} + leftSection={} + > + View detail + + ) : null} + {showHistory ? ( + onHistory?.(record)} + leftSection={} + > + History + + ) : null} {showDetail ? ( Edit + {showViewDetail ? ( + onViewDetail?.(record)} + leftSection={} + > + View detail + + ) : null} {showDetail ? ( >(); @@ -20,6 +20,16 @@ export const formatFleetCell = ( format?: FleetColumnFormat, accessorKey?: string, ): ReactNode => { + if (format === "verifiedBadge") { + return value === true ? ( + + Verified + + ) : ( + + ); + } + if (format === "statusBadge") { const status = value == null || value === "" ? "—" : String(value); const getStatusColor = (st: string): string => { diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx new file mode 100644 index 000000000..d4860a414 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx @@ -0,0 +1,93 @@ +import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core"; +import { Check } from "lucide-react"; + +/** One stage of the last-mile delivery workflow. */ +export interface LastMileStepState { + label: string; + done: boolean; + active: boolean; + /** Optional stamp/value shown next to the step (plate, time, distance…). */ + detail?: string | null; +} + +/** + * Compact 6-dot progress bar for a table row — filled = done, ringed = current, + * hollow = pending. Hover a dot for its label + stamp. + */ +export function LastMileStepBar({ steps }: { steps: LastMileStepState[] }) { + return ( + + {steps.map((s, i) => { + const color = s.done + ? "var(--mantine-color-green-6)" + : s.active + ? "var(--mantine-color-blue-5)" + : "var(--mantine-color-gray-4)"; + return ( + + + + ); + })} + + ); +} + +/** + * Vertical stepper for the detail view — completed steps bulleted + green, the + * current step highlighted, each showing its stamp/value when known. + */ +export function LastMileStepper({ steps }: { steps: LastMileStepState[] }) { + const activeIndex = steps.findIndex((s) => s.active); + // Timeline highlights items with index < `active`; count of done steps drives it. + const doneCount = steps.filter((s) => s.done).length; + return ( + + {steps.map((s, i) => ( + : undefined} + title={ + + {s.label} + + } + lineVariant={s.done ? "solid" : "dashed"} + > + + + {s.done ? "Done" : s.active ? "Current step" : "Pending"} + + {s.detail && ( + + {s.detail} + + )} + + + ))} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 6ae71cdc0..8553bcf0b 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -179,7 +179,16 @@ const RuleEngineFormDialog = ({ const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]); const setField = (name: string, value: unknown) => { - setValues((current) => ({ ...current, [name]: value })); + setValues((current) => { + const next = { ...current, [name]: value }; + // Changing what a rate applies to (or its surcharge trigger) can invalidate + // the previously-chosen unit — reset it so the admin re-picks from the new + // allowed set instead of submitting a stale, rejected unit. + if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) { + next.rateUnit = ""; + } + return next; + }); }; const handleSubmit = (event: React.FormEvent) => { @@ -250,6 +259,9 @@ const RuleEngineFormDialog = ({ const label = ; if (field.type === "select") { + // Dynamic options (e.g. rate unit) resolve from the live form values so + // the choices track the other fields the admin has picked. + const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []); return ( { + if (!next) return; + // Only the display unit changes; the stored native value stays put. + // displayValue re-derives from it on the next render. + setUnit(next as DurationUnit); + }} + allowDeselect={false} + disabled={disabled} + w={90} + /> + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx new file mode 100644 index 000000000..ab1d907e6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx @@ -0,0 +1,172 @@ +import { useMemo, useState } from "react"; +import { PackageCheck } from "lucide-react"; +import { Badge, Button, Checkbox, Group, Loader, Paper, Stack, Text } from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import type { + ImportLoadingBooking, + ImportLoadingBookingsResponse, + LoadingStatus, +} from "@/types/trainScheduling"; + +function ImportLoadingBookingRow({ + booking, + selected, + onToggle, +}: { + booking: ImportLoadingBooking; + selected: boolean; + onToggle: () => void; +}) { + return ( + + + + + + + {booking.reference ?? booking.id} + + + {booking.loadingStatus} + + + + {booking.customer ?? "Unknown customer"} + + + {booking.weightTons}T + + + + ); +} + +export function ImportLoadingConfirmationPanel({ + scheduleId, + items, + isLoading, +}: { + scheduleId: string; + items: ImportLoadingBooking[]; + isLoading?: boolean; +}) { + const [selectedIds, setSelectedIds] = useState([]); + const queryClient = useQueryClient(); + + const updateStatus = useMutation< + ImportLoadingBookingsResponse, + Error, + { id: string; bookingIds: string[]; loadingStatus: LoadingStatus } + >({ + ...api.trainScheduling.updateImportLoadingStatus.mutationOptions(), + onSuccess: () => { + setSelectedIds([]); + queryClient.invalidateQueries({ + queryKey: api.trainScheduling.importLoadingBookings.queryKey({ id: scheduleId }), + }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Could not update loading status"); + }, + }); + + const toggle = (id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + + const allIds = useMemo(() => items.map((b) => b.id), [items]); + + if (isLoading) { + return ( + + + + Loading import bookings… + + + ); + } + + if (!items.length) { + return ( + + + No paid import bookings with wagons allocated on this schedule + + + ); + } + + return ( + + + + Import bookings ({items.length}) + + + + + + + + + {items.map((booking) => ( + toggle(booking.id)} + /> + ))} + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx new file mode 100644 index 000000000..2a7689582 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -0,0 +1,618 @@ +import { useMemo, useState } from "react"; +import { isAxiosError } from "axios"; +import { + Badge, + Box, + Button, + Group, + Modal, + Paper, + Progress, + ScrollArea, + Select, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowLeftRight, + ArrowRight, + CheckCircle2, + Inbox, + PackageCheck, + Repeat, + Train, + Weight, + X, +} from "lucide-react"; + +import { CountdownTimer } from "@edr/ui-common"; + +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import type { + EligibleContainerBooking, + FreightType, + TrainScheduleDetail, +} from "@/types/trainScheduling"; + +interface ScheduleWorkspacePanelProps { + schedule: TrainScheduleDetail; + /** Refetch the schedule detail after a mutation so both panels refresh. */ + onChanged: () => void; +} + +const GREEN = "var(--mantine-color-edr-green-6)"; + +/** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */ +function apiErrorMessage(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const data = error.response?.data as Record | undefined; + const violations = data?.violations; + if (Array.isArray(violations) && violations.length) return violations.join(", "); + if (typeof data?.message === "string") return data.message; + if (Array.isArray(data?.message)) return (data.message as string[]).join(", "); + } + return fallback; +} + +/** + * Deadline + label for the window phase this schedule is currently in. + * Phases run: window open (windowClosesAt) → document review (docReviewEndsAt) + * → payment (paymentPhaseEndsAt). Display only. Returns null off-phase. + */ +function phaseCountdown( + schedule: TrainScheduleDetail, +): { label: string; deadline: string } | null { + switch (schedule.windowPhase) { + case "OPEN": + return schedule.windowClosesAt + ? { label: "Booking window closes in", deadline: schedule.windowClosesAt } + : null; + case "DOC_REVIEW": + return schedule.docReviewEndsAt + ? { label: "Document review ends in", deadline: schedule.docReviewEndsAt } + : null; + case "PAYMENT": + return schedule.paymentPhaseEndsAt + ? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt } + : null; + default: + return null; + } +} + +/** Cargo weight already allocated to this train (sum of on-train bookings). */ +function usedWeight(schedule: TrainScheduleDetail): number { + return (schedule.bookings ?? []).reduce( + (sum, b) => sum + (Number(b.weightTons) || 0), + 0, + ); +} + +/** Max pull weight across all locomotives on the set (0 when unknown). */ +function pullCapacity(schedule: TrainScheduleDetail): number { + const set = schedule.trainSet; + if (!set) return 0; + const locos = + set.locomotives && set.locomotives.length > 0 + ? set.locomotives + : set.locomotive + ? [set.locomotive] + : []; + return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0); +} + +export function ScheduleWorkspacePanel({ + schedule, + onChanged, +}: ScheduleWorkspacePanelProps) { + const { toast } = useToast(); + + const freightType: FreightType | undefined = + schedule.freightType === "CONTAINER" || schedule.freightType === "BULK" + ? schedule.freightType + : undefined; + + const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status); + const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status); + + // Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not + // yet linked to any schedule (same filter the auto-batch uses). + const poolQuery = useQuery( + api.trainScheduling.eligibleBookings.queryOptions({ + input: { + filters: { + originStationId: schedule.originStation?.id, + destinationStationId: schedule.destinationStation?.id, + trainScheduleId: schedule.id, + }, + freightType, + }, + enabled: Boolean(schedule.originStation?.id && schedule.destinationStation?.id), + }), + ); + + const onTrainIds = useMemo( + () => new Set((schedule.bookings ?? []).map((b) => b.id)), + [schedule.bookings], + ); + + const pool: EligibleContainerBooking[] = useMemo( + () => (poolQuery.data?.items ?? []).filter((b) => !onTrainIds.has(b.id)), + [poolQuery.data, onTrainIds], + ); + + const onTrain = schedule.bookings ?? []; + + // ── Mutations (reuse the existing endpoints) ─────────────────────────────── + const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); + const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); + const moveSchedule = useMutation( + api.trainScheduling.moveBookingSchedule.mutationOptions(), + ); + + const [moveBookingId, setMoveBookingId] = useState(null); + const [moveTarget, setMoveTarget] = useState(null); + + const { data: targets } = useQuery( + api.trainScheduling.bookableSchedules.queryOptions({ + input: { + originYardId: schedule.originStation?.id, + destinationYardId: schedule.destinationStation?.id, + }, + enabled: Boolean( + schedule.originStation?.id && schedule.destinationStation?.id, + ), + }), + ); + const moveOptions = useMemo( + () => + (targets ?? []) + .filter((s) => s.id !== schedule.id) + .map((s) => ({ + value: s.id, + label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date( + s.scheduleDate, + ).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`, + })), + [targets, schedule.id], + ); + + // ── Capacity meter (by cargo weight vs locomotive pull) ──────────────────── + const used = usedWeight(schedule); + const capacity = pullCapacity(schedule); + const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0; + const over = capacity > 0 && used > capacity; + + const forceAdd = (bookingId: string, ref: string, weightTons: number) => { + const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity; + assign + .mutateAsync({ + id: schedule.id, + freightType, + payload: { + bookingIds: [...onTrainIds, bookingId], + forceAssign: true, + }, + }) + .then(() => { + toast({ + title: `${ref} added to train`, + description: wouldOverfill + ? "Force-added past the pull-weight limit — review capacity." + : "Wagons auto-pinned.", + variant: wouldOverfill ? "destructive" : undefined, + }); + onChanged(); + void poolQuery.refetch(); + }) + .catch((error) => + toast({ + title: "Could not add booking", + description: apiErrorMessage(error, "Validation failed — check capacity and status."), + variant: "destructive", + }), + ); + }; + + const removeFromTrain = (bookingId: string, ref: string) => { + unassign + .mutateAsync({ id: schedule.id, bookingId }) + .then(() => { + toast({ title: `${ref} removed from train` }); + onChanged(); + void poolQuery.refetch(); + }) + .catch((error) => + toast({ + title: "Could not remove booking", + description: apiErrorMessage(error, "Please try again."), + variant: "destructive", + }), + ); + }; + + const doMove = () => { + if (!moveBookingId || !moveTarget) return; + moveSchedule + .mutateAsync({ bookingId: moveBookingId, trainScheduleId: moveTarget }) + .then(() => { + toast({ title: "Booking reassigned to another train" }); + setMoveBookingId(null); + onChanged(); + void poolQuery.refetch(); + }) + .catch((error) => + toast({ + title: "Could not reassign booking", + description: apiErrorMessage(error, "Target train may be closed or full."), + variant: "destructive", + }), + ); + }; + + return ( + + + {/* Header + capacity meter */} + + + + + +
+ Allocation workspace + + Manually add ready-to-pay bookings, remove, or reassign them + +
+
+ + + + + + + Load {used.toFixed(1)}T + {capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""} + + + {over ? ( + + Over capacity + + ) : ( + + {capacity > 0 ? `${pct}%` : "—"} + + )} + + 0 ? pct : 0} + color={over ? "red" : pct > 85 ? "orange" : "edr-green"} + radius="xl" + size="md" + /> + +
+ + {(() => { + const cd = phaseCountdown(schedule); + return cd ? ( + + + + ) : null; + })()} + + {over ? ( + + + + This train is loaded beyond its locomotive pull weight. Force-adds are + allowed, but review before dispatch. + + + ) : null} + + {locked ? ( + + This train is {schedule.status.toLowerCase()} — bookings can no longer be + changed. + + ) : null} + + {/* Two-panel board */} + + {/* Pool */} + + {pool.map((b) => ( + + + + ) : null + } + /> + ))} + + + {/* On train */} + + {onTrain.map((b) => ( + + + + + + + + + ) : null + } + /> + ))} + + +
+ + {/* Reassign modal */} + setMoveBookingId(null)} + title={ + + + Reassign booking to another train + + } + centered + radius="lg" + > + + { + setStatusFilter(v); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + clearable + searchable + radius="lg" + style={{ minWidth: 200 }} + /> + { + setFreightTypeFilter(v); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + clearable + radius="lg" + style={{ minWidth: 170 }} + /> + + - {isOperationsTab ? ( - - - - setOperationsSubTab((value as OperationsSubTab) ?? "ready") - } - > - - Ready to allocate - On train / scheduled - - - {isError ? ( - - ) : operationsSubTab === "ready" ? ( - - ) : ( - - )} - - - ) : showEmpty ? ( + {showEmpty ? ( + + {queueTabOptions.length > 1 ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 8502a996c..c81f5b919 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -61,9 +61,11 @@ import { useContractMutations, } from "@/hooks/contracts/useContracts"; import { contractsService } from "@/services/contracts.service"; +import { api } from "@/services/api"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { fileViewUrl } from "@/constants/apiConfig"; import { downloadBookingFile } from "@/services/files.service"; +import type { CustomerDocument } from "@/types/customer"; import type { Freight } from "@edr/types"; // Clearance phase — staff can still ACT (approve / query / finalize). @@ -152,6 +154,34 @@ export default function ContractRequestDetailPage() { enabled: Boolean(id) && showClearanceTabQuery, }); + // Customer profile documents (national ID, TIN, import/business license) for + // the company this contract belongs to. Shown as a separate section in the + // Documents tab, alongside the contract's own attached files. + const companyId = contract?.companyId ?? ""; + const profileDocumentsQuery = useQuery( + api.customers.documents.queryOptions({ + input: { id: companyId }, + enabled: Boolean(companyId), + }), + ); + const profileDocumentsRaw = Array.isArray(profileDocumentsQuery.data) + ? profileDocumentsQuery.data + : []; + // Reshape to the contract-file shape so we can reuse ContractDocumentsCard. + const profileDocuments = profileDocumentsRaw.map( + (doc: CustomerDocument) => + ({ + id: doc.id, + code: doc.code, + name: doc.name, + url: doc.url ?? "", + mimeType: doc.mimeType, + size: doc.size, + resourceId: companyId, + resource: "company", + }) satisfies NonNullable[number], + ); + const downloadContractPdf = async () => { if (!contract?.id) return; try { @@ -247,6 +277,11 @@ export default function ContractRequestDetailPage() { const selfClear = !contract.customsClearingEnabled; const files = contract.files ?? []; const contractPdf = files.find((f) => f.code === "contract"); + // Signature files (code `signature_`) are baked into the contract PDF — + // don't list them as standalone documents in the Documents tab. + const contractDocuments = files.filter( + (f) => !f.code.startsWith("signature_"), + ); const hasContractDocument = Boolean( contractPdf || contract.contractGeneratedAt, ); @@ -406,9 +441,9 @@ export default function ContractRequestDetailPage() { value="documents" leftSection={} rightSection={ - files.length > 0 ? ( + contractDocuments.length + profileDocuments.length > 0 ? ( - {files.length} + {contractDocuments.length + profileDocuments.length} ) : null } @@ -453,7 +488,18 @@ export default function ContractRequestDetailPage() { ) : currentTab === "documents" ? ( + @@ -550,21 +596,58 @@ export default function ContractRequestDetailPage() { No cargo scope lines. ) : ( - - {(contract.cargoScope ?? []).map((s) => ( - - - - {s.containerSize ?? - s.cargoFreeText ?? - s.cargoTypeId ?? - "Cargo"} - - - ))} + + {(contract.cargoScope ?? []).map((s) => { + const isContainer = Boolean(s.containerSize); + // Bulk lines carry their commodity detail (name + unit); + // container lines carry the size (20ft / 40ft). + const title = isContainer + ? `${s.containerSize} container` + : (s.cargoType?.cargoTypeName ?? + s.cargoFreeText ?? + s.cargoType?.code ?? + "Bulk cargo"); + // quantityCap unit: containers for a size line, else the + // cargo type's unit of measure (tons / items / …), default tons. + const capUnit = isContainer + ? "containers" + : (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons"); + return ( + + +
+ + {title} + + + + {isContainer ? "Container" : "Bulk"} + + {s.cargoType?.code ? ( + + Code: {s.cargoType.code} + + ) : null} + + {s.quantityCap != null + ? `Cap: ${s.quantityCap} ${capUnit}` + : "Cap: uncapped"} + + +
+
+ ); + })}
)}
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index a94dd4e05..d5e3f3954 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -185,7 +185,7 @@ export default function GlClearanceDetailPage() { - + {data.kind === "booking" ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 7f62ea3f5..ad888b1f6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -135,9 +135,11 @@ export default function CustomerDetailPage() { }), ); - const bookings = bookingsQuery.data ?? []; - const documents = documentsQuery.data ?? []; - const payments = paymentsQuery.data ?? []; + const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : []; + const documents = Array.isArray(documentsQuery.data) + ? documentsQuery.data + : []; + const payments = Array.isArray(paymentsQuery.data) ? paymentsQuery.data : []; const invoices = invoicesQuery.data?.items ?? []; const invoiceTotal = invoicesQuery.data?.total ?? 0; const invoicePageCount = Math.max( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx new file mode 100644 index 000000000..d5e8655be --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx @@ -0,0 +1,286 @@ +import { useMemo } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + ActionIcon, + Badge, + Card, + Center, + Container, + Group, + Loader, + SimpleGrid, + Stack, + Table, + Tabs, + Text, + Timeline, + Title, +} from "@mantine/core"; +import { + ArrowLeft, + History, + Route, + ShieldCheck, + Truck, + User, +} from "lucide-react"; + +import { driversService } from "@/services/drivers.service"; +import { vehiclesService } from "@/services/vehicles.service"; +import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service"; + +const fmtDate = (iso?: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); +}; +const fmtDateTime = (iso?: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); +}; +const meta = (e: FleetHistoryEvent, k: string) => { + const v = e.metadata?.[k]; + return typeof v === "string" && v ? v : null; +}; + +const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => ( + + {label} + {value} + +); +const Loading = () => ( +
+); + +const DriverDetailPage = () => { + const { id = "" } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: driver, isLoading } = useQuery({ + queryKey: ["driver", id], + queryFn: () => driversService.getById(id).then((r) => r.data), + enabled: Boolean(id), + }); + + const name = driver ? `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim() : ""; + const licenseExpired = + driver?.licenseExpiryDate && new Date(driver.licenseExpiryDate) < new Date(); + + return ( + + + navigate("/dashboard/drivers")} aria-label="Back"> + + + + + {name || "Driver"} + {driver && ( + + {driver.status} + {driver.faydaVerified && ( + }> + Fayda verified + + )} + {licenseExpired && ( + License expired + )} + {driver.licenseNumber} + + )} + + + + {isLoading ? ( + + ) : !driver ? ( + Driver not found. + ) : ( + + + }>Overview + }>Vehicles + }>History + }>Trips + + + + + + + + + + + {fmtDate(driver.licenseExpiryDate)} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ); +}; + +const useDriverHistory = (driverId: string) => + useQuery({ + queryKey: ["driver-history", driverId], + queryFn: () => fleetHistoryService.driver(driverId), + }); + +/** id → "code · plate" map so events without a stored plate still show a name. */ +const useVehicleMap = () => { + const { data } = useQuery({ + queryKey: ["vehicles-all"], + queryFn: () => vehiclesService.getAll({}).then((r) => r.data), + }); + return useMemo(() => { + const m = new Map(); + for (const v of data ?? []) { + m.set(v.id, [v.code, v.plateNumber].filter(Boolean).join(" · ") || v.id); + } + return m; + }, [data]); +}; + +const VehiclesTab = ({ driverId }: { driverId: string }) => { + const { data = [], isLoading } = useDriverHistory(driverId); + const vmap = useVehicleMap(); + const rows = data + .filter((e) => e.eventType === "DRIVER_ASSIGNED") + .map((e) => ({ + id: e.id, + plate: meta(e, "vehiclePlate") || (e.vehicleId ? vmap.get(e.vehicleId) : null) || "Vehicle", + at: e.createdAt, + })); + if (isLoading) return ; + return ( + + Vehicles driven ({rows.length}) + {rows.length === 0 ? ( + No vehicle assignments recorded. + ) : ( + + + VehicleAssigned + + + {rows.map((r) => ( + + {r.plate} + {fmtDateTime(r.at)} + + ))} + +
+ )} +
+ ); +}; + +const HistoryTab = ({ driverId }: { driverId: string }) => { + const { data = [], isLoading } = useDriverHistory(driverId); + if (isLoading) return ; + if (!data.length) return No activity recorded yet.; + return ( + + {data.map((e) => ( + {e.eventType.replaceAll("_", " ")}}> + {(meta(e, "vehiclePlate") || meta(e, "bookingRef") || e.label) && ( + + {[meta(e, "vehiclePlate"), meta(e, "bookingRef") && `Booking ${meta(e, "bookingRef")}`, e.label] + .filter(Boolean) + .join(" · ")} + + )} + {fmtDateTime(e.createdAt)} + + ))} + + ); +}; + +const TripsTab = ({ driverId }: { driverId: string }) => { + const { data = [], isLoading } = useDriverHistory(driverId); + const vmap = useVehicleMap(); + const trips = useMemo( + () => + data + .filter((e) => e.eventType === "MILE_VEHICLE_ASSIGNED") + .map((e) => ({ + id: e.id, + mile: meta(e, "mile") === "LAST" ? "Last-mile" : "First-mile", + booking: meta(e, "bookingRef") ?? "—", + vehicle: meta(e, "vehiclePlate") || (e.vehicleId ? vmap.get(e.vehicleId) : null) || "—", + status: e.label ?? "—", + at: e.createdAt, + })), + [data, vmap], + ); + if (isLoading) return ; + return ( + + Trips assigned ({trips.length}) + {trips.length === 0 ? ( + No trips recorded. + ) : ( + + + + + MileBooking + VehicleStatusWhen + + + + {trips.map((t) => ( + + {t.mile} + {t.booking} + {t.vehicle} + {t.status} + {fmtDateTime(t.at)} + + ))} + +
+
+ )} +
+ ); +}; + +export default DriverDetailPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx index 73a7456af..a62c4cbc5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -1,26 +1,10 @@ import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress, Container } from '@mantine/core'; +import { Card, Stack, Group, Grid, Select, Text, RingProgress, Container, Title } from '@mantine/core'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; -import { api } from '@/services/api'; +import { api } from '@/auth/http'; import { vehiclesService } from '@/services/vehicles.service'; -import { freightBrand } from '@/theme/freight-brand'; - -interface FuelStats { - vehicleId: string; - totalPurchases: number; - totalFuel: number; - totalCost: number; - averageCostPerLiter: number; -} - -interface MaintenanceStats { - vehicleId: string; - totalCost: number; - numberOfMaintenanceItems: number; - averageCostPerMaintenance: number; - costByType: Record; -} interface CombinedReport { vehicleId: string; @@ -31,6 +15,9 @@ interface CombinedReport { maintenancePercentage: number; } +const etb = (n: number) => + 'ETB ' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + export function FinancialReportsPage() { const [selectedVehicle, setSelectedVehicle] = useState(null); const [months, setMonths] = useState('12'); @@ -45,13 +32,21 @@ export function FinancialReportsPage() { const { data: fuelStats } = useQuery({ queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''), - queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null), + queryFn: async () => { + if (!selectedVehicle) return Promise.resolve(null); + const res = await api.get(`/fuel/stats/${selectedVehicle}?months=${months}`); + return res.data; + }, enabled: !!selectedVehicle, }); const { data: maintenanceStats } = useQuery({ queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''), - queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null), + queryFn: async () => { + if (!selectedVehicle) return Promise.resolve(null); + const res = await api.get(`/maintenance/stats/${selectedVehicle}`); + return res.data; + }, enabled: !!selectedVehicle, }); @@ -60,11 +55,11 @@ export function FinancialReportsPage() { [vehicles] ); - const report = useMemo(() => { - if (!fuelStats || !maintenanceStats) return null; + const report = useMemo(() => { + if (!fuelStats && !maintenanceStats) return null; - const fuelCost = Number(fuelStats.totalCost) || 0; - const maintenanceCost = Number(maintenanceStats.totalCost) || 0; + const fuelCost = Number(fuelStats?.totalCost ?? 0) || 0; + const maintenanceCost = Number(maintenanceStats?.totalCost ?? 0) || 0; const total = fuelCost + maintenanceCost; return { @@ -93,6 +88,9 @@ export function FinancialReportsPage() { return ( + + Financial Reports + Fleet Financial Analysis @@ -126,13 +124,13 @@ export function FinancialReportsPage() { <> - + - + - + @@ -141,7 +139,7 @@ export function FinancialReportsPage() { Monthly Avg - ${(report.totalOperatingCost / parseInt(months)).toFixed(2)} + {etb(report.totalOperatingCost / parseInt(months))} @@ -166,7 +164,7 @@ export function FinancialReportsPage() { + {report.fuelPercentage}% } @@ -184,7 +182,7 @@ export function FinancialReportsPage() { + {report.maintenancePercentage}% } @@ -228,7 +226,7 @@ export function FinancialReportsPage() { Avg Maintenance Cost - ${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'} + {etb(maintenanceStats?.averageCostPerMaintenance ?? 0)} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx index a311a16eb..32341505a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx @@ -1,7 +1,8 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs, Button } from '@mantine/core'; -import { Truck, Fuel, Wrench, TrendingUp, AlertCircle, Users, User, MapPin, Calendar, BarChart3 } from 'lucide-react'; +import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core'; +import { Truck, Fuel, Wrench, AlertCircle, Users, User, MapPin } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/auth/http'; @@ -39,7 +40,20 @@ interface FleetMetrics { assignedDrivers: number; } -const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: any) => ( +/** ETB money, whole-birr for dashboard headlines. */ +const etb = (n: number) => `ETB ${(Number(n) || 0).toLocaleString('en-US', { maximumFractionDigits: 0 })}`; +/** Safe percentage — 0 when the denominator is 0 (empty fleet). */ +const pct = (n: number, d: number) => (d > 0 ? (n / d) * 100 : 0); + +interface StatCardProps { + icon: LucideIcon; + label: string; + value: string | number; + color?: string; + change?: number; +} + +const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: StatCardProps) => ( @@ -111,8 +125,8 @@ export function FleetDashboard() { const totalDrivers = (drivers as Driver[]).length; const assignedDrivers = (drivers as Driver[]).filter(d => d.assignedVehicle).length; - const fuelTotal = fuelStats?.totalCost || 0; - const maintenanceTotal = maintenanceStats?.totalCost || 0; + const fuelTotal = Number(fuelStats?.totalCost) || 0; + const maintenanceTotal = Number(maintenanceStats?.totalCost) || 0; return { totalVehicles, @@ -152,10 +166,10 @@ export function FleetDashboard() {
- + - +
@@ -173,7 +187,7 @@ export function FleetDashboard() { Active Vehicles {metrics.activeVehicles} / {metrics.totalVehicles} - +
@@ -189,7 +203,7 @@ export function FleetDashboard() { Idle / Under Maintenance {metrics.totalVehicles - metrics.activeVehicles} - +
@@ -212,7 +226,7 @@ export function FleetDashboard() { label={
- ${operatingCost.toFixed(0)} + {etb(operatingCost)} Total Cost @@ -325,13 +339,12 @@ export function FleetDashboard() { {d.licenseNumber || 'N/A'} - + {d.phone && ( - - - {d.phone} - - + + + {d.phone} + )} {d.email && {d.email}} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 13be9ef71..f7bee5b41 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -10,6 +10,7 @@ import { Navigate, useLocation } from "react-router-dom"; import FleetCardGrid from "@/components/fleet/FleetCardGrid"; import FleetFormDialog from "@/components/fleet/FleetFormDialog"; +import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; @@ -42,6 +43,7 @@ const FleetResourcePage = () => { const [editing, setEditing] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); const [assigningDriver, setAssigningDriver] = useState(null); + const [historyTarget, setHistoryTarget] = useState(null); const [selectedDriver, setSelectedDriver] = useState(""); const { viewMode, setViewMode } = useFleetViewMode(slug); @@ -273,6 +275,7 @@ const FleetResourcePage = () => { }} onRemove={setRemoveTarget} onAssignDriver={setAssigningDriver} + onHistory={setHistoryTarget} />
), @@ -354,7 +357,7 @@ const FleetResourcePage = () => { const itemLabel = config.label.toLowerCase(); return ( - + @@ -510,6 +513,7 @@ const FleetResourcePage = () => { isSubmitting={create.isPending || update.isPending} selectOptionsLoading={selectOptionsLoading} onSubmit={handleFormSubmit} + verifyWithFayda={Boolean(config.faydaVerification)} /> { + + setHistoryTarget(null)} + entity={slug === "vehicles" ? "vehicle" : "driver"} + record={historyTarget} + /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index b3c168df3..6ca9f8870 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - Box, Button, Card, Container, Group, + Loader, Modal, NumberInput, Select, @@ -17,13 +17,12 @@ import { Badge, Grid, } from "@mantine/core"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { useToast } from "@/hooks/use-toast"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; -import { freightBrand } from "@/theme/freight-brand"; interface FuelPurchase { id: string; @@ -67,7 +66,7 @@ export default function FuelPurchasePage() { }); // Fetch fuel purchases - const { data: purchasesData = [] } = useQuery({ + const { data: purchasesData = [], isLoading: isLoadingPurchases } = useQuery({ queryKey: ["fuel-purchases"], queryFn: async () => { const res = await api.get("/fuel/purchases"); @@ -104,8 +103,8 @@ export default function FuelPurchasePage() { onError: (error: any) => { toast({ title: "Error recording purchase", - message: error?.response?.data?.message || "Failed to record fuel purchase", - color: "red", + description: error?.response?.data?.message || "Failed to record fuel purchase", + variant: "destructive", }); }, }); @@ -118,6 +117,17 @@ export default function FuelPurchasePage() { const totalCost = formData.liters * formData.costPerLiter; + // Aggregate stats (guarded against divide-by-zero when there are no purchases) + const totalLiters = (purchasesData as FuelPurchase[]).reduce( + (sum, p) => sum + Number(p.liters), + 0 + ); + const totalPurchaseCost = (purchasesData as FuelPurchase[]).reduce( + (sum, p) => sum + Number(p.totalCost), + 0 + ); + const avgPricePerLiter = totalLiters > 0 ? totalPurchaseCost / totalLiters : 0; + return ( @@ -147,10 +157,7 @@ export default function FuelPurchasePage() { Total Liters - {purchasesData - .reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) - .toFixed(2)}{" "} - L + {totalLiters.toFixed(2)} L @@ -160,9 +167,7 @@ export default function FuelPurchasePage() { Total Cost - ETB {purchasesData - .reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) - .toLocaleString("en-US", { maximumFractionDigits: 2 })} + ETB {totalPurchaseCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} @@ -172,11 +177,7 @@ export default function FuelPurchasePage() { Avg Price/L - ETB{" "} - {( - purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) / - purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) || 0 - ).toFixed(2)} + ETB {avgPricePerLiter.toFixed(2)} @@ -197,6 +198,23 @@ export default function FuelPurchasePage() { + {isLoadingPurchases ? ( + + + + + + + + ) : purchasesData.length === 0 ? ( + + + + No fuel purchases recorded yet. + + + + ) : null} {(purchasesData as FuelPurchase[])?.map((purchase) => ( {(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx index 04c872966..904347cf0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -1,20 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, Badge } from "@mantine/core"; +import { Card, Container, Grid, Group, Loader, Select, Stack, Text, Title } from "@mantine/core"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; import { useState } from "react"; -interface FuelStats { - vehicleId: string; - totalPurchases: number; - totalLiters: number; - totalCost: number; - averagePricePerLiter: number; - dateRange: { startDate: string; endDate: string }; -} - export default function FuelStatsPage() { const [selectedVehicleId, setSelectedVehicleId] = useState(""); const [monthsBack, setMonthsBack] = useState("12"); @@ -29,7 +20,7 @@ export default function FuelStatsPage() { }); // Fetch fuel stats - const { data: statsData } = useQuery({ + const { data: statsData, isFetching: isStatsFetching } = useQuery({ queryKey: ["fuel-stats", selectedVehicleId, monthsBack], queryFn: async () => { if (!selectedVehicleId) return null; @@ -102,7 +93,7 @@ export default function FuelStatsPage() { Total Purchases - {statsData.totalPurchases} + {Number(statsData.totalPurchases) || 0} @@ -112,7 +103,7 @@ export default function FuelStatsPage() { Total Fuel - {statsData.totalLiters.toFixed(2)} L + {(Number(statsData.totalLiters) || 0).toFixed(2)} L @@ -122,7 +113,7 @@ export default function FuelStatsPage() { Total Cost - ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + ETB {(Number(statsData.totalCost) || 0).toLocaleString("en-US", { maximumFractionDigits: 2 })} @@ -132,7 +123,7 @@ export default function FuelStatsPage() { Avg Price/L - ETB {statsData.averagePricePerLiter.toFixed(2)} + ETB {(Number(statsData.averagePricePerLiter) || 0).toFixed(2)} @@ -172,15 +163,15 @@ export default function FuelStatsPage() { {selectedVehicle?.plateNumber} consumed{" "} - {statsData.totalLiters.toFixed(2)} liters + {(Number(statsData.totalLiters) || 0).toFixed(2)} liters {" "} over the last {monthsBack} months, costing{" "} - ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + ETB {(Number(statsData.totalCost) || 0).toLocaleString("en-US", { maximumFractionDigits: 2 })} . Average fuel price was{" "} - ETB {statsData.averagePricePerLiter.toFixed(2)} per liter + ETB {(Number(statsData.averagePricePerLiter) || 0).toFixed(2)} per liter . @@ -188,6 +179,12 @@ export default function FuelStatsPage() {
+ ) : selectedVehicleId && isStatsFetching ? ( + + + + + ) : ( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index b2bd155d3..3aa2506b5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -1,12 +1,26 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text, Container } from '@mantine/core'; -import { DateInput } from '@mantine/dates'; +import { + Card, + Button, + Modal, + Stack, + Group, + Select, + TextInput, + NumberInput, + Table, + Badge, + Text, + Title, + Container, +} from '@mantine/core'; import { Plus } from 'lucide-react'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { useToast } from '@/hooks/use-toast'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; -import { api } from '@/services/api'; -import { vehiclesService } from '@/services/vehicles.service'; -import { freightBrand } from '@/theme/freight-brand'; +import { api } from '@/auth/http'; +import { vehiclesService, type Vehicle as VehicleType } from '@/services/vehicles.service'; interface MaintenanceSchedule { id: string; @@ -21,21 +35,23 @@ interface MaintenanceSchedule { serviceProvider?: string; } +const emptyForm = { + maintenanceType: 'PREVENTIVE', + description: '', + scheduledDate: new Date().toISOString().split('T')[0], + estimatedCost: 0, + serviceProvider: '', + notes: '', +}; + export function MaintenancePage() { + const { toast } = useToast(); + const queryClient = useQueryClient(); const [selectedVehicle, setSelectedVehicle] = useState(null); const [openScheduleModal, setOpenScheduleModal] = useState(false); - const [formData, setFormData] = useState({ - maintenanceType: 'PREVENTIVE', - description: '', - scheduledDate: new Date(), - estimatedCost: 0, - serviceProvider: '', - notes: '', - }); + const [formData, setFormData] = useState(emptyForm); - const queryClient = useQueryClient(); - - const { data: vehicles } = useQuery({ + const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { const res = await vehiclesService.getAll({ limit: 1000 }); @@ -45,36 +61,49 @@ export function MaintenancePage() { const { data: upcoming, isLoading } = useQuery({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), - queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]), + queryFn: async () => { + if (!selectedVehicle) return []; + const res = await api.get(`/maintenance/upcoming/${selectedVehicle}`); + return res.data || []; + }, enabled: !!selectedVehicle, }); + const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : []; + const scheduleMutation = useMutation({ mutationFn: async () => { if (!selectedVehicle) return; - return api.post('/maintenance/schedules', { + const res = await api.post('/maintenance/schedules', { vehicleId: selectedVehicle, ...formData, }); + return res.data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') }); + toast({ title: 'Maintenance scheduled' }); + queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), + }); setOpenScheduleModal(false); - setFormData({ - maintenanceType: 'PREVENTIVE', - description: '', - scheduledDate: new Date(), - estimatedCost: 0, - serviceProvider: '', - notes: '', + setFormData(emptyForm); + }, + onError: (err: any) => { + toast({ + title: 'Error', + description: err?.response?.data?.message ?? 'Failed', + variant: 'destructive', }); }, }); - const vehicleOptions = useMemo( - () => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [], - [vehicles] - ); + const vehicleOptions = + vehiclesData?.map((v: VehicleType) => ({ + value: v.id, + label: v.plateNumber + ? `${v.plateNumber} - ${v.manufacturer} ${v.model}` + : v.registrationNumber || v.id, + })) || []; const statusColor = (status: string) => { const colors: Record = { @@ -88,66 +117,85 @@ export function MaintenancePage() { return ( + + + + Maintenance + + + - - - - Schedule Maintenance - - - - + + + {vehicleRows.map((row, i) => ( + + @@ -1281,29 +1640,123 @@ const LastMilePage = () => { ) : ( No unassigned deliveries available. )} - - o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value), + )} + value={row.vehicleId} + onChange={(v) => + setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))) + } + searchable + clearable + disabled={assignVehicleOptions.length === 0} + /> + + n === row.containerNumber || + !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), + )} + value={row.containerNumber} + onChange={(value) => + setVehicleRows((prev) => + prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), + ) + } + /> + {vehicleRows.length > 1 && ( + setVehicleRows((prev) => prev.filter((_, idx) => idx !== i))} + > + + + )} + + ))} + + @@ -1320,6 +1773,43 @@ const LastMilePage = () => { > {activeRecord && } + {activeRecord && (activeRecord.vehicleAssignments?.length ?? 0) > 0 && ( + + Assigned vehicles + + {activeRecord.vehicleAssignments!.map((a) => { + const v = a.vehicle; + const label = v + ? [v.code, v.plateNumber].filter(Boolean).join(" · ") + : a.vehicleId; + return ( + + {label} + {a.containerNumber ? ( + + {a.containerNumber} + + ) : ( + No container no. + )} + + ); + })} + + + )} + {activeRecord && ( + + Delivery steps + + + )} @@ -1336,7 +1826,7 @@ const LastMilePage = () => { centered > - {tripSlipRecord && } + {tripSlipRecord && } @@ -1345,6 +1835,61 @@ const LastMilePage = () => { + {/* Trip slip — pick a vehicle (multi-truck) */} + setTripSlipSelectOpen(false)} + title={Print trip slip — select vehicle} + radius="lg" + centered + > + + + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — pick a truck to print its slip. + + {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + chooseTripSlipVehicle(a.vehicleId)} + style={{ cursor: "pointer" }} + className="hover:bg-gray-50" + > + + + + + {label} + + + Driver: {v?.assignedDriverName || "—"} + + + Container: {a.containerNumber || "—"} + + + + + + + + ); + })} + {(tripSlipRecord?.vehicleAssignments?.length ?? 0) === 0 && ( + No vehicles assigned yet. + )} + + + + + {/* Add Actual Distance modal */} { {activeRecord && ( - + {bookingRef(activeRecord)} - - Customer - {customerName(activeRecord)} - - - Est. Distance (KM) - {activeRecord.estimatedKm ?? "—"} - - + Est. {activeRecord.estimatedKm ?? "—"} km + )} - setDistanceValue(String(v ?? ""))} - min={0} - step={0.1} - decimalScale={2} - /> + {(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? ( + Assign a vehicle before entering distance. + ) : ( + + {activeRecord!.vehicleAssignments!.map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") })) + } + min={0} + step={0.1} + decimalScale={2} + /> + ); + })} + + Total + + {Object.values(distanceRows) + .reduce((s, val) => s + (parseFloat(val) || 0), 0) + .toFixed(2)}{" "} + km + + + + )} @@ -1392,155 +1954,63 @@ const LastMilePage = () => { - {/* Invoice modal */} + {/* Generate Invoice — confirmation summary */} Invoice #345} - size="lg" + opened={Boolean(invoiceConfirm)} + onClose={() => setInvoiceConfirm(null)} + title={Generate Invoice} radius="lg" centered > - - {invoiceRecord && ( - <> - - - - EDR Freight - Invoice #345 - - - - - - - - - - - - - - - - - Post Payment - {formatPrice(invoiceRecord.remainingPayment)} - - - Advanced Payment - {formatPrice(invoiceRecord.advancedPayment)} - - - {(() => { - const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); - const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); - const difference = postPayment - advancedPayment; - - if (difference > 0) { - return ( - - Remaining to Pay - {formatPrice(difference)} - - ); - } else if (difference < 0) { - return ( - - Refund - {formatPrice(Math.abs(difference))} - - ); - } else { - return ( - - Status - Settled - - ); - } - })()} - - - - - - )} - - - - - - - {/* Container Allocation modal */} - Allocate Containers to Vehicles} - size="xl" - radius="lg" - centered - > - - {activeRecord && ( - <> - - - - - {bookingRef(activeRecord)} - {customerName(activeRecord)} - - - Cargo Type - {activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"} - - - - - - {/* Capacity logic based on cargo type */} - {activeRecord.booking?.cargoType?.name === "BULK" ? ( - - - - Smart Capacity Allocation + {invoiceConfirm && ( + + + {bookingRef(invoiceConfirm)} + {customerName(invoiceConfirm)} + + + + {(invoiceConfirm.vehicleAssignments ?? []).map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + + {label} + {a.containerNumber ? ` · ${a.containerNumber}` : ""} + + {a.distanceKm != null ? `${a.distanceKm} km` : "—"} - - Capacity: TBD - - TODO: add vehicle capacity_tons to vehicle API if missing - - - TODO: add container weight to booking if missing - - - - Select multiple containers per vehicle based on capacity - - - - ) : ( - - One vehicle per container - - )} - - )} - - { - await allocateMutation.mutateAsync(mappings); - }} - /> - - - - - + ); + })} + + + + Total distance + {invoiceConfirm.exactKm ?? 0} km + + + Invoice amount + {formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))} + + + This creates the delivery-fee invoice. Confirm the distances and amount are correct. + + + + + + + )} { const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG); + // Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK). + const { data: wagonTypeOptions } = useWagonTypeOptions(canManage); + const formFields = useMemo( + () => + FORM_FIELDS.map((field) => + field.name === "wagonTypeId" + ? { + ...field, + options: [ + { label: "None", value: RULE_ENGINE_SELECT_NONE }, + ...(wagonTypeOptions ?? []), + ], + } + : field, + ), + [wagonTypeOptions], + ); + const [search, setSearch] = useState(""); const [formMode, setFormMode] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -350,7 +382,7 @@ const CargoTypesPage = () => { ? "Create a top-level cargo category." : "Create a cargo type inside this category. It's attached here automatically." } - fields={FORM_FIELDS} + fields={formFields} initialRecord={formMode?.kind === "edit" ? formMode.record : null} isSubmitting={create.isPending || update.isPending} onSubmit={handleSubmit} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 99260946f..253cdffcc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -33,6 +33,7 @@ import { useCargoTypeParentOptions, useContainerTypeOptions, useLiveRateOptions, + useWagonTypeOptions, useRateWorkflow, useRuleEngineList, useRuleEngineMutations, @@ -151,6 +152,9 @@ const RuleEngineResourcePage = () => { const usesLiveRateField = Boolean( config?.formFields.some((f) => f.name === "rateId"), ); + const usesWagonTypeField = Boolean( + config?.formFields.some((f) => f.name === "wagonTypeId"), + ); const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } = useCargoTypeParentOptions(editingId, config?.slug === "cargo-types"); @@ -160,6 +164,8 @@ const RuleEngineResourcePage = () => { useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField); const { data: liveRateOptions, isLoading: liveRateOptionsLoading } = useLiveRateOptions(usesLiveRateField); + const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } = + useWagonTypeOptions(usesWagonTypeField); const formFields = useMemo(() => { if (!config) return []; @@ -193,9 +199,16 @@ const RuleEngineResourcePage = () => { options: liveRateOptions ?? [], }; } + if (field.name === "wagonTypeId") { + return { + ...field, + type: "select" as const, + options: wagonTypeOptions ?? [], + }; + } return field; }); - }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]); + }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]); const rows = data?.data ?? []; const meta = data?.meta; @@ -341,6 +354,10 @@ const RuleEngineResourcePage = () => { } else if (config.slug === "priority-configs") { // Label is required by the backend but hidden in the UI for now. payload = { ...values, label: String(Date.now()) }; + } else if (config.slug === "weight-limit-rules") { + // Empty max capacity means "no ceiling" — send null explicitly so an + // edit can clear a previously-set ceiling (omitting the key keeps it). + payload = { ...values, maxCapacityTons: values.maxCapacityTons ?? null }; } if (editing?.id) { @@ -498,7 +515,8 @@ const RuleEngineResourcePage = () => { (config.slug === "cargo-types" && cargoParentOptionsLoading) || (usesContainerTypeField && containerTypeOptionsLoading) || (usesCargoTypeField && cargoLeafOptionsLoading) || - (usesLiveRateField && liveRateOptionsLoading) + (usesLiveRateField && liveRateOptionsLoading) || + (usesWagonTypeField && wagonTypeOptionsLoading) } positionOptions={!editing ? createPositionOptions : undefined} positionLoading={createPositionLoading} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 8016dfcc9..2014f9c65 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -47,6 +47,14 @@ export interface FormFieldDef { * showWhen and not match hideWhen. */ showWhen?: { field: string; equals: string[] }; + /** + * Select options computed from other fields' current values. When set, the + * form resolves the option list at render time from the live form state + * instead of the static `options` list. Used for the rate unit selector, + * whose valid choices depend on `appliesTo` + `trigger`. (Named distinctly + * from the fleet config's string-based `dynamicOptions` to avoid a clash.) + */ + optionsFromValues?: (values: Record) => { label: string; value: string }[]; } export interface RuleEngineOrderConfig { @@ -116,12 +124,54 @@ const RATE_TRIGGERS = [ { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, ]; -const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map( - (v) => ({ - label: v.replace(/_/g, " "), - value: v, - }), -); +const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value }); + +/** + * Valid weighting units for a rate shape — mirrors the API's + * `allowedRateUnits`. The unit is driven by the *type* being billed: containers + * bill per container, bulk per ton, overweight always per excess ton, etc. Kept + * in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts. + */ +const allowedRateUnits = (appliesTo: string, trigger: string): string[] => { + if (appliesTo === "OTHER") { + switch (trigger) { + case "OVERWEIGHT": + return ["PER_TON"]; + case "REEFER": + case "HAZARDOUS": + case "DEMURRAGE": + return ["PER_CONTAINER", "PER_TON"]; + case "CANCELLATION": + return ["FLAT", "PER_INVOICE"]; + case "CONSOLIDATION": + case "SHIPPING_LINE": + case "PIL_EXTRA_FEE": + return ["PER_CONTAINER", "FLAT"]; + default: + return ["FLAT", "PER_TON", "PER_CONTAINER"]; + } + } + switch (appliesTo) { + case "CONTAINER": + return ["PER_CONTAINER", "PER_WAGON"]; + case "BULK": + return ["PER_TON", "PER_WAGON"]; + case "INTERCITY": + return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"]; + case "FIRST_MILE": + case "LAST_MILE": + return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"]; + default: + return ["FLAT"]; + } +}; + +const rateUnitOptions = (values: Record) => { + const appliesTo = String(values.appliesTo ?? ""); + const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS"; + if (!appliesTo) return []; + return allowedRateUnits(appliesTo, trigger).map(unitOption); +}; const CURRENCIES = [ { label: "USD", value: "USD" }, @@ -198,6 +248,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ formFields: [ { name: "label", label: "Label", type: "text", required: true }, { name: "sizeFt", label: "Size (ft)", type: "number", required: true }, + // Options injected at render from useWagonTypeOptions (RuleEngineResourcePage). + { + name: "wagonTypeId", + label: "Wagon type", + type: "select", + required: true, + description: "Wagon type used to carry this container during train scheduling.", + }, { name: "isOpenTop", label: "Open top", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ], @@ -324,8 +382,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ }, { id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" }, { id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" }, - { id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" }, - { id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" }, + { + id: "maxCapacityTons", + header: "Max capacity (t)", + accessorKey: "maxCapacityTons", + format: "number", + }, ], formFields: [ { @@ -343,8 +405,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ options: TRADE_DIRECTIONS, }, { name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true }, - { name: "effectiveFrom", label: "Effective from", type: "date", required: true }, - { name: "effectiveTo", label: "Effective to", type: "date" }, + { + name: "maxCapacityTons", + label: "Max capacity (tons)", + type: "number", + optional: true, + description: + "Hard ceiling — a booking whose line weight exceeds this cannot be created at all. Leave empty for no ceiling (overweight surcharge only).", + }, ], }, { @@ -407,7 +475,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" }, { id: "rateUnit", header: "Unit", accessorKey: "rateUnit" }, { id: "status", header: "Status", accessorKey: "status", format: "rateStatus" }, - { id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" }, ], formFields: [ { @@ -457,9 +524,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] }, }, { name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" }, - { name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS }, - { name: "effectiveFrom", label: "Effective from", type: "date", required: true }, - { name: "effectiveTo", label: "Effective to", type: "date" }, + // Unit choices are driven by the rate shape (appliesTo + trigger). Overweight + // is always per excess ton, so the unit field is hidden for it — the API + // forces PER_TON regardless. + { + name: "rateUnit", + label: "Rate unit", + type: "select", + required: true, + optionsFromValues: rateUnitOptions, + description: "Weighting basis — options depend on what the rate applies to.", + hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] }, + }, ], }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index a4ebd531e..de6ecfa9b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -1,8 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Accordion, - ActionIcon, Alert, Badge, Box, @@ -24,9 +23,7 @@ import { Boxes, CalendarDays, CheckCircle2, - ChevronLeft, ClipboardCheck, - ChevronRight, Clock, FileSignature, Hourglass, @@ -40,7 +37,7 @@ import { XCircle, } from "lucide-react"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; +import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common"; import { KpiStrip, PageContainer } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; @@ -431,101 +428,148 @@ function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) { } /** "05 Jun 2026 · 06:00 – 09:00 EAT" → "06:00 – 09:00 EAT" (date lives in the day header). */ -function timeLabelOf(label: string): string { - const idx = label.indexOf("·"); - return idx >= 0 ? label.slice(idx + 1).trim() : label; -} - const EAT_TZ = "Africa/Addis_Ababa"; -const dateKeyFmt = new Intl.DateTimeFormat("en-CA", { - timeZone: EAT_TZ, - year: "numeric", - month: "2-digit", - day: "2-digit", -}); const dateLabelFmt = new Intl.DateTimeFormat("en-GB", { timeZone: EAT_TZ, weekday: "short", day: "2-digit", month: "short", }); +const timeFmt = new Intl.DateTimeFormat("en-GB", { + timeZone: EAT_TZ, + hour: "2-digit", + minute: "2-digit", + hour12: false, +}); -/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */ -function windowDateKey(w: BatchWindowGroup): string { - if (w.date) return w.date; - if (w.start) return dateKeyFmt.format(new Date(w.start)); - return "undated"; +interface ScheduleWindow { + windowPhase: BatchBoardScheduleDetail["windowPhase"]; + bookingWindowStatus: string; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo?: number; } -/** Human day label for a window — prefers the API field, falls back to `start`. */ -function windowDateLabel(w: BatchWindowGroup): string { - if (w.dateLabel) return w.dateLabel; - if (w.start) return dateLabelFmt.format(new Date(w.start)); - return "Undated"; +/** + * Deadline + label for the phase the schedule's booking window is currently in — + * the SAME phases the customer sees on the portal: pre-window (opens) → open + * (closes) → document review → payment. `expiredText` names the next step so a + * lapsed deadline reads as a handover, not a bare "Expired". + */ +function windowPhaseCountdown( + w: ScheduleWindow, +): { label: string; deadline: string; expiredText: string } | null { + switch (w.windowPhase) { + case "PRE_WINDOW": + return w.windowOpensAt + ? { label: "Booking opens in", deadline: w.windowOpensAt, expiredText: "Booking opening now…" } + : null; + case "OPEN": + return w.windowClosesAt + ? { label: "Window closes in", deadline: w.windowClosesAt, expiredText: "Document review starting…" } + : null; + case "DOC_REVIEW": + return w.docReviewEndsAt + ? { label: "Document review ends in", deadline: w.docReviewEndsAt, expiredText: "Payment starting…" } + : null; + case "PAYMENT": + return w.paymentPhaseEndsAt + ? { label: "Payment window ends in", deadline: w.paymentPhaseEndsAt, expiredText: "Payment window closing…" } + : null; + default: + return null; + } } -function WindowAccordionItem({ window }: { window: BatchWindowGroup }) { - const total = window.bookings.length; - const hasIssues = window.bookings.some( - (b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED", +/** One phase row: label + its clock time (or "—" when unset). */ +function PhaseTimeRow({ + label, + iso, + active, +}: { + label: string; + iso: string | null; + active: boolean; +}) { + return ( + + + {label} + + + {iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"} + + ); +} + +/** + * The schedule's REAL booking window — the exact same window the customer sees on + * the portal (frozen open/close from the schedule's own snapshot + the post-close + * document-review and payment phases), with a live countdown to the current phase. + * Replaces the old theoretical "3-hour windows across every day" projection. + */ +function ScheduleWindowPanel({ window: w }: { window: ScheduleWindow }) { + const phase = w.windowPhase; + const cd = windowPhaseCountdown(w); + const open = phase === "OPEN" && w.bookingWindowStatus === "OPEN"; + + const openDay = w.windowOpensAt + ? dateLabelFmt.format(new Date(w.windowOpensAt)) + : null; return ( - - - - - - - - - - {timeLabelOf(window.label)} - - - {total - ? `${total} booking${total === 1 ? "" : "s"}` - : "Empty window"} - - - - - {hasIssues ? ( - } - > - Issues - - ) : null} - - + + + + {phase ? ( + + ) : null} + - - - - - + {openDay ? ( + + Booking day · {openDay} + + ) : null} + + + {cd ? ( + + + + ) : null} + + + + + + + + ); } +/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */ + export default function BatchScheduleDetailPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const navigate = useNavigate(); @@ -577,6 +621,34 @@ export default function BatchScheduleDetailPage() { return [...byId.values()]; }, [data]); + // All bookings that fall inside the schedule's booking window (every window + // cycle, flattened) — the window is one booking day, so these belong to the + // single window panel above. + const windowBookings = useMemo( + () => (data?.windows ?? []).flatMap((w) => w.bookings), + [data?.windows], + ); + + const windowCounts = useMemo(() => { + const counts = { + allocated: 0, + selectedForBatch: 0, + ready: 0, + waiting: 0, + expired: 0, + pendingContract: 0, + }; + for (const b of windowBookings) { + if (b.state === "ALLOCATED") counts.allocated += 1; + else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; + else if (b.state === "READY") counts.ready += 1; + else if (b.state === "WAITING") counts.waiting += 1; + else if (b.state === "EXPIRED") counts.expired += 1; + else counts.pendingContract += 1; + } + return counts; + }, [windowBookings]); + // Batch bookings by state for the composition side panel (payment / expired lists). const batchBookings = useMemo(() => { const all = allBookings; @@ -591,102 +663,10 @@ export default function BatchScheduleDetailPage() { [data?.status], ); - // Group the flat window list into per-day sections (one per EAT calendar date). - const dayGroups = useMemo(() => { - if (!data) return []; - const byDate = new Map< - string, - { - date: string; - dateLabel: string; - windows: BatchWindowGroup[]; - totalBookings: number; - counts: BatchWindowGroup["counts"]; - hasIssues: boolean; - } - >(); - for (const w of data.windows) { - const dateKey = windowDateKey(w); - let group = byDate.get(dateKey); - if (!group) { - group = { - date: dateKey, - dateLabel: windowDateLabel(w), - windows: [], - totalBookings: 0, - counts: { - allocated: 0, - selectedForBatch: 0, - ready: 0, - waiting: 0, - expired: 0, - pendingContract: 0, - }, - hasIssues: false, - }; - byDate.set(dateKey, group); - } - group.windows.push(w); - group.totalBookings += w.bookings.length; - group.counts.allocated += w.counts.allocated; - group.counts.selectedForBatch += w.counts.selectedForBatch; - group.counts.ready += w.counts.ready; - group.counts.waiting += w.counts.waiting; - group.counts.expired += w.counts.expired; - group.counts.pendingContract += w.counts.pendingContract; - group.hasIssues = - group.hasIssues || - w.bookings.some( - (b) => - b.allocationStatus === "FAILED" || - b.allocationStatus === "DEFERRED", - ); - } - return [...byDate.values()]; - }, [data]); - - // Windows with bookings open by default (inside an expanded day). - const openWindowKeys = useMemo( - () => - data - ? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key) - : [], - [data], - ); - - const todayEat = useMemo( - () => - new Intl.DateTimeFormat("en-CA", { - timeZone: "Africa/Addis_Ababa", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(new Date()), - [], - ); - - // Date-stepper: which day is currently shown. Default to today, else the first - // day with bookings, else the first day. Keep the selection if still valid. - const [selectedDate, setSelectedDate] = useState(null); const [activeTab, setActiveTab] = useState("overview"); const [selectedBookingId, setSelectedBookingId] = useState( null, ); - useEffect(() => { - if (!dayGroups.length) return; - if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return; - const preferred = - dayGroups.find((d) => d.date === todayEat) ?? - dayGroups.find((d) => d.totalBookings > 0) ?? - dayGroups[0]; - setSelectedDate(preferred.date); - }, [dayGroups, selectedDate, todayEat]); - - const selectedIndex = Math.max( - 0, - dayGroups.findIndex((d) => d.date === selectedDate), - ); - const selectedDay = dayGroups[selectedIndex]; const handleCompleteDocReview = () => { completeDocReview @@ -1012,137 +992,30 @@ export default function BatchScheduleDetailPage() { - Batch windows (EAT) + Booking window (EAT) - 3-hour windows for every day from when the booking window - opened through the departure date. Bookings appear under the - date their contract was signed — open a day to see its - windows. + The schedule's real booking window — the same window and + phase timings the customer sees on the portal. Bookings in + the window are listed below. - {dayGroups.length && selectedDay ? ( - <> - {/* Date stepper — page back/forward through each day in the range */} - - - setSelectedDate( - dayGroups[selectedIndex - 1]?.date ?? null, - ) - } - > - - + - - - - - {selectedDay.dateLabel} - - {selectedDay.date === todayEat ? ( - - Today - - ) : null} - - - {selectedDay.totalBookings - ? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows` - : `${selectedDay.windows.length} windows · no bookings`} - - - - = dayGroups.length - 1} - onClick={() => - setSelectedDate( - dayGroups[selectedIndex + 1]?.date ?? null, - ) - } - > - - - - - - - Day {selectedIndex + 1} of {dayGroups.length} + {windowBookings.length ? ( + + + + Bookings in this window - - {selectedDay.hasIssues ? ( - } - > - Issues - - ) : null} - - + - - - {selectedDay.windows.map((window) => ( - - ))} - - + + ) : ( - No batch windows for this schedule. + No bookings in this window yet. )} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 9319e6a01..958049a63 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -8,6 +8,7 @@ import { Paper, RingProgress, Stack, + Tabs, Text, Textarea, TextInput, @@ -25,10 +26,12 @@ import { LayoutGrid, Navigation, Package, + PackageCheck, Route as RouteIcon, Send, Train, Weight, + Workflow as WorkflowIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; @@ -42,9 +45,11 @@ import { } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; +import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; +import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { RouteCorridor, @@ -141,6 +146,13 @@ export default function TrainScheduleV2DetailPage() { }, }); + const importLoadingQuery = useQuery( + api.trainScheduling.importLoadingBookings.queryOptions({ + input: { id: scheduleId ?? "" }, + enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"), + }), + ); + const eligibleFilters = useMemo( () => schedule @@ -951,6 +963,23 @@ export default function TrainScheduleV2DetailPage() { ]} /> + {schedule?.direction === "IMPORT" ? ( + + + Import loading confirmation + + Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded + is tracking only — it does not block dispatch. + + + + + ) : null} + {gatepassApplies ? ( @@ -1028,6 +1057,18 @@ export default function TrainScheduleV2DetailPage() { ) : null} + + + }> + Workflow + + }> + Workspace + + + + + {/* Workflow header with ring progress */} @@ -1085,7 +1126,20 @@ export default function TrainScheduleV2DetailPage() { - + + + + + + { + autoPreviewedRef.current = false; + void detailQuery.refetch(); + }} + /> + + {scheduleId ? ( >({}); + // Fields hold raw NumberInput values (number | string) while editing; coerced to Number on save. + const [form, setForm] = useState< + Partial> + >({}); useEffect(() => { void (async () => { try { const rules = await trainSchedulingService.getGlobalRules(); - setForm(rules); + // `numeric` columns come back from the API as strings (e.g. "250.00"). + // Coerce every field to a real number so Mantine's controlled + // NumberInput edits cleanly (a string value fights the caret) and the + // default can be cleared and replaced. + const numeric: Partial> = {}; + for (const [key, value] of Object.entries(rules)) { + if (key === "id") continue; + const num = value === "" || value == null ? "" : Number(value); + numeric[key as keyof TrainSchedulingGlobalRules] = + typeof num === "number" && Number.isNaN(num) ? "" : num; + } + setForm(numeric); } catch { toast({ title: "Failed to load train scheduling rules", variant: "destructive" }); } finally { setLoading(false); } })(); - }, [toast]); + // Run once on mount only. `toast` from useToast is a fresh function every + // render — listing it here re-fired the effect on every render, refetching + // the rules and overwriting whatever the user was typing (values snapped + // back to the saved defaults). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const handleSave = async () => { + // Every field must hold a real number — an empty box (cleared but not + // refilled) must not silently save as 0. Collect the numeric payload and + // reject if any value is blank or NaN. + const fields: (keyof TrainSchedulingGlobalRules)[] = [ + "maxTrainLengthMeters", + "maxTrainWeightTons", + "maxWagonsPerTrain", + "max20ftContainerWeightTons", + "max20ftPairWeightDiffTons", + "importWindowLeadDays", + "exportBookingLeadHours", + "windowOpenHour", + "windowDurationHours", + "docReviewMinutes", + "paymentWindowMinutes", + "reopenDelayMinutes", + ]; + const payload: Partial> = {}; + for (const key of fields) { + const raw = form[key]; + const num = raw === "" || raw == null ? NaN : Number(raw); + if (!Number.isFinite(num)) { + toast({ + title: "All fields are required — fill every value before saving.", + variant: "destructive", + }); + return; + } + payload[key] = num; + } + setSaving(true); try { - const updated = await trainSchedulingService.updateGlobalRules({ - maxTrainLengthMeters: Number(form.maxTrainLengthMeters), - maxTrainWeightTons: Number(form.maxTrainWeightTons), - maxWagonsPerTrain: Number(form.maxWagonsPerTrain), - max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons), - max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons), - importWindowLeadDays: Number(form.importWindowLeadDays), - exportBookingLeadHours: Number(form.exportBookingLeadHours), - windowOpenHour: Number(form.windowOpenHour), - windowDurationHours: Number(form.windowDurationHours), - docReviewMinutes: Number(form.docReviewMinutes), - paymentWindowMinutes: Number(form.paymentWindowMinutes), - reopenDelayMinutes: Number(form.reopenDelayMinutes), - }); + const updated = await trainSchedulingService.updateGlobalRules(payload); setForm(updated); toast({ title: "Train scheduling rules saved" }); } catch { @@ -65,8 +103,10 @@ export default function TrainSchedulingGlobalRulesPage() { description="Sum of all wagon lengths must not exceed this" value={form.maxTrainLengthMeters ?? ""} onChange={(value) => - setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) })) + setForm((current) => ({ ...current, maxTrainLengthMeters: value })) } + clampBehavior="none" + allowDecimal min={1} disabled={loading} /> @@ -75,8 +115,10 @@ export default function TrainSchedulingGlobalRulesPage() { description="Total container and bulk cargo weight must not exceed this" value={form.maxTrainWeightTons ?? ""} onChange={(value) => - setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) })) + setForm((current) => ({ ...current, maxTrainWeightTons: value })) } + clampBehavior="none" + allowDecimal min={1} disabled={loading} /> @@ -84,8 +126,10 @@ export default function TrainSchedulingGlobalRulesPage() { label="Max wagons per train" value={form.maxWagonsPerTrain ?? ""} onChange={(value) => - setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) })) + setForm((current) => ({ ...current, maxWagonsPerTrain: value })) } + clampBehavior="none" + allowDecimal min={1} disabled={loading} /> @@ -96,9 +140,11 @@ export default function TrainSchedulingGlobalRulesPage() { onChange={(value) => setForm((current) => ({ ...current, - max20ftContainerWeightTons: Number(value), + max20ftContainerWeightTons: value, })) } + clampBehavior="none" + allowDecimal min={0.001} disabled={loading} /> @@ -109,9 +155,11 @@ export default function TrainSchedulingGlobalRulesPage() { onChange={(value) => setForm((current) => ({ ...current, - max20ftPairWeightDiffTons: Number(value), + max20ftPairWeightDiffTons: value, })) } + clampBehavior="none" + allowDecimal min={0} disabled={loading} /> @@ -124,22 +172,24 @@ export default function TrainSchedulingGlobalRulesPage() { title="Booking windows" subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)." /> - - setForm((current) => ({ ...current, importWindowLeadDays: Number(value) })) + setForm((current) => ({ ...current, importWindowLeadDays: value })) } min={0} disabled={loading} /> - - setForm((current) => ({ ...current, exportBookingLeadHours: Number(value) })) + setForm((current) => ({ ...current, exportBookingLeadHours: value })) } min={1} disabled={loading} @@ -149,49 +199,54 @@ export default function TrainSchedulingGlobalRulesPage() { description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)" value={form.windowOpenHour ?? ""} onChange={(value) => - setForm((current) => ({ ...current, windowOpenHour: Number(value) })) + setForm((current) => ({ ...current, windowOpenHour: value })) } + clampBehavior="none" + allowDecimal min={0} max={23} disabled={loading} /> - - setForm((current) => ({ ...current, windowDurationHours: Number(value) })) - } - min={0.25} - max={12} - step={0.25} - disabled={loading} - /> - - setForm((current) => ({ ...current, docReviewMinutes: Number(value) })) - } - min={0} - disabled={loading} - /> - - setForm((current) => ({ ...current, paymentWindowMinutes: Number(value) })) + setForm((current) => ({ ...current, windowDurationHours: value })) } min={1} disabled={loading} /> - - setForm((current) => ({ ...current, reopenDelayMinutes: Number(value) })) + setForm((current) => ({ ...current, docReviewMinutes: value })) + } + min={0} + disabled={loading} + /> + + setForm((current) => ({ ...current, paymentWindowMinutes: value })) + } + min={1} + disabled={loading} + /> + + setForm((current) => ({ ...current, reopenDelayMinutes: value })) } min={1} disabled={loading} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx index 31ee5ea36..609995e3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx @@ -14,7 +14,17 @@ import { Text, } from '@mantine/core'; import { useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + ChevronDown, + ChevronRight, + Eye, + FileText, + History, + PackageOpen, + ShieldCheck, + Truck, +} from 'lucide-react'; import { PageHeader } from '@/components/page'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; @@ -36,6 +46,7 @@ import { useInterchangeDocuments, } from '@/hooks/useInterchangeDocuments'; import { useToast } from '@/hooks/use-toast'; +import { trainSchedulingService } from '@/services/trainScheduling.service'; import type { AutoUnloadExportDjiboutiResult, ExportTrain, @@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() { const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' }); const autoUnload = useAutoUnloadExportAtDjibouti(); const generateInterchange = useGenerateInterchangeDocument(); + const qc = useQueryClient(); + const secureGatePass = useMutation({ + mutationFn: (scheduleId: string) => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId), + onSuccess: () => + qc.invalidateQueries({ + queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'], + }), + }); const [openScheduleId, setOpenScheduleId] = useState(null); const [busyScheduleId, setBusyScheduleId] = useState(null); const [historyInventoryId, setHistoryInventoryId] = useState(null); @@ -189,6 +208,25 @@ export default function ExportDjiboutiUnloadingQueuePage() { .map((doc) => [doc.scheduleId as string, doc]), ); + const secureGate = async (train: ExportTrain) => { + setBusyScheduleId(train.scheduleId); + try { + await secureGatePass.mutateAsync(train.scheduleId); + toast({ + title: 'Gate pass secured', + description: `Djibouti Port entry allowed for ${train.trainNumber ?? 'the train'}. You can now auto unload.`, + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Could not secure gate pass', + description: getErrorMessage(error), + }); + } finally { + setBusyScheduleId(null); + } + }; + const unloadTrain = async (train: ExportTrain) => { setBusyScheduleId(train.scheduleId); try { @@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() { > Open + ); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx index fbb704292..6dcece7ef 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx @@ -1,7 +1,12 @@ -import { Box, Group, Skeleton, Stack, Text } from "@mantine/core"; -import { memo } from "react"; -import { useNavigate } from "react-router-dom"; -import { ArrowRight, CalendarClock } from "lucide-react"; +import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { memo, useMemo, useState } from "react"; +import { + ArrowRight, + CalendarClock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; +import { CountdownTimer } from "@edr/ui-common"; import type { MyBookingWindow } from "@/services/bookings.service"; import { Card } from "./Card"; @@ -43,6 +48,57 @@ function windowLabel(w: MyBookingWindow): string { return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " "); } +/** + * The countdown for whichever phase the window is currently in. Phases run: + * pre-window (opens at windowOpensAt) → open (closes at windowClosesAt) → + * document review (docReviewEndsAt) → payment (paymentPhaseEndsAt). + * + * `label` describes the deadline being counted down to; `expiredText` names the + * NEXT step so that when a deadline lapses between the 60s refetches the row + * announces what comes next ("Booking opening now…", "Review starting…") rather + * than the bare word "Expired". Returns null when no phase is timing down. + */ +function phaseCountdown( + w: MyBookingWindow, +): { label: string; deadline: string; expiredText: string } | null { + switch (w.windowPhase) { + case "PRE_WINDOW": + if (w.windowOpensAt) + return { + label: "Booking opens in", + deadline: w.windowOpensAt, + expiredText: "Booking opening now…", + }; + return null; + case "OPEN": + if (w.windowClosesAt) + return { + label: "Window closes in", + deadline: w.windowClosesAt, + expiredText: "Document review starting…", + }; + return null; + case "DOC_REVIEW": + if (w.docReviewEndsAt) + return { + label: "Document review ends in", + deadline: w.docReviewEndsAt, + expiredText: "Payment starting…", + }; + return null; + case "PAYMENT": + if (w.paymentPhaseEndsAt) + return { + label: "Payment due in", + deadline: w.paymentPhaseEndsAt, + expiredText: "Payment window closing…", + }; + return null; + default: + return null; + } +} + function Pill({ children, bg, @@ -117,15 +173,38 @@ interface UpcomingWindowsSectionProps { } /** - * The customer's upcoming/open booking windows on their active-contract - * lanes. Import trains open a window on one booking day; export trains open - * 24h before departure. Hidden entirely when there is nothing to show. + * All announced upcoming/open booking windows, shown to every customer + * regardless of whether they hold a contract on the lane. Import trains open a + * window on one booking day; export trains open 24h before departure. Rows on a + * lane the customer has an active contract for carry a "Book now" action; + * others route to the contract list. Hidden entirely when nothing is announced. */ +/** Rows shown per carousel page. */ +const PER_PAGE = 3; + export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ windows, isLoading, }: UpcomingWindowsSectionProps) { - const navigate = useNavigate(); + const [page, setPage] = useState(0); + + // Open lanes first, then by opening time — the ones the customer can act on + // lead the carousel. + const sorted = useMemo( + () => + [...windows].sort((a, b) => { + const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); + if (openDiff !== 0) return openDiff; + const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity; + const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity; + return at - bt; + }), + [windows], + ); + + const pageCount = Math.max(1, Math.ceil(sorted.length / PER_PAGE)); + const safePage = Math.min(page, pageCount - 1); + const visible = sorted.slice(safePage * PER_PAGE, safePage * PER_PAGE + PER_PAGE); // Nothing upcoming — keep the dashboard uncluttered. if (!isLoading && windows.length === 0) return null; @@ -138,20 +217,61 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ Booking Windows - Upcoming and open booking windows on your contract lanes + Upcoming and open booking windows across all lanes + + {pageCount > 1 ? ( + + setPage((p) => Math.max(0, p - 1))} + > + + + + {Array.from({ length: pageCount }, (_, i) => ( + setPage(i)} + style={{ + width: i === safePage ? 18 : 7, + height: 7, + borderRadius: 999, + cursor: "pointer", + background: i === safePage ? "#0A6F4D" : "#D8E2EB", + transition: "width 200ms ease, background 200ms ease", + }} + /> + ))} + + = pageCount - 1} + onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} + > + + + + ) : null} {isLoading ? ( - {[1, 2].map((i) => ( + {[1, 2, 3].map((i) => ( ))} ) : ( - - {windows.map((w) => ( + + {visible.map((w) => ( navigate("/contracts") : undefined - } > @@ -184,8 +300,23 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({ {windowLabel(w)} · Departs {fmtDay(w.departureDate)} + {(() => { + const cd = phaseCountdown(w); + return cd ? ( + + + + ) : null; + })()} + {/* Windows are informational here — booking is done from the + contract page while a window is open, not via a home CTA. */} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index f0f75f537..6d274f22f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,12 +1,10 @@ -import { Box, Group, Text } from "@mantine/core"; +import { Group } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { CreditCard, Download, Eye } from "lucide-react"; +import { CreditCard } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { isViewable } from "@edr/ui-common"; import { api } from "@/services/api"; -import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; import { invoicesService } from "@/services/invoices.service"; import { paymentsService, type PaymentMethod } from "@/services/payments.service"; @@ -19,9 +17,8 @@ import { ClearanceCard } from "./components/ClearanceCard"; import { ContainersCard } from "./components/ContainersCard"; import { ContractCard } from "./components/ContractCard"; import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard"; -import { DocRow, IconSquare } from "./components/Documents"; import { KeyFactsStrip } from "./components/KeyFactsStrip"; -import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout"; +import { BodyGrid, PageShell } from "./components/layout"; import { CancelledBanner, ConsolidationPairedNotice, @@ -51,7 +48,7 @@ export function ReadonlyBookingView({ useScrollToHash(); const status = booking.status as string; const [payModalOpen, setPayModalOpen] = useState(false); - const { view, viewer } = useFileViewer(); + const { viewer } = useFileViewer(); // Re-book opens the New Shipment Booking form for the same contract, not the // New Contract page. Fall back to /contracts/new only if the link is missing. @@ -160,7 +157,6 @@ export function ReadonlyBookingView({ ) } menuActions={{ - onViewContract: booking.signedByCeoAt ? () => {} : undefined, onRebook, onSupport: () => navigate("/support"), }} @@ -199,7 +195,7 @@ export function ReadonlyBookingView({ - + {isClearance && } @@ -220,52 +216,6 @@ export function ReadonlyBookingView({ )} - {booking.files && booking.files.length > 0 && ( - - - Documents - - {booking.files.length} files - - - - {booking.files.map((file, i) => ( - - {isViewable({ - name: file.name, - url: fileViewUrl(file.id), - mimeType: file.mimeType, - }) && ( - } - onClick={() => - view({ - name: file.name, - url: fileViewUrl(file.id), - mimeType: file.mimeType, - }) - } - /> - )} - } - /> - - } - /> - ))} - - - )} - } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx index f56e07349..083e4de26 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx @@ -1,6 +1,4 @@ -import { Box, Button, Group, Paper, Text } from "@mantine/core"; -import { FileSignature } from "lucide-react"; -import type { useNavigate } from "react-router-dom"; +import { Box, Group, Paper, Text } from "@mantine/core"; import type { Freight } from "@edr/types"; @@ -37,13 +35,7 @@ const CONTRACT_CONFIG: Record< }, }; -export function ContractCard({ - booking, - navigate, -}: { - booking: Freight.IBooking; - navigate: ReturnType; -}) { +export function ContractCard({ booking }: { booking: Freight.IBooking }) { const c = CONTRACT_CONFIG[booking.status as string]; if (!c) return null; @@ -76,20 +68,6 @@ export function ContractCard({ {c.description} - {c.buttonLabel && ( - - )} ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 80ed05eb0..afcd2f777 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -524,11 +524,10 @@ export default function NewBookingPage() { } : { customsClearingEnabled: false }), ...(cargoFreeText ? { cargoFreeText } : {}), - // Multi-route general contracts: routes are pure origin→destination lanes - // the contract covers — they carry NO quantity. Route #1 is the primary - // origin/destination; the rest come from the extra-routes step. The - // contracted quantity lives in a single shared pool (the container - // quantities / bulk total), drawn down per order against a chosen lane. + // A general contract covers exactly ONE route — the same single + // origin→destination pair as a one-time booking (multi-route on bookings + // was dropped). The contracted quantity lives in a single shared pool + // (container quantities / bulk total), drawn down per order. ...(isContract ? { routes: [ @@ -536,12 +535,6 @@ export default function NewBookingPage() { originYardId: data.originYard, destinationYardId: data.destinationYard, }, - ...(data.extraRoutes ?? []) - .filter((r) => r.originYard && r.destinationYard) - .map((r) => ({ - originYardId: r.originYard, - destinationYardId: r.destinationYard, - })), ], } : {}), diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 1b75ab65c..b2f14b16f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -147,6 +147,31 @@ async function searchPlaces( return found; } +/** + * Build the address label for a picked place. + * + * For an establishment / POI (e.g. "Bole Medhanialem") Google's + * `formatted_address` is the *postal* address, which for many Ethiopian places + * collapses to just the city ("Addis Ababa, Ethiopia") — so taking it verbatim + * silently replaces the specific place the user picked with a broad city. The + * place `name` carries the specific label, so we lead with it and only append + * the formatted address for context when it doesn't already contain the name. + * Falls back to the prediction's own description (what the user saw and clicked). + */ +function placeDisplayName( + place: google.maps.places.PlaceResult | null, + prediction: PlacePrediction, +): string { + const name = place?.name?.trim(); + const formatted = place?.formatted_address?.trim(); + if (name && formatted) { + return formatted.toLowerCase().includes(name.toLowerCase()) + ? formatted + : `${name}, ${formatted}`; + } + return name || formatted || prediction.displayName; +} + /** * Resolve a picked prediction to its coordinates via Place Details. Runs once * per selection (closes the Autocomplete session), so billing stays on the @@ -174,10 +199,7 @@ async function resolvePrediction( return; } resolve({ - displayName: - place?.formatted_address || - place?.name || - prediction.displayName, + displayName: placeDisplayName(place, prediction), lat: loc.lat(), lng: loc.lng(), }); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 2b902bdc8..b137635ae 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -144,10 +144,9 @@ export const bookingFormSchema = z // The contracted quantity now comes from the cargo step (cargoWeight), the // same as a one-time booking, so per-route quantity is no longer entered. primaryRouteQuantity: z.string().default(""), - // Additional routes for a GENERAL contract (the primary origin/destination - // above is route #1). Each route is just an (origin, destination) pair — - // identical to the one-time route — so a contract can cover several routes. - // Ignored for one-time bookings. quantity/km kept for payload back-compat. + // LEGACY — multi-route general contracts were dropped; a contract booking + // now covers exactly one route, like a one-time booking. Field retained only + // so previously saved drafts still hydrate; never collected or sent anymore. extraRoutes: z .array( z.object({ @@ -434,7 +433,6 @@ export const stepFields: Record>> = { "originYard", "destinationYard", "primaryRouteQuantity", - "extraRoutes", // Estimated shipment date now lives in the Route step (one-time bookings only). "scheduledDate", ], diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 783162a35..15b2c5a8e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -1,26 +1,9 @@ import type { Freight } from "@edr/types"; -import { - Box, - Button, - Group, - Skeleton, - Stack, - Text, -} from "@mantine/core"; +import { Box, Skeleton, Stack } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; -import { - CalendarDays, - MapPin, - Plus, - Route as RouteIcon, - Trash2, -} from "lucide-react"; +import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react"; import { useCallback, useEffect, useMemo } from "react"; -import { - Controller, - useFieldArray, - type UseFormReturn, -} from "react-hook-form"; +import { Controller, type UseFormReturn } from "react-hook-form"; import { BookingFormInputValues, type BookingFormValues, @@ -70,16 +53,6 @@ export function Step4Route({ } }, [operationType]); - const { - fields: extraRoutes, - append: appendRoute, - remove: removeRoute, - } = useFieldArray({ control: form.control, name: "extraRoutes" }); - - // useFieldArray's `fields` don't re-render on value change, so watch the live - // route values to filter each row's yard options by what it has selected. - const watchedExtraRoutes = form.watch("extraRoutes") ?? []; - const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; return referenceData.yard.map((y) => ({ value: y.id, label: y.name })); @@ -129,25 +102,6 @@ export function Step4Route({ } }, [destinationCountry, dest, form]); - // Same cleanup for the extra contract routes: when the operation type changes, - // clear any extra-route yard whose country no longer matches the required side - // so an added route can't contradict the operation either. - useEffect(() => { - watchedExtraRoutes.forEach((route, i) => { - const ro = referenceData?.yard.find((y) => y.id === route?.originYard); - if (originCountry && ro && ro.country !== originCountry) { - form.setValue(`extraRoutes.${i}.originYard`, ""); - } - const rd = referenceData?.yard.find( - (y) => y.id === route?.destinationYard, - ); - if (destinationCountry && rd && rd.country !== destinationCountry) { - form.setValue(`extraRoutes.${i}.destinationYard`, ""); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [originCountry, destinationCountry, referenceData, form]); - const directionStyle: Record = { EXPORT: "bg-sky-50 text-sky-800 border-sky-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200", @@ -161,11 +115,10 @@ export function Step4Route({ const stationSelectDisabled = yardOptions.length === 0; - // A general contract can cover several routes, but each route is just an - // (origin, destination) pair — the same shape as the one-time route. The - // contracted quantity comes from the cargo step, so no per-route quantity or - // distance is collected here. Cargo handling (hazardous / refrigerated) also - // lives in the Cargo step now, not here. + // A general contract covers exactly ONE route — the same single + // origin/destination pair as a one-time booking. (Multi-route contracts were + // dropped; the multi-lane concept lives on the contracts module, not on + // bookings.) The contracted quantity comes from the cargo step. // Earliest selectable shipment date (today, local) for the date input's `min`. const todayISODate = useMemo(() => { @@ -256,103 +209,6 @@ export function Step4Route({ )} - {isGeneralContract && !isLoading && ( - - - Additional contract routes - - - - A general contract can cover several routes. The route above is your - primary route; add more origin–destination routes the contract should - cover. - - - {extraRoutes.map((rf, i) => { - // Each extra route is constrained by the SAME operation type as the - // primary route: its origin must sit in originCountry and its - // destination in destinationCountry. Watch this row's current values - // so each side also excludes the yard picked on the other side. - const rowOrigin = watchedExtraRoutes[i]?.originYard ?? ""; - const rowDestination = - watchedExtraRoutes[i]?.destinationYard ?? ""; - const rowOriginData = yardsForSide(originCountry, rowDestination); - const rowDestData = yardsForSide(destinationCountry, rowOrigin); - return ( - - - ( - - )} - /> - - - ( - - )} - /> - - - - ); - })} - - - )} - ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index a02138373..ac336c47c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -26,6 +26,70 @@ type BookingForm = UseFormReturn< BookingFormValues >; +/** + * One numbered toggle per container unit in the line — tap units to mark how + * many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection + * fills from unit 1: tapping unit N selects 1..N, tapping a selected unit N + * keeps 1..N-1 — the count is always derived, never free-typed, so it can't + * exceed the line quantity. + */ +function UnitCountToggles({ + total, + value, + onChange, + label, + activeBg, + activeBorder, + activeColor, +}: { + total: number; + value: string; + onChange: (v: string) => void; + label: string; + activeBg: string; + activeBorder: string; + activeColor: string; +}) { + const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0))); + + return ( +
+ + {label} · {count}/{total} selected + +
+ {Array.from({ length: total }, (_, i) => { + const selected = i < count; + return ( + + ); + })} +
+
+ ); +} + export function Step5CargoDetails({ form, referenceData, @@ -144,16 +208,6 @@ export function Step5CargoDetails({ const lineQtyOf = (index: number) => Math.max(1, Number(form.getValues(`containers.${index}.qty`) ?? 1) || 1); const lineMax = (index: number) => lineQtyOf(index); - // When a flag is switched on, default its count to the whole line. - const defaultLineQty = (index: number) => lineQtyOf(index).toString(); - // Clamp a typed value into 1..lineQty (empty stays empty so the field can be - // cleared; the schema flags an empty value as required while the switch is on). - const clampToLine = (raw: string, index: number) => { - if (raw === "") return ""; - const n = Number(raw); - if (Number.isNaN(n)) return raw; - return Math.min(lineQtyOf(index), Math.max(1, Math.floor(n))).toString(); - }; // After the line quantity changes, pull any active count back within bounds. const clampDependentQty = (index: number, newLineQty: number) => { const max = Math.max(1, newLineQty); @@ -636,7 +690,7 @@ export function Step5CargoDetails({ hazField.onChange(v); form.setValue( `containers.${index}.hazardousQty`, - v ? defaultLineQty(index) : "0", + v ? "1" : "0", { shouldDirty: true, shouldValidate: true }, ); }} @@ -645,22 +699,22 @@ export function Step5CargoDetails({ name={`containers.${index}.hazardousQty`} control={form.control} render={({ field: hq, fieldState }) => ( - - hq.onChange( - clampToLine(e.currentTarget.value, index), - ) - } - onBlur={hq.onBlur} - error={fieldState.error?.message} - radius="md" - /> +
+ + {fieldState.error?.message ? ( + + {fieldState.error.message} + + ) : null} +
)} /> @@ -681,7 +735,7 @@ export function Step5CargoDetails({ reeField.onChange(v); form.setValue( `containers.${index}.reeferQty`, - v ? defaultLineQty(index) : "0", + v ? "1" : "0", { shouldDirty: true, shouldValidate: true }, ); }} @@ -690,22 +744,22 @@ export function Step5CargoDetails({ name={`containers.${index}.reeferQty`} control={form.control} render={({ field: rq, fieldState }) => ( - - rq.onChange( - clampToLine(e.currentTarget.value, index), - ) - } - onBlur={rq.onBlur} - error={fieldState.error?.message} - radius="md" - /> +
+ + {fieldState.error?.message ? ( + + {fieldState.error.message} + + ) : null} +
)} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx index f9429a3e6..10b64c79a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ClearancePhaseStepper.tsx @@ -15,6 +15,18 @@ const PHASE_LABELS: Record = { POST_TRANSIT: "Transit", }; +/** One-line hint under each phase label, for the vertical layout. */ +const PHASE_HINTS: Record = { + CUSTOMER_INTAKE: "You upload the required clearance documents", + GL_ET_REVIEW: "Global Logistics reviews your documents in Ethiopia", + GL_DJ_COLLECTION: "Delivery order collected in Djibouti", + GL_ET_OUTPUT: "Customs declaration prepared", + CUSTOMER_DUTY: "You pay the assessed duty / tax", + GL_ET_POST_CLEARANCE: "Transit cleared and paperwork finalised", + GL_DJ_LOADING: "Cargo loaded for departure", + POST_TRANSIT: "In transit", +}; + const IMPORT_PHASES = [ "CUSTOMER_INTAKE", "GL_ET_REVIEW", @@ -51,62 +63,92 @@ export function ClearancePhaseStepper({ const current = clearance?.phase ?? phases[0]; const activeIdx = phaseIndex(phases, current); + const dot = compact ? 26 : 30; + const rowGap = compact ? 18 : 24; + + // Vertical timeline: every phase is a row, so all steps stay visible on any + // width without horizontal scrolling. The connector runs down between dots. return ( - + {phases.map((phase, index) => { const isComplete = index < activeIdx; const isActive = index === activeIdx; const isLast = index === phases.length - 1; + // const doneOrActive = isComplete || isActive; return ( - - - - - {isComplete ? : null} - - - {PHASE_LABELS[phase] ?? phase} - - + + {/* Dot + connector column */} + + + {isComplete ? ( + + ) : ( + + {index + 1} + + )} + {!isLast && ( )} - - + + + {/* Label + hint */} + + + {PHASE_LABELS[phase] ?? phase} + + {PHASE_HINTS[phase] && ( + + {PHASE_HINTS[phase]} + + )} + + ); })} - +
); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx index 736cc67e4..cc5318cf9 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceWorkflowBanner.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Alert, Box, Button, Group, Paper, Stack, Text } from "@mantine/core"; -import { AlertTriangle, Download, Receipt, Upload } from "lucide-react"; +import { AlertTriangle, ArrowRight, Download, PackageCheck, Receipt, Upload } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; @@ -87,7 +87,36 @@ export function ContractClearanceWorkflowBanner({ onDownload={downloadWorkflowFile} /> - {clearance.bookingReady ? ( + {clearance.linkedBookingId ? ( + }> + + + Shipment booking created + {clearance.linkedBookingReference + ? ` · ${clearance.linkedBookingReference}` + : ""} + + + Global Logistics has created your shipment booking + {clearance.linkedBookingStatus + ? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})` + : ""} + . Track its progress from the booking. + + + + + ) : clearance.bookingReady ? ( Clearance is complete. Global Logistics will create your shipment booking shortly. diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index f15200d49..268e8627a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -16,6 +16,8 @@ import { Group, Loader, Paper, + Progress, + RingProgress, SimpleGrid, Stack, Tabs, @@ -61,6 +63,7 @@ import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBann import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { getContractBookingAction } from "./contract-booking-action"; +import { closedWindowMessage, hasOpenWindow } from "./booking-window"; import { BORDER, ContractStatusBadge, @@ -198,6 +201,29 @@ export default function ContractDetailPage() { (r) => r.status === "PENDING" || r.status === "ACCEPTED", ); + // Booking windows for this contract's routes — gates the direct "New shipment + // booking" entry so the customer only sees it while a window is open. + // Refetched every minute so "Open now" flips without a manual reload. + const { data: bookingWindows = [] } = useQuery({ + ...api.bookings.getContractBookingWindows.queryOptions({ + input: { contractId: id! }, + refetchInterval: 60_000, + }), + enabled: !!id, + }); + const bookingWindowOpen = hasOpenWindow(bookingWindows); + + // Draw-down capacity per cargo line (GENERAL contracts only). The backend + // excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships + // releases its share and the tracker fills back up. Refetched on window focus so + // it reflects newly created / cancelled shipments. + const { data: capacityLines = [] } = useQuery({ + queryKey: ["contract-capacity", id], + queryFn: () => contractsService.getCapacity(id!), + enabled: !!id && contract?.contractKind === "GENERAL", + refetchOnWindowFocus: true, + }); + const contractBookings = useMemo( () => (bookingsPage?.items ?? []).filter( @@ -370,17 +396,40 @@ export default function ContractDetailPage() { Request shipment )} - {canBookShipment && ( - - )} + {canBookShipment && + (bookingWindowOpen ? ( + + ) : ( + + + + + {closedWindowMessage(bookingWindows)} + + + + ))} {glPreparingBooking && ( + {/* Draw-down capacity — GENERAL contracts with a per-line quantity cap. + Fills as shipments consume capacity; empties again when a shipment is + cancelled/rejected/expired (backend releases it). */} + {isGeneral && capacityLines.length > 0 && ( + + Contract capacity + + {capacityLines.map((line, i) => { + const cap = line.cap ?? 0; + const booked = line.booked ?? 0; + const remaining = line.remaining ?? Math.max(0, cap - booked); + const usedPct = cap > 0 ? Math.min(100, (booked / cap) * 100) : 0; + const remainingPct = cap > 0 ? Math.round((remaining / cap) * 100) : 0; + const unit = capacityUnitLabel(contract, line); + const label = isContainer + ? `${line.containerSize ?? "Containers"}` + : (contract.cargoScope ?? []).find( + (s) => s.cargoTypeId === line.cargoTypeId, + )?.cargoType?.cargoTypeName ?? + (contract.cargoScope ?? [])[0]?.cargoFreeText ?? + "Bulk commodity"; + return ( + + + {remainingPct}% + + } + /> + + + + {isContainer ? ( + + ) : ( + + )} + + {label} + + + + {booked} / {cap} {unit} booked + + + + + {remaining} {unit} remaining + + + + ); + })} + + + )} + {/* Signatures */} {(contract.signatures ?? []).length > 0 && ( Bookings under this contract - {canBookShipment && ( + {canBookShipment && bookingWindowOpen && ( + + + } + title="Booking is not open right now" + > + {closedWindowMessage(bookingWindows)} + + Come back when the booking window opens to book your shipment. + + + + + ); + } + return ; } @@ -151,8 +218,6 @@ function NewShipmentBookingForm({ mode: "onChange", }); - const isContainerContract = contract.freightType === "CONTAINER"; - const submitMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => api.contracts.createBookingUnderContract.call({ id: contractId, dto }), @@ -220,21 +285,22 @@ function NewShipmentBookingForm({ } // Submit validates the whole form, then opens the price modal for - // confirmation. For container contracts we also run the server-side shipment - // validation (overweight warnings + 20ft pairing hard-blocks) so the modal - // can surface them before the booking is created. + // confirmation. The server-side shipment validation also returns the + // authoritative price breakdown (rail + first/last mile + every surcharge) — + // run it for every freight type; container contracts additionally get + // overweight warnings + 20ft pairing hard-blocks surfaced in the modal. const handleReview = form.handleSubmit((values) => { setPendingValues(values); - if (isContainerContract) { - validateMutation.reset(); - validateMutation.mutate(buildDto(values)); - } + validateMutation.reset(); + validateMutation.mutate(buildDto(values)); }); const handleConfirm = () => { if (!pendingValues) return; // Guard: never let a booking with unresolved 20ft pairing errors submit. if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return; + // Guard: a line above the container type's max capacity can never book. + if ((validateMutation.data?.capacityErrors?.length ?? 0) > 0) return; submitMutation.mutate(buildDto(pendingValues)); }; @@ -371,15 +437,63 @@ function PriceConfirmModal({ onConfirm: () => void; onReject: () => void; }) { - const total = useMemo( + const baseTotal = useMemo( () => (values ? computeShipmentTotal(contract, values) : null), [contract, values], ); const overweightLines = validation?.overweightLines ?? []; + const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0; const pairingErrors = validation?.pairingErrors ?? []; const hasPairingBlock = pairingErrors.length > 0; - const confirmDisabled = loading || validationLoading || hasPairingBlock; + const capacityErrors = validation?.capacityErrors ?? []; + const hasCapacityBlock = capacityErrors.length > 0; + const confirmDisabled = + loading || validationLoading || hasPairingBlock || hasCapacityBlock; + + // Authoritative server breakdown — the SAME BookingPricingService pass that + // prices the booking on create, so it carries every line the booking will be + // charged: rail freight, first/last mile trucking, overweight, hazard/reefer + // and any other rule-engine surcharge. + const serverTotal = useMemo(() => { + const items = validation?.lineItems; + if (!items?.length) return null; + return { + currency: validation?.currency ?? baseTotal?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [validation, baseTotal]); + + // Fallback while the server preview loads: the contract's frozen unit rates + // (container/bulk + hazard/reefer only) with the overweight surcharge folded + // in. Replaced by the full server breakdown the moment it arrives. + const total = useMemo(() => { + if (serverTotal) return serverTotal; + if (!baseTotal) return null; + if (!(overweightSurchargeAmount > 0)) return baseTotal; + return { + ...baseTotal, + lines: [ + ...baseTotal.lines, + { + label: "Overweight surcharge", + unitPrice: overweightSurchargeAmount, + unit: "flat" as const, + quantity: 1, + amount: overweightSurchargeAmount, + }, + ], + total: baseTotal.total + overweightSurchargeAmount, + }; + }, [serverTotal, baseTotal, overweightSurchargeAmount]); return ( - Checking container weights and wagon pairing… + Computing the final price breakdown and checking container + weights… )} @@ -440,6 +555,28 @@ function PriceConfirmModal({ )} + {hasCapacityBlock && ( + } + title="Cannot create booking — over maximum capacity" + > + + {capacityErrors.map((msg, i) => ( + + {msg} + + ))} + + Reduce the cargo weight or split it across more containers to + book this shipment. + + + + )} + {overweightLines.length > 0 && ( ))} - An overweight surcharge applies. You can still submit, or go - back and adjust weights. + {overweightSurchargeAmount > 0 + ? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${ + validation?.currency ?? total?.currency ?? "" + } applies (included in the total below). You can still submit, or go back and adjust weights.` + : "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts new file mode 100644 index 000000000..44b97c741 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts @@ -0,0 +1,63 @@ +import type { MyBookingWindow } from "@/services/bookings.service"; + +/** All booking-window times are communicated in East Africa Time. */ +const TZ = "Africa/Addis_Ababa"; + +/** "Thu, 10 Jul, 08:00 EAT" — a full opening date/time in Addis Ababa time. */ +export function formatWindowOpensAt(iso: string): string { + const day = new Date(iso).toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: TZ, + }); + const time = new Date(iso).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: TZ, + }); + return `${day}, ${time}`; +} + +/** True when at least one of the contract's windows is bookable right now. */ +export function hasOpenWindow(windows: MyBookingWindow[]): boolean { + return windows.some((w) => w.isOpenNow); +} + +/** + * The soonest upcoming (not-yet-open) window with a known opening time, so the + * customer can be told when to come back. Returns `null` when nothing upcoming + * carries an opening time. + */ +export function soonestUpcomingWindow( + windows: MyBookingWindow[], +): MyBookingWindow | null { + const upcoming = windows + .filter((w) => !w.isOpenNow && w.windowOpensAt) + .sort( + (a, b) => + new Date(a.windowOpensAt!).getTime() - + new Date(b.windowOpensAt!).getTime(), + ); + return upcoming[0] ?? null; +} + +/** + * The closed-state message shown when no booking window is open: the soonest + * upcoming window's opening time + lane, or a generic notice when nothing is + * scheduled. + */ +export function closedWindowMessage(windows: MyBookingWindow[]): string { + const next = soonestUpcomingWindow(windows); + if (!next || !next.windowOpensAt) { + return "No upcoming booking window scheduled."; + } + const lane = + next.origin && next.destination + ? ` for ${next.origin}→${next.destination}` + : ""; + return `Booking is not open right now. Next window: ${formatWindowOpensAt( + next.windowOpensAt, + )} EAT${lane}.`; +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index c86e17fc5..eec7e896b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -15,7 +15,8 @@ import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared"; const CONTRACT_TYPE_OPTIONS = [ { value: "new", label: "New Contract" }, - { value: "renewal", label: "Contract Renewal" }, + // Renewal is disabled for now — not yet available to customers. + { value: "renewal", label: "Contract Renewal (coming soon)", disabled: true }, ]; type ContractForm = UseFormReturn< diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index ed359d998..81a2488a9 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -376,6 +376,12 @@ export const api = { "myBookingWindows", () => bookingsService.getMyBookingWindows(), ), + + getContractBookingWindows: endpoint<{ contractId: string }, MyBookingWindow[]>( + "train-scheduling", + "contractBookingWindows", + ({ contractId }) => bookingsService.getContractBookingWindows(contractId), + ), }, contracts: { diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index a4ce87131..c2729bebb 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -47,17 +47,26 @@ export interface PriceLineItem { } /** - * An upcoming/open booking window on one of the signed-in customer's - * active-contract lanes. Import trains open a window on one booking day; + * An announced upcoming/open booking window, shown to every signed-in customer + * regardless of contract. Import trains open a window on one booking day; * export trains open 24h before departure (first come, first served). */ export interface MyBookingWindow { scheduleId: string; + /** + * The customer's active contract on this lane, when they hold one — enables + * "Book now" to target it. Null for lanes they have no contract on. + */ + contractId: string | null; + /** ONE_TIME contracts can't draw down against a window — button is hidden. */ + contractKind: "ONE_TIME" | "GENERAL" | null; direction: "IMPORT" | "EXPORT" | null; windowPhase: string | null; isOpenNow: boolean; windowOpensAt: string | null; windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; bookingWindowStatus: string; bookingCycleNo: number; departureDate: string; @@ -375,4 +384,18 @@ export const bookingsService = { ); return data.data ?? data; }, + + /** + * Booking windows for a single contract's routes (same row shape as + * `getMyBookingWindows`). Used to gate the direct "New shipment booking" + * entry on the contract detail page and the new-shipment form. + */ + getContractBookingWindows: async ( + contractId: string, + ): Promise => { + const { data } = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId), + ); + return data.data ?? data; + }, }; diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 3af87ee42..10ef9716f 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -42,14 +42,39 @@ export interface OverweightLine { } /** - * Pre-submit validation for a shipment booking under a CONTAINER contract. + * One line of the server-priced booking breakdown — the exact line the booking + * will persist at create time (rail freight, first/last mile, surcharges…). + */ +export interface ShipmentPriceLine { + code: string; + description: string; + amount: number; + unitAmount: number; + /** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */ + unit: string; + quantity: number; + currency: string; +} + +/** + * Pre-submit validation + authoritative price preview for a shipment booking. * `overweightLines` are WARNINGS only (an overweight surcharge applies — the * customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers * that cannot be balanced onto wagons) and must prevent booking. + * `lineItems`/`totalAmount` are the full server-computed breakdown — the same + * BookingPricingService pass that prices the booking on create, so the confirm + * modal shows first/last mile, overweight, and every surcharge, not just the + * container estimate. */ export interface ShipmentValidation { overweightLines: OverweightLine[]; + overweightSurchargeAmount: number; + currency: string | null; pairingErrors: string[]; + /** Lines above the container type's hard max capacity — booking cannot be created. */ + capacityErrors?: string[]; + lineItems?: ShipmentPriceLine[]; + totalAmount?: number; } export interface ContractListFilter { diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 810e0689a..d1952da23 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import { useSearchParams, useRouter } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; @@ -147,10 +147,16 @@ export default function ResultsPage() { // For one-way, check if outbound has results // For round-trip, check if BOTH outbound and inbound have results - const hasResults = isRoundTrip + const hasResults = isRoundTrip ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) : outboundSchedules.length > 0; + // One-way searches that come back with an empty outbound list may still include + // date-shifted alternatives from the API — surface those instead of a dead end. + const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; + const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : []; + const requestedDate: string = (results && results.requestedDate) || searchData.date; + const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); }; @@ -217,7 +223,190 @@ export default function ResultsPage() { router.push('/booking/auth-check'); }; - const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => { + // Shared "Choose Your Coach" drawer — used by both the normal results view and the + // Alternative Travel Options fallback, so selecting an alternative opens the exact + // same coach-type picker as a normal schedule. + const renderClassModal = () => { + if (!classModal) return null; + + const scheduleId = classModal.scheduleId || classModal.id || ''; + const selectedCoachType = selectedCoachTypes[scheduleId]; + const isOutbound = (classModal as any).isOutbound; + const coachTypes = classModal.coachTypes || []; + + const getCoachIcon = (typeName: string) => { + const lower = typeName.toLowerCase(); + if (lower.includes('soft') || lower.includes('vip')) return Star; + if (lower.includes('bed')) return Bed; + return Armchair; + }; + + return ( + <> +
setClassModal(null)} /> +
+
+
+

Choose Your Coach

+

+ + {classModal.trainNumber} + · + {classModal.origin?.name} → {classModal.destination?.name} +

+
+ +
+ +
+ {coachTypes.length > 0 ? ( +
+ {coachTypes.map((coachType: any, index: number) => { + const isSelected = selectedCoachType?.id === coachType.coachTypeId; + const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; + const coachCurrency = 'ETB'; + const CoachIcon = getCoachIcon(coachType.coachTypeName); + + return ( + + ); + })} +
+ ) : ( +
+
+ +
+

No coach types available for this journey

+
+ )} +
+ +
+
+ + {!selectedCoachType && ( +

+ + Select a coach type to continue +

+ )} +
+
+
+ + + ); + }; + + const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false, isAlternative: boolean = false) => { const scheduleId = schedule.scheduleId || schedule.id || ''; const selectedCoachType = selectedCoachTypes[scheduleId]; @@ -242,7 +431,22 @@ export default function ResultsPage() { const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString(); return ( -
+
+ {isAlternative && ( +
+ + + Different date — {schedule.departureAt ? format(new Date(schedule.departureAt), 'EEEE, MMMM d, yyyy') : 'N/A'} + + + {schedule.hasAvailability ? 'Seats available' : 'Fully booked'} + +
+ )}
@@ -310,9 +514,10 @@ export default function ResultsPage() { )}
@@ -478,6 +683,54 @@ export default function ResultsPage() { } if (!hasResults) { + // ONE_WAY search with an explicit empty outbound list — surface any date-shifted + // alternatives the API suggests instead of a dead-end "no trains found" screen. + if (isOneWayNoOutbound) { + const requestedDateLabel = requestedDate + ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') + : 'your selected date'; + const hasAlternatives = alternativeOutbound.length > 0; + + return ( +
+ {renderClassModal()} +
+
+
+
+ +
+

No trains available

+

+ No trains are available on {requestedDateLabel}. This + may be due to no scheduled service or full capacity. {hasAlternatives + ? 'Please check the alternative options below or try a different date.' + : 'Please try a different date.'} +

+ +
+ + {hasAlternatives && ( +
+
+

Alternative Travel Options

+

+ These trains run on different dates than requested — adjust your travel date to book one of them. +

+
+
+ {alternativeOutbound.map((schedule) => renderScheduleCard(schedule, true, true))} +
+
+ )} +
+
+
+ ); + } + return (
@@ -505,183 +758,7 @@ export default function ResultsPage() {
- {classModal && (() => { - const scheduleId = classModal.scheduleId || classModal.id || ''; - const selectedCoachType = selectedCoachTypes[scheduleId]; - const isOutbound = (classModal as any).isOutbound; - const coachTypes = classModal.coachTypes || []; - - const getCoachIcon = (typeName: string) => { - const lower = typeName.toLowerCase(); - if (lower.includes('soft') || lower.includes('vip')) return Star; - if (lower.includes('bed')) return Bed; - return Armchair; - }; - - return ( - <> -
setClassModal(null)} /> -
-
-
-

Choose Your Coach

-

- - {classModal.trainNumber} - · - {classModal.origin?.name} → {classModal.destination?.name} -

-
- -
- -
- {coachTypes.length > 0 ? ( -
- {coachTypes.map((coachType: any, index: number) => { - const isSelected = selectedCoachType?.id === coachType.coachTypeId; - const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; - const coachCurrency = 'ETB'; - const CoachIcon = getCoachIcon(coachType.coachTypeName); - - return ( - - ); - })} -
- ) : ( -
-
- -
-

No coach types available for this journey

-
- )} -
- -
-
- - {!selectedCoachType && ( -

- - Select a coach type to continue -

- )} -
-
-
- - - ); - })()} + {renderClassModal()} {promoData && (
diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index ef893e68f..fe653573d 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -132,6 +132,13 @@ export interface IContractCargoScope { /** "20ft" | "40ft"; null for bulk. */ containerSize?: string | null; cargoTypeId?: string | null; + /** Bulk cargo type detail (name + unit), loaded on the contract detail. */ + cargoType?: { + id: string; + code?: string | null; + cargoTypeName?: string | null; + unitOfMeasure?: string | null; + } | null; cargoFreeText?: string | null; /** * GENERAL contracts: total quantity bookable across all shipments on this line @@ -356,6 +363,9 @@ export interface ContractClearanceView { /** Export post-booking clearance finalized after transit permit upload. */ exportClearanceFinalized?: boolean; linkedBookingId?: string | null; + /** Reference + status of the GL-created shipment booking, once it exists. */ + linkedBookingReference?: string | null; + linkedBookingStatus?: string | null; dutyAdvice?: { amount: number; currency: string; @@ -577,6 +587,12 @@ export interface IContract extends BaseEntity { status: ContractStatus; clearanceStatus: ContractClearanceStatus; clearanceCycleNumber: number; + /** + * Latest clearance cycle's current phase (list responses only). Lets list + * consumers show step-accurate customer actions without fetching the full + * clearance view per contract. + */ + clearancePhase?: ContractDocPhase | string | null; pricingBreakdown?: ContractPricingBreakdown | null; pricingDisplayMode?: "UNIT_RATES"; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 261f22d0b..3afc21a43 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -198,6 +198,11 @@ export enum TrainSetWagonStatus { Departed = "DEPARTED", } +export enum LoadingStatus { + Loaded = "LOADED", + Unloaded = "UNLOADED", +} + export enum WagonStatus { Available = "AVAILABLE", Assigned = "ASSIGNED", diff --git a/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx b/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx new file mode 100644 index 000000000..faadb0adf --- /dev/null +++ b/packages/ui-common/src/components/CountdownTimer/CountdownTimer.tsx @@ -0,0 +1,87 @@ +import { Group, Text } from "@mantine/core"; +import { Clock } from "lucide-react"; +import { useEffect, useState } from "react"; + +export interface CountdownTimerProps { + /** ISO timestamp the countdown targets. */ + deadline: string | null | undefined; + /** Optional label shown before the time (e.g. "Window closes in"). */ + label?: string; + /** Text shown once the deadline has passed. */ + expiredText?: string; + /** Visual size of the time text. */ + size?: "xs" | "sm" | "md" | "lg"; + /** Colour once under this many seconds remain (urgency). Default 300 (5 min). */ + urgentUnderSeconds?: number; +} + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +/** Break a remaining-milliseconds figure into a human string. */ +function formatRemaining(ms: number): string { + const total = Math.floor(ms / 1000); + const days = Math.floor(total / 86400); + const hours = Math.floor((total % 86400) / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + + if (days > 0) return `${days}d ${pad(hours)}h ${pad(minutes)}m`; + if (hours > 0) return `${hours}h ${pad(minutes)}m ${pad(seconds)}s`; + return `${pad(minutes)}m ${pad(seconds)}s`; +} + +/** + * Live countdown to an ISO deadline. Ticks once a second, shows the remaining + * time (d/h/m/s), turns red when under `urgentUnderSeconds`, and shows + * `expiredText` once the deadline is in the past. Display only — enforcement + * lives server-side. + */ +export function CountdownTimer({ + deadline, + label, + expiredText = "Expired", + size = "sm", + urgentUnderSeconds = 300, +}: CountdownTimerProps) { + const [remaining, setRemaining] = useState(() => + deadline ? new Date(deadline).getTime() - Date.now() : null, + ); + + useEffect(() => { + if (!deadline) { + setRemaining(null); + return; + } + const target = new Date(deadline).getTime(); + const tick = () => setRemaining(target - Date.now()); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [deadline]); + + if (!deadline || remaining == null || Number.isNaN(remaining)) { + return null; + } + + const expired = remaining <= 0; + const urgent = !expired && remaining <= urgentUnderSeconds * 1000; + const color = expired ? "red.7" : urgent ? "orange.7" : "dimmed"; + + return ( + + + {label && ( + + {label} + + )} + + {expired ? expiredText : formatRemaining(remaining)} + + + ); +} + +export default CountdownTimer; diff --git a/packages/ui-common/src/components/CountdownTimer/index.ts b/packages/ui-common/src/components/CountdownTimer/index.ts new file mode 100644 index 000000000..d1f61e322 --- /dev/null +++ b/packages/ui-common/src/components/CountdownTimer/index.ts @@ -0,0 +1,2 @@ +export { CountdownTimer, default } from "./CountdownTimer"; +export type { CountdownTimerProps } from "./CountdownTimer"; diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts index 43c39d962..136e9e93b 100644 --- a/packages/ui-common/src/index.ts +++ b/packages/ui-common/src/index.ts @@ -25,6 +25,9 @@ export { useFileViewer } from "./hooks/useFileViewer"; export { OperationDatePicker } from "./components/OperationDatePicker"; export type { OperationDatePickerProps } from "./components/OperationDatePicker"; +export { CountdownTimer } from "./components/CountdownTimer"; +export type { CountdownTimerProps } from "./components/CountdownTimer"; + export { Badge } from "./components/badge"; // export type { BadgeProps } from "./components/badge"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fcc8ce59..a610ea30a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: handlebars: specifier: ^4.7.9 version: 4.7.9 + jose: + specifier: ^5.10.0 + version: 5.10.0 libphonenumber-js: specifier: ^1.13.6 version: 1.13.6