diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72ad6de66..62530611c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -182,24 +182,6 @@ jobs: set -euo pipefail docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate - - name: Verify deployment health - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) - run: | - set -euo pipefail - PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2) - echo "Waiting for service to become healthy on port ${PORT}..." - for i in $(seq 1 12); do - if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then - echo "Service is healthy." - exit 0 - fi - echo "Attempt ${i}/12 — not ready yet, waiting 10s..." - sleep 10 - done - echo "Service failed health check after 120s — rolling back" - docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true - exit 1 - - name: Remove npm credentials from workspace if: always() run: rm -f .npmrc .npmrc_temp 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..39eed76ea 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -22,6 +22,7 @@ "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", + "seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts", "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", @@ -63,6 +64,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..c2a3a5abb 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"; @@ -59,28 +64,32 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; +import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.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 +150,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera LastMileModule, InterchangeDocumentsModule, ImportOperationsModule, + VerifaydaModule, + FleetHistoryModule, ], providers: [ EdrOrgSeeder, @@ -160,6 +171,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, + PaidImportExportMileDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -211,4 +223,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/migrations/1950000000000-AddCustomerTruckAssignments.ts b/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts new file mode 100644 index 000000000..7c95199b1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Multi-truck customer (self-haul) assignment. Replaces the single + * booking.customer_truck_* fields with a per-booking list of trucks, each + * carrying 1–2 containers and tracking its own arrival. The legacy + * booking.customer_truck_* columns are kept as a synced booking-level flag + * (any truck assigned / all trucks arrived) so the warehouse exit-gate and + * delivery-approval logic keep working. + */ +export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface { + name = 'AddCustomerTruckAssignments1950000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.customer_truck_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + plate_number varchar(32) NOT NULL, + driver_name varchar(120) NOT NULL, + truck_type varchar(60) NOT NULL, + assigned_at timestamptz NOT NULL DEFAULT now(), + arrived_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_customer_truck_assignments_booking" ON freight.customer_truck_assignments (booking_id);`, + ); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.customer_truck_containers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id uuid NOT NULL REFERENCES freight.customer_truck_assignments(id) ON DELETE CASCADE, + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + container_number varchar(64) NOT NULL, + 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_customer_truck_containers_assignment" ON freight.customer_truck_containers (assignment_id);`, + ); + // One container number can be loaded onto exactly one truck per booking. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_customer_truck_containers_booking_number" + ON freight.customer_truck_containers (booking_id, container_number) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts new file mode 100644 index 000000000..59a0441c5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container receive tracking. A booking's containers arrive individually + * (on separate self-haul trucks), so each container unit tracks whether it has + * been received into the port and, once staff confirm it, the GRN it belongs to. + * A single GRN covers the containers received together — so if the whole booking + * arrives at once, all its units share one GRN (per-booking GRN). + */ +export class AddContainerReceiptToBookingContainerUnits1960000000000 + implements MigrationInterface +{ + name = 'AddContainerReceiptToBookingContainerUnits1960000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS received_at timestamptz, + ADD COLUMN IF NOT EXISTS grn_number varchar(100) + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`); + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + DROP COLUMN IF EXISTS received_to_port, + DROP COLUMN IF EXISTS received_at, + DROP COLUMN IF EXISTS grn_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts new file mode 100644 index 000000000..e36420751 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Import self-haul trucks are weighed on leaving. The customer does not + * pre-specify what an import truck takes — staff register the containers loaded + * and the weighed gross when the truck departs. These columns capture that. + */ +export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface { + name = 'AddCustomerTruckDeparture1970000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2), + ADD COLUMN IF NOT EXISTS departed_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + DROP COLUMN IF EXISTS gross_weight_kg, + DROP COLUMN IF EXISTS departed_at + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts new file mode 100644 index 000000000..13084d1c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; + +import { CheckAvailabilityService } from "./check-availability.service"; + +@ApiTags("auth") +@Controller("auth") +@Public() +export class CheckAvailabilityController { + constructor( + private readonly checkAvailabilityService: CheckAvailabilityService, + ) {} + + @Get("check-availability") + @ApiOperation({ + summary: "Check whether an email and/or phone number is already registered", + }) + check(@Query("email") email?: string, @Query("phone") phone?: string) { + return this.checkAvailabilityService.check({ email, phone }); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.service.ts b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts new file mode 100644 index 000000000..c9ce84b72 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts @@ -0,0 +1,47 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +export interface CheckAvailabilityQuery { + email?: string; + phone?: string; +} + +export interface CheckAvailabilityResult { + emailTaken: boolean; + phoneTaken: boolean; +} + +@Injectable() +export class CheckAvailabilityService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async check({ + email, + phone, + }: CheckAvailabilityQuery): Promise { + if (!email && !phone) { + throw new BadRequestException("email or phone is required"); + } + + const matches = await this.userRepository.find({ + where: [ + ...(email ? [{ email }] : []), + ...(phone ? [{ phoneNumber: phone }] : []), + ], + select: { id: true, email: true, phoneNumber: true }, + }); + + return { + emailTaken: email ? matches.some((user) => user.email === email) : false, + phoneTaken: phone + ? matches.some((user) => user.phoneNumber === phone) + : false, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index a689ba24e..16fbeffda 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -1,10 +1,16 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; + +import { CheckAvailabilityController } from './check-availability.controller'; +import { CheckAvailabilityService } from './check-availability.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; @Module({ - controllers: [FreightMeController], - providers: [FreightMeService], + imports: [TypeOrmModule.forFeature([User])], + controllers: [FreightMeController, CheckAvailabilityController], + providers: [FreightMeService, CheckAvailabilityService], }) export class FreightAuthModule {} 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..a334b5e28 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, @@ -947,12 +957,27 @@ export class BillingService { returnUrl: opts.returnUrl, failureUrl: opts.failureUrl, }); - +// // Link the intent to the invoice BEFORE any settlement can correlate against it. await this.dataSource .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); + // DEMO: manually fire the gateway `payment.succeeded` callback here, without + // waiting for real gateway settlement. Runs AFTER the paymentId link above so + // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: + // remove — real settlement flips this via the `${source}.invoice.paid` handler. + if (!result.immediateSuccess) { + await this.payment.handlePaymentEvent({ + eventType: "payment.succeeded", + eventId: `demo-${result.intentId}`, + referenceId: invoice.sourceId, + intentId: result.intentId, + providerTxnId: result.providerTxnId, + paidAt: (result.paidAt ?? new Date()).toISOString(), + }); + } + if (result.immediateSuccess) { await this.settleByPaymentId( result.intentId, 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..06edbf04e 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); } @@ -1076,31 +1086,78 @@ export class BookingTransitionService { offeredAmount: number; paymentDeadline: Date; } | null; + /** Flat list of physical container numbers on this booking (for the + * customer truck-assignment container picker). */ + containerNumbers: string[]; } > { - 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}`, + ); + } + // Physical container numbers entered at booking time (booking_container + // units), flattened for the customer truck-assignment container picker. + const containerNumbers = (booking.bookingContainers ?? []) + .flatMap((bc) => bc.units ?? []) + .map((unit) => unit.containerNumber) + .filter((n): n is string => Boolean(n)); + return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, activeBatchOffer, + containerNumbers, }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c5574377a..106b7ed5b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, HttpCode, Param, @@ -61,6 +62,11 @@ import { } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; +import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; +import { CustomerTruckService } from './customer-truck.service'; +import { GenerateGrnDto } from './dto/generate-grn.dto'; +import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { @@ -83,6 +89,8 @@ export class BookingsController { private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, private readonly bookingClearanceService: BookingClearanceService, + private readonly customerTruckService: CustomerTruckService, + private readonly containerReceiptService: ContainerReceiptService, ) {} @Post() @@ -309,6 +317,94 @@ export class BookingsController { res.send(buffer); } + @Get(':id/customer-trucks') + @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) + async listCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.listTrucks(id); + } + + @Post(':id/customer-trucks') + @ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' }) + async addCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AddCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addTruck(id, dto); + } + + @Delete(':id/customer-trucks/:assignmentId') + @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) + async removeCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.removeTruck(id, assignmentId); + } + + @Post(':id/customer-trucks/:assignmentId/depart') + @ApiOperation({ + summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', + }) + async departCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: DepartCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + // Weighing + registering the load on exit is a warehouse/gate staff action. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can register a truck departure'); + } + return this.customerTruckService.departTruck(id, assignmentId, dto); + } + + @Get(':id/received-pending-grn') + @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) + async receivedPendingGrn( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.listReceivedPendingGrn(id); + } + + @Post(':id/generate-grn') + @ApiOperation({ + summary: + 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', + }) + async generateGrn( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: GenerateGrnDto, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.generateGrn(id, dto.containerNumbers); + } + @Get(':id/tracking') @ApiOperation({ summary: "Shipment tracking timeline for a booking", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 92790c273..2cb10ce8e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -33,6 +33,11 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; +import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; +import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; +import { CustomerTruckService } from './customer-truck.service'; +import { ContainerReceiptService } from './container-receipt.service'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; @@ -55,6 +60,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingReviewNote, BookingContractSignature, BookingContainerAllocation, + CustomerTruckAssignment, + CustomerTruckContainer, ]), BillingModule, forwardRef(() => FirstMileModule), @@ -91,12 +98,17 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, + CustomerTruckAssignmentsRepository, + CustomerTruckService, + ContainerReceiptService, ], exports: [ BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService, + CustomerTruckService, + ContainerReceiptService, ], }) export class BookingsModule { } 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..1f2c1a09e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -145,7 +145,28 @@ export class BookingsService { throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated'); } - const html = this.buildCustomerTruckFreightOrderHtml(booking); + const trucks: Array<{ + plateNumber: string; + driverName: string; + truckType: string; + arrivedAt: string | null; + containers: string | null; + }> = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.arrived_at AS "arrivedAt", + string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers" + FROM freight.customer_truck_assignments a + LEFT JOIN freight.customer_truck_containers c + ON c.assignment_id = a.id AND c.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at + ORDER BY a.assigned_at`, + [bookingId], + ); + + const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); const buffer = await this.contractPdfService.htmlToPdfBuffer(html); return { filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, @@ -190,37 +211,85 @@ export class BookingsService { return `BK-${year}-${String(count + 1).padStart(6, '0')}`; } - private buildCustomerTruckFreightOrderHtml(booking: Booking): string { + private buildCustomerTruckFreightOrderHtml( + booking: Booking, + trucks: Array<{ + plateNumber: string; + driverName: string; + truckType: string; + arrivedAt: string | null; + containers: string | null; + }>, + ): string { const assignedAt = booking.customerTruckAssignedAt ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') : '-'; - const rows: Array<[string, string | null | undefined]> = [ + const bookingRows: Array<[string, string | null | undefined]> = [ ['Booking Reference', booking.reference], ['Client Name', booking.company?.name], ['Client ID', booking.companyId], ['Trade Direction', booking.tradeDirection], ['Freight Type', booking.freightType], - ['Truck Plate Number', booking.customerTruckPlateNumber], - ['Driver Name', booking.customerTruckDriverName], - ['Truck Type', booking.customerTruckType], - ['Container Number to Load', booking.customerTruckContainerNumber], ['Assigned At', assignedAt], ['Booking Status', booking.status], ]; - const rowHtml = rows + const bookingRowHtml = bookingRows .map(([label, value]) => `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`) .join(''); + + // Fall back to the legacy single-truck booking columns when there are no + // multi-truck rows (bookings assigned before the multi-truck feature). + const truckList = + trucks.length > 0 + ? trucks + : booking.customerTruckPlateNumber + ? [ + { + plateNumber: booking.customerTruckPlateNumber, + driverName: booking.customerTruckDriverName ?? '', + truckType: booking.customerTruckType ?? '', + arrivedAt: booking.customerTruckArrivedAt + ? String(booking.customerTruckArrivedAt) + : null, + containers: booking.customerTruckContainerNumber ?? null, + }, + ] + : []; + + const truckBlocks = truckList + .map((t, i) => { + const rows: Array<[string, string | null | undefined]> = [ + ['Truck Plate Number', t.plateNumber], + ['Driver Name', t.driverName], + ['Truck Type', t.truckType], + ['Containers Loaded', t.containers], + [ + 'Arrival', + t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival', + ], + ]; + const html = rows + .map( + ([label, value]) => + `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`, + ) + .join(''); + return `

Truck ${i + 1}

${html}
`; + }) + .join(''); + const copy = (watermark: string) => `
${this.escapeHtml(watermark)}

Freight Order

-

Customer external truck assignment

+

Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}

${this.escapeHtml(booking.reference)}
- ${rowHtml}
+ ${bookingRowHtml}
+ ${truckBlocks}
Customer / Carrier Signature
Port Operations Verification
@@ -238,11 +307,13 @@ export class BookingsService { .watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; } header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; } h1 { margin: 0; font-size: 28px; letter-spacing: 0; } + h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; } p { margin: 4px 0 0; color: #64748b; } strong { font-size: 16px; color: #0a9f6a; } - table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; } + table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; } th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; } th { width: 34%; background: #f1f5f9; } + .truck { page-break-inside: avoid; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; } .signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; } @@ -1001,6 +1072,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/container-receipt.service.ts b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts new file mode 100644 index 000000000..fde3ab797 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts @@ -0,0 +1,145 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; + +export interface ReceivedUnitRow { + id: string; + containerNumber: string; + receivedToPort: boolean; + receivedAt: string | null; + grnNumber: string | null; +} + +/** + * Per-container receive + GRN tracking on booking_container_units. + * + * Containers arrive individually (on separate self-haul trucks), so each unit is + * flipped `received_to_port` when its truck arrives (auto). Staff then confirm a + * Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a + * batch, so if the whole booking arrives together every unit shares a single GRN + * (per-booking GRN); if trucks arrive separately each batch gets its own GRN. + */ +@Injectable() +export class ContainerReceiptService { + constructor(private readonly dataSource: DataSource) {} + + /** + * Auto-mark the containers loaded on an arrived truck as received into the + * port. Idempotent — only flips units not already received. Runs inside the + * caller's transaction when a manager is supplied. + */ + async markReceivedForAssignment( + bookingId: string, + assignmentId: string, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + await m.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc, + freight.customer_truck_containers ctc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND ctc.assignment_id = $2 + AND ctc.deleted_at IS NULL + AND ctc.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId, assignmentId], + ); + } + + /** Received-into-port containers that have not yet been assigned a GRN. */ + async listReceivedPendingGrn(bookingId: string): Promise { + return this.dataSource.query( + `SELECT bcu.id, + bcu.container_number AS "containerNumber", + bcu.received_to_port AS "receivedToPort", + bcu.received_at AS "receivedAt", + bcu.grn_number AS "grnNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ORDER BY bcu.received_at`, + [bookingId], + ); + } + + /** + * Confirm a GRN over the currently received-but-un-GRN'd containers (optionally + * a subset by container number). Assigns one GRN number to the whole batch and + * returns it with the covered containers. If the batch covers every container + * on the booking it is effectively a per-booking GRN. + */ + async generateGrn( + bookingId: string, + containerNumbers?: string[], + ): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> { + const [booking] = await this.dataSource.query( + `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + return this.dataSource.transaction(async (manager) => { + const wanted = containerNumbers?.map((n) => n.trim().toUpperCase()); + const pending: ReceivedUnitRow[] = await manager.query( + `SELECT bcu.id, bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`, + wanted ? [bookingId, wanted] : [bookingId], + ); + if (!pending.length) { + throw new BadRequestException('No received containers are awaiting a GRN'); + } + + // Batch sequence = number of GRNs already issued for this booking + 1. + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT bcu.grn_number) AS batches + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`, + [bookingId], + ); + const seq = Number(batches) + 1; + const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`; + + const ids = pending.map((p) => p.id); + await manager.query( + `UPDATE freight.booking_container_units + SET grn_number = $1, updated_at = NOW() + WHERE id = ANY($2::uuid[])`, + [grnNumber, ids], + ); + + // Per-booking when no container on the booking is left un-GRN'd. + const [{ remaining }]: Array<{ remaining: string }> = await manager.query( + `SELECT COUNT(*) AS remaining + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`, + [bookingId], + ); + + return { + grnNumber, + containerNumbers: pending.map((p) => p.containerNumber), + perBooking: Number(remaining) === 0 && seq === 1, + }; + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts new file mode 100644 index 000000000..45a09a6a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; + +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; + +@Injectable() +export class CustomerTruckAssignmentsRepository extends BaseRepository { + constructor( + @InjectRepository(CustomerTruckAssignment) + private readonly repo: Repository, + ) { + super(repo); + } + + /** All trucks assigned to a booking, oldest first, with their containers. */ + findByBookingId(bookingId: string): Promise { + return this.repo.find({ + where: { bookingId }, + relations: { containers: true }, + order: { assignedAt: 'ASC' }, + }); + } + + findByIdWithContainers(id: string): Promise { + return this.repo.findOne({ where: { id }, relations: { containers: true } }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts new file mode 100644 index 000000000..5d0650219 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -0,0 +1,321 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager, IsNull } from 'typeorm'; + +import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; +import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; +import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; + +interface BookingGuardRow { + tradeDirection: string | null; + firstMile: string | null; + lastMile: string | null; + paymentStatus: string | null; + status: string | null; +} + +/** + * Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg + * can have several customer trucks, each carrying 1–2 of its containers and + * tracking its own arrival. The legacy booking.customer_truck_* columns are kept + * as a booking-level flag (any truck assigned / all arrived) so the warehouse + * exit-gate + delivery-approval logic keep working unchanged. + */ +@Injectable() +export class CustomerTruckService { + constructor( + private readonly dataSource: DataSource, + private readonly assignments: CustomerTruckAssignmentsRepository, + ) {} + + listTrucks(bookingId: string): Promise { + return this.assignments.findByBookingId(bookingId); + } + + async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise { + const booking = await this.loadBookingGuard(bookingId); + this.assertSelfHaulPaid(booking); + + const isExport = booking.tradeDirection === 'EXPORT'; + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + + // EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are + // not pre-specified — they are registered + weighed when the truck leaves. + if (isExport) { + if (requested.length < 1 || requested.length > 2) { + throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers'); + } + } else if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); + } + + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const alreadyAssigned = await this.assignedContainerNumbers(bookingId); + for (const n of requested) { + if (alreadyAssigned.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + } + + await this.dataSource.transaction(async (manager) => { + const assignment = await manager.getRepository(CustomerTruckAssignment).save( + manager.getRepository(CustomerTruckAssignment).create({ + bookingId, + plateNumber: dto.truckPlateNumber.trim().toUpperCase(), + driverName: dto.driverName.trim(), + truckType: dto.truckType.trim(), + }), + ); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId: assignment.id, + bookingId, + containerNumber, + }), + ), + ); + // Booking-level flag: first truck marks the booking as truck-assigned. + await manager.query( + `UPDATE freight.bookings + SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()), + status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END, + updated_at = NOW() + WHERE id = $1`, + [bookingId], + ); + }); + + return this.listTrucks(bookingId); + } + + async removeTruck(bookingId: string, assignmentId: string): Promise { + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + if (assignment.arrivedAt) { + throw new ConflictException('Cannot remove a truck that has already arrived'); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckAssignment).softDelete(assignmentId); + const remaining = await manager + .getRepository(CustomerTruckAssignment) + .count({ where: { bookingId } }); + if (remaining === 0) { + // No trucks left — clear the booking-level flag and revert the status. + await manager.query( + `UPDATE freight.bookings + SET customer_truck_assigned_at = NULL, + status = CASE WHEN status = 'TRUCK_ASSIGNED' THEN 'PAID' ELSE status END, + updated_at = NOW() + WHERE id = $1`, + [bookingId], + ); + } + }); + + return this.listTrucks(bookingId); + } + + /** + * Register an IMPORT self-haul truck leaving the port: the containers it + * actually loaded (replacing any provisional list) and its weighed gross. + * Export bookings have no truck departure — trucks only deliver (receive). + */ + async departTruck( + bookingId: string, + assignmentId: string, + dto: DepartCustomerTruckDto, + ): Promise { + const booking = await this.loadBookingGuard(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'Truck departure/weighing applies to import self-haul only (export trucks only deliver)', + ); + } + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + // Once filled, the departure record is uneditable. + if (assignment.departedAt) { + throw new ConflictException('This truck has already departed — its exit record is locked'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (elsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + } + + await this.dataSource.transaction(async (manager) => { + if (requested.length) { + // Replace the truck's containers with what was actually loaded. + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + } + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + grossWeightKg: dto.grossWeightKg, + departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(), + arrivedAt: assignment.arrivedAt ?? new Date(), + }); + }); + + return this.listTrucks(bookingId); + } + + /** + * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse + * receive flow. When every truck on the booking has arrived, the booking-level + * customer_truck_arrived_at flag is stamped (used by the delivery-approval + * gate). No-op when the container is not on any customer truck. + */ + async markArrivedByContainer( + bookingId: string, + containerNumber: string, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const cn = containerNumber.trim().toUpperCase(); + const container = await m.getRepository(CustomerTruckContainer).findOne({ + where: { bookingId, containerNumber: cn }, + }); + if (!container) return; + + await m + .getRepository(CustomerTruckAssignment) + .update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); + + await this.syncBookingArrival(bookingId, m); + } + + /** Mark every truck on the booking arrived (fallback when no container is known). */ + async markAllArrived(bookingId: string, manager?: EntityManager): Promise { + const m = manager ?? this.dataSource.manager; + await m + .getRepository(CustomerTruckAssignment) + .update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); + await this.syncBookingArrival(bookingId, m); + } + + /** + * Stamp the booking-level arrival flag on the FIRST truck arrival. The import + * handover is signed once, before the first truck leaves, even though trucks + * pick up per-container — so the flag fires on the first arrival (COALESCE + * keeps it), not once all trucks have arrived. + */ + private async syncBookingArrival(bookingId: string, m: EntityManager): Promise { + await m.query( + `UPDATE freight.bookings + SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), + updated_at = NOW() + WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL`, + [bookingId], + ); + } + + private async loadBookingGuard(bookingId: string): Promise { + const [row]: BookingGuardRow[] = await this.dataSource.query( + `SELECT trade_direction AS "tradeDirection", + first_mile_pickup_address AS "firstMile", + last_mile_delivery_address AS "lastMile", + payment_status AS "paymentStatus", + status + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!row) throw new NotFoundException(`Booking ${bookingId} not found`); + return row; + } + + private assertSelfHaulPaid(booking: BookingGuardRow): void { + const hasFirstMile = Boolean(booking.firstMile?.trim()); + const hasLastMile = Boolean(booking.lastMile?.trim()); + const usesMileService = + booking.tradeDirection === 'IMPORT' + ? hasLastMile + : booking.tradeDirection === 'EXPORT' + ? hasFirstMile + : hasFirstMile || hasLastMile; + if (usesMileService) { + throw new BadRequestException( + 'Customer truck assignment is only allowed when first/last mile delivery is not selected', + ); + } + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException( + 'Booking must be paid before assigning an external customer truck', + ); + } + } + + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + private async assignedContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.customer_truck_containers + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + private async assignedContainerNumbersExcept( + bookingId: string, + exceptAssignmentId: string, + ): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.customer_truck_containers + WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`, + [bookingId, exceptAssignmentId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts new file mode 100644 index 000000000..4356d66ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -0,0 +1,47 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from 'class-validator'; + +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +/** + * Add one external customer truck to a booking. + * - EXPORT: the truck delivers 1–2 known containers (required, validated in the + * service against the booking's containers). + * - IMPORT: the customer does not pre-specify — containers are registered and + * weighed when the truck leaves, so `containerNumbers` may be omitted/empty. + */ +export class AddCustomerTruckDto { + @IsString() + @IsNotEmpty() + @MaxLength(32) + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(120) + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts new file mode 100644 index 000000000..31ab1b5bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts @@ -0,0 +1,37 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsDateString, + IsNumber, + IsOptional, + Matches, + Min, +} from 'class-validator'; + +/** + * Register an import self-haul truck leaving the port: the containers it actually + * loaded (staff read them off the truck) and the weighed gross. Container numbers + * are optional here only because they may already have been recorded; the weighed + * gross is required. + */ +export class DepartCustomerTruckDto { + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + + @IsNumber() + @Min(0) + grossWeightKg!: number; + + /** Gate-out time. Defaults to now when omitted. */ + @IsOptional() + @IsDateString() + gateOutTime?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts new file mode 100644 index 000000000..2f5ea86af --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts @@ -0,0 +1,17 @@ +import { ArrayUnique, IsArray, IsOptional, Matches } from 'class-validator'; + +/** + * Confirm a Goods Received Note. Omit `containerNumbers` to GRN every + * received-but-un-GRN'd container on the booking (per-booking when that's all of + * them); pass a subset to GRN just those. + */ +export class GenerateGrnDto { + @IsOptional() + @IsArray() + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index e8ef1b138..619013280 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; + + /** Whether this container has been received into the port (auto-set when its + * self-haul truck arrives). */ + @Column({ name: 'received_to_port', type: 'boolean', default: false }) + receivedToPort!: boolean; + + @Column({ name: 'received_at', type: 'timestamptz', nullable: true }) + receivedAt?: Date | null; + + /** The GRN this container was received under (assigned when staff confirm the + * Goods Received Note for a batch of received containers). */ + @Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true }) + grnNumber?: string | null; } 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/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts new file mode 100644 index 000000000..6eeaba963 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -0,0 +1,47 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Booking } from './booking.entity'; +import { CustomerTruckContainer } from './customer-truck-container.entity'; + +/** + * One external (self-haul) truck a customer assigns to a booking that has no + * EDR first/last-mile leg. Each truck carries 1–2 containers and tracks its own + * arrival at the terminal/warehouse. + */ +@Entity({ schema: 'freight', name: 'customer_truck_assignments' }) +@Index(['bookingId']) +export class CustomerTruckAssignment extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'plate_number', type: 'varchar', length: 32 }) + plateNumber!: string; + + @Column({ name: 'driver_name', type: 'varchar', length: 120 }) + driverName!: string; + + @Column({ name: 'truck_type', type: 'varchar', length: 60 }) + truckType!: string; + + @Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'now()' }) + assignedAt!: Date; + + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + /** Weighed gross of what the truck actually loaded (import), captured on + * leaving. Null until the truck departs. */ + @Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true }) + grossWeightKg?: number | null; + + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) + departedAt?: Date | null; + + @OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true }) + containers?: CustomerTruckContainer[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts new file mode 100644 index 000000000..110e31671 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { CustomerTruckAssignment } from './customer-truck-assignment.entity'; + +/** + * A container number loaded onto a customer truck. A container may be loaded + * onto exactly one truck per booking (enforced by a partial unique index on + * booking_id + container_number). + */ +@Entity({ schema: 'freight', name: 'customer_truck_containers' }) +@Index(['assignmentId']) +export class CustomerTruckContainer extends BaseEntity { + @Column({ name: 'assignment_id', type: 'uuid' }) + assignmentId!: string; + + @ManyToOne(() => CustomerTruckAssignment, (a) => a.containers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assignment_id' }) + assignment?: CustomerTruckAssignment; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @Column({ name: 'container_number', type: 'varchar', length: 64 }) + containerNumber!: string; +} 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/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 02f77b2e0..62be578bb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1183,9 +1183,11 @@ export class CompaniesService { const { businessInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( - "No business license found for this TIN. Please check the number and try again.", + "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - return this.etradeService.extractRegistrationData(businessInfo); + const registrationData = this.etradeService.extractRegistrationData(businessInfo); + const tinTaken = await this.companiesRepo.existsByTin(tin); + return { ...registrationData, tinTaken }; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index e5b686d11..a56ea5ad8 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -17,10 +17,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index 200b69fee..ef7eb2a21 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerName!: string; managerEmail?: string; managerPhone!: string; + tinTaken?: boolean; constructor(data: CompanyRegistrationData) { this.licenceNumber = data.licenceNumber; @@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerName = data.managerName; this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; + this.tinTaken = data.tinTaken; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 316038dc9..9fd8f28ae 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -34,10 +34,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 3f169c49c..61ac93925 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -205,7 +205,7 @@ export class BookingClearanceService { const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); const bookingMilestone = (code: string) => milestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = await this.glOperationsService.gatepassForBooking(bookingId); const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState(milestones, files); @@ -242,14 +242,8 @@ export class BookingClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt 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..2c79ba42f 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; @@ -275,7 +278,9 @@ export class ContractClearanceService { } const bookingMilestone = (code: string) => bookingMilestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = cycle?.bookingId + ? await this.glOperationsService.gatepassForBooking(cycle.bookingId) + : { granted: false, grantedAt: null }; const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState( @@ -284,13 +289,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,18 +340,14 @@ export class ContractClearanceService { preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), exportClearanceFinalized: Boolean(cycle?.completedAt), linkedBookingId: cycle?.bookingId ?? null, + linkedBookingReference, + linkedBookingStatus, dutyAdvice, workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt 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..a22c7cad4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -77,7 +77,6 @@ import { } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, - GatepassDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @@ -688,30 +687,6 @@ export class ContractsController { return this.clearanceService.djQueue(filter); } - @Get('clearance/dj-schedules') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' }) - djClearanceSchedules() { - return this.glOperationsService.djSchedules(); - } - - @Post('clearance/schedules/:scheduleId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ - summary: 'GL DJ grants the gate pass for every customs booking on a train schedule', - }) - grantScheduleGatepass( - @Param('scheduleId', ParseUUIDPipe) scheduleId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantScheduleGatepass( - scheduleId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -791,7 +766,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, @@ -947,21 +922,6 @@ export class ContractsController { return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); } - @Post('bookings/:bookingId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' }) - grantGatepass( - @Param('bookingId', ParseUUIDPipe) bookingId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantGatepass( - bookingId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - @Post('bookings/:bookingId/final-invoice') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) 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/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts index 6b784073b..34a903427 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -36,11 +36,3 @@ export class RoAmendmentDto { note?: string; } -export class GatepassDto { - @ApiPropertyOptional({ - description: 'When the gate pass was granted (ISO datetime; defaults to now)', - }) - @IsOptional() - @IsString() - gatepassAt?: string; -} 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/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 8fed3a8ff..e639f0867 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { DataSource, In, IsNull } from 'typeorm'; +import { DataSource, IsNull } from 'typeorm'; import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; import { BillingService } from '../billing/billing.service'; @@ -17,7 +17,6 @@ import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { @@ -198,6 +197,7 @@ export class GlOperationsService { } return { + scheduleId: schedule?.id ?? null, wagonAllocated, departedAt: schedule?.actualDepartureAt ? new Date(schedule.actualDepartureAt).toISOString() @@ -208,6 +208,41 @@ export class GlOperationsService { }; } + /** + * Gate pass status for a booking, sourced from the train schedule's Djibouti + * gate-pass operation (secured via the train-scheduling "Save as Secured" + * action) rather than a clearance milestone. For EXPORT bookings this also + * backfills the arrival-chain milestones once secured, same as the retired + * clearance-side grant action used to. + */ + async gatepassForBooking( + bookingId: string, + ): Promise<{ granted: boolean; grantedAt: string | null }> { + const train = await this.trainState(bookingId); + if (!train.scheduleId) return { granted: false, grantedAt: null }; + const operation = await this.dataSource + .getRepository(ImportDjiboutiOperation) + .findOne({ where: { trainScheduleId: train.scheduleId } }); + const grantedAt = operation?.gatepassGrantedAt + ? new Date(operation.gatepassGrantedAt).toISOString() + : null; + + if (grantedAt) { + const booking = await this.getBooking(bookingId); + if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') { + const milestones = await this.milestoneService.listForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { + if (byCode.get(code)?.status === 'PENDING') { + await this.milestoneService.completeForBooking(bookingId, code); + } + } + } + } + + return { granted: Boolean(grantedAt), grantedAt }; + } + /** * T1 transit-document lifecycle state for an import shipment booking. Wagon * allocation opens the upload window; train departure locks it; train arrival @@ -302,8 +337,11 @@ export class GlOperationsService { 'The transport document must be uploaded before T1 can be closed.', ); } - if (!done('GATEPASS_GRANTED')) { - throw new BadRequestException('Grant the gate pass before closing T1.'); + const gatepass = await this.gatepassForBooking(bookingId); + if (!gatepass.granted) { + throw new BadRequestException( + 'Secure the Djibouti gate pass on the train schedule before closing T1.', + ); } // Export bookings seeded before T1_CLOSED joined the catalog lack the row. await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); @@ -322,182 +360,6 @@ export class GlOperationsService { 'ARRIVED_AT_DJIBOUTI', ]; - /** - * GL Djibouti grants the gate pass for a customs booking, capturing the time. - * Export: requires the train to have arrived at Djibouti; back-fills the - * arrival-chain milestones. Import: requires wagon allocation (pre-loading). - */ - async grantGatepass( - bookingId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ bookingId: string; gatepassAt: string }> { - const booking = await this.getBooking(bookingId); - if (!booking.customsClearingEnabled) { - throw new BadRequestException('Gate pass applies to customs bookings only.'); - } - const tradeDirection = booking.tradeDirection ?? 'IMPORT'; - const milestones = await this.milestoneService.listForBooking(bookingId); - const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); - - const existing = byCode.get('GATEPASS_GRANTED'); - if (existing?.status === 'COMPLETED') { - return { - bookingId, - gatepassAt: - existing.metadata?.gatepassAt ?? - (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''), - }; - } - - const train = await this.trainState(bookingId); - if (tradeDirection === 'EXPORT') { - if (!train.arrivedAt) { - throw new BadRequestException( - 'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.', - ); - } - for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { - if (byCode.get(code)?.status === 'PENDING') { - await this.milestoneService.completeForBooking(bookingId, code, userId); - } - } - } else if (!train.wagonAllocated) { - throw new BadRequestException( - 'Wagons must be allocated before the gate pass can be granted.', - ); - } - - const at = gatepassAt?.trim() || new Date().toISOString(); - await this.milestoneService.completeWithMetadataForBooking( - bookingId, - 'GATEPASS_GRANTED', - { gatepassAt: at }, - userId, - ); - return { bookingId, gatepassAt: at }; - } - - /** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */ - async djSchedules(): Promise { - const schedules = await this.dataSource.getRepository(TrainSchedule).find({ - relations: { - scheduleBookings: { booking: true }, - originStation: true, - destinationStation: true, - }, - order: { scheduledDepartureDate: 'DESC' }, - }); - - const withCustoms = schedules - .filter((s) => s.status !== 'CANCELLED') - .map((s) => ({ - schedule: s, - customs: (s.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)), - })) - .filter((s) => s.customs.length > 0); - - const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id)); - const gatepassRows = bookingIds.length - ? await this.dataSource.getRepository(ClearanceMilestone).find({ - where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' }, - }) - : []; - const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m])); - - return withCustoms.map(({ schedule, customs }) => { - const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))]; - return { - id: schedule.id, - trainNumber: schedule.trainNumber ?? null, - routeName: null, - origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, - destination: - schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, - status: schedule.status, - scheduledDepartureDate: schedule.scheduledDepartureDate - ? new Date(schedule.scheduledDepartureDate).toISOString() - : null, - actualDepartureAt: schedule.actualDepartureAt - ? new Date(schedule.actualDepartureAt).toISOString() - : null, - actualArrivalAt: schedule.actualArrivalAt - ? new Date(schedule.actualArrivalAt).toISOString() - : null, - freightType: - freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null, - customsBookings: customs.map((b) => { - const m = gatepassByBooking.get(b.id); - const granted = m?.status === 'COMPLETED'; - return { - bookingId: b.id, - reference: b.reference ?? b.id, - tradeDirection: b.tradeDirection ?? 'IMPORT', - contractId: b.contractId ?? null, - gatepassGranted: granted, - gatepassAt: granted - ? (m?.metadata?.gatepassAt ?? - (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null)) - : null, - }; - }), - }; - }); - } - - /** - * One-click gate pass for every customs booking on a train schedule. Per-booking - * guard failures are collected, not fatal. Import schedules also get the - * schedule-level ImportDjiboutiOperation gate pass so loading unblocks. - */ - async grantScheduleGatepass( - scheduleId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id: scheduleId }, - relations: { scheduleBookings: { booking: true } }, - }); - if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - - const customs = (schedule.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)); - if (customs.length === 0) { - throw new BadRequestException('No customs bookings ride this schedule.'); - } - - let granted = 0; - const skipped: Array<{ bookingId: string; error: string }> = []; - for (const booking of customs) { - try { - await this.grantGatepass(booking.id, gatepassAt, userId); - granted += 1; - } catch (e) { - skipped.push({ - bookingId: booking.id, - error: e instanceof Error ? e.message : 'Failed', - }); - } - } - - if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) { - const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation); - let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } }); - if (!operation) { - operation = opRepo.create({ trainScheduleId: scheduleId }); - } - if (!operation.gatepassGrantedAt) { - operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date(); - await opRepo.save(operation); - } - } - - return { granted, skipped }; - } /** * GL Djibouti raises the post-offload final invoice (export): manual amount + 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/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index d773ebe1f..5b8a3ddca 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -479,7 +479,7 @@ export class PaymentService { alreadyFinalized?: boolean; reason?: string; }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); + this.logger.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, @@ -490,13 +490,12 @@ export class PaymentService { reason: `No local intent for reference ${event.referenceId}`, }; } - console.log(`Processing payment succeeded event for intent: }`, intent); const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, notify: true, }); - console.log( + this.logger.log( `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, ); 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..0b239990b 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 @@ -310,7 +310,10 @@ export class BookingBatchService implements OnModuleInit { private async openRouteDayGroups(): Promise { const open = ( await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: "OPEN" }, + where: [ + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft }, + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled }, + ], }) ).filter((s) => s.windowPhase == null); const groups = new Map(); @@ -590,6 +593,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 +636,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 +722,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..bf93cf391 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,15 +10,17 @@ 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'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -36,6 +39,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 +50,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 +90,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 +104,7 @@ import { import { computeExportWindowTimes, computeImportWindowTimes, + earliestSchedulableDeparture, eatDay, } from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; @@ -168,8 +171,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 +273,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 +477,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 +604,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 +633,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 +876,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) { @@ -956,12 +1169,47 @@ export class TrainSchedulingService { notes: dto.notes ?? operation.notes ?? null, }); + await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt); + console.log( `[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`, ); return this.getImportDjiboutiOperation(schedule.id); } + /** + * Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED + * milestone for every customs booking on this schedule, so contract/booking + * clearance views still reading that milestone (older deployed builds) see + * the gate pass as done. Drop once every clearance-api deployment reads + * ImportDjiboutiOperation.gatepassGrantedAt directly. + */ + private async completeGatepassMilestoneForSchedule( + scheduleId: string, + securedAt: Date, + ): Promise { + const bookings = await this.dataSource.getRepository(Booking).find({ + where: { trainScheduleId: scheduleId, customsClearingEnabled: true }, + }); + if (bookings.length === 0) return; + + const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone); + const rows = await milestoneRepo.find({ + where: { + bookingId: In(bookings.map((b) => b.id)), + milestoneCode: 'GATEPASS_GRANTED', + }, + }); + + for (const row of rows) { + if (row.status === 'COMPLETED') continue; + row.status = 'COMPLETED'; + row.triggeredAt = securedAt; + row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() }; + await milestoneRepo.save(row); + } + } + async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); @@ -995,9 +1243,7 @@ export class TrainSchedulingService { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); this.assertImportDjiboutiGatepassGranted(operation); - if (!operation.loadedOnTrainAt) { - throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); - } + // Loading confirmation does not block departure (see assertImportDjiboutiMayDepart). if (schedule.status === TrainScheduleStatusEnum.Scheduled) { await this.dispatchSchedule(schedule.id); @@ -1053,7 +1299,9 @@ export class TrainSchedulingService { performedBy: 'DOCUMENT_GENERATION', }); const html = this.buildImportLoadListHtml(loadList); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (would mislabel this as a + // gate-clearance / release order when Chromium is unavailable). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list'); const reference = loadList.trainNumber ?? loadList.trainScheduleId; return { filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1071,7 +1319,8 @@ export class TrainSchedulingService { } const html = this.buildExportLoadListHtml(schedule); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (see importLoadListDocument). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; return { filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1371,9 +1620,9 @@ export class TrainSchedulingService { where: { trainScheduleId: schedule.id }, }); this.assertImportDjiboutiGatepassGranted(operation); - if (!operation?.loadedOnTrainAt) { - throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); - } + // Loading confirmation does NOT gate dispatch. Per-booking loading is + // tracking only and the loaded-on-train step is optional — a scheduled train + // dispatches without waiting on loading. } private async getImportDjiboutiSchedule(scheduleId: string): Promise { @@ -1837,7 +2086,9 @@ export class TrainSchedulingService { await this.trainSchedulesRepository.updateStatus( id, TrainScheduleStatusEnum.Cancelled, - {}, + // Retire the booking window so a canceled schedule never lingers as an + // "open window" in booking-window lists or the legacy batch fill. + { bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' }, manager, ); if (schedule.trainSetId) { @@ -2045,9 +2296,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 +2322,7 @@ export class TrainSchedulingService { ); } } else { - violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits)); + pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits)); if (requireContainerPlacements && resolvedMode === 'CONTAINER') { violations.push( @@ -2087,8 +2345,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 +2373,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 +2393,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 +2762,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 +3243,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 +3287,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 +3526,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 +3597,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 +3682,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 +3859,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..c9844051b 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(); @@ -919,6 +920,23 @@ export class WarehouseInventoryService { }), ); + // Receiving the booking flags every container unit as received into the + // port (self-haul export: the delivering truck's goods are now in) so + // staff can raise the per-container GRN over what's received. + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId], + ); + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -1773,6 +1791,26 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, dto, weight, volume, containerCount); + // Per-container receive: flag this container's unit as received into the + // port so staff can raise the GRN over what's received. + if (dto.bookingId && dto.containerId) { + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc, freight.containers cont + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND cont.id = $2 + AND cont.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [dto.bookingId, dto.containerId], + ); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -2032,6 +2070,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() @@ -2051,6 +2105,29 @@ export class WarehouseInventoryService { notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), }); if (!isTruckLeaving && item.bookingId) { + // Per-truck arrival: mark the customer truck carrying THIS item's + // container as arrived (matched via the physical container number). + if (item.containerId) { + await manager.query( + `UPDATE freight.customer_truck_assignments a + SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW() + FROM freight.customer_truck_containers c + JOIN freight.containers cont ON cont.container_number = c.container_number + WHERE c.assignment_id = a.id + AND c.deleted_at IS NULL + AND c.booking_id = $1 + AND cont.id = $2 + AND a.arrived_at IS NULL + AND a.deleted_at IS NULL`, + [item.bookingId, item.containerId], + ); + // NB: import arrival changes nothing on the goods — received_to_port is + // an EXPORT concept (set when a truck delivers into the port). Import + // load + weight are captured on truck departure, not arrival. + } + // Booking-level flag stamped on the FIRST truck arrival. The import + // handover is signed ONCE (before the first truck leaves), even though + // trucks pick up per-container — COALESCE keeps the first timestamp. await manager.query( `UPDATE freight.bookings SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), @@ -2130,6 +2207,48 @@ export class WarehouseInventoryService { } await this.invoices.assertClearanceAllowed(id); + // Import self-haul: the exit paper names the pickup truck + all containers it + // carries, so gate staff can verify the goods leaving on that truck. + let truck: { + plateNumber: string; + driverName: string; + truckType: string; + containerNumbers: string; + truckWeightTons: string | number | null; + grossWeightKg: string | number | null; + departedAt: string | null; + } | null = null; + if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers", + COALESCE(( + SELECT SUM(bcu.vgm_tons) + FROM freight.customer_truck_containers cc + JOIN freight.booking_container_units bcu + ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + AND bc.booking_id = c.booking_id + WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL + ), 0) AS "truckWeightTons" + FROM freight.customer_truck_containers c + JOIN freight.customer_truck_assignments a + ON a.id = c.assignment_id AND a.deleted_at IS NULL + JOIN freight.customer_truck_containers c2 + ON c2.assignment_id = a.id AND c2.deleted_at IS NULL + WHERE c.booking_id = $1 AND c.container_number = $2 AND c.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, c.booking_id + LIMIT 1`, + [row.bookingId, row.containerNumber], + ); + truck = truckRow ?? null; + } + const bookingReference = row?.bookingReference || 'N/A'; const reference = row?.releaseOrderReference || @@ -2153,6 +2272,17 @@ export class WarehouseInventoryService { inventoryStatus: row?.status ?? null, clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT', exitInspectionSummary: this.extractExitInspectionNote(row?.notes), + truckPlateNumber: truck?.plateNumber ?? null, + truckDriverName: truck?.driverName ?? null, + truckType: truck?.truckType ?? null, + truckGateOut: truck?.departedAt ?? null, + // Prefer the weighed gross captured on departure; fall back to the summed + // container VGM when the truck hasn't been weighed yet. + truckWeightKg: truck + ? Number(truck.grossWeightKg ?? 0) > 0 + ? Number(truck.grossWeightKg) + : Number(truck.truckWeightTons ?? 0) * 1000 + : null, }); return { @@ -3082,6 +3212,11 @@ export class WarehouseInventoryService { inventoryStatus: string | null; clearanceStatus: string; exitInspectionSummary?: string | null; + truckPlateNumber?: string | null; + truckDriverName?: string | null; + truckType?: string | null; + truckGateOut?: string | null; + truckWeightKg?: number | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -3106,12 +3241,37 @@ export class WarehouseInventoryService { ['Container Number', data.containerNumber], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Declared Weight', `${data.weight.toLocaleString()} kg`], + [ + data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight', + `${(data.truckPlateNumber && data.truckWeightKg + ? data.truckWeightKg + : data.weight + ).toLocaleString()} kg`, + ], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], ['Inventory Status', data.inventoryStatus], ['Clearance Status', data.clearanceStatus], + ...(data.truckPlateNumber + ? ([ + ['Pickup Truck Plate', data.truckPlateNumber], + ['Truck Driver', data.truckDriverName], + ['Truck Type', data.truckType], + [ + 'Gate-Out Time', + data.truckGateOut + ? new Date(data.truckGateOut).toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : null, + ], + ] as [string, string | null][]) + : []), ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []), ]; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index f8c0dd355..68e630e0b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService { }); } + /** + * Render arbitrary document HTML to PDF via the shared renderer WITHOUT the + * release-order fallback. Non-release documents (e.g. the import/export + * marshalling load list) must use this so a Chromium-less fallback degrades to + * a plain-text dump of *their own* content — instead of masquerading as a + * "Warehouse Gate Clearance / Release Order", which the release-specific + * fallback would otherwise draw regardless of the input HTML. + */ + renderDocumentHtml(html: string, label = 'Document'): Promise { + return this.pdf.htmlToPdfBuffer(html, { label }); + } + private htmlToBasicPdfBuffer(html: string): Buffer { const doc = this.extractReleaseDocument(html); const body: string[] = [ diff --git a/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts new file mode 100644 index 000000000..6b2a422e8 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { PaidImportExportMileDemoSeeder } from '../seed/paid-import-export-mile-demo.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(PaidImportExportMileDemoSeeder); + await seeder.run(); + console.log('Paid import/export mile demo bookings seeded.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Paid import/export mile demo booking seed failed:', err); + process.exit(1); +}); 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/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts new file mode 100644 index 000000000..708afb86b --- /dev/null +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -0,0 +1,299 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { DataSource } from 'typeorm'; + +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; +import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; +import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; + +const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE'; +const COMPANY_TIN = 'PAIDMILE001'; +const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; + +const YARDS = [ + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, +]; + +const CONTAINER_TYPES = [ + { code: '20FT', label: '20FT', sizeFt: 20 }, + { code: '40FT', label: '40FT', sizeFt: 40 }, +]; + +/** + * Six paid, approved container bookings that mirror the real trucking legs: + * - EXPORT (Ethiopia -> Djibouti) carries a FIRST-MILE leg (factory -> rail terminal). + * - IMPORT (Djibouti -> Ethiopia) carries a LAST-MILE leg (dry port -> final delivery). + * Each booking is paymentStatus PAID and its single mile leg is marked paid + ready to transit. + */ +const DEMO_BOOKINGS = [ + // ── IMPORT: last mile only ───────────────────────────────────────────── + { + reference: 'PAID-IMP-001', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 8, + totalWeightTons: 224, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-01T08:00:00.000Z', + lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa', + lastMileDeliveryLat: 8.8808, + lastMileDeliveryLng: 38.7876, + }, + { + reference: 'PAID-IMP-002', + tradeDirection: 'IMPORT', + containerCode: '20FT', + quantity: 12, + totalWeightTons: 240, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-02T08:00:00.000Z', + lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa', + lastMileDeliveryLat: 8.9137, + lastMileDeliveryLng: 38.7815, + }, + { + reference: 'PAID-IMP-003', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 6, + totalWeightTons: 180, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-03T08:00:00.000Z', + lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa', + lastMileDeliveryLat: 8.9806, + lastMileDeliveryLng: 38.8736, + }, + // ── EXPORT: first mile only ──────────────────────────────────────────── + { + reference: 'PAID-EXP-001', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 7, + totalWeightTons: 196, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-01T10:00:00.000Z', + firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa', + firstMilePickupLat: 8.9806, + firstMilePickupLng: 38.8736, + }, + { + reference: 'PAID-EXP-002', + tradeDirection: 'EXPORT', + containerCode: '20FT', + quantity: 11, + totalWeightTons: 220, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-02T10:00:00.000Z', + firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa', + firstMilePickupLat: 8.8808, + firstMilePickupLng: 38.7876, + }, + { + reference: 'PAID-EXP-003', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 4, + totalWeightTons: 128, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-03T10:00:00.000Z', + firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa', + firstMilePickupLat: 8.9137, + firstMilePickupLng: 38.7815, + }, +] as const; + +@Injectable() +export class PaidImportExportMileDemoSeeder { + private readonly logger = new Logger(PaidImportExportMileDemoSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: 'Rail Container with Paid First/Last Mile', + description: 'Demo service type for paid import/export bookings with a single mile leg', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 11, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: 'Paid Import/Export Mile Demo Customer', + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: COMPANY_TIN, + vatNumber: COMPANY_TIN, + fanNumber: 'PMD0000000000001', + country: 'Ethiopia', + address: 'Addis Ababa', + phone: '251900000202', + email: COMPANY_EMAIL, + website: null, + contactPersonName: 'Paid Mile Demo', + contactPersonPhone: '251900000202', + generalManagerName: 'Demo Manager', + generalManagerEmail: COMPANY_EMAIL, + generalManagerPhone: '251900000202', + }, + { conflictPaths: { tin: true } }, + ); + + const [serviceType, company, yards, containerTypes] = await Promise.all([ + manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [containerType.code, containerType]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get(demoBooking.containerCode); + + if (!origin || !destination || !containerType) { + throw new Error(`paid_import_export_mile_demo_dependency_missing:${demoBooking.reference}`); + } + + const isImport = demoBooking.tradeDirection === 'IMPORT'; + const wagonsRequired = + Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + companyId: company.id, + status: 'APPROVED', + scheduledDate: new Date(demoBooking.scheduledDate), + estimatedShipmentDate: new Date(demoBooking.scheduledDate), + totalAmount: demoBooking.totalWeightTons * 25, + paymentStatus: 'PAID', + contractType: 'NEW', + serviceTypeId: serviceType.id, + // Only the leg that matches the trade direction carries an address. + firstMilePickupAddress: isImport ? null : demoBooking.firstMilePickupAddress, + firstMilePickupLat: isImport ? null : demoBooking.firstMilePickupLat, + firstMilePickupLng: isImport ? null : demoBooking.firstMilePickupLng, + lastMileDeliveryAddress: isImport ? demoBooking.lastMileDeliveryAddress : null, + lastMileDeliveryLat: isImport ? demoBooking.lastMileDeliveryLat : null, + lastMileDeliveryLng: isImport ? demoBooking.lastMileDeliveryLng : null, + equipmentReturn: 'WITHOUT_RETURN', + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: demoBooking.tradeDirection, + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: 'Demo container cargo', + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + isReefer: false, + paymentCurrency: 'ETB', + approvedByStaffAt: new Date(), + priorityScore: 20, + wagonsRequired, + schedulingStatus: 'NOT_SCHEDULED', + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired, + weightLimitRuleId: null, + isOverweight: vgmPerUnitTons > 35, + overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null, + }); + + // Reset any existing legs for idempotency, then create the single paid leg. + await manager.getRepository(FirstMile).delete({ bookingId: booking.id }); + await manager.getRepository(LastMile).delete({ bookingId: booking.id }); + + const paidAmount = demoBooking.totalWeightTons * 25; + + if (isImport) { + await manager.getRepository(LastMile).insert({ + bookingId: booking.id, + status: 'READY_TO_TRANSIT', + advancedPayment: paidAmount, + remainingPayment: 0, + paid: true, + estimatedKm: 22, + exactKm: null, + vehicleId: null, + }); + } else { + await manager.getRepository(FirstMile).insert({ + bookingId: booking.id, + status: 'READY_TO_TRANSIT', + advancedPayment: paidAmount, + remainingPayment: 0, + paid: true, + estimatedKm: 18, + exactKm: null, + vehicleId: null, + }); + } + } + }); + + this.logger.log( + 'Seeded 6 paid bookings: 3 import (last-mile) + 3 export (first-mile).', + ); + } +} 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/public/assets/edr_image.jpg b/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg new file mode 100644 index 000000000..b89941eea Binary files /dev/null and b/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg differ diff --git a/apps/edr-freight-web/backoffice/public/assets/edr_image.png b/apps/edr-freight-web/backoffice/public/assets/edr_image.png new file mode 100644 index 000000000..1654c747c Binary files /dev/null and b/apps/edr-freight-web/backoffice/public/assets/edr_image.png differ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 53d636145..f7b26af00 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -23,6 +23,7 @@ import { Users, Wallet, } from "lucide-react"; +import { useEffect } from "react"; import { Navigate, Outlet, @@ -52,8 +53,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 +85,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 +124,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[] => [ { @@ -131,17 +136,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "User Management", + label: "Staff", href: "/um", icon: , }, { - label: "Booking requests", + label: "Bookings", href: "/dashboard/booking-requests", icon: , }, { - label: "Contract requests", + label: "Contracts", href: "/dashboard/contract-requests", icon: , permission: FREIGHT_PERMS.contracts.view, @@ -170,7 +175,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ title: "Operations", items: [ { - label: "Document Clearance", + label: "Clearance", href: "/dashboard/contracts/clearance", icon: , permission: [ @@ -178,6 +183,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", @@ -307,7 +313,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ title: "Port & Terminal", items: [ { - label: "Import Operations", + label: "Imports", href: "/dashboard/import-warehouse", icon: , children: [ @@ -328,7 +334,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , }, { @@ -339,7 +345,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - label: "Export Operations", + label: "Exports", href: "/dashboard/export-warehouse", icon: , children: [ @@ -375,7 +381,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , }, ], @@ -511,6 +517,38 @@ const filterSidebarByPermission = ( .filter((section) => section.items.length > 0); }; +const APP_TITLE = "EDR Freight Backoffice"; + +/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */ +const flattenSidebarItems = ( + sections: SidebarSection[], +): { href: string; label: string }[] => + sections.flatMap((section) => + section.items.flatMap((item) => [ + ...(item.href ? [{ href: item.href, label: item.label }] : []), + ...(item.children ?? []) + .filter((child): child is SidebarItem & { href: string } => + Boolean(child.href), + ) + .map((child) => ({ href: child.href, label: child.label })), + ]), + ); + +/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */ +const findActiveSidebarLabel = ( + pathname: string, + sections: SidebarSection[], +): string | undefined => { + const path = pathname.toLowerCase(); + const candidates = flattenSidebarItems(sections) + .map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() })) + .sort((a, b) => b.href.length - a.href.length); + + return candidates.find( + ({ href }) => path === href || path.startsWith(`${href}/`), + )?.label; +}; + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); @@ -536,6 +574,14 @@ const DashboardShell = () => { : null : null; + useEffect(() => { + const activeLabel = findActiveSidebarLabel( + location.pathname, + sidebarSections, + ); + document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE; + }, [location.pathname, sidebarSections]); + if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) { return ; } @@ -567,6 +613,7 @@ const App = () => { } /> } /> + } /> } /> ); @@ -576,6 +623,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/auth/AuthShell.tsx b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx new file mode 100644 index 000000000..c4fd7f39e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx @@ -0,0 +1,148 @@ +import type { ReactNode } from "react"; +import { Box, Image, Stack, Text, Title } from "@mantine/core"; +import { ChevronDown, Globe } from "lucide-react"; + +const EDR_IMAGE = "/assets/edr_image.png"; +const EDR_LOGO = "/assets/logo.svg"; + +/** Muted deep-green brand wash for the left panel. */ +const LEFT_PANEL_BG = + "linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)"; + +/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */ +const IMAGE_FADE_MASK = + "linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)"; + +export interface AuthShellProps { + children: ReactNode; + /** Headline shown in the top-left of the green panel. */ + tagline?: string; + taglineBody?: string; +} + +const LeftPanel = ({ + tagline, + taglineBody, +}: Pick) => ( + + {/* Top-left: logo, title, description — stacked, left aligned. */} + + EDR Freight + + + + {tagline ?? "Ethiopian Djibouti Railway"} + + + {taglineBody ?? + "Manage bookings, track cargo, and run day-to-day logistics for the Ethio–Djibouti Railway from a single backoffice."} + + + + + {/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */} + + +); + +const RightPanelDecor = () => ( +
+
+
+ + + + + + + + +
+); + +const LanguageSelector = () => ( +
+ + Eng + +
+); + +export default function AuthShell({ + children, + tagline, + taglineBody, +}: AuthShellProps) { + return ( +
+
+ + +
+ + +
+ +
+ +
+
+
+ {children} +
+
+
+
+
+
+ ); +} 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/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index d7cd9207b..b13e38961 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -14,7 +14,7 @@ import { Text, Textarea, } from "@mantine/core"; -import { DateInput, DateTimePicker } from "@mantine/dates"; +import { DateInput } from "@mantine/dates"; import { AlertTriangle, CheckCircle2, @@ -397,15 +397,10 @@ export function ExportClearanceStepper({ : } > - + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -526,68 +513,21 @@ function GatepassStep({ done={false} pendingLabel={ arrived - ? "Train arrived — GL Djibouti can grant the gate pass." + ? "Train arrived — secure the gate pass on the train schedule." : "Available once the train arrives at Djibouti." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); 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..dc95729c1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -0,0 +1,334 @@ +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), + ); + // Canceled schedules are retired to windowPhase='DONE' server-side, so the + // guard above already excludes them; they never reach the upcoming list. + // 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/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 82ac61382..d0b7f2c87 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -4,7 +4,6 @@ import { Badge, Button, Group, - Modal, NumberInput, Paper, SegmentedControl, @@ -15,7 +14,6 @@ import { Text, TextInput, } from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { TransitPermitMultiUpload, @@ -536,17 +534,12 @@ export function PhasedClearanceActionPanel({ : } > - + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -852,68 +836,21 @@ function ImportGatepassStep({ done={false} pendingLabel={ wagonAllocated - ? "Wagons allocated — GL Djibouti can grant the gate pass." + ? "Wagons allocated — secure the gate pass on the train schedule." : "Available once wagons are allocated." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); 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/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 59efe49a7..391a8157d 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -18,6 +18,7 @@ import { } from "react"; import type { SidebarItem, SidebarSection } from "./types"; +import { Link } from "react-router-dom"; export interface FreightSidebarProps { sections: SidebarSection[]; @@ -35,15 +36,15 @@ const BRAND_LOGO = "/assets/logo.svg"; const navClassNames = (active: boolean) => active ? { - root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", - label: "text-edr-primary-dark! font-medium! text-sm!", - section: "text-edr-primary-dark!", - } + root: "rounded-md transition-all py-1.5! duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", + label: "text-edr-primary-dark! font-medium! text-sm!", + section: "text-edr-primary-dark!", + } : { - root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", - label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!", - section: "text-edr-text!", - }; + root: "rounded-md transition-all py-1.5! duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", + label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!", + section: "text-edr-text!", + }; const itemKey = (parentKey: string, item: SidebarItem, index: number) => `${parentKey}/${item.href ?? item.label}/${index}`; @@ -65,7 +66,9 @@ const FreightSidebar = ({ const isHrefActive = useCallback( (href: string) => { const normalized = href.toLowerCase(); - return activePath === normalized || activePath.startsWith(`${normalized}/`); + return ( + activePath === normalized || activePath.startsWith(`${normalized}/`) + ); }, [activePath], ); @@ -109,9 +112,7 @@ const FreightSidebar = ({ if (hasChildren) { const isLink = !!item.href; - const active = - (isLink ? isHrefActive(item.href!) : false) || - branchActive(item.children!); + const active = isLink ? isHrefActive(item.href!) : false; const isOpen = openMap[key] ?? false; return ( @@ -124,7 +125,7 @@ const FreightSidebar = ({ active={active} opened={isOpen} classNames={navClassNames(active)} - onClick={ () => toggle(key)} + onClick={() => toggle(key)} rightSection={ } @@ -161,8 +164,9 @@ const FreightSidebar = ({ label={item.label} leftSection={item.icon} active={active} + component={Link} classNames={navClassNames(active)} - onClick={() => onNavigate?.(item.href!)} + to={item.href!} /> ); }, @@ -178,7 +182,7 @@ const FreightSidebar = ({ tt="uppercase" px="sm" mb={6} - className={ "text-edr-muted!" } + className={"text-edr-muted!"} style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }} > {section.title} @@ -232,14 +236,24 @@ const FreightSidebar = ({ {onClose && ( - + )} {/* Nav */} - + {renderedSections} 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" + > + + setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" - autoComplete="username" - className={fieldClass} - /> -
+ + setIdentifier(event.target.value)} + /> -
- -
- setPassword(event.target.value)} - placeholder="Enter your password" - className={`${fieldClass} pr-11`} - /> - -
-
+ setPassword(event.target.value)} + /> {error ? ( -
+ }> {error} -
+ ) : null} - - -

- Need an account?{" "} - - Contact your admin - -

-
- + + + ); const mfaForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

+ + Multi-factor verification - </h1> - <p className="text-sm leading-relaxed text-gray-500"> + + We sent a verification code to{" "} - + {normalizedIdentifier} - + . Enter it below to complete sign in. -

-

+ + -
-
- - + + + Verification code + + setOtp(event.target.value)} - placeholder="Enter the code" - className={fieldClass} + placeholder="0" + disabled={submitting} + styles={{ input: { textAlign: "center" } }} + onChange={setOtp} /> -
+ {error ? ( -
+ }> {error} -
+ ) : null} -
- - -
-
- + Verify + + + +
); - return ( - <> - - - - -
-
- - -
- - -
- -
- -
-
-
- {!needsMfa ? loginForm : mfaForm} -
-
-
- - -
-
-
- - ); + return {!needsMfa ? loginForm : mfaForm}; }; export default LoginPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index d49e24f95..7408b9aca 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -18,8 +18,8 @@ import { type BookingDetailView, } from "@/components/bookings/detail"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import ContainerAllocationTable from "@/components/ContainerAllocationTable"; -import { api } from "@/services/api"; +import { ContainerAllocationTable } from "@/components/ContainerAllocationTable"; +import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; const BookingDetailPage = () => { @@ -160,9 +160,9 @@ const BookingDetailPage = () => { type: c.containerType?.label ?? "Unknown", qty: c.quantity, }))} - onSave={(allocations) => - allocateMutation.mutateAsync({ allocations }) - } + onSave={async (allocations) => { + await allocateMutation.mutateAsync({ allocations }); + }} /> (); @@ -77,14 +64,6 @@ export default function BookingRequestDetailPage() { } = useBookingDetail(id); const mutations = useBookingMutations(id ?? ""); - const handleDownloadFile = async (file: BookingFileView) => { - try { - await downloadBookingFile(file.id, file.name); - } catch { - toast.error("Could not download file."); - } - }; - if (isLoading) { return ( @@ -149,11 +128,6 @@ export default function BookingRequestDetailPage() { const row = toBookingListRow(booking); const statusMeta = getStatusMeta(booking.status); - const showContractButton = [ - "CONTRACT_READY", - "SIGNED_CUSTOMER", - "FULLY_EXECUTED", - ].includes(booking.status); const showApprovalCard = booking.status === "PENDING_APPROVAL" || booking.status === "APPROVED_PENDING_SIGNATURE"; @@ -246,11 +220,7 @@ export default function BookingRequestDetailPage() { - + {isGeneralContract && ( @@ -270,11 +240,7 @@ export default function BookingRequestDetailPage() { )} ) : ( - + )} @@ -306,20 +272,6 @@ export default function BookingRequestDetailPage() { View document clearance )} - {showContractButton && ( - - )} {showApprovalCard && ( )} @@ -332,15 +284,13 @@ export default function BookingRequestDetailPage() { ); } -/** The booking's primary detail cards — route, services, cargo, contract, docs. */ +/** The booking's primary detail cards — route, services, cargo, containers. */ function OverviewPanel({ booking, row, - onDownload, }: { booking: BookingDetail; row: ReturnType; - onDownload: (file: BookingFileView) => void; }) { return ( @@ -351,15 +301,10 @@ function OverviewPanel({ /> + {booking.contractSummary && ( )} - !SIGNATURE_FILE_CODES.has(f.code ?? ""), - )} - onDownload={onDownload} - /> ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index d61a47139..477900ee1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -4,6 +4,7 @@ import { Button, Card, Group, + Select, Stack, Tabs, Text, @@ -30,17 +31,12 @@ import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; -import { - BookingStatusTabs, - type BookingStatusTabKey, -} from "@/components/bookings/BookingStatusTabs"; +// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty"; -import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue"; -import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard"; -import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config"; +import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { useBookingDetail, @@ -57,11 +53,29 @@ import { type ColumnDef, } from "@edr/ui-common"; -function getStatusesForTab(tab: BookingStatusTabKey): string | undefined { - const match = BOOKING_LIST_TABS.find((t) => t.key === tab); - if (!match?.statuses?.length) return undefined; - return match.statuses.join(","); -} +/** The two booking-kind tabs: one-time vs general-contract bookings. */ +type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT"; + +const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [ + { value: "ONE_TIME", label: "One-time booking" }, + { value: "GENERAL_CONTRACT", label: "General booking" }, +]; + +/** Status options for the filter select — built from the shared status styles. */ +const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map( + ([value, { label }]) => ({ value, label }), +); + +const TRADE_DIRECTION_OPTIONS = [ + { value: "IMPORT", label: "Import" }, + { value: "EXPORT", label: "Export" }, + { value: "DOMESTIC", label: "Domestic" }, +]; + +const FREIGHT_TYPE_OPTIONS = [ + { value: "CONTAINER", label: "Container" }, + { value: "BULK", label: "Bulk" }, +]; function formatDate(value: string | null | undefined): string { if (!value) return "—"; @@ -75,14 +89,16 @@ function formatDate(value: string | null | undefined): string { }); } -type OperationsSubTab = "ready" | "scheduled"; - export default function BookingRequestsPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); - const [activeTab, setActiveTab] = useState("all"); - const [operationsSubTab, setOperationsSubTab] = useState("ready"); + // Booking-kind tabs (one-time vs general contract) replace the old status tabs. + const [kindTab, setKindTab] = useState("ONE_TIME"); + // Per-tab filter selects (each nullable = "all"). + const [statusFilter, setStatusFilter] = useState(null); + const [directionFilter, setDirectionFilter] = useState(null); + const [freightTypeFilter, setFreightTypeFilter] = useState(null); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); const suppressRowClickRef = useRef(false); @@ -93,47 +109,26 @@ export default function BookingRequestsPage() { }, 400); }, []); - const tabStatuses = getStatusesForTab(activeTab); - const isOperationsTab = activeTab === "operations"; - const filter: BookingListFilter = useMemo(() => { - if (isOperationsTab) { - if (operationsSubTab === "ready") { - return { - page: 1, - pageSize: 100, - statuses: "PAID", - assignedToSchedule: "false", - sortBy: "createdAt", - sortOrder: "DESC", - tab: activeTab, - }; - } - return { - page: 1, - pageSize: 100, - statuses: "PAID", - schedulingStatuses: "SCHEDULED,DISPATCHED", - sortBy: "scheduledDate", - sortOrder: "ASC", - tab: activeTab, - }; - } return { page: pagination.pageIndex + 1, pageSize: pagination.pageSize, sortBy: "createdAt", sortOrder: "DESC", - tab: activeTab, - ...(tabStatuses ? { statuses: tabStatuses } : {}), + // React Query cache key per kind tab. + tab: kindTab, + bookingType: kindTab, + ...(statusFilter ? { statuses: statusFilter } : {}), + ...(directionFilter ? { tradeDirection: directionFilter } : {}), + ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), }; }, [ - isOperationsTab, - operationsSubTab, pagination.pageIndex, pagination.pageSize, - activeTab, - tabStatuses, + kindTab, + statusFilter, + directionFilter, + freightTypeFilter, ]); const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter); @@ -154,7 +149,8 @@ export default function BookingRequestsPage() { return items.filter( (b) => b.reference.toLowerCase().includes(q) || - b.customerLabel.toLowerCase().includes(q), + b.customerLabel.toLowerCase().includes(q) || + (b.contractReference?.toLowerCase().includes(q) ?? false), ); }, [data?.items, query]); @@ -171,18 +167,6 @@ export default function BookingRequestsPage() { void refetchSummary(); }, [refetch, refetchSummary]); - const handleAllocateFromQueue = useCallback( - (ids: string[]) => { - const selected = rows.filter((b) => ids.includes(b.id)); - const sorted = [...selected].sort( - (a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0), - ); - setAllocateIds(sorted.map((b) => b.id)); - setAllocateOpen(true); - }, - [rows], - ); - const handleRowClick = useCallback( (row: BookingListRow) => { if (suppressRowClickRef.current) return; @@ -213,6 +197,22 @@ export default function BookingRequestsPage() { ); }, }, + { + id: "contract", + header: () => Contract, + cell: ({ row }) => { + const ref = row.original.contractReference; + return ( +
+ {ref ? ( + {ref} + ) : ( + + )} +
+ ); + }, + }, { id: "route", header: () => Route, @@ -356,6 +356,8 @@ export default function BookingRequestsPage() { ]} /> + {/* Status tabs replaced by booking-kind tabs (one-time / general). The + old BookingStatusTabs is commented out — status is now a filter select. { @@ -364,73 +366,97 @@ export default function BookingRequestsPage() { }} counts={tabCounts} /> + */} + + { + setKindTab((value as BookingKindTab) ?? "ONE_TIME"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + > + + {BOOKING_KIND_TABS.map((t) => ( + + {t.label} + + ))} + + - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query && ( - setQuery("")} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - - {total} record{total !== 1 ? "s" : ""} - - + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query && ( + setQuery("")} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> + + {total} record{total !== 1 ? "s" : ""} + + + + { + setDirectionFilter(v); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + clearable + radius="lg" + style={{ minWidth: 170 }} + /> + - - - - {selectedVehicle && ( - - - Upcoming Maintenance - - - {isLoading ? ( - Loading... - ) : (upcoming || []).length > 0 ? ( - - - - Type - Description - Scheduled - Est. Cost - Status - - - - {(upcoming as MaintenanceSchedule[]).map(m => ( - - {m.maintenanceType} - {m.description} - {new Date(m.scheduledDate).toLocaleDateString()} - ${m.estimatedCost?.toFixed(2) || '—'} - - {m.status} - - - ))} - -
- ) : ( - No upcoming maintenance - )} -
- )} + + {!selectedVehicle ? ( + + + Select a vehicle to view its maintenance schedule + + + ) : ( + + + Upcoming Maintenance + + + {isLoading ? ( + Loading... + ) : upcomingList.length > 0 ? ( + + + + Type + Description + Scheduled + Est. Cost + Status + + + + {upcomingList.map((m) => ( + + {m.maintenanceType} + {m.description} + {new Date(m.scheduledDate).toLocaleDateString()} + + {m.estimatedCost != null + ? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}` + : '—'} + + + {m.status} + + + ))} + +
+ ) : ( + No upcoming maintenance + )} +
+
+ )} + setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })} + onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })} /> setFormData({ ...formData, description: e.currentTarget.value })} + onChange={(e) => setFormData({ ...formData, description: e.currentTarget.value })} /> - setFormData({ ...formData, scheduledDate: d || new Date() })} + onChange={(e) => setFormData({ ...formData, scheduledDate: e.currentTarget.value })} /> setFormData({ ...formData, estimatedCost: Number(v) })} + onChange={(v) => setFormData({ ...formData, estimatedCost: Number(v) })} /> setFormData({ ...formData, serviceProvider: e.currentTarget.value })} + onChange={(e) => setFormData({ ...formData, serviceProvider: e.currentTarget.value })} /> setFormData({ ...formData, notes: e.currentTarget.value })} + onChange={(e) => setFormData({ ...formData, notes: e.currentTarget.value })} /> - - ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx index a40579027..bdba6dc97 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -24,7 +24,6 @@ import { SimpleGrid, Stack, Text, - TextInput, ThemeIcon, Tooltip, } from "@mantine/core"; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index 8230a7446..f60c7f72b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, ThemeIcon, SimpleGrid } from '@mantine/core'; +import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, SimpleGrid } from '@mantine/core'; import { MapPin, Navigation, Radio, Activity } from 'lucide-react'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; @@ -24,8 +24,8 @@ interface GPSLocation { lastUpdate?: string; } -// Mock GPS data for demo -const generateMockGPS = (index: number): GPSLocation => ({ +// Mock GPS data for demo (no real GPS backend exists — these are simulated values) +const generateMockGPS = (): GPSLocation => ({ lat: 9.0 + Math.random() * 0.5, lng: 38.7 + Math.random() * 0.5, speed: Math.floor(Math.random() * 120), @@ -36,7 +36,6 @@ const generateMockGPS = (index: number): GPSLocation => ({ export function TrackingPage() { const [selectedVehicleId, setSelectedVehicleId] = useState(null); const [mapCenter] = useState({ lat: 9.0, lng: 38.8 }); - const mapZoom = 10; const { data: vehicles = [] } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), @@ -48,9 +47,9 @@ export function TrackingPage() { // Generate mock GPS data for each vehicle const vehiclesWithGPS = useMemo(() => { - return (vehicles as Vehicle[]).map((v, idx) => ({ + return (vehicles as Vehicle[]).map((v) => ({ ...v, - gps: generateMockGPS(idx), + gps: generateMockGPS(), })); }, [vehicles]); @@ -84,9 +83,14 @@ export function TrackingPage() {
- - Real-Time Vehicle Tracking - + + + Real-Time Vehicle Tracking + + + Simulated GPS + + Monitor vehicle locations, speed, and status @@ -109,17 +113,18 @@ export function TrackingPage() { - + + {/* Grid background */} + + + Simulated map — coordinates, speed, and heading are demo values, not live GPS. + @@ -314,7 +323,7 @@ export function TrackingPage() { Tracked Vehicles ({trackableVehicles.length})
- +
{trackableVehicles.map(v => ( { + 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 money = (n?: number | null) => + n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`; + +const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => ( + + {label} + {value} + +); + +const Loading = () => ( +
+); + +const VehicleDetailPage = () => { + const { id = "" } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: vehicle, isLoading } = useQuery({ + queryKey: ["vehicle", id], + queryFn: () => vehiclesService.getById(id).then((r) => r.data), + enabled: Boolean(id), + }); + + const plate = vehicle + ? [vehicle.code, vehicle.plateNumber].filter(Boolean).join(" · ") + : ""; + + return ( + + + navigate("/dashboard/vehicles")} aria-label="Back"> + + + + + {plate || "Vehicle"} + {vehicle && ( + + {vehicle.status} + + {vehicle.availability} + + {vehicle.vehicleType && {vehicle.vehicleType}} + + )} + + + + {isLoading ? ( + + ) : !vehicle ? ( + Vehicle not found. + ) : ( + + + }>Overview + }>Driver + }>History + }>Maintenance + }>Fuel + }>First/Last mile + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ); +}; + +const DriverTab = ({ + vehicleId, + driverId, + fallbackName, +}: { + vehicleId: string; + driverId?: string | null; + fallbackName?: string | null; +}) => { + const { data: driver } = useQuery({ + queryKey: ["driver", driverId], + queryFn: () => driversService.getById(driverId!).then((r) => r.data), + enabled: Boolean(driverId), + }); + // All drivers that have driven this vehicle, from the assignment history. + const { data: history = [], isLoading } = useQuery({ + queryKey: ["vehicle-history", vehicleId], + queryFn: () => fleetHistoryService.vehicle(vehicleId), + }); + const drivers = history + .filter((e) => e.eventType === "DRIVER_ASSIGNED") + .map((e) => ({ + id: e.id, + driverId: e.driverId, + name: (typeof e.metadata?.driverName === "string" && e.metadata.driverName) || e.label || "Driver", + at: e.createdAt, + })); + + return ( + + {driverId && driver ? ( + + Current driver + + + + + + + + + + ) : ( + {fallbackName ? `Assigned: ${fallbackName}` : "No driver currently assigned."} + )} + + + Driver history ({drivers.length}) + {isLoading ? ( + + ) : drivers.length === 0 ? ( + No driver assignments recorded. + ) : ( +
+ + + DriverAssigned + + + + {drivers.map((d) => ( + + {d.name} + {fmtDateTime(d.at)} + + ))} + +
+ )} + + + ); +}; + +const HistoryTab = ({ vehicleId }: { vehicleId: string }) => { + const { data = [], isLoading } = useQuery({ + queryKey: ["vehicle-history", vehicleId], + queryFn: () => fleetHistoryService.vehicle(vehicleId), + }); + if (isLoading) return ; + if (!data.length) return No activity recorded yet.; + return ( + + {data.map((e) => ( + {e.eventType.replaceAll("_", " ")}}> + {(e.label || e.fromValue || e.toValue) && ( + + {[e.label, e.fromValue && e.toValue ? `${e.fromValue} → ${e.toValue}` : e.toValue] + .filter(Boolean) + .join(" · ")} + + )} + {fmtDateTime(e.createdAt)} + + ))} + + ); +}; + +const MaintenanceTab = ({ vehicleId }: { vehicleId: string }) => { + const { data = [], isLoading } = useQuery({ + queryKey: ["vehicle-maintenance", vehicleId], + queryFn: () => api.get(`/maintenance/history/${vehicleId}`).then((r) => r.data), + }); + const total = useMemo(() => data.reduce((s, m) => s + (Number(m.costAmount) || 0), 0), [data]); + if (isLoading) return ; + return ( + + + Last 12 months + Total: {money(total)} + + {data.length === 0 ? ( + No maintenance records. + ) : ( + + + + + DateTypeAmount + DescriptionProvider + + + + {data.map((m) => ( + + {fmtDate(m.incurredDate)} + {m.costType} + {money(m.costAmount)} + {m.description ?? "—"} + {m.serviceProvider ?? "—"} + + ))} + +
+
+ )} +
+ ); +}; + +const FuelTab = ({ vehicleId }: { vehicleId: string }) => { + const { data = [], isLoading } = useQuery({ + queryKey: ["vehicle-fuel", vehicleId], + queryFn: () => { + const end = new Date(); + const start = new Date(); + start.setMonth(start.getMonth() - 12); + const qs = `startDate=${start.toISOString()}&endDate=${end.toISOString()}`; + return api.get(`/fuel/purchases/${vehicleId}?${qs}`).then((r) => r.data); + }, + }); + const totals = useMemo( + () => ({ + liters: data.reduce((s, f) => s + (Number(f.liters) || 0), 0), + cost: data.reduce((s, f) => s + (Number(f.totalCost) || 0), 0), + }), + [data], + ); + if (isLoading) return ; + return ( + + + + Total litres + {totals.liters.toLocaleString(undefined, { maximumFractionDigits: 1 })} L + + + Total fuel cost + {money(totals.cost)} + + + Purchases + {data.length} + + + {data.length === 0 ? ( + No fuel purchases in the last 12 months. + ) : ( + + + + + DateLitresCost/L + TotalOdometerStation + + + + {data.map((f) => ( + + {fmtDate(f.purchaseDate)} + {f.liters} L + {money(f.costPerLiter)} + {money(f.totalCost)} + {f.odometerReading ?? "—"} + {f.fuelStation ?? "—"} + + ))} + +
+
+ )} +
+ ); +}; + +const mileTotal = (rows: MileRecord[]) => + rows.reduce((s, r) => s + (Number(r.remainingPayment) || 0), 0); + +const MileTable = ({ title, rows }: { title: string; rows: MileRecord[] }) => ( + + + {title} ({rows.length}) + Total: {money(mileTotal(rows))} + + {rows.length === 0 ? ( + None. + ) : ( + + + + BookingStatus + Distance (km)Cost + + + + {rows.map((r) => ( + + {r.booking?.reference ?? r.bookingId} + {r.status} + {r.exactKm ?? r.estimatedKm ?? "—"} + {money(r.remainingPayment)} + + ))} + +
+ )} +
+); + +const MileTab = ({ vehicleId }: { vehicleId: string }) => { + const first = useQuery({ + queryKey: ["vehicle-first-mile", vehicleId], + queryFn: () => + api.get<{ data: MileRecord[] }>(`/first-mile?vehicleId=${vehicleId}&pageSize=1000`).then((r) => r.data.data ?? []), + }); + const last = useQuery({ + queryKey: ["vehicle-last-mile", vehicleId], + queryFn: () => + api.get<{ data: MileRecord[] }>(`/last-mile?vehicleId=${vehicleId}&pageSize=1000`).then((r) => r.data.data ?? []), + }); + if (first.isLoading || last.isLoading) return ; + const firstRows = first.data ?? []; + const lastRows = last.data ?? []; + const grandTotal = mileTotal(firstRows) + mileTotal(lastRows); + return ( + + + + Total first + last mile revenue for this vehicle + {money(grandTotal)} + + + + + + ); +}; + +export default VehicleDetailPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts index 1274b0972..fdfdd367d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/drivers.ts @@ -8,11 +8,18 @@ const DRIVER_STATUS_OPTIONS = [ { label: "On leave", value: "ON_LEAVE" }, ]; +const DRIVER_GENDER_OPTIONS = [ + { label: "Male", value: "MALE" }, + { label: "Female", value: "FEMALE" }, + { label: "Other", value: "OTHER" }, +]; + export const driversConfig: FleetResourceConfig = { slug: "drivers", label: "Drivers", subtitle: "Manage driver records and licenses", basePath: "/dashboard/drivers", + detailPath: "/dashboard/drivers/:id", addLabel: "Add Driver", entityLabel: "Driver", searchPlaceholder: "Search drivers…", @@ -29,24 +36,28 @@ export const driversConfig: FleetResourceConfig = { options: DRIVER_STATUS_OPTIONS, }, ], + faydaVerification: true, searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"], columns: [ - { id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 }, + { id: "licenseNumber", header: "Driver's License Number", accessorKey: "licenseNumber", format: "code", size: 180 }, { id: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 }, { id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 }, { id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 }, { id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 }, + { id: "gender", header: "Gender", accessorKey: "gender", format: "code", size: 90 }, { id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, + { id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 }, ], formFields: [ - { name: "licenseNumber", label: "License Number", type: "text", required: true }, - { name: "firstName", label: "First Name", type: "text", required: true }, - { name: "lastName", label: "Last Name", type: "text", required: true }, - { name: "email", label: "Email", type: "email", required: true }, - { name: "phoneNumber", label: "Phone Number", type: "text", required: true }, - { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true }, - { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true }, + { name: "licenseNumber", label: "Driver's License Number", type: "text", required: true }, + { name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true }, + { name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true }, + { name: "email", label: "Email", type: "email", required: true, faydaLocked: true }, + { name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true }, + { name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true }, + { name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS, faydaLocked: true }, + { name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true, dateBound: "future" }, { name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS }, { name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS }, { name: "address", label: "Address", type: "textarea" }, @@ -60,6 +71,7 @@ export const driversConfig: FleetResourceConfig = { email: "", phoneNumber: "", dateOfBirth: "", + gender: "", licenseExpiryDate: "", status: "ACTIVE", vehicleTypesAuthorized: [], diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 41ef390ca..3ac7f2458 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -33,17 +33,27 @@ export interface FleetResourceColumn { id: string; header: string; accessorKey: string; - format?: ColumnFormat | "statusBadge"; + format?: ColumnFormat | "statusBadge" | "verifiedBadge"; size?: number; } export interface FleetFormFieldDef extends FormFieldDef { dynamicOptions?: FleetDynamicOptions; noneOption?: boolean; + /** + * Field is owned by the Fayda identity — populated only by verification and + * never hand-edited. Rendered disabled in the form. + */ + faydaLocked?: boolean; + /** + * Direction a `date` field is constrained to. "future" = must be after today + * (e.g. a license expiry); "past" (default) = cannot be in the future. + */ + dateBound?: "past" | "future"; } export interface FleetListFilterDef { - key: "status" | "currentYardId" | "wagonTypeId" | "trainId"; + key: "status" | "availability" | "currentYardId" | "wagonTypeId" | "trainId"; label: string; options?: Array<{ value: string; label: string }>; allLabel?: string; @@ -73,6 +83,8 @@ export interface FleetResourceConfig { cardCodeKey?: string; cardSubtitleKey?: string; searchKeys: string[]; + /** Offer Fayda identity verification in the add/edit form (drivers). */ + faydaVerification?: boolean; } export const FLEET_BASE_PATH_BY_SLUG: Record = { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index de6eb09c7..5e76584ed 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -34,6 +34,7 @@ export const vehiclesConfig: FleetResourceConfig = { label: "Vehicles", subtitle: "Manage vehicle master data for fleet operations", basePath: "/dashboard/vehicles", + detailPath: "/dashboard/vehicles/:id", addLabel: "Add Vehicle", entityLabel: "Vehicle", searchPlaceholder: "Search vehicles…", diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index cec4d4351..2b130848c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -5,17 +5,22 @@ import { Eye, MoreHorizontal, PackageCheck, + Plus, Printer, + Receipt, RefreshCw, Ruler, Trash, Truck, + X, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { ActionIcon, + Autocomplete, Badge, Box, Button, @@ -31,12 +36,12 @@ import { Stack, Text, TextInput, + Tooltip, UnstyledButton, - Alert, } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; -import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable"; +import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; @@ -44,16 +49,16 @@ import { FIRST_MILE_STATUSES, type FirstMileApiStatus, type FirstMileRecord, + type FirstMileVehicle, firstMileService, } from "@/services/first-mile.service"; import { bookingsService } from "@/services/bookings.service"; import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; -import { api } from "@/auth/http"; import type { BookingDetail } from "@/types/booking"; -const formatPrice = (amount: number) => - `ETB ${amount.toLocaleString("en-US", { +const formatPrice = (amount: number | string | null | undefined, currency = "ETB") => + `${currency || "ETB"} ${(Number(amount) || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; @@ -65,6 +70,24 @@ const STATUS_META: Record RECEIVED_TO_PORT: { label: "Received to Port", color: "green" }, }; +// Middle-truncate a long invoice number for the table (full value on hover). +// "INV-20260704-00002" → "INV-2…02" +const shortInvoiceNo = (n: string) => + n && n.length > 9 ? `${n.slice(0, 5)}…${n.slice(-2)}` : n; + +// Invoice payment state → badge color, keyed by upper-cased status. +const INVOICE_STATUS_META: Record = { + PAID: { label: "Paid", color: "green" }, + PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, + PENDING: { label: "Pending", color: "yellow" }, + UNPAID: { label: "Unpaid", color: "yellow" }, + OPEN: { label: "Open", color: "yellow" }, + ISSUED: { label: "Issued", color: "blue" }, + OVERDUE: { label: "Overdue", color: "red" }, + CANCELLED: { label: "Cancelled", color: "gray" }, + VOID: { label: "Void", color: "gray" }, +}; + const NEXT_STATUS: Partial> = { PAYMENT_PENDING: "READY_TO_TRANSIT", READY_TO_TRANSIT: "IN_TRANSIT", @@ -91,10 +114,84 @@ const vehicleLabel = (record: FirstMileRecord) => { return parts.join(" · "); }; -const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId); +const isAssigned = (record: FirstMileRecord) => + Boolean(record.vehicleId) || Boolean(record.vehicleAssignments?.length); + +/** Real per-physical-container numbers on a booking, in order. Prefers each + * line's `units` (the actual numbers) over the line-level number (often a + * "TBD-…" placeholder). One entry per physical container, for per-truck prefill. */ +const bookingContainerNumbers = (record: FirstMileRecord): string[] => { + const out: string[] = []; + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + for (const c of record.booking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber!); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber!); + } + } + return out; +}; + +/** Progress stages of the first-mile pickup workflow, for the detail stepper. */ +const computeFirstMileSteps = (record: FirstMileRecord): LastMileStepState[] => { + const exactKm = record.exactKm; + const inTransitOrPast = + record.status === "IN_TRANSIT" || record.status === "RECEIVED_TO_PORT"; + const flags = [ + record.status !== "PAYMENT_PENDING", // Ready to Transit + isAssigned(record), // Assign vehicle + inTransitOrPast, // In transit + exactKm != null, // Add distance + Boolean(record.invoice), // Generate Invoice + record.status === "RECEIVED_TO_PORT",// Received to port + ]; + // Current step = earliest incomplete one. + const activeIdx = flags.findIndex((f) => !f); + const labels = [ + "Ready to Transit", + "Assign vehicle", + "In transit", + "Add distance", + "Generate Invoice", + "Received to port", + ]; + const vehCount = record.vehicleAssignments?.length ?? (record.vehicleId ? 1 : 0); + const primaryPlate = + record.vehicle?.plateNumber ?? + record.vehicleAssignments?.[0]?.vehicle?.plateNumber ?? + null; + const details: (string | null)[] = [ + null, + vehCount > 1 ? `${vehCount} vehicles` : primaryPlate, + null, + exactKm != null ? `${exactKm} KM` : null, + record.invoice?.number ?? null, + null, + ]; + return labels.map((label, i) => ({ + label, + done: flags[i], + active: i === activeIdx, + detail: details[i], + })); +}; + +// Paid = record flag set OR its invoice reached PAID. +const isPaidRecord = (r: FirstMileRecord) => + Boolean((r as { paid?: boolean }).paid) || + (r.invoice?.status ?? "").toUpperCase() === "PAID"; +// Post payment pending = a post payment is owed but not yet paid. +const isPostPaymentPending = (r: FirstMileRecord) => + Number(r.remainingPayment) > 0 && !isPaidRecord(r); // Map API record → display fields used in modals and trip slip const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId; +const currencyOf = (r: FirstMileRecord) => r.booking?.paymentCurrency ?? "ETB"; const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—"; const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—"; const cargoDesc = (r: FirstMileRecord) => { @@ -151,8 +248,8 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => { {hasPickupAddress && } - - + + @@ -165,21 +262,48 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => { ); }; -const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ - ["Customer", customerName(record)], - ["Service", serviceTypeName(record)], - ["Pickup location", pickupLocation(record)], - ["Destination yard", destinationYardName(record)], - ["Cargo", cargoDesc(record)], - ["Advanced Payment", formatPrice(record.advancedPayment)], - ["Post Payment", formatPrice(record.remainingPayment)], - ["Vehicle", vehicleLabel(record) ?? "Unassigned"], - ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], - ["Requested date", requestedDate(record)], - ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], - ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], - ["Status", STATUS_META[record.status].label], -]; +type TripSlipVehicle = NonNullable[number]; + +const tripSlipRows = ( + record: FirstMileRecord, + vehicle?: TripSlipVehicle | null, +): [string, string][] => { + // Per-vehicle block when a specific truck is chosen (its own driver, container + // and distance); else fall back to the record-level vehicle summary. + const vehicleRows: [string, string][] = vehicle + ? [ + [ + "Vehicle", + vehicle.vehicle + ? [vehicle.vehicle.code, vehicle.vehicle.plateNumber].filter(Boolean).join(" · ") + : vehicle.vehicleId, + ], + ["Driver", vehicle.vehicle?.assignedDriverName || "—"], + [ + "Container(s)", + vehicle.containerNumber || bookingContainerNumbers(record).join(", ") || "—", + ], + ["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"], + ] + : [ + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], + ]; + return [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Pickup location", pickupLocation(record)], + ["Destination yard", destinationYardName(record)], + ["Cargo", cargoDesc(record)], + ["Advanced Payment", formatPrice(record.advancedPayment, currencyOf(record))], + ["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))], + ...vehicleRows, + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], + ["Status", STATUS_META[record.status].label], + ]; +}; const SampleStamp = () => ( @@ -245,7 +369,13 @@ const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) ); -const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( +const TripSlipDocument = ({ + record, + vehicle, +}: { + record: FirstMileRecord; + vehicle?: TripSlipVehicle | null; +}) => ( EDR Freight @@ -257,7 +387,7 @@ const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( - {tripSlipRows(record).map(([label, value]) => ( + {tripSlipRows(record, vehicle).map(([label, value]) => ( ))} @@ -272,8 +402,8 @@ const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( const escapeHtml = (v: string) => v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (record: FirstMileRecord) => { - const rows = tripSlipRows(record) +const buildTripSlipHtml = (record: FirstMileRecord, vehicle?: TripSlipVehicle | null) => { + const rows = tripSlipRows(record, vehicle) .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); const sig = (title: string, withStamp: boolean) => ` @@ -319,6 +449,7 @@ const buildTripSlipHtml = (record: FirstMileRecord) => { const FirstMilePage = () => { const { toast } = useToast(); const qc = useQueryClient(); + const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); @@ -331,8 +462,14 @@ const FirstMilePage = () => { const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); + // Which vehicle the trip slip is for (per-truck), + the pre-print picker. + const [tripSlipVehicleId, setTripSlipVehicleId] = useState(null); + const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(null); - const [vehicleValue, setVehicleValue] = useState(null); + // Multi-vehicle assign: one row per truck — vehicle + the container it carries. + const [vehicleRows, setVehicleRows] = useState< + Array<{ vehicleId: string | null; containerNumber: string }> + >([{ vehicleId: null, containerNumber: "" }]); const [acceptOpen, setAcceptOpen] = useState(false); const [acceptStep, setAcceptStep] = useState<1 | 2>(1); @@ -341,14 +478,11 @@ const FirstMilePage = () => { const [bookingSearch, setBookingSearch] = useState(""); const [distanceOpen, setDistanceOpen] = useState(false); - const [distanceValue, setDistanceValue] = useState(""); - const [invoiceOpen, setInvoiceOpen] = useState(false); - const [invoiceRecord, setInvoiceRecord] = useState(null); + // Per-vehicle actual distance, keyed by vehicleId. + const [distanceRows, setDistanceRows] = useState>({}); const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false); const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState(null); - const [containerAllocationOpen, setContainerAllocationOpen] = useState(false); - const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState(null); const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.FIRST_MILE.list(), @@ -411,14 +545,36 @@ const FirstMilePage = () => { }, }); - const updateDistanceMutation = useMutation({ - mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => - firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), + const setVehiclesMutation = useMutation({ + mutationFn: ({ + id, + vehicles, + }: { + id: string; + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>; + }) => firstMileService.setVehicles(id, vehicles), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); - if (activeRecord) { - toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); - } + void qc.invalidateQueries({ queryKey: ["vehicles"] }); + }, + onError: () => { + toast({ title: "Assign failed", variant: "destructive" }); + }, + }); + + const setDistancesMutation = useMutation({ + mutationFn: ({ + id, + distances, + remainingPayment, + }: { + id: string; + distances: Array<{ vehicleId: string; distanceKm: number }>; + remainingPayment?: number; + }) => firstMileService.setDistances(id, distances, remainingPayment), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); closeDistance(); }, onError: () => { @@ -437,6 +593,17 @@ const FirstMilePage = () => { }, }); + const generateInvoiceMutation = useMutation({ + mutationFn: (id: string) => firstMileService.generateInvoice(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Invoice generated" }); + }, + onError: () => { + toast({ title: "Invoice generation failed", variant: "destructive" }); + }, + }); + const acceptMutation = useMutation({ mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { const res = await firstMileService.accept(reference); @@ -461,25 +628,71 @@ const FirstMilePage = () => { }, }); - const allocateMutation = useMutation({ - mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), - onSuccess: () => { - toast({ title: "Containers allocated" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") }); - void qc.invalidateQueries({ queryKey: ["vehicles"] }); - setContainerAllocationOpen(false); - setContainerAllocationFirstMileId(null); - }, - onError: () => { - toast({ title: "Allocation failed", variant: "destructive" }); - }, - }); - const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], ); + // Picker options = free vehicles PLUS the ones already on this record (which are + // BUSY, so absent from the free list) so a reassign shows its current trucks + // selected instead of blank. + const assignVehicleOptions = useMemo(() => { + const opts = [...vehicleOptions]; + const seen = new Set(opts.map((o) => o.value)); + const pushVehicle = (v?: FirstMileVehicle | null) => { + if (v && !seen.has(v.id)) { + seen.add(v.id); + const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber]; + if (v.code) parts.unshift(v.code); + opts.push({ value: v.id, label: parts.join(" · ") }); + } + }; + for (const a of activeRecord?.vehicleAssignments ?? []) pushVehicle(a.vehicle); + pushVehicle(activeRecord?.vehicle); + for (const a of activeRecord?.vehicleAssignments ?? []) { + if (!seen.has(a.vehicleId)) { + seen.add(a.vehicleId); + opts.push({ + value: a.vehicleId, + label: a.containerNumber ? `Assigned · ${a.containerNumber}` : "Assigned vehicle", + }); + } + } + if (activeRecord?.vehicleId && !seen.has(activeRecord.vehicleId)) { + opts.push({ value: activeRecord.vehicleId, label: "Assigned vehicle" }); + } + return opts; + }, [vehicleOptions, activeRecord]); + + // Full booking (with container units) for the assign modal's container dropdown. + // Fetched on open so container numbers show regardless of what the list embeds. + const { data: assignBooking } = useQuery({ + queryKey: activeRecord?.bookingId + ? QUERY_KEYS.BOOKINGS.byId(activeRecord.bookingId) + : ["bookings", "detail", "none"], + queryFn: () => bookingsService.getById(activeRecord!.bookingId), + enabled: assignOpen && !bulkMode && Boolean(activeRecord?.bookingId), + }); + + // Container-number options for the dropdown = the booking's real per-container + // numbers (units), falling back to whatever the list record carried. + const containerOptions = useMemo(() => { + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + const out: string[] = []; + for (const c of assignBooking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber); + } + } + return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : []; + }, [assignBooking, activeRecord]); + const selectedIds = useMemo( () => Object.keys(rowSelection).filter((id) => rowSelection[id]), [rowSelection], @@ -533,26 +746,22 @@ const FirstMilePage = () => { }; const openDistance = (id: string) => { + const rec = records.find((r) => r.id === id); + const rows: Record = {}; + for (const a of rec?.vehicleAssignments ?? []) { + rows[a.vehicleId] = a.distanceKm != null ? String(a.distanceKm) : ""; + } setActiveId(id); - setDistanceValue(""); + setDistanceRows(rows); setDistanceOpen(true); }; const closeDistance = () => { setDistanceOpen(false); setActiveId(null); - setDistanceValue(""); + setDistanceRows({}); }; - const openInvoice = (record: FirstMileRecord) => { - setInvoiceRecord(record); - setInvoiceOpen(true); - }; - - const closeInvoice = () => { - setInvoiceOpen(false); - setInvoiceRecord(null); - }; const openWarehouseReceive = (record: FirstMileRecord) => { setWarehouseReceiveRecord(record); @@ -564,38 +773,32 @@ const FirstMilePage = () => { setWarehouseReceiveRecord(null); }; - const openContainerAllocation = (firstMileId: string) => { - setContainerAllocationFirstMileId(firstMileId); - setContainerAllocationOpen(true); - }; - - const closeContainerAllocation = () => { - setContainerAllocationOpen(false); - setContainerAllocationFirstMileId(null); - }; - const handleSaveDistance = () => { - const distance = parseFloat(distanceValue); - if (!activeId || isNaN(distance) || distance < 0) { - toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); + const distances = Object.entries(distanceRows) + .map(([vehicleId, val]) => ({ vehicleId, distanceKm: parseFloat(val) })) + .filter((d) => !Number.isNaN(d.distanceKm) && d.distanceKm >= 0); + + if (!activeId || !distances.length) { + toast({ title: "Invalid distance", description: "Enter a distance for at least one vehicle.", variant: "destructive" }); return; } + const total = distances.reduce((s, d) => s + d.distanceKm, 0); let remainingPayment: number | undefined; if (ratesData?.data) { const firstMileRate = ratesData.data.find( (r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") ); if (firstMileRate) { - const rateValue = parseFloat(firstMileRate.rateValue); - remainingPayment = distance * rateValue; + remainingPayment = total * parseFloat(firstMileRate.rateValue); } } - updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); + setDistancesMutation.mutate({ id: activeId, distances, remainingPayment }); }; const matchesFilter = (r: FirstMileRecord) => { + if (filterPostPaymentPending && !isPostPaymentPending(r)) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -622,6 +825,11 @@ const FirstMilePage = () => { return counts; }, [records]); + const postPaymentPendingCount = useMemo( + () => records.filter(isPostPaymentPending).length, + [records], + ); + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); return records.filter((r) => { @@ -643,16 +851,29 @@ const FirstMilePage = () => { const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; + const rec = records.find((r) => r.id === resolved); + // Prefill each row's container number from the booking's container numbers + // (by order) when the assignment doesn't already carry one. + const nums = rec ? bookingContainerNumbers(rec) : []; + const rows = + rec?.vehicleAssignments?.length + ? rec.vehicleAssignments.map((a, i) => ({ + vehicleId: a.vehicleId, + containerNumber: a.containerNumber ?? nums[i] ?? "", + })) + : rec?.vehicleId + ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }] + : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]; setBulkMode(false); setActiveId(resolved); - setVehicleValue(null); + setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleValue(null); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); setAssignOpen(true); }; @@ -660,28 +881,31 @@ const FirstMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleValue(null); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); }; const handleAssign = () => { - if (!vehicleValue) { - toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); - return; - } - + const seen = new Set(); + const vehicles = vehicleRows + .filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId)) + .filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId))) + .map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null })); + const count = vehicles.length; const targetIds = bulkMode ? selectedIds : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); if (!targetIds.length) return; - const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; - - Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) + // Empty set = unassign all (setVehicles releases the removed vehicles). + Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { toast({ - title: "Vehicle assigned", - description: bulkMode ? `${targetIds.length} pickups → ${selectedLabel}` : selectedLabel, + title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned", + description: + count === 0 + ? bulkMode ? `${targetIds.length} pickups` : undefined + : `${bulkMode ? `${targetIds.length} pickups · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`, }); if (bulkMode) setRowSelection({}); closeAssign(); @@ -702,10 +926,27 @@ const FirstMilePage = () => { }; const handlePrintTripSlip = (record: FirstMileRecord) => { + // Always open the picker so the operator chooses which truck to print. setTripSlipRecord(record); + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + }; + + const printBookingSlip = () => { + setTripSlipVehicleId(null); + setTripSlipSelectOpen(false); setTripSlipOpen(true); }; + const chooseTripSlipVehicle = (vehicleId: string) => { + setTripSlipVehicleId(vehicleId); + setTripSlipSelectOpen(false); + setTripSlipOpen(true); + }; + + const tripSlipVehicle = + tripSlipRecord?.vehicleAssignments?.find((a) => a.vehicleId === tripSlipVehicleId) ?? null; + const printTripSlip = () => { if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); @@ -713,8 +954,17 @@ const FirstMilePage = () => { toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipRecord)); + win.document.write(buildTripSlipHtml(tripSlipRecord, tripSlipVehicle)); win.document.close(); + win.focus(); + // Explicit print after the doc paints (onload can miss with document.write). + setTimeout(() => { + try { + win.print(); + } catch { + /* window may have been closed */ + } + }, 250); }; const columns = useMemo((): ColumnDef[] => { @@ -754,47 +1004,49 @@ const FirstMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => customerName(row.original), }, - { - id: "pickup", - header: "Pickup", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => pickupLocation(row.original), - }, - { - id: "destination", - header: "Destination", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => destinationYardName(row.original), - }, - { - id: "cargo", - header: "Cargo", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => cargoDesc(row.original), - }, - { - id: "advancedPayment", - header: "Advanced Payment", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.advancedPayment), - }, { id: "postPayment", header: "Post Payment", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.remainingPayment), + cell: ({ row }) => formatPrice(row.original.remainingPayment, currencyOf(row.original)), }, { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => vehicleLabel(row.original) ?? , - }, - { - id: "estimatedKm", - header: "Est. Distance (KM)", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : , + cell: ({ row }) => { + const assigns = row.original.vehicleAssignments ?? []; + if (assigns.length > 1) { + const labelFor = (a: (typeof assigns)[number]) => { + const v = a.vehicle; + const l = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return a.containerNumber ? `${l} · ${a.containerNumber}` : l; + }; + return ( + + {assigns.map(labelFor).join("\n")} +
+ } + > + + + {assigns[0].vehicle + ? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ") + : assigns[0].vehicleId} + + + +{assigns.length - 1} + + + + ); + } + return vehicleLabel(row.original) ?? Unassigned; + }, }, { id: "exactKm", @@ -807,35 +1059,31 @@ const FirstMilePage = () => { header: "Invoice", meta: { headerClassName, cellClassName }, cell: ({ row }) => { - const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; - const isPaid = (row.original as any).paid; - if (!hasDistance) { + // Only show once actually generated — not merely on distance. + const invoice = row.original.invoice; + if (!invoice) { return ; } - if (isPaid) { - return ( - - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - - Paid - - ); - } + const status = String((row.original as any).paid ? "PAID" : invoice.status || "").toUpperCase(); + const badge = INVOICE_STATUS_META[status] ?? { color: "gray", label: status || "—" }; return ( - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - + + + invoice.id + ? navigate(`/dashboard/invoices/${invoice.id}`) + : toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" }) + } + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + + {shortInvoiceNo(invoice.number)} + + + {status && {badge.label}} + ); }, }, @@ -848,16 +1096,6 @@ const FirstMilePage = () => { return {meta.label}; }, }, - { - id: "assignment", - header: "Assignment", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - {isAssigned(row.original) ? "Assigned" : "Unassigned"} - - ), - }, { id: "actions", header: "Actions", @@ -870,7 +1108,12 @@ const FirstMilePage = () => { const canReceiveToWarehouse = row.original.status === "RECEIVED_TO_PORT"; return ( - + @@ -879,10 +1122,14 @@ const FirstMilePage = () => { } - disabled={!nextStatus} + disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus === "IN_TRANSIT" && !assigned + ? "Assign a vehicle first" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} { } - disabled={!assigned} + disabled={!assigned || Boolean(row.original.invoice)} onClick={() => openAssign(row.original.id)} > Reassign @@ -915,10 +1162,21 @@ const FirstMilePage = () => { } + disabled={Boolean(row.original.invoice)} onClick={() => openDistance(row.original.id)} > Add distance + } + disabled={ + !(row.original.exactKm != null && row.original.exactKm > 0) || + Boolean(row.original.invoice) + } + onClick={() => generateInvoiceMutation.mutate(row.original.id)} + > + {row.original.invoice ? "Invoice generated" : "Generate Invoice"} + {canPrint && ( } @@ -933,6 +1191,7 @@ const FirstMilePage = () => { } color="red" + disabled={Boolean(row.original.invoice)} onClick={() => { if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) { deleteMutation.mutate(row.original.id); @@ -954,7 +1213,7 @@ const FirstMilePage = () => { }, [vehicleOptions]); return ( - + @@ -1004,7 +1263,7 @@ const FirstMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - Post Payment Pending + Post Payment Pending ({postPaymentPendingCount}) @@ -1050,7 +1309,7 @@ const FirstMilePage = () => { {bulkMode ? ( - Assigning a vehicle to{" "} + Assigning vehicles to{" "} {selectedIds.length}{" "} selected {selectedIds.length === 1 ? "pickup" : "pickups"}. @@ -1060,28 +1319,84 @@ const FirstMilePage = () => { No unassigned pickups 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))} + > + + + )} + + ))} + + @@ -1098,12 +1413,98 @@ const FirstMilePage = () => { > {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 && ( + + Pickup steps + + + )} + {/* 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. + )} + + + + + {/* Trip slip modal */} { centered > - {tripSlipRecord && } + {tripSlipRecord && } @@ -1288,171 +1689,57 @@ const FirstMilePage = () => { {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 + + + + )} - - {/* Invoice modal */} - Invoice #345} - size="lg" - 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 && ( - <> - {/* Capacity guidance */} - {activeRecord.booking?.cargoType?.label === "BULK" ? ( - - - Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows. - - - Capacity: TBD — TODO: add vehicle capacity_tons to vehicle API if missing - - - ) : ( - - - One vehicle per container. Each container will be assigned to a single vehicle. - - - )} - - - {/* Container table */} - { - await allocateMutation.mutateAsync(allocations); - }} - /> - - )} - - - - - ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 7ec9d1b33..4b5cec080 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -3,17 +3,23 @@ import { ArrowRight, Eye, MoreHorizontal, + Plus, Printer, + Receipt, RefreshCw, Ruler, Trash, Truck, + X, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { ActionIcon, + Alert, + Autocomplete, Badge, Box, Button, @@ -23,6 +29,7 @@ import { Group, Menu, Modal, + MultiSelect, NumberInput, ScrollArea, Select, @@ -30,10 +37,12 @@ import { Stack, Text, TextInput, + Tooltip, UnstyledButton, } from "@mantine/core"; import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse"; import { warehouseService } from "@/services/warehouse.service"; +import { bookingsService } from "@/services/bookings.service"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; @@ -42,17 +51,17 @@ import { LAST_MILE_STATUSES, type LastMileApiStatus, type LastMileRecord, + type LastMileVehicle, lastMileService, } from "@/services/last-mile.service"; import { vehiclesService } from "@/services/vehicles.service"; import { driversService, type Driver } from "@/services/drivers.service"; import { ratesService } from "@/services/rates.service"; -import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; -import { api } from "@/auth/http"; +import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; -const formatPrice = (amount: number) => - `ETB ${amount.toLocaleString("en-US", { +const formatPrice = (amount: number | string | null | undefined, currency = "ETB") => + `${currency || "ETB"} ${(Number(amount) || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; @@ -64,6 +73,24 @@ const STATUS_META: Record = DELIVERED: { label: "Delivered", color: "green" }, }; +// Middle-truncate a long invoice number for the table (full value on hover). +// "INV-20260704-00002" → "INV-2…02" +const shortInvoiceNo = (n: string) => + n && n.length > 9 ? `${n.slice(0, 5)}…${n.slice(-2)}` : n; + +// Invoice payment state → badge color, keyed by upper-cased status. +const INVOICE_STATUS_META: Record = { + PAID: { label: "Paid", color: "green" }, + PARTIALLY_PAID: { label: "Partially Paid", color: "teal" }, + PENDING: { label: "Pending", color: "yellow" }, + UNPAID: { label: "Unpaid", color: "yellow" }, + OPEN: { label: "Open", color: "yellow" }, + ISSUED: { label: "Issued", color: "blue" }, + OVERDUE: { label: "Overdue", color: "red" }, + CANCELLED: { label: "Cancelled", color: "gray" }, + VOID: { label: "Void", color: "gray" }, +}; + const NEXT_STATUS: Partial> = { PAYMENT_PENDING: "READY_TO_TRANSIT", READY_TO_TRANSIT: "IN_TRANSIT", @@ -90,9 +117,123 @@ const vehicleLabel = (record: LastMileRecord) => { return parts.join(" · "); }; +/** One vehicle (with trailer) carries two containers. */ +const CONTAINERS_PER_VEHICLE = 2; +const containerCount = (record: LastMileRecord) => + (record.booking?.bookingContainers ?? []).reduce( + (sum, c) => sum + (Number(c.quantity) || 0), + 0, + ); +/** Trucks needed for a booking = ceil(containers / 2). 0 when no container data. */ +const requiredVehicles = (record: LastMileRecord) => { + const n = containerCount(record); + return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0; +}; + +/** Real per-physical-container numbers on a booking, in order. Prefers each + * line's `units` (the actual numbers) over the line-level number (often a + * "TBD-…" placeholder). One entry per physical container, for per-truck prefill. */ +const bookingContainerNumbers = (record: LastMileRecord): string[] => { + const out: string[] = []; + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + for (const c of record.booking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber!); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber!); + } + } + return out; +}; + +/** Container badges for a booking: the container number when known, else the + * type × quantity. */ +const containerLabels = (record: LastMileRecord): string[] => { + const out: string[] = []; + for (const c of record.booking?.bookingContainers ?? []) { + const size = c.containerSize ? ` · ${c.containerSize}` : ""; + if (c.containerNumber) { + out.push(`${c.containerNumber}${size}`); + } else { + const type = + c.containerType?.code ?? + c.containerType?.label ?? + c.containerType?.name ?? + (c.containerSize || "Container"); + out.push(`${type} × ${c.quantity}`); + } + } + return out; +}; + const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); +// Paid = record flag set OR its invoice reached PAID. +const isPaidRecord = (r: LastMileRecord) => + Boolean((r as { paid?: boolean }).paid) || + (r.invoice?.status ?? "").toUpperCase() === "PAID"; +// Post payment pending = a post payment is owed but not yet paid. +const isPostPaymentPending = (r: LastMileRecord) => + Number(r.remainingPayment) > 0 && !isPaidRecord(r); + +const fmtStamp = (iso?: string | null) => { + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d.toLocaleString(); +}; + +/** + * Derive the 6-step last-mile workflow state for a record. Step completion is + * read from the record + its pickup-ready (warehouse release) row: + * assign→vehicleId, arrived→release order issued, leave→releaseDate, + * in-transit/delivered→status, distance→exactKm. + */ +const computeLastMileSteps = ( + record: LastMileRecord, + releaseRow?: ImportUnloadedItem, +): LastMileStepState[] => { + const exactKm = (record as { exactKm?: number | null }).exactKm; + // Truck arrival/leave live in the transient warehouse pickup-ready queue and + // vanish once the item is released. So once the leg is IN_TRANSIT/DELIVERED, + // treat both as done (the truck must have arrived + left to get there). + const past = record.status === "IN_TRANSIT" || record.status === "DELIVERED"; + const flags = [ + record.status !== "PAYMENT_PENDING", + Boolean(record.vehicleId), + past || Boolean(releaseRow?.releaseOrderReference), + past || Boolean(releaseRow?.releaseDate), + past, + exactKm != null, + exactKm != null, // Generate Invoice — auto-generated when distance is saved + record.status === "DELIVERED", + ]; + // Current step = earliest incomplete one. + const activeIdx = flags.findIndex((f) => !f); + const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Generate Invoice", "Delivered"]; + const details: (string | null)[] = [ + null, + record.vehicle?.plateNumber ?? null, + releaseRow?.releaseOrderReference ?? null, + fmtStamp(releaseRow?.releaseDate), + null, + exactKm != null ? `${exactKm} KM` : null, + exactKm != null ? "Invoice ready" : null, + fmtStamp(releaseRow?.deliveredAt), + ]; + return labels.map((label, i) => ({ + label, + done: flags[i], + active: i === activeIdx, + detail: details[i], + })); +}; + const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; +const currencyOf = (r: LastMileRecord) => r.booking?.paymentCurrency ?? "ETB"; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; const cargoDesc = (r: LastMileRecord) => { @@ -195,8 +336,8 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => { {hasDeliveryAddress && } - - + + @@ -209,21 +350,46 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => { ); }; -const tripSlipRows = (record: LastMileRecord): [string, string][] => [ - ["Customer", customerName(record)], - ["Service", serviceTypeName(record)], - ["Pickup (origin yard)", originYardName(record)], - ["Destination", deliveryLocation(record)], - ["Cargo", cargoDesc(record)], - ["Advanced Payment", formatPrice(record.advancedPayment)], - ["Post Payment", formatPrice(record.remainingPayment)], - ["Vehicle", vehicleLabel(record) ?? "Unassigned"], - ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], - ["Requested date", requestedDate(record)], - ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], - ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], - ["Status", STATUS_META[record.status].label], -]; +type TripSlipVehicle = NonNullable[number]; + +const tripSlipRows = ( + record: LastMileRecord, + vehicle?: TripSlipVehicle | null, +): [string, string][] => { + // Per-vehicle block when a specific truck is chosen (its own driver, container(s) + // and distance); else fall back to the record-level vehicle summary. + const vehicleRows: [string, string][] = vehicle + ? [ + [ + "Vehicle", + vehicle.vehicle + ? [vehicle.vehicle.code, vehicle.vehicle.plateNumber].filter(Boolean).join(" · ") + : vehicle.vehicleId, + ], + ["Driver", vehicle.vehicle?.assignedDriverName || "—"], + [ + "Container(s)", + vehicle.containerNumber || bookingContainerNumbers(record).join(", ") || "—", + ], + ["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"], + ] + : [ + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], + ]; + return [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Pickup (origin yard)", originYardName(record)], + ["Destination", deliveryLocation(record)], + ["Cargo", cargoDesc(record)], + ["Post Payment", formatPrice(record.remainingPayment, currencyOf(record))], + ...vehicleRows, + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Status", STATUS_META[record.status].label], + ]; +}; const SampleStamp = () => ( @@ -281,7 +447,13 @@ const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) ); -const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( +const TripSlipDocument = ({ + record, + vehicle, +}: { + record: LastMileRecord; + vehicle?: TripSlipVehicle | null; +}) => ( EDR Freight @@ -293,7 +465,7 @@ const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( - {tripSlipRows(record).map(([label, value]) => ( + {tripSlipRows(record, vehicle).map(([label, value]) => ( ))} @@ -308,8 +480,8 @@ const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( const escapeHtml = (v: string) => v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (record: LastMileRecord) => { - const rows = tripSlipRows(record) +const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | null) => { + const rows = tripSlipRows(record, vehicle) .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); const sig = (title: string, withStamp: boolean) => ` @@ -343,7 +515,7 @@ const buildTripSlipHtml = (record: LastMileRecord) => { .ring-inner span { font-size: 9px; font-weight: 700; letter-spacing: 1px; } .ring-inner strong { font-size: 13px; font-weight: 800; } - +

EDR Freight

Last Mile Trip Slip

${escapeHtml(bookingRef(record))}${escapeHtml(requestedDate(record))}
${rows}
@@ -355,6 +527,7 @@ const buildTripSlipHtml = (record: LastMileRecord) => { const LastMilePage = () => { const { toast } = useToast(); const qc = useQueryClient(); + const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); @@ -367,23 +540,28 @@ const LastMilePage = () => { const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); + // Which vehicle the trip slip is for (per-truck), + the pre-print picker. + const [tripSlipVehicleId, setTripSlipVehicleId] = useState(null); + const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(null); - const [vehicleValue, setVehicleValue] = useState(null); + // Multi-vehicle assign: one row per truck — vehicle + the container it carries. + const [vehicleRows, setVehicleRows] = useState< + Array<{ vehicleId: string | null; containerNumber: string }> + >([{ vehicleId: null, containerNumber: "" }]); // 2-step "Assign Mile" accept modal (arrival queue → vehicle) const [acceptOpen, setAcceptOpen] = useState(false); const [acceptStep, setAcceptStep] = useState<1 | 2>(1); const [selectedArrivalItems, setSelectedArrivalItems] = useState([]); - const [acceptVehicleValue, setAcceptVehicleValue] = useState(null); + const [acceptVehicleValues, setAcceptVehicleValues] = useState([]); const [arrivalSearch, setArrivalSearch] = useState(""); const [distanceOpen, setDistanceOpen] = useState(false); - const [distanceValue, setDistanceValue] = useState(""); - const [invoiceOpen, setInvoiceOpen] = useState(false); - const [invoiceRecord, setInvoiceRecord] = useState(null); + // Per-vehicle actual distance, keyed by vehicleId. + const [distanceRows, setDistanceRows] = useState>({}); + // Record pending invoice-generation confirmation (shows a summary first). + const [invoiceConfirm, setInvoiceConfirm] = useState(null); - const [allocationOpen, setAllocationOpen] = useState(false); - const [allocationContainers, setAllocationContainers] = useState([]); const [releaseItem, setReleaseItem] = useState(null); const [releaseTruckPrefill, setReleaseTruckPrefill] = useState(null); @@ -462,14 +640,36 @@ const LastMilePage = () => { }, }); - const updateDistanceMutation = useMutation({ - mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => - lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), + const setVehiclesMutation = useMutation({ + mutationFn: ({ + id, + vehicles, + }: { + id: string; + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>; + }) => lastMileService.setVehicles(id, vehicles), onSuccess: () => { - void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() }); - if (activeRecord) { - toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); - } + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); + }, + onError: () => { + toast({ title: "Assign failed", variant: "destructive" }); + }, + }); + + const distanceMutation = useMutation({ + mutationFn: ({ + id, + distances, + remainingPayment, + }: { + id: string; + distances: Array<{ vehicleId: string; distanceKm: number }>; + remainingPayment?: number; + }) => lastMileService.setDistances(id, distances, remainingPayment), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); closeDistance(); }, onError: () => { @@ -477,6 +677,17 @@ const LastMilePage = () => { }, }); + const generateInvoiceMutation = useMutation({ + mutationFn: (id: string) => lastMileService.generateInvoice(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + toast({ title: "Invoice generated" }); + }, + onError: () => { + toast({ title: "Invoice generation failed", variant: "destructive" }); + }, + }); + const deleteMutation = useMutation({ mutationFn: (id: string) => lastMileService.remove(id), onSuccess: () => { @@ -488,19 +699,6 @@ const LastMilePage = () => { }, }); - const allocateMutation = useMutation({ - mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) => - api.post(`/last-mile/${activeId}/allocate-containers`, data), - onSuccess: () => { - toast({ title: "Containers allocated", variant: "default" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.byId(activeId ?? "") }); - void qc.invalidateQueries({ queryKey: ["vehicles"] }); - closeAllocation(); - }, - onError: () => { - toast({ title: "Allocation failed", variant: "destructive" }); - }, - }); const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({ queryKey: ["warehouse-inventory", "arrival-queue"], @@ -522,12 +720,13 @@ const LastMilePage = () => { }, [arrivalQueue, arrivalSearch, existingLastMileBookingIds]); const acceptMutation = useMutation({ - mutationFn: async ({ items, vehicleId }: { items: ArrivalQueueItem[]; vehicleId: string | null }) => { + mutationFn: async ({ items, vehicleIds }: { items: ArrivalQueueItem[]; vehicleIds: string[] }) => { const created = await Promise.all( items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)), ); - if (vehicleId) { - await Promise.all(created.map((record) => lastMileService.update(record.id, { vehicleId }))); + if (vehicleIds.length) { + const vehicles = vehicleIds.map((v) => ({ vehicleId: v })); + await Promise.all(created.map((record) => lastMileService.setVehicles(record.id, vehicles))); } return created; }, @@ -549,7 +748,7 @@ const LastMilePage = () => { setAcceptOpen(true); setAcceptStep(1); setSelectedArrivalItems([]); - setAcceptVehicleValue(null); + setAcceptVehicleValues([]); setArrivalSearch(""); }; @@ -557,7 +756,7 @@ const LastMilePage = () => { setAcceptOpen(false); setAcceptStep(1); setSelectedArrivalItems([]); - setAcceptVehicleValue(null); + setAcceptVehicleValues([]); setArrivalSearch(""); }; @@ -571,62 +770,50 @@ const LastMilePage = () => { const handleAcceptConfirm = () => { if (!selectedArrivalItems.length) return; - acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue }); + acceptMutation.mutate({ items: selectedArrivalItems, vehicleIds: acceptVehicleValues }); }; const openDistance = (id: string) => { + const rec = records.find((r) => r.id === id); + const rows: Record = {}; + for (const a of rec?.vehicleAssignments ?? []) { + rows[a.vehicleId] = a.distanceKm != null ? String(a.distanceKm) : ""; + } setActiveId(id); - setDistanceValue(""); + setDistanceRows(rows); setDistanceOpen(true); }; const closeDistance = () => { setDistanceOpen(false); setActiveId(null); - setDistanceValue(""); + setDistanceRows({}); }; - const openInvoice = (record: LastMileRecord) => { - setInvoiceRecord(record); - setInvoiceOpen(true); - }; - const closeInvoice = () => { - setInvoiceOpen(false); - setInvoiceRecord(null); - }; - - const openAllocation = (id: string, containers?: LastMileContainerRow[]) => { - setActiveId(id); - setAllocationContainers(containers ?? []); - setAllocationOpen(true); - }; - - const closeAllocation = () => { - setAllocationOpen(false); - setActiveId(null); - setAllocationContainers([]); - }; const handleSaveDistance = () => { - const distance = parseFloat(distanceValue); - if (!activeId || isNaN(distance) || distance < 0) { - toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); + const distances = Object.entries(distanceRows) + .map(([vehicleId, val]) => ({ vehicleId, distanceKm: parseFloat(val) })) + .filter((d) => !Number.isNaN(d.distanceKm) && d.distanceKm >= 0); + + if (!activeId || !distances.length) { + toast({ title: "Invalid distance", description: "Enter a distance for at least one vehicle.", variant: "destructive" }); return; } + const total = distances.reduce((s, d) => s + d.distanceKm, 0); let remainingPayment: number | undefined; if (ratesData?.data) { const lastMileRate = ratesData.data.find( (r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") ); if (lastMileRate) { - const rateValue = parseFloat(lastMileRate.rateValue); - remainingPayment = distance * rateValue; + remainingPayment = total * parseFloat(lastMileRate.rateValue); } } - updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); + distanceMutation.mutate({ id: activeId, distances, remainingPayment }); }; const activeRecord = useMemo( @@ -634,6 +821,68 @@ const LastMilePage = () => { [records, activeId], ); + // Vehicle picker options for the assign modal = free vehicles PLUS the ones + // already on this record (which are BUSY, so absent from the free list) so a + // reassign shows its current trucks selected instead of blank. + const assignVehicleOptions = useMemo(() => { + const opts = [...vehicleOptions]; + const seen = new Set(opts.map((o) => o.value)); + const pushVehicle = (v?: LastMileVehicle | null) => { + if (v && !seen.has(v.id)) { + seen.add(v.id); + const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber]; + if (v.code) parts.unshift(v.code); + opts.push({ value: v.id, label: parts.join(" · ") }); + } + }; + for (const a of activeRecord?.vehicleAssignments ?? []) pushVehicle(a.vehicle); + pushVehicle(activeRecord?.vehicle); + // Fallback: an assigned vehicle whose relation didn't load still needs an + // option so the reassign Select can render it as selected (not blank). + for (const a of activeRecord?.vehicleAssignments ?? []) { + if (!seen.has(a.vehicleId)) { + seen.add(a.vehicleId); + opts.push({ + value: a.vehicleId, + label: a.containerNumber ? `Assigned · ${a.containerNumber}` : "Assigned vehicle", + }); + } + } + if (activeRecord?.vehicleId && !seen.has(activeRecord.vehicleId)) { + opts.push({ value: activeRecord.vehicleId, label: "Assigned vehicle" }); + } + return opts; + }, [vehicleOptions, activeRecord]); + + // Full booking (with container units) for the assign modal's container dropdown. + // Fetched on open so container numbers show regardless of what the list embeds. + const { data: assignBooking } = useQuery({ + queryKey: activeRecord?.bookingId + ? QUERY_KEYS.BOOKINGS.byId(activeRecord.bookingId) + : ["bookings", "detail", "none"], + queryFn: () => bookingsService.getById(activeRecord!.bookingId), + enabled: assignOpen && !bulkMode && Boolean(activeRecord?.bookingId), + }); + + // Container-number options for the dropdown = the booking's real per-container + // numbers (units), falling back to whatever the list record carried. + const containerOptions = useMemo(() => { + const real = (n?: string | null): n is string => + Boolean(n) && !/^TBD/i.test(n!.trim()); + const out: string[] = []; + for (const c of assignBooking?.bookingContainers ?? []) { + const units = [...(c.units ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + if (units.length) { + for (const u of units) if (real(u.containerNumber)) out.push(u.containerNumber); + } else if (real(c.containerNumber)) { + out.push(c.containerNumber); + } + } + return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : []; + }, [assignBooking, activeRecord]); + const pickupReadyByBooking = useMemo(() => { const map = new Map(); for (const row of pickupReadyRows) { @@ -675,11 +924,16 @@ const LastMilePage = () => { return counts; }, [records]); + const postPaymentPendingCount = useMemo( + () => records.filter(isPostPaymentPending).length, + [records], + ); + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); return records.filter((r) => { if (!matchesFilter(r)) return false; - if (filterPostPaymentPending && !(r.remainingPayment > 0)) return false; + if (filterPostPaymentPending && !isPostPaymentPending(r)) return false; if (!term) return true; return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)] .join(" ") @@ -697,16 +951,29 @@ const LastMilePage = () => { const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; + const rec = records.find((r) => r.id === resolved); + // Prefill each row's container number from the booking's container numbers + // (by order) when the assignment doesn't already carry one. + const nums = rec ? bookingContainerNumbers(rec) : []; + const rows = + rec?.vehicleAssignments?.length + ? rec.vehicleAssignments.map((a, i) => ({ + vehicleId: a.vehicleId, + containerNumber: a.containerNumber ?? nums[i] ?? "", + })) + : rec?.vehicleId + ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }] + : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]; setBulkMode(false); setActiveId(resolved); - setVehicleValue(null); + setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleValue(null); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); setAssignOpen(true); }; @@ -714,28 +981,31 @@ const LastMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleValue(null); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); }; const handleAssign = () => { - if (!vehicleValue) { - toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); - return; - } - + const seen = new Set(); + const vehicles = vehicleRows + .filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId)) + .filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId))) + .map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null })); + const count = vehicles.length; const targetIds = bulkMode ? selectedIds : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); if (!targetIds.length) return; - const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; - - Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) + // Empty set = unassign all (setVehicles releases the removed vehicles). + Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { toast({ - title: "Vehicle assigned", - description: bulkMode ? `${targetIds.length} deliveries → ${selectedLabel}` : selectedLabel, + title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned", + description: + count === 0 + ? bulkMode ? `${targetIds.length} deliveries` : undefined + : `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`, }); if (bulkMode) setRowSelection({}); closeAssign(); @@ -756,7 +1026,21 @@ const LastMilePage = () => { }; const handlePrintTripSlip = (record: LastMileRecord) => { + // Always open the picker so the operator chooses which truck to print. setTripSlipRecord(record); + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + }; + + const printBookingSlip = () => { + setTripSlipVehicleId(null); + setTripSlipSelectOpen(false); + setTripSlipOpen(true); + }; + + const chooseTripSlipVehicle = (vehicleId: string) => { + setTripSlipVehicleId(vehicleId); + setTripSlipSelectOpen(false); setTripSlipOpen(true); }; @@ -784,6 +1068,15 @@ const LastMilePage = () => { setReleaseItem(toReleaseInventoryItem(row)); }; + // Truck leaving the warehouse = the leg is now in transit. Advance the status + // (same as "Mark In Transit") alongside the warehouse exit-weighing flow. + const handleTruckLeaving = (record: LastMileRecord) => { + openTruckArrival(record); + if (record.status === "READY_TO_TRANSIT") { + updateMutation.mutate({ id: record.id, data: { status: "IN_TRANSIT" } }); + } + }; + const closeTruckArrival = () => { setReleaseItem(null); setReleaseTruckPrefill(null); @@ -791,6 +1084,9 @@ const LastMilePage = () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); }; + const tripSlipVehicle = + tripSlipRecord?.vehicleAssignments?.find((a) => a.vehicleId === tripSlipVehicleId) ?? null; + const printTripSlip = () => { if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); @@ -798,8 +1094,17 @@ const LastMilePage = () => { toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipRecord)); + win.document.write(buildTripSlipHtml(tripSlipRecord, tripSlipVehicle)); win.document.close(); + win.focus(); + // Explicit print after the doc paints (onload can miss with document.write). + setTimeout(() => { + try { + win.print(); + } catch { + /* window may have been closed */ + } + }, 250); }; const columns = useMemo((): ColumnDef[] => { @@ -839,47 +1144,49 @@ const LastMilePage = () => { meta: { headerClassName, cellClassName }, cell: ({ row }) => customerName(row.original), }, - { - id: "pickup", - header: "Pickup", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => originYardName(row.original), - }, - { - id: "destination", - header: "Destination", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => deliveryLocation(row.original), - }, - { - id: "cargo", - header: "Cargo", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => cargoDesc(row.original), - }, - { - id: "advancedPayment", - header: "Advanced Payment", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.advancedPayment), - }, { id: "postPayment", header: "Post Payment", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.remainingPayment), + cell: ({ row }) => formatPrice(row.original.remainingPayment, currencyOf(row.original)), }, { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => vehicleLabel(row.original) ?? , - }, - { - id: "estimatedKm", - header: "Est. Distance (KM)", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : , + cell: ({ row }) => { + const assigns = row.original.vehicleAssignments ?? []; + if (assigns.length > 1) { + const labelFor = (a: (typeof assigns)[number]) => { + const v = a.vehicle; + const l = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return a.containerNumber ? `${l} · ${a.containerNumber}` : l; + }; + return ( + + {assigns.map(labelFor).join("\n")} +
+ } + > + + + {assigns[0].vehicle + ? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ") + : assigns[0].vehicleId} + + + +{assigns.length - 1} + + + + ); + } + return vehicleLabel(row.original) ?? Unassigned; + }, }, { id: "exactKm", @@ -892,35 +1199,32 @@ const LastMilePage = () => { header: "Invoice", meta: { headerClassName, cellClassName }, cell: ({ row }) => { - const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; - const isPaid = (row.original as any).paid; - if (!hasDistance) { + // Only show an invoice once it's actually been generated — NOT merely + // because distance was entered. + const invoice = row.original.invoice; + if (!invoice) { return ; } - if (isPaid) { - return ( - - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - - Paid - - ); - } + const status = String((row.original as any).paid ? "PAID" : invoice.status || "").toUpperCase(); + const badge = INVOICE_STATUS_META[status] ?? { color: "gray", label: status || "—" }; return ( - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - + + + invoice.id + ? navigate(`/dashboard/invoices/${invoice.id}`) + : toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" }) + } + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + + {shortInvoiceNo(invoice.number)} + + + {status && {badge.label}} + ); }, }, @@ -933,16 +1237,6 @@ const LastMilePage = () => { return {meta.label}; }, }, - { - id: "assignment", - header: "Assignment", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - {isAssigned(row.original) ? "Assigned" : "Unassigned"} - - ), - }, { id: "actions", header: "Actions", @@ -955,10 +1249,33 @@ const LastMilePage = () => { const delivered = row.original.status === "DELIVERED"; const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); - const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival"; + // Gate on PERSISTENT state (status/vehicle/distance), not the truck + // arrival/leave signals — those live in the warehouse queue and vanish + // once the item is released, so they can't gate the status advance. + const status = row.original.status; + const hasDistance = row.original.exactKm != null; + // Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a + // vehicle), IN_TRANSIT→Delivered (needs distance/invoice). + const canAdvance = + status === "PAYMENT_PENDING" || + (status === "READY_TO_TRANSIT" && assigned) || + (status === "IN_TRANSIT" && hasDistance); + const canAssignStep = !assigned && status !== "DELIVERED"; + const canDistance = status === "IN_TRANSIT"; + // Truck arrival/leaving are independent — each driven by its own + // warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED. + const pastTransit = status === "IN_TRANSIT" || status === "DELIVERED"; + const canArrive = assigned && !releaseRow?.releaseOrderReference && !pastTransit; + const canLeave = + Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit; return ( - + @@ -967,32 +1284,57 @@ const LastMilePage = () => { } - disabled={!nextStatus} + disabled={!nextStatus || !canAdvance} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} } - disabled={assigned || delivered} + disabled={!canAssignStep} onClick={() => openAssign(row.original.id)} > Assign } - disabled={!assigned || delivered} + disabled={!assigned || delivered || Boolean(row.original.invoice)} onClick={() => openAssign(row.original.id)} > Reassign + } + disabled={!assigned || delivered || Boolean(row.original.invoice)} + onClick={() => + setVehiclesMutation.mutate( + { id: row.original.id, vehicles: [] }, + { + onSuccess: () => + toast({ title: "Vehicles unassigned", description: bookingRef(row.original) }), + }, + ) + } + > + Unassign + } - disabled={!assigned} + disabled={!canArrive} onClick={() => openTruckArrival(row.original)} > - {truckArrivalLabel} + Truck Arrival + + } + disabled={!canLeave} + onClick={() => handleTruckLeaving(row.original)} + > + Truck Leaving { } - disabled={delivered} + disabled={!canDistance || Boolean(row.original.invoice)} onClick={() => openDistance(row.original.id)} > Add distance + } + disabled={ + !(row.original.exactKm != null && row.original.exactKm > 0) || + Boolean(row.original.invoice) + } + onClick={() => setInvoiceConfirm(row.original)} + > + {row.original.invoice ? "Invoice generated" : "Generate Invoice"} + {canPrint && ( } @@ -1022,7 +1374,7 @@ const LastMilePage = () => { } color="red" - disabled={delivered} + disabled={delivered || Boolean(row.original.invoice)} onClick={() => { if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) { deleteMutation.mutate(row.original.id); @@ -1044,7 +1396,7 @@ const LastMilePage = () => { }, [vehicleOptions, pickupReadyByBooking]); return ( - + @@ -1094,7 +1446,7 @@ const LastMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - Post Payment Pending + Post Payment Pending ({postPaymentPendingCount}) @@ -1228,19 +1580,26 @@ const LastMilePage = () => { - { + const containers = containerCount(activeRecord); + const needed = requiredVehicles(activeRecord); + const picked = vehicleRows.filter((r) => r.vehicleId).length; + if (needed === 0) { + return ( + + No container count on this booking — assign trucks as needed. + + ); } - data={vehicleOptions} - value={vehicleValue} - onChange={setVehicleValue} - searchable - disabled={vehicleOptions.length === 0} - /> + const ok = picked === needed; + return ( + + One truck (with trailer) carries {CONTAINERS_PER_VEHICLE} containers. + {picked > 0 && !ok && + ` You've selected ${picked} — ${picked < needed ? "add more" : "that's more than needed"}.`} + + ); + })()} + {!bulkMode && activeRecord && containerLabels(activeRecord).length > 0 && ( + + + Containers ({containerLabels(activeRecord).length}) + + + {containerLabels(activeRecord).map((label, i) => ( + + {label} + + ))} + + + )} + + + {vehicleRows.map((row, i) => ( + + setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" + + setIdentifier(event.target.value)} + /> + +
+
+ Password + + Forgot password? + +
+ setPassword(event.target.value)} />
-
-
- - - Forgot password? - -
-
- setPassword(event.target.value)} - placeholder="Enter your password" - disabled={loading} - className={`${fieldClass} pr-11`} - /> - -
-
- {error ? ( -
+ }> {error} -
+ ) : null} - +

Don't have an account?{" "} @@ -129,7 +115,7 @@ export default function LoginPage() { Create an account

-
+ ); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 50320f699..1b13f7cfc 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -34,14 +34,15 @@ import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; import { extractApiError } from "@/utils/result"; -const EDR_LOGO = "/assets/edr-logo.png"; - const passwordRequirements = [ { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, { label: "One number", test: (v: string) => /\d/.test(v) }, - { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, + { + label: "One special character", + test: (v: string) => /[^A-Za-z0-9]/.test(v), + }, ] as const; const userSchema = z @@ -52,8 +53,14 @@ const userSchema = z .min(1, "Phone number is required") .refine(isValidPhone, "Enter a valid phone number"), userType: z.string(), - firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), - lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), + firstName: z.object({ + en: z.string().min(2, "Name is required"), + am: z.string().nullable(), + }), + lastName: z.object({ + en: z.string().min(2, "Name is required"), + am: z.string().nullable(), + }), password: z .string() .min(8, "Password must be at least 8 characters") @@ -132,12 +139,30 @@ export default function SignupPage() { const passwordValue = watch("password") ?? ""; - // Step 1 — form is valid: send a fresh code to the chosen channel, then - // move to the OTP challenge. + // Step 1 — form is valid: make sure the email/phone aren't already + // registered, then send a fresh code to the chosen channel and move to + // the OTP challenge. const requestOtp = async (data: FormData) => { setError(null); setSending(true); try { + const availability = await api.auth.checkAvailability.call({ + email: data.email, + phone: data.phone, + }); + if (availability.emailTaken && availability.phoneTaken) { + setError("An account with this email and phone number already exists."); + return; + } + if (availability.emailTaken) { + setError("An account with this email already exists."); + return; + } + if (availability.phoneTaken) { + setError("An account with this phone number already exists."); + return; + } + await api.auth.sendOTP.call( channel === "email" ? { email: data.email } : { phone: data.phone }, ); @@ -217,242 +242,270 @@ export default function SignupPage() { return ( -
-
- EDR Freight -
- - {stage === "form" ? ( -
-
-

- Create account -

-

- Register to access EDR Freight services. +

+ { stage === "form" ? ( + +
+

+ Create account +

+ < p className = "text-sm leading-relaxed text-gray-500" > + Register to access EDR Freight services.

-
+
- - - + + - - + - - + < ControlledPhoneField + control = { control } + name = "phone" + label = "Phone" + required + disabled = { sending } + /> -
- - Send verification code via - - setChannel(v as OtpChannel)} - data={[ - { - value: "phone", - label: ( - - Phone - +
+ + Send verification code via + + < SegmentedControl + fullWidth + disabled = { sending } + value = { channel } + onChange = {(v) => setChannel(v as OtpChannel) +} +data = { + [ + { + value: "phone", + label: ( + + Phone + ), - }, - { - value: "email", - label: ( - - Email - +}, +{ + value: "email", + label: ( + + Email + ), }, ]} /> -
+
-
- + - {passwordValue.length > 0 ? ( -
- {passwordRequirements.map((req) => { - const met = req.test(passwordValue); - return ( -
- 0 ? ( +
+ { + passwordRequirements.map((req) => { + const met = req.test(passwordValue); + return ( +
+ - {met ? : } - - - {req.label} - -
+ { + met?( + + ): ( + + ) + } + + < span + className = {`text-xs ${met ? "text-primary" : "text-gray-500"}` +} + > + { req.label } + +
); })} -
+
) : null} -
+
- - {error ? ( - }> - {error} - +{ + error ? ( + } + > + { error } + ) : null} - + Continue + -

- Already have an account?{" "} - -

- - + < p className = "text-center text-sm text-gray-500" > + Already have an account ? { " "} + < button + type = "button" +onClick = {() => navigate("/login")} +className = "font-semibold text-primary hover:underline" + > + Sign In + +

+ + ) : ( - -
- - - -
-
-

- Verify your {otpChannel === "email" ? "email" : "phone"} -

-

- We sent a 6-digit code to{" "} - - {otpChannel === "email" - ? maskEmail(pendingData?.email ?? "") - : maskPhone(pendingData?.phone ?? "")} - - . Enter it to finish creating your account. + +

+ + + +
+ < div className = "space-y-1.5 text-center" > +

+ Verify your { otpChannel === "email" ? "email" : "phone" } +

+ < p className = "text-sm leading-relaxed text-gray-500" > + We sent a 6 - digit code to{ " " } + + { otpChannel === "email" + ? maskEmail(pendingData?.email ?? "") + : maskPhone(pendingData?.phone ?? "")} + + .Enter it to finish creating your account.

-
+
- {otpError ? ( - }> - {otpError} - +{ + otpError ? ( + } + > + { otpError } + ) : null} - - - Verification code - - - + + + Verification code + + < PinInput +length = { 6} +type = "number" +oneTimeCode +value = { otpCode } +placeholder = "0" +disabled = { verifying } +styles = {{ input: { textAlign: "center" } }} +onChange = { setOtpCode } + /> + - + < Button +color = "edr-green" +fullWidth +loading = { verifying } +disabled = { verifying || otpCode.trim().length !== 6} +onClick = { confirmOtp } + > + Verify & amp; create account + -
- - -
- + Back + + < Button +variant = "subtle" +color = "edr-green" +leftSection = {< RotateCw size = { 14} />} +disabled = { resendIn > 0 || sending || verifying} +onClick = { resendOtp } + > + { resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"} + + + )} - -
+ + ); } 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/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 37e65ab2b..65af584d3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -1,15 +1,30 @@ -import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core"; -import { useMutation } from "@tanstack/react-query"; +import { + ActionIcon, + Alert, + Badge, + Button, + Divider, + Group, + Loader, + MultiSelect, + Select, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; -import { Download, Lock, Truck } from "lucide-react"; +import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react"; import { useState } from "react"; +import toast from "react-hot-toast"; import { api } from "@/services/api"; +import { customerTrucksService } from "@/services/customer-trucks.service"; import { CardTitle, SectionCard } from "./layout"; const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"]; -const ISO_CONTAINER_PATTERN = /^[A-Z]{4}\d{7}$/; const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); @@ -22,6 +37,13 @@ const downloadBlob = (blob: Blob, filename: string) => { URL.revokeObjectURL(url); }; +const errorMessage = (error: unknown, fallback: string) => { + const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data; + if (Array.isArray(data?.message)) return data.message.join(", "); + if (data?.message) return data.message; + return error instanceof Error ? error.message : fallback; +}; + export function CustomerTruckAssignmentCard({ booking, onAssigned, @@ -29,43 +51,86 @@ export function CustomerTruckAssignmentCard({ booking: Freight.IBooking; onAssigned: () => void; }) { - const assigned = Boolean(booking.customerTruckAssignedAt); - const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? ""); - const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? ""); - const [truckType, setTruckType] = useState(booking.customerTruckType ?? ""); - const [containerNumberToLoad, setContainerNumberToLoad] = useState( - booking.customerTruckContainerNumber ?? "", - ); + const queryClient = useQueryClient(); + const trucksKey = ["customer-trucks", booking.id]; + + const { data: trucks = [], isLoading } = useQuery({ + queryKey: trucksKey, + queryFn: () => customerTrucksService.list(booking.id), + }); + + const [plateNumber, setPlateNumber] = useState(""); + const [driverName, setDriverName] = useState(""); + const [truckType, setTruckType] = useState(""); + const [containers, setContainers] = useState([]); const [error, setError] = useState(null); - const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions()); - const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); + // Container numbers on the booking that aren't already loaded onto a truck. + const assignedNumbers = new Set( + trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)), + ); + const availableContainers = (booking.containerNumbers ?? []).filter( + (n) => !assignedNumbers.has(n), + ); - const submit = async () => { - const payload = { - truckPlateNumber: truckPlateNumber.trim().toUpperCase(), - driverName: driverName.trim(), - truckType: truckType.trim(), - containerNumberToLoad: containerNumberToLoad.trim().toUpperCase(), - }; - if (!payload.truckPlateNumber || !payload.driverName || !payload.truckType || !payload.containerNumberToLoad) { - setError("All truck assignment fields are required."); - return; - } - if (!ISO_CONTAINER_PATTERN.test(payload.containerNumberToLoad)) { - setError("Container number must match ISO format, e.g. ABCD1234567."); - return; - } + // EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't — + // staff register + weigh what was loaded when the truck leaves. + const isExport = booking.tradeDirection === "EXPORT"; + + const resetForm = () => { + setPlateNumber(""); + setDriverName(""); + setTruckType(""); + setContainers([]); setError(null); - await assignMutation.mutateAsync({ id: booking.id, payload }); - onAssigned(); }; + const addMutation = useMutation({ + mutationFn: () => + customerTrucksService.add(booking.id, { + truckPlateNumber: plateNumber.trim().toUpperCase(), + driverName: driverName.trim(), + truckType: truckType.trim(), + // Import: containers are registered + weighed on departure, not here. + containerNumbers: isExport ? containers : [], + }), + onSuccess: (list) => { + queryClient.setQueryData(trucksKey, list); + resetForm(); + onAssigned(); + toast.success("Truck added"); + }, + onError: (e) => setError(errorMessage(e, "Could not add truck")), + }); + + const removeMutation = useMutation({ + mutationFn: (assignmentId: string) => customerTrucksService.remove(booking.id, assignmentId), + onSuccess: (list) => { + queryClient.setQueryData(trucksKey, list); + onAssigned(); + }, + onError: (e) => toast.error(errorMessage(e, "Could not remove truck")), + }); + + const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); const downloadFreightOrder = async () => { const blob = await downloadMutation.mutateAsync({ id: booking.id }); downloadBlob(blob, `freight-order-${booking.reference}.pdf`); }; + const submitAdd = () => { + if (!plateNumber.trim() || !driverName.trim() || !truckType.trim()) { + setError("Plate number, driver name and truck type are required."); + return; + } + if (isExport && (containers.length < 1 || containers.length > 2)) { + setError("Select 1 or 2 container numbers for this truck."); + return; + } + setError(null); + addMutation.mutate(); + }; + return ( @@ -74,64 +139,135 @@ export function CustomerTruckAssignmentCard({ External Truck Assignment - {assigned && ( - - - - Truck Assigned - - + {trucks.length > 0 && ( + + {trucks.length} truck{trucks.length !== 1 ? "s" : ""} + )} + {/* Assigned trucks */} + {isLoading ? ( + + + + ) : ( + trucks.map((t) => ( + + + + + {t.plateNumber} + + {t.arrivedAt ? ( + }> + Arrived + + ) : ( + }> + Awaiting arrival + + )} + + + {t.driverName} · {t.truckType} + + + {(t.containers ?? []).map((c) => ( + + {c.containerNumber} + + ))} + + + {!t.arrivedAt && ( + removeMutation.mutate(t.id)} + loading={removeMutation.isPending} + > + + + )} + + )) + )} + {error && ( {error} )} - {assignMutation.isError && ( - - {assignMutation.error instanceof Error - ? assignMutation.error.message - : "Truck assignment failed."} - + + {/* Add-truck form. Export needs unassigned containers; import always allows another truck. */} + {(isExport ? availableContainers.length > 0 : true) ? ( + <> + + + setPlateNumber(e.currentTarget.value.toUpperCase())} + /> + setDriverName(e.currentTarget.value)} + /> + setTruckType(value ?? "")} - disabled={assigned} - /> - setContainerNumberToLoad(e.currentTarget.value.toUpperCase())} - readOnly={assigned} - /> - - - - {assigned ? ( + {trucks.length > 0 && ( + - ) : ( - - )} - + + )} ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index 146e31c1e..acd4dbb25 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -1,16 +1,24 @@ -import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; -import { Download, Receipt } from "lucide-react"; +import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { CreditCard, Download, Receipt } from "lucide-react"; +import { useState } from "react"; import toast from "react-hot-toast"; +import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import { warehouseInvoicesService, type PortalWarehouseInvoice, } from "@/services/warehouse-invoices.service"; import { saveBlob } from "@/utils/download"; +import { PaymentMethodModal } from "./PaymentMethodModal"; import { CardTitle, SectionCard } from "./layout"; +/** Warehouse fee invoices the customer can still settle online. */ +const PAYABLE_STATUSES = new Set(["ISSUED", "PARTIALLY_PAID"]); +const isPayable = (inv: PortalWarehouseInvoice) => + PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0; + const money = (amount: number | string | null | undefined, currency: string) => `${Number(amount ?? 0).toLocaleString()} ${currency}`; @@ -44,10 +52,12 @@ function StatusPill({ status }: { status: string }) { } /** - * Warehouse fee invoices linked to this booking — display + PDF download only. - * Paying them online is tracked separately (in-system demurrage/storage - * payment). Renders nothing when the booking has no warehouse fees. Carries - * `id="warehouse-payments"` so the invoice detail page can deep-link here. + * Warehouse fee invoices linked to this booking. Customers can pay outstanding + * demurrage/storage invoices online (Telebirr/Waafi) so they can then sign the + * delivery handover; paid invoices expose the receipt PDF. The backoffice cash + * `/pay` (record-a-payment) path is unaffected. Renders nothing when the booking + * has no warehouse fees. Carries `id="warehouse-payments"` so the invoice detail + * page can deep-link here. */ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { const { data: invoices = [] } = useQuery({ @@ -55,6 +65,41 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { queryFn: () => warehouseInvoicesService.listForBooking(bookingId), }); + const [payInvoice, setPayInvoice] = useState(null); + + const payMutation = useMutation({ + mutationFn: (method: PaymentMethod) => { + if (!payInvoice) throw new Error("No invoice selected for payment."); + return warehouseInvoicesService.payOnline(payInvoice.id, { + method, + platform: "web", + }); + }, + onSuccess: (data, method) => { + if (!payInvoice) return; + // Redirect to the provider (or the fallback checkout page) — same as the + // booking "Pay now" flow, so behaviour is identical everywhere. + const redirectUrl = + data?.clientAction?.type === "REDIRECT" && data.clientAction.url + ? data.clientAction.url + : paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method }); + window.location.href = redirectUrl; + }, + }); + + const payError = payMutation.isError + ? payMutation.error instanceof Error + ? payMutation.error.message + : "Could not start payment. Please try again." + : null; + + const closePayModal = () => { + if (!payMutation.isPending) { + setPayInvoice(null); + payMutation.reset(); + } + }; + if (invoices.length === 0) return null; const download = async (inv: PortalWarehouseInvoice) => { @@ -128,6 +173,17 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { + {isPayable(inv) && ( + + )} + + payMutation.mutate(method)} + processing={payMutation.isPending} + error={payError} + /> ); } 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..932f6fc46 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -66,6 +66,8 @@ import type { import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { AuthUser, + CheckAvailabilityPayload, + CheckAvailabilityResponse, GenerateVerificationCodePayload, LoginPayload, LoginResponse, @@ -107,6 +109,11 @@ export const api = { "setPassword", authService.setPassword, ), + checkAvailability: endpoint( + "auth", + "checkAvailability", + authService.checkAvailability, + ), sendOTP: endpoint( "auth", "sendOTP", @@ -376,6 +383,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/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index e1de1889a..3f9ef4e53 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -1,14 +1,16 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import type { - AuthUser, - GenerateVerificationCodePayload, - LoginPayload, - LoginResponse, - OtpPayload, - OtpResponse, - SetPasswordPayload, - SignupPayload, - SignupResponse, + AuthUser, + CheckAvailabilityPayload, + CheckAvailabilityResponse, + GenerateVerificationCodePayload, + LoginPayload, + LoginResponse, + OtpPayload, + OtpResponse, + SetPasswordPayload, + SignupPayload, + SignupResponse, } from "@/types/auth"; import { client } from "@/utils/api"; import { ApiResponse } from "@edr/types"; @@ -23,7 +25,7 @@ export const authService = { }, createUser: async (body: SignupPayload) => { - const res = await client.post> ( + const res = await client.post>( URL_CONSTANTS.USERS.SIGN_UP, body, ); @@ -31,9 +33,7 @@ export const authService = { }, getMyInfo: async () => { - const res = await client.get( - URL_CONSTANTS.USERS.ME, - ); + const res = await client.get(URL_CONSTANTS.USERS.ME); return res.data; }, @@ -53,6 +53,14 @@ export const authService = { return res.data.data; }, + checkAvailability: async (params: CheckAvailabilityPayload) => { + const res = await client.get( + URL_CONSTANTS.USERS.CHECK_AVAILABILITY, + { params }, + ); + return res.data; + }, + sendOTP: async (body: OtpPayload) => { const res = await client.post>( URL_CONSTANTS.OTP.SEND, 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-freight-web/portal/src/services/customer-trucks.service.ts b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts new file mode 100644 index 000000000..ee317e204 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts @@ -0,0 +1,33 @@ +import type { Freight } from "@edr/types"; + +import { URL_CONSTANTS } from "@/constants/URLS"; +import { client } from "../utils/api"; + +const B = URL_CONSTANTS.BOOKINGS; + +/** + * Multi-truck self-haul assignment for a booking (no EDR first/last mile). + * Each truck carries 1–2 of the booking's containers and tracks its own arrival. + */ +export const customerTrucksService = { + list: async (bookingId: string): Promise => { + const { data } = await client.get(B.CUSTOMER_TRUCKS(bookingId)); + return data.data ?? data; + }, + + add: async ( + bookingId: string, + payload: Freight.AddCustomerTruckPayload, + ): Promise => { + const { data } = await client.post(B.CUSTOMER_TRUCKS(bookingId), payload); + return data.data ?? data; + }, + + remove: async ( + bookingId: string, + assignmentId: string, + ): Promise => { + const { data } = await client.delete(B.CUSTOMER_TRUCK(bookingId, assignmentId)); + return data.data ?? data; + }, +}; diff --git a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts index f1fa4e841..91cd6a0d2 100644 --- a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts +++ b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts @@ -1,5 +1,10 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import { client } from "../utils/api"; +import type { + InitiateResponse, + PaymentMethod, + PaymentPlatform, +} from "./payments.service"; const W = URL_CONSTANTS.WAREHOUSE_INVOICES; @@ -51,4 +56,27 @@ export const warehouseInvoicesService = { const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" }); return data; }, + + /** + * Initiate a Telebirr/Waafi online payment for a warehouse demurrage/storage + * invoice. Returns the payment intent + `clientAction` to redirect the browser + * to the provider (mirrors the booking `/pay` flow). The backoffice cash + * `/pay` (record-a-payment) path is unaffected. + */ + payOnline: async ( + id: string, + payload: { + method: PaymentMethod; + platform?: PaymentPlatform; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; + }, + ): Promise => { + const { data } = await client.post(W.PAY_ONLINE(id), { + platform: "web", + ...payload, + }); + return data.data ?? data; + }, }; diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 7b58f573b..04357a9ca 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -45,6 +45,16 @@ export interface OtpResponse { message: string; } +export interface CheckAvailabilityPayload { + email?: string; + phone?: string; +} + +export interface CheckAvailabilityResponse { + emailTaken: boolean; + phoneTaken: boolean; +} + export interface SetPasswordPayload { newPassword: string; confirmPassword: string; diff --git a/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql new file mode 100644 index 000000000..365c15559 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "packageId" TEXT, +ADD COLUMN "priceTierId" TEXT; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql new file mode 100644 index 000000000..51e09889a --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "FraudAlert" ADD COLUMN "acknowledgedAt" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql new file mode 100644 index 000000000..0ce87d7de --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE "passenger"."AppRelease" ( + "id" TEXT NOT NULL, + "os" TEXT NOT NULL, + "version" TEXT NOT NULL, + "forceUpdate" BOOLEAN NOT NULL DEFAULT false, + "storeLink" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AppRelease_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AppRelease_os_version_key" ON "passenger"."AppRelease"("os", "version"); diff --git a/apps/edr-passenger-api/prisma/migrations/20260706000000_add_departure_station_to_bookings/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706000000_add_departure_station_to_bookings/migration.sql new file mode 100644 index 000000000..f782c3c64 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706000000_add_departure_station_to_bookings/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable: add package_departure_station_id to Booking +ALTER TABLE "passenger"."Booking" + ADD COLUMN "packageDepartureStationId" TEXT; + +-- AlterTable: add package_departure_station_id to PackageBooking +ALTER TABLE "passenger"."PackageBooking" + ADD COLUMN "packageDepartureStationId" TEXT; + +-- AddForeignKey: Booking -> Station +ALTER TABLE "passenger"."Booking" + ADD CONSTRAINT "Booking_packageDepartureStationId_fkey" + FOREIGN KEY ("packageDepartureStationId") + REFERENCES "passenger"."Station"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey: PackageBooking -> Station +ALTER TABLE "passenger"."PackageBooking" + ADD CONSTRAINT "PackageBooking_packageDepartureStationId_fkey" + FOREIGN KEY ("packageDepartureStationId") + REFERENCES "passenger"."Station"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 68b3ac234..83c2b884e 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -330,7 +330,9 @@ model Station { originSchedules TrainSchedule[] @relation("OriginTrips") destinationSchedules TrainSchedule[] @relation("DestinationTrips") stopTimes TripStopTime[] - crowdSignals StationCrowdSignal[] + crowdSignals StationCrowdSignal[] + bookingDepartures Booking[] @relation("BookingPackageDepartureStation") + packageBookingDepartures PackageBooking[] @relation("PackageBookingDepartureStation") @@index([city, countryCode]) @@index([sequence]) @@schema("passenger") @@ -506,6 +508,8 @@ model Booking { bookingRef String @unique passengerId String scheduleId String + packageId String? + priceTierId String? bookingType String @default("ONE_WAY") status BookingStatus @default(DRAFT) currency String @default("ETB") @@ -539,11 +543,15 @@ model Booking { promoCode String? paidAt DateTime? paymentReminderSentAt DateTime? + packageDepartureStationId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id]) returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id]) + package TravelPackage? @relation(fields: [packageId], references: [id]) + priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id]) + departureStation Station? @relation("BookingPackageDepartureStation", fields: [packageDepartureStationId], references: [id]) seats BookingSeat[] paymentIntent PaymentIntent? tickets Ticket[] @@ -1301,6 +1309,7 @@ model FraudAlert { context Json severity String @default("MEDIUM") acknowledged Boolean @default(false) + acknowledgedAt DateTime? createdAt DateTime @default(now()) @@index([iamUserId, createdAt]) @@index([acknowledged]) @@ -1428,7 +1437,8 @@ model TravelPackage { outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id]) returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id]) priceTiers PackagePriceTier[] - bookings PackageBooking[] + bookings Booking[] + packageBookings PackageBooking[] inquiries PackageInquiry[] @@index([status, validFrom]) @@ -1446,7 +1456,8 @@ model PackagePriceTier { bookedSeats Int @default(0) package TravelPackage @relation(fields: [packageId], references: [id]) - bookings PackageBooking[] + bookings Booking[] + packageBookings PackageBooking[] inquiries PackageInquiry[] @@unique([packageId, seatType]) @@ -1472,10 +1483,12 @@ model PackageBooking { paidAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + packageDepartureStationId String? package TravelPackage @relation(fields: [packageId], references: [id]) priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id]) passenger Passenger? @relation(fields: [passengerId], references: [id]) + departureStation Station? @relation("PackageBookingDepartureStation", fields: [packageDepartureStationId], references: [id]) passengers PackageBookingPassenger[] paymentIntent PackagePaymentIntent? @@ -1536,3 +1549,17 @@ model PackageInquiry { @@index([packageId]) @@schema("passenger") } + +model AppRelease { + id String @id @default(uuid()) + os String // "android" | "ios" + version String + forceUpdate Boolean @default(false) + storeLink String? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([os, version]) + @@schema("passenger") +} diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 0e9f85079..313ce34b0 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -854,26 +854,64 @@ async function runStep(name: string, step: () => Promise): Promise Promise]> = [ - ['System Users', seedSystemUsers], - ['Stations', seedStations], - ['Coach Types & Classes', seedCoachTypesAndClasses], - ['Route', seedRoute], - ['Coaches', seedCoaches], - ['Trips', seedTrips], - ['Fare Rules', seedFareRules], - ['Currency', seedCurrency], - ['Payment Methods', seedPaymentMethods], - ['Segment Fares', seedSegmentFares], - ['Notification Templates', seedNotificationTemplates], - ['Menu & Food', seedMenuAndFood], - ['Promotions', seedPromotions], - ['FAQ', seedFAQ], - ['Fraud Rules', seedFraudRules], - ['Kulubbi Package', seedKulubbiPackage], + // ['System Users', seedSystemUsers], + // ['Stations', seedStations], + // ['Coach Types & Classes', seedCoachTypesAndClasses], + // ['Route', seedRoute], + // ['Coaches', seedCoaches], + // ['Trips', seedTrips], + // ['Fare Rules', seedFareRules], + // ['Currency', seedCurrency], + // ['Payment Methods', seedPaymentMethods], + // ['Segment Fares', seedSegmentFares], + // ['Notification Templates', seedNotificationTemplates], + // ['Menu & Food', seedMenuAndFood], + // ['Promotions', seedPromotions], + // ['FAQ', seedFAQ], + // ['Fraud Rules', seedFraudRules], + // ['Kulubbi Package', seedKulubbiPackage], + // ['Package Bookings', seedPackageBookings], ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index ba7a18086..1e41076e7 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -61,6 +61,9 @@ import { PackagesModule } from './modules/packages/packages.module'; import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; import { HealthModule } from './modules/health/health.module'; import { TasksModule } from './modules/tasks/tasks.module'; +import { AppReleasesModule } from './modules/app-releases/app-releases.module'; +import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module'; +import { SegmentFareSeeder } from './seed/segment-fare.seeder'; @Module({ imports: [ @@ -130,6 +133,8 @@ import { TasksModule } from './modules/tasks/tasks.module'; ExcessBaggageModule, HealthModule, TasksModule, + AppReleasesModule, + ConfigurableFareModule, ], providers: [ { provide: APP_GUARD, useClass: DynamicThrottlerGuard }, @@ -137,6 +142,7 @@ import { TasksModule } from './modules/tasks/tasks.module'; DynamicThrottlerGuard, EdrPassengerOrgSeeder, PassengerStaffUsersSeeder, + SegmentFareSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -145,6 +151,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly seeder: DataSeeder, private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder, + private readonly segmentFareSeeder: SegmentFareSeeder, ) {} async onApplicationBootstrap() { @@ -163,5 +170,10 @@ export class AppModule implements OnApplicationBootstrap { } catch (err) { this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message); } + try { + await this.segmentFareSeeder.run(); + } catch (err) { + this.logger.error('[SegmentFareSeeder] Seed failed (non-fatal):', (err as Error).message); + } } } diff --git a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts index 39d492b2c..0cc8d93b6 100644 --- a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts +++ b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts @@ -6,6 +6,7 @@ import { HttpStatus, Logger, } from '@nestjs/common'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { @@ -22,15 +23,27 @@ export class HttpExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); + let prismaMessage: string | null = null; + if (exception instanceof PrismaClientKnownRequestError) { + if (exception.code === 'P2003') { + const field = (exception.meta?.field_name as string | undefined) ?? 'a related record'; + prismaMessage = `Cannot delete this record because it is still referenced by ${field}. Remove the related records first.`; + } else if (exception.code === 'P2025') { + prismaMessage = 'Record not found.'; + } + } + const status = exception instanceof HttpException ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + : prismaMessage + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; const messageRaw = exception instanceof HttpException ? exception.getResponse() - : 'Internal server error'; + : prismaMessage ?? 'Internal server error'; const message = typeof messageRaw === 'string' diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts new file mode 100644 index 000000000..7fbfa7de0 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, SetMetadata } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { AppReleasesService, AppReleaseDto } from './app-releases.service'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; + +@ApiTags('App Releases') +@Controller('app-releases') +export class AppReleasesController { + constructor(private service: AppReleasesService) {} + + @Get() + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'List all app releases (public)' }) + getAll() { + return this.service.getAll(); + } + + @Get('latest/:os') + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get latest release for a given OS (public)' }) + @ApiParam({ name: 'os', enum: ['android', 'ios'] }) + getLatest(@Param('os') os: string) { + return this.service.getLatest(os); + } + + @Post() + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Create an app release (admin)' }) + create(@Body() dto: AppReleaseDto) { + return this.service.create(dto); + } + + @Patch(':id') + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Update an app release (admin)' }) + update(@Param('id') id: string, @Body() dto: Partial) { + return this.service.update(id, dto); + } + + @Delete(':id') + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Delete an app release (admin)' }) + remove(@Param('id') id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts new file mode 100644 index 000000000..89e1f733a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AppReleasesController } from './app-releases.controller'; +import { AppReleasesService } from './app-releases.service'; +import { PrismaModule } from '../../common/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [AppReleasesController], + providers: [AppReleasesService], +}) +export class AppReleasesModule {} diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts new file mode 100644 index 000000000..16b221ecc --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PrismaService } from '../../common/prisma.service'; + +export class AppReleaseDto { + @ApiProperty({ enum: ['android', 'ios'] }) + @IsIn(['android', 'ios']) + os: string; + + @ApiProperty({ example: '1.2.3' }) + @IsString() + version: string; + + @ApiProperty({ default: false }) + @IsBoolean() + forceUpdate: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + storeLink?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +@Injectable() +export class AppReleasesService { + constructor(private prisma: PrismaService) {} + + private get db() { + return (this.prisma as any); + } + + getAll() { + return this.db.appRelease.findMany({ orderBy: [{ os: 'asc' }, { createdAt: 'desc' }] }); + } + + async getLatest(os: string) { + const release = await this.db.appRelease.findFirst({ + where: { os }, + orderBy: { createdAt: 'desc' }, + }); + if (!release) throw new NotFoundException(`No release found for ${os}`); + return release; + } + + async create(dto: AppReleaseDto) { + const existing = await this.db.appRelease.findUnique({ + where: { os_version: { os: dto.os, version: dto.version } }, + }); + if (existing) throw new ConflictException(`Release ${dto.os} ${dto.version} already exists`); + return this.db.appRelease.create({ data: dto }); + } + + async update(id: string, dto: Partial) { + const release = await this.db.appRelease.findUnique({ where: { id } }); + if (!release) throw new NotFoundException('App release not found'); + return this.db.appRelease.update({ where: { id }, data: dto }); + } + + async remove(id: string) { + const release = await this.db.appRelease.findUnique({ where: { id } }); + if (!release) throw new NotFoundException('App release not found'); + await this.db.appRelease.delete({ where: { id } }); + return { deleted: true, id }; + } +} diff --git a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts index 1202e4d45..3a1e94760 100644 --- a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -30,8 +30,8 @@ export class AuditController { entityType: entityType || undefined, }; - const items = await this.auditService.getLogs(filters); - return { items }; + const result = await this.auditService.getLogs(filters); + return { items: result.data, total: result.total, limit: result.limit, offset: result.offset }; } @Get('logs/:id') diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index d53ef40d7..a6f596bf6 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes import { Throttle, SkipThrottle } from '@nestjs/throttler'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PassengerAuthService } from './passenger-auth.service'; -import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto'; +import { RegisterDto, LoginDto, ResendRegistrationCodeDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Passenger Auth') @@ -14,14 +14,28 @@ export class AuthController { @Post('register') @IsPublic() - @ApiOperation({ summary: 'Register new passenger account' }) - @ApiResponse({ status: 201, description: 'Account created. Returns token + user.' }) + @ApiOperation({ summary: 'Register new passenger account (sends SMS verification code)' }) + @ApiResponse({ + status: 201, + description: + 'Account created as pending. A verification code is sent via SMS — complete signup via PATCH /v1/auth/set-password.', + }) @ApiResponse({ status: 409, description: 'Email or phone already registered' }) @ApiBody({ type: RegisterDto }) register(@Request() req: any, @Body() dto: RegisterDto) { return this.passengerAuthService.register(dto, req); } + @Post('register/resend-code') + @IsPublic() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Resend the registration verification code for a pending account' }) + @ApiResponse({ status: 200, description: 'Verification code re-sent if the account is pending.' }) + @ApiBody({ type: ResendRegistrationCodeDto }) + resendRegistrationCode(@Request() req: any, @Body() dto: ResendRegistrationCodeDto) { + return this.passengerAuthService.resendRegistrationCode(dto, req); + } + @Post('login') @IsPublic() @HttpCode(HttpStatus.OK) diff --git a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts index 6f43e7212..d0a691b0f 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts @@ -1,6 +1,6 @@ -import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator'; +import { IsEmail, IsString, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; export class NameDto { @ApiProperty({ example: 'ቀለሙ ቀጸላ' }) @@ -29,15 +29,16 @@ export class RegisterDto { @ValidateNested() @Type(() => NameDto) name: NameDto; +} - @ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' }) - @IsString() - @MinLength(8) - password: string; +export class ResendRegistrationCodeDto { + @ApiProperty({ example: 'kelemu@email.com' }) + @IsEmail() + email: string; - @ApiProperty({ example: 'SecurePass123', format: 'password' }) + @ApiProperty({ example: '+251912345678' }) @IsString() - confirmPassword: string; + phoneNumber: string; } export class LoginDto { diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 0213bb8f6..1784261e5 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -50,14 +50,17 @@ export class PassengerAuthService { const iamAuthService = await this.resolveIamAuthService(req); - const { token, refreshToken } = await iamAuthService.signupWithPassword({ + // IAM `signup` creates the user as PENDING/isActive=false with NO credential and + // SMS-sends a 6-digit verification code. The account cannot log in until the code is + // redeemed via PATCH /v1/auth/set-password. We intentionally discard the session + // token `signup` returns — the account is not verified yet, so it must never reach + // the client. + await iamAuthService.signup({ email: dto.email, username: dto.username, phoneNumber: dto.phoneNumber, userType: EUserType.INDIVIDUAL, name: dto.name, - password: dto.password, - confirmPassword: dto.confirmPassword, }); const iamRows = await this.dataSource.query( @@ -70,20 +73,98 @@ export class PassengerAuthService { } const iamUserId = iamRows[0].id; - let passengerId: string; + // The Prisma "passenger satellite" (Passenger + wallet + loyalty) is NOT provisioned + // here — `login()` lazy-provisions it on first successful login, so satellites exist + // only for verified users who complete set-password and sign in. + return { + iamUserId, + email: dto.email, + phoneNumber: dto.phoneNumber, + requiresPasswordSetup: true, + }; + } + + /** + * Immediate-activation account creation used by the payment-gated guest-checkout + * "create account" path only. Unlike the public `register()` (OTP-gated), this creates a + * ready-to-use account from the password entered at checkout and provisions the passenger + * satellite synchronously so the booking can attach to it. Do NOT wire this to the public + * registration form — that flow must stay behind SMS verification. + */ + async registerWithPassword( + dto: { + email: string; + username: string; + phoneNumber: string; + name: { en: string; am: string }; + password: string; + }, + req: any, + ): Promise<{ iamUserId: string; passengerId: string }> { + const existing = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`, + [dto.email, dto.phoneNumber], + ); + if (existing.length) throw new ConflictException('Email or phone already registered'); + + const iamAuthService = await this.resolveIamAuthService(req); + await iamAuthService.signupWithPassword({ + email: dto.email, + username: dto.username, + phoneNumber: dto.phoneNumber, + userType: EUserType.INDIVIDUAL, + name: dto.name, + password: dto.password, + confirmPassword: dto.password, + }); + + const iamRows = await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, + [dto.email], + ); + if (!iamRows.length) { + await this.compensateIamSignup(dto.email); + throw new InternalServerErrorException('Account creation failed. Please try again.'); + } + const iamUserId = iamRows[0].id; + try { const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' }); - passengerId = result.passengerId; + return { iamUserId, passengerId: result.passengerId }; } catch { await this.compensateIamSignup(dto.email); throw new InternalServerErrorException('Account creation failed. Please try again.'); } + } - return { - token, - refreshToken, - user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.name.en, passengerId }, - }; + async resendRegistrationCode( + dto: { email: string; phoneNumber: string }, + req: any, + ): Promise<{ sent: boolean }> { + // Only regenerate for accounts still pending password setup. A fully-registered user + // should use forgot-password instead. Always return { sent: true } to avoid leaking + // whether the email/phone maps to a pending account (enumeration guard). + const users = await this.dataSource.query<{ email: string; phone_number: string }[]>( + `SELECT email, phone_number FROM iam.users + WHERE email = $1 AND phone_number = $2 AND has_set_password = false LIMIT 1`, + [dto.email, dto.phoneNumber], + ); + if (!users.length) return { sent: true }; + + const iamAuthService = await this.resolveIamAuthService(req); + try { + await iamAuthService.generateVerificationCode({ + email: users[0].email, + phoneNumber: users[0].phone_number, + type: EOtpType.VERIFY_PHONE_NUMBER, + }); + } catch (err) { + this.logger.error( + `[PassengerAuthService] resend registration code failed for ${dto.email}`, + (err as Error).message, + ); + } + return { sent: true }; } async login(dto: LoginDto, req: any) { diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 5df5f7107..6e45aeab4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -137,6 +137,12 @@ export class CreateBookingDto { @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; + @ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier instead of the fare engine' }) + @IsOptional() @IsString() packageId?: string; + + @ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' }) + @IsOptional() @IsString() priceTierId?: string; + @ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' }) @IsOptional() @IsString() promoCode?: string; diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 2ca7d5ac4..9ed00249c 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -202,9 +202,12 @@ export class BookingsService { async findAll(filters: BookingFilters = {}) { const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - + + const onlyPackages = bookingType === 'PACKAGE'; + const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT'; + const where: any = {}; - + if (search) { const iamRows = await this.dataSource.query<{ id: string }[]>( `SELECT u.id FROM iam.users u @@ -229,10 +232,10 @@ export class BookingsService { { seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, ]; } - + if (status) where.status = status; if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus; - if (bookingType) where.bookingType = bookingType; + if (bookingType && !onlyPackages) where.bookingType = bookingType; if (dateFrom || dateTo) { where.createdAt = { ...(dateFrom ? { gte: new Date(dateFrom) } : {}), @@ -240,17 +243,126 @@ export class BookingsService { }; } if (paymentStatus) { - const statusMap: Record = { - PAID: 'SUCCEEDED', - PENDING: 'REQUIRES_ACTION', - FAILED: 'FAILED', - REFUNDED: 'REFUNDED', - }; + const statusMap: Record = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' }; const mapped = statusMap[paymentStatus] ?? paymentStatus; where.paymentIntent = { is: { status: mapped } }; } - - const [items, total] = await Promise.all([ + + const pkgWhere: any = {}; + if (search) { + pkgWhere.OR = [ + { bookingRef: { contains: search, mode: 'insensitive' } }, + { contactEmail: { contains: search, mode: 'insensitive' } }, + { contactPhone: { contains: search, mode: 'insensitive' } }, + { passengers: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, + ]; + } + if (status) pkgWhere.status = status; + if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt; + if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } }; + + if (onlyPackages) { + // Package bookings live in two places: + // 1. PackageBooking table (dedicated package bookings) + // 2. Booking table with packageId != null (round-trip bookings linked to a package) + const bookingPkgWhere: any = { packageId: { not: null } }; + if (status) bookingPkgWhere.status = status; + if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt; + if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent; + if (search) bookingPkgWhere.OR = where.OR; + + const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([ + this.prisma.packageBooking.findMany({ + where: pkgWhere, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + }), + this.prisma.packageBooking.count({ where: pkgWhere }), + this.prisma.booking.findMany({ + where: bookingPkgWhere, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + passenger: { select: { id: true, iamUserId: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + paymentIntent: true, + seats: { include: { seat: true } }, + }, + }), + this.prisma.booking.count({ where: bookingPkgWhere }), + ]); + + const iamUserIds = regPkgItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( + `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + + const mappedRegPkg = regPkgItems.map((booking: any) => { + const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; + const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); + const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + return { + id: booking.id, bookingRef: booking.bookingRef, status: booking.status, + totalMinor: booking.totalMinor, currency: 'ETB', + displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, + bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true, + returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, childCount: booking.childCount, + createdAt: booking.createdAt, + passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, + passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], + passengers: uniquePassengers, + schedule: booking.schedule ? { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + } : null, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }); + + const mappedPkg = pkgItems.map((b: any) => ({ + id: b.id, bookingRef: b.bookingRef, status: b.status, + totalMinor: b.totalMinor, currency: b.currency || 'ETB', + displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, + contactEmail: b.contactEmail, contactPhone: b.contactPhone, + bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId, + isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code, + tierLabel: b.priceTier?.label ?? null, + returnLegStatus: null, adultCount: b.passengerCount, childCount: 0, + createdAt: b.createdAt, passenger: null, + passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], + passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [], + schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount, + })); + + const total = pkgTotal + regPkgTotal; + const allItems = [...mappedPkg, ...mappedRegPkg] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, pageSize); + + return { + items: allItems, + meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, + }; + } + + const [regularItems, regularTotal, pkgItems, pkgTotal] = await Promise.all([ this.prisma.booking.findMany({ where, skip, @@ -261,12 +373,27 @@ export class BookingsService { schedule: { include: { originStation: true, destinationStation: true, train: true } }, paymentIntent: true, seats: { include: { seat: true } }, + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true } }, }, }), this.prisma.booking.count({ where }), + includePackageBookings + ? this.prisma.packageBooking.findMany({ + where: pkgWhere, + orderBy: { createdAt: 'desc' }, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + }) + : Promise.resolve([] as any[]), + includePackageBookings ? this.prisma.packageBooking.count({ where: pkgWhere }) : Promise.resolve(0), ]); - const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[]; + const iamUserIds = regularItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[]; const iamRows = iamUserIds.length > 0 ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, @@ -275,59 +402,86 @@ export class BookingsService { : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); + const mappedRegular = regularItems.map((booking: any) => { + const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; + const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); + const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, + contactPhone: booking.contactPhone, + bookingType: booking.bookingType, + packageId: booking.packageId ?? null, + priceTierId: (booking as any).priceTierId ?? null, + packageName: (booking as any).package?.name ?? null, + packageCode: (booking as any).package?.code ?? null, + tierLabel: (booking as any).priceTier?.label ?? null, + isPackageBooking: !!booking.packageId, + returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, + childCount: booking.childCount, + createdAt: booking.createdAt, + passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, + passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], + passengers: uniquePassengers, + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }); + + const mappedPkg = pkgItems.map((b: any) => ({ + id: b.id, + bookingRef: b.bookingRef, + status: b.status, + totalMinor: b.totalMinor, + currency: b.currency || 'ETB', + displayCurrency: b.displayCurrency, + displayTotalMinor: b.displayTotalMinor, + contactEmail: b.contactEmail, + contactPhone: b.contactPhone, + bookingType: 'PACKAGE', + packageId: b.packageId, + priceTierId: b.priceTierId, + isPackageBooking: true, + packageName: b.package?.name, + packageCode: b.package?.code, + tierLabel: b.priceTier?.label ?? null, + returnLegStatus: null, + adultCount: b.passengerCount, + childCount: 0, + createdAt: b.createdAt, + passenger: null, + passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], + passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [], + schedule: null, + paymentIntent: b.paymentIntent, + seatCount: b.passengerCount, + })); + + const total = regularTotal + pkgTotal; + const allItems = [...mappedRegular, ...mappedPkg] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, pageSize); + return { - items: items.map(booking => { - const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; - // Build passenger list with categories - const passengerDetails = booking.seats.map((s: any) => ({ - name: s.passengerName, - category: s.passengerCategory // 'ADULT' or 'CHILD' - })); - // Get unique names with their categories - const uniquePassengers = Array.from( - new Map(passengerDetails.map(p => [p.name, p])).values() - ); - - return { - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: booking.totalMinor, - currency: 'ETB', - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - contactEmail: booking.contactEmail, - contactPhone: booking.contactPhone, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - adultCount: booking.adultCount, - childCount: booking.childCount, - createdAt: booking.createdAt, - passenger: iam - ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } - : null, - passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], - passengers: uniquePassengers, // Include category info - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - }, - paymentIntent: booking.paymentIntent, - seatCount: booking.seats.length, - }; - }), - meta: { - page, - pageSize, - total, - totalPages: Math.ceil(total / pageSize), - }, + items: allItems, + meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, }; } - async create(dto: CreateBookingDto) { + async create(dto: CreateBookingDto) { if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto); if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto); if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto); @@ -363,7 +517,9 @@ export class BookingsService { const passengersData = await this.processPassengers(dto.passengers as any[]); const { adultCount, childCount } = this.countPassengers(passengersData); - const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); + const fareCalculation = dto.packageId && dto.priceTierId + ? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount) + : await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = fareCalculation.totalMinor; @@ -371,20 +527,18 @@ export class BookingsService { displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency); } - // Track which child gets free fare (first child encountered) + // Track per-seat fare. For package bookings children pay 10% of adult fare; + // for regular bookings the first child is free. let freeChildUsed = false; const passengersWithFares = passengersData.map(p => { let fareMinor: number; if (p.category === PassengerCategory.ADULT) { fareMinor = fareCalculation.baseFareMinor; + } else if (dto.packageId) { + fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1); } else { - // Child: first child is free, subsequent children pay full fare - if (!freeChildUsed) { - fareMinor = 0; - freeChildUsed = true; - } else { - fareMinor = fareCalculation.baseFareMinor; - } + if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; } + else fareMinor = fareCalculation.baseFareMinor; } return { ...p, fareMinor }; }); @@ -401,6 +555,7 @@ export class BookingsService { childCount, displayCurrency, displayTotalMinor, + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), seats: { create: passengersWithFares.map(p => ({ seat: { connect: { id: p.seatId } }, @@ -421,6 +576,12 @@ export class BookingsService { }); await this.seatsService.confirmSeats(passengersData.map(p => p.seatId)); + if (dto.packageId && dto.priceTierId) { + await this.prisma.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { bookedSeats: { increment: passengersData.length } }, + }); + } this.eventEmitter.emit('booking.created', { booking }); return { ...booking, fareBreakdown: fareCalculation }; } @@ -468,23 +629,38 @@ export class BookingsService { const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]); const { adultCount, childCount } = this.countPassengers(passengersData); - const [outboundFare, returnFare] = await Promise.all([ - this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), - this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) - ]); - - const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; + // Package bookings use fixed tier price split equally across both legs + let outboundFare: Awaited>; + let returnFare: Awaited>; + let combinedBaseFareMinor: number; let discountMinor = 0; - if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); - if (promo?.active && promo.validUntil > new Date()) { - discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); - } - } + let loyaltyMinor = 0; + let totalMinor: number; - const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + if (dto.packageId && dto.priceTierId) { + const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount); + // Split evenly across both legs for per-seat fare recording + const halfMinor = Math.round(pkgFare.baseFareMinor / 2); + outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) }; + returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) }; + combinedBaseFareMinor = pkgFare.totalBaseFareMinor; + totalMinor = pkgFare.totalMinor; + } else { + [outboundFare, returnFare] = await Promise.all([ + this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), + this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) + ]); + combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); + } const taxesMinor = 0; - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -492,34 +668,27 @@ export class BookingsService { displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); } - // Track which child gets free fare for outbound and return legs + // Track per-seat fare. For package bookings children pay 10% of adult fare; + // for regular bookings the first child is free per leg. let outboundFreeChildUsed = false; let returnFreeChildUsed = false; const passengersWithFares = passengersData.map(p => { let outboundFareMinor: number; let returnFareMinor: number; - + if (p.category === PassengerCategory.ADULT) { outboundFareMinor = outboundFare.baseFareMinor; returnFareMinor = returnFare.baseFareMinor; + } else if (dto.packageId) { + outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1); + returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1); } else { - // Child fare for outbound - if (!outboundFreeChildUsed) { - outboundFareMinor = 0; - outboundFreeChildUsed = true; - } else { - outboundFareMinor = outboundFare.baseFareMinor; - } - - // Child fare for return - if (!returnFreeChildUsed) { - returnFareMinor = 0; - returnFreeChildUsed = true; - } else { - returnFareMinor = returnFare.baseFareMinor; - } + if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; } + else outboundFareMinor = outboundFare.baseFareMinor; + if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; } + else returnFareMinor = returnFare.baseFareMinor; } - + return { ...p, outboundFareMinor, returnFareMinor }; }); @@ -541,6 +710,7 @@ export class BookingsService { returnHoldId: dto.returnHoldId, returnSeatClassId: dto.returnSeatClassId, returnLegStatus: 'NEITHER_USED', + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), seats: { create: [ ...passengersWithFares.map(p => ({ @@ -586,6 +756,13 @@ export class BookingsService { this.seatsService.confirmSeats(returnSeatIds) ]); + if (dto.packageId && dto.priceTierId) { + await this.prisma.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { bookedSeats: { increment: passengersData.length } }, + }); + } + this.eventEmitter.emit('booking.created', { booking }); return { @@ -1048,6 +1225,34 @@ export class BookingsService { return { adultCount, childCount }; } + private async calculatePackageFare( + priceTierId: string, + adultCount: number, + childCount: number, + ) { + const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } }); + // For round-trip packages the caller splits the tier price across legs, so + // priceMinor here is already the per-leg amount. Children pay 10% of adult fare. + const childFareMinor = Math.round(tier.priceMinor * 0.1); + const adultFareMinor = tier.priceMinor * adultCount; + const childTotalMinor = childFareMinor * childCount; + const totalBaseFareMinor = adultFareMinor + childTotalMinor; + return { + baseFareMinor: tier.priceMinor, + adultCount, + adultFareMinor, + childCount, + freeChildrenCount: 0, + paidChildrenCount: childCount, + childFareMinor: childTotalMinor, + totalBaseFareMinor, + discountMinor: 0, + loyaltyRedemptionMinor: 0, + taxesFeesMinor: 0, + totalMinor: totalBaseFareMinor, + }; + } + private async calculateFare( scheduleId: string, seatClassId: string, @@ -1182,7 +1387,64 @@ export class BookingsService { paymentIntent: true, tickets: { take: 1 }, }, }); - if (!booking) throw new NotFoundException('Booking not found'); + + if (!booking) { + // Fall back to PackageBooking + const pkgBooking = await this.prisma.packageBooking.findUnique({ + where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId }, + include: { + package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } } } }, + priceTier: true, + passengers: true, + paymentIntent: true, + }, + }); + if (!pkgBooking) throw new NotFoundException('Booking not found'); + return { + id: pkgBooking.id, + bookingRef: pkgBooking.bookingRef, + status: pkgBooking.status, + totalMinor: pkgBooking.totalMinor, + currency: pkgBooking.currency || 'ETB', + adultCount: pkgBooking.passengerCount, + childCount: 0, + displayCurrency: pkgBooking.displayCurrency, + displayTotalMinor: pkgBooking.displayTotalMinor ?? undefined, + bookingType: 'PACKAGE', + packageId: pkgBooking.packageId, + priceTierId: pkgBooking.priceTierId, + packageName: (pkgBooking as any).package?.name, + packageCode: (pkgBooking as any).package?.code, + tierLabel: (pkgBooking as any).priceTier?.label, + isPackageBooking: true, + returnLegStatus: null, + contactEmail: pkgBooking.contactEmail, + contactPhone: pkgBooking.contactPhone, + createdAt: pkgBooking.createdAt, + schedule: (pkgBooking as any).package?.outboundSchedule ? { + id: (pkgBooking as any).package.outboundSchedule.id, + trainNumber: (pkgBooking as any).package.outboundSchedule.train?.number, + trainName: (pkgBooking as any).package.outboundSchedule.train?.name, + origin: (pkgBooking as any).package.outboundSchedule.originStation, + destination: (pkgBooking as any).package.outboundSchedule.destinationStation, + departureAt: (pkgBooking as any).package.outboundSchedule.departureAt, + arrivalAt: (pkgBooking as any).package.outboundSchedule.arrivalAt, + } : null, + passengers: (pkgBooking as any).passengers?.map((p: any) => ({ + fullName: p.passengerName, + category: 'ADULT', + leg: 1, + fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount), + verifaydaVerified: false, + seat: null, + })), + payment: (pkgBooking as any).paymentIntent + ? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status } + : undefined, + ticket: undefined, + }; + } + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: booking.totalMinor, currency: 'ETB', diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index d3d1e206d..c6f27d9c3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -144,6 +144,12 @@ export class CreateGuestBookingDto { @ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' }) @IsOptional() @IsString() deviceId?: string; + + @ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier' }) + @IsOptional() @IsString() packageId?: string; + + @ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' }) + @IsOptional() @IsString() priceTierId?: string; } export class SavedPassengerProfileDto { diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 6907d14a3..f4139d842 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -81,8 +81,10 @@ export class GuestBookingService { throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); } - const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); - const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId) + ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined); + const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId) + ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; @@ -142,21 +144,34 @@ export class GuestBookingService { }); } - // Calculate fare - const primaryNationality = passengersData[0]?.nationality; - const baseFareMinor = await this.getBaseFare( - dto.scheduleId, - dto.seatClassId, - segmentRoute, - fullRoute, - primaryNationality, - dto.originStationId, - dto.destinationStationId, - ); + // Calculate fare — package bookings use the fixed tier price, bypassing the fare engine + const isPackageOneway = !!dto.packageId && !!dto.priceTierId; + let baseFareMinor: number; + let paidChildrenCount: number; + let childUnitFare: number; + + if (isPackageOneway) { + const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } }); + baseFareMinor = tier.priceMinor; + paidChildrenCount = childCount; + childUnitFare = Math.round(baseFareMinor * 0.1); + } else { + const primaryNationality = passengersData[0]?.nationality; + baseFareMinor = await this.getBaseFare( + dto.scheduleId, + dto.seatClassId, + segmentRoute, + fullRoute, + primaryNationality, + dto.originStationId, + dto.destinationStationId, + ); + paidChildrenCount = Math.max(0, childCount - 1); + childUnitFare = baseFareMinor; + } const adultFareMinor = baseFareMinor * adultCount; - const paidChildrenCount = Math.max(0, childCount - 1); - const childFareMinor = baseFareMinor * paidChildrenCount; + const childFareMinor = childUnitFare * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childFareMinor; let discountMinor = 0; @@ -215,6 +230,7 @@ export class GuestBookingService { displayCurrency, displayTotalMinor, bookingType: 'ONE_WAY', + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), userAgent: dto.deviceId, contactEmail: firstPassenger.email || null, contactPhone: firstPassenger.phone || null, @@ -229,7 +245,7 @@ export class GuestBookingService { passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0), + fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare, displayCurrency, })), }, @@ -256,7 +272,7 @@ export class GuestBookingService { adultCount, adultFareMinor, childCount, - freeChildrenCount: Math.min(childCount, 1), + freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, @@ -306,10 +322,16 @@ export class GuestBookingService { throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); } - const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); - const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); - const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); - const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId); + const synth = (sched: any, stationId: string, seq: number) => { + const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation; + return { stationId, sequence: seq, station }; + }; + const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)]; + const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)]; + const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0]; + const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1]; + const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0]; + const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1]; if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule'); if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); @@ -358,18 +380,36 @@ export class GuestBookingService { passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } - // Calculate fares for both legs + // Calculate fares for both legs — package bookings use the fixed tier price split across legs const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId; - const primaryNationality = passengersData[0]?.nationality; + const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId; + let outboundBaseFare: number; + let returnBaseFare: number; + let paidChildrenCount: number; + let outboundChildUnitFare: number; + let returnChildUnitFare: number; - const [outboundBaseFare, returnBaseFare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId), - this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId), - ]); - - const paidChildrenCount = Math.max(0, childCount - 1); - const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount; - const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount; + if (isPackageRoundTrip) { + const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } }); + // tier.priceMinor is the full round-trip price per adult; split evenly across legs + const halfMinor = Math.round(tier.priceMinor / 2); + outboundBaseFare = halfMinor; + returnBaseFare = tier.priceMinor - halfMinor; + paidChildrenCount = childCount; + outboundChildUnitFare = Math.round(outboundBaseFare * 0.1); + returnChildUnitFare = Math.round(returnBaseFare * 0.1); + } else { + const primaryNationality = passengersData[0]?.nationality; + [outboundBaseFare, returnBaseFare] = await Promise.all([ + this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId), + this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId), + ]); + paidChildrenCount = Math.max(0, childCount - 1); + outboundChildUnitFare = outboundBaseFare; + returnChildUnitFare = returnBaseFare; + } + const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount; + const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount; const combinedBaseFareMinor = outboundTotalBase + returnTotalBase; let discountMinor = 0; @@ -415,6 +455,7 @@ export class GuestBookingService { returnHoldId: dto.returnHoldId, returnSeatClassId, returnLegStatus: 'NEITHER_USED', + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), userAgent: dto.deviceId, contactEmail: passengersData[0]?.email || null, contactPhone: passengersData[0]?.phone || null, @@ -432,7 +473,7 @@ export class GuestBookingService { passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0), + fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare, displayCurrency, })), ...passengersData.map((p) => ({ @@ -447,7 +488,7 @@ export class GuestBookingService { passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0), + fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare, displayCurrency, })), ], @@ -476,7 +517,7 @@ export class GuestBookingService { returnBaseFareMinor: returnBaseFare, adultCount, childCount, - freeChildrenCount: Math.min(childCount, 1), + freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1), paidChildrenCount, combinedBaseFareMinor, discountMinor, @@ -886,18 +927,17 @@ export class GuestBookingService { ): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> { if (dto.createAccount && firstPassenger.email && dto.password) { const guestName = firstPassenger.passengerName ?? 'Guest'; - const result = await this.passengerAuthService.register( + const result = await this.passengerAuthService.registerWithPassword( { email: firstPassenger.email, username: firstPassenger.email, phoneNumber: firstPassenger.phone || `+251900000000`, name: { en: guestName, am: guestName }, password: dto.password, - confirmPassword: dto.password, }, req, ); - return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true }; + return { guestPassengerId: result.passengerId, iamUserId: result.iamUserId, createdAccount: true }; } // Create guest passenger with basic profile diff --git a/apps/edr-passenger-api/src/modules/configurable-fare/configurable-fare.service.ts b/apps/edr-passenger-api/src/modules/configurable-fare/configurable-fare.service.ts index 34b2be08f..06107c30e 100644 --- a/apps/edr-passenger-api/src/modules/configurable-fare/configurable-fare.service.ts +++ b/apps/edr-passenger-api/src/modules/configurable-fare/configurable-fare.service.ts @@ -329,7 +329,7 @@ export class ConfigurableFareService { ); if (childRule) { - const freeChildren = Math.min(childCount, childRule.max_free_passengers); + const freeChildren = Math.min(childCount, adultCount); const paidChildren = Math.max(0, childCount - freeChildren); if (freeChildren > 0) { diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 592aadacd..70f18564d 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -130,8 +130,8 @@ export class FareEngineService { const adultCount = dto.adultCount ?? 1; const childCount = dto.childCount ?? 0; - const freeChildrenCount = Math.min(childCount, 1); - const paidChildrenCount = Math.max(0, childCount - 1); + const freeChildrenCount = Math.min(childCount, adultCount); + const paidChildrenCount = Math.max(0, childCount - freeChildrenCount); // Subtotal includes: (distance-based fare + premium + insurance) × passengers // First child is free, but pays premium and insurance @@ -169,7 +169,7 @@ export class FareEngineService { `Total fare/pax: ${farePerPassengerMinor} ETB minor`, ``, `Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`, - `Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`, + `Children: ${childCount} (${freeChildrenCount} free [1 per adult] + ${paidChildrenCount} paid)`, ` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`, ` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`, ``, diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts index c53d3a5fb..87007f16a 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { FraudService, FraudRuleConfig } from './fraud.service'; import { PassengerStaff } from '../../common/passenger-guards'; @@ -48,6 +48,31 @@ export class FraudController { return { data: rule, message: 'Rule updated successfully' }; } + /** + * Acknowledge a fraud alert + */ + @Patch('alerts/:id/acknowledge') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @ApiOperation({ summary: 'Acknowledge a fraud alert' }) + async acknowledgeAlert(@Param('id') id: string) { + const alert = await this.fraudService.acknowledgeAlert(id); + return { data: alert, message: 'Alert acknowledged' }; + } + + /** + * Block user via userId + */ + @Post('users/:userId/block') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @ApiOperation({ summary: 'Block user by userId' }) + async blockUserById( + @Param('userId') userId: string, + @Body() body: { reason?: string; durationMinutes?: number }, + ) { + await this.fraudService.blockUserTemporarily(userId, body.durationMinutes ?? 60); + return { message: `User blocked for ${body.durationMinutes ?? 60} minutes` }; + } + /** * Block user temporarily */ diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts index a95078578..17b86705f 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { FraudService } from './fraud.service'; import { FraudController } from './fraud.controller'; @Module({ - imports: [HttpModule], + imports: [HttpModule, TypeOrmModule], providers: [FraudService], controllers: [FraudController], exports: [FraudService], diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts index a75db4449..2f988fd31 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts @@ -164,6 +164,16 @@ export class FraudService { this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`); } + /** + * Acknowledge a fraud alert + */ + async acknowledgeAlert(id: string) { + return this.prisma.fraudAlert.update({ + where: { id }, + data: { acknowledged: true, acknowledgedAt: new Date() }, + }); + } + /** * Get all fraud alerts */ diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts index 7095110e4..b4b1a63c6 100644 --- a/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { LoyaltyService } from './loyalty.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -9,7 +9,9 @@ import { JwtGuard } from '../../common/jwt.guard'; @ApiBearerAuth('JWT-auth') export class LoyaltyController { constructor(private service: LoyaltyService) {} + @Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all loyalty accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); } @Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); } @Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); } @Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); } + @Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete loyalty account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); } } diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts index 4cc69b214..22b919f6f 100644 --- a/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts @@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service'; export class LoyaltyService { constructor(private prisma: PrismaService) {} + async getAccounts(params: { search?: string; tier?: string; page?: string; pageSize?: string } = {}) { + const { search, tier, page = '1', pageSize = '20' } = params; + const skip = (parseInt(page) - 1) * parseInt(pageSize); + const where: any = {}; + if (tier) where.tier = tier; + if (search) { + where.passenger = { + OR: [ + { user: { fullName: { contains: search, mode: 'insensitive' } } }, + { user: { email: { contains: search, mode: 'insensitive' } } }, + ], + }; + } + const [items, total] = await Promise.all([ + this.prisma.loyaltyAccount.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { pointsBalance: 'desc' }, + include: { passenger: { include: { user: true } } }, + }), + this.prisma.loyaltyAccount.count({ where }), + ]); + return { + items: items.map(a => ({ + ...a, + passenger: a.passenger ? { + id: a.passenger.id, + fullName: (a.passenger as any).user?.fullName ?? null, + email: (a.passenger as any).user?.email ?? null, + phone: (a.passenger as any).user?.phone ?? null, + } : null, + })), + meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) }, + }; + } async getAccount(passengerId: string) { const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } }); if (!account) throw new NotFoundException('Loyalty account not found'); @@ -40,4 +76,15 @@ export class LoyaltyService { await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } }); return { redeemed: true, pointsUsed: reward.costPoints, balanceAfter: newBalance }; } + + async deleteAccount(id: string) { + const account = await this.prisma.loyaltyAccount.findUnique({ where: { id } }); + if (!account) throw new NotFoundException('Loyalty account not found'); + await this.prisma.$transaction([ + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { accountId: id } }), + this.prisma.loyaltyReward.deleteMany({ where: { accountId: id } }), + this.prisma.loyaltyAccount.delete({ where: { id } }), + ]); + return { deleted: true, accountId: id }; + } } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index c88bdc15d..de61f92ca 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -1,8 +1,8 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PackagesService } from './packages.service'; -import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto'; +import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto'; import { IamGuard } from '../../common/iam-adapter'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; @@ -63,6 +63,19 @@ export class PackagesController { return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20); } + @Get('bookings') + @UseGuards(IamGuard) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'List all package bookings (backoffice)' }) + listBookings( + @Query('packageId') packageId?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.listBookings({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 }); + } + @Get('my-bookings') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @@ -78,6 +91,30 @@ export class PackagesController { return this.service.getBookingByRef(ref); } + @Post('book') + @IsPublic() + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Book a package (public or authenticated)' }) + book(@Body() dto: BookPackageDto, @Request() req: any) { + return this.service.book(dto, req.user?.passengerId); + } + + @Get(':id/booking-context') + @IsPublic() + @ApiOperation({ summary: 'Get booking context for self-service package booking' }) + @ApiQuery({ name: 'tierId', required: true }) + @ApiQuery({ name: 'adultCount', required: true }) + @ApiQuery({ name: 'childCount', required: false }) + getBookingContext( + @Param('id') id: string, + @Query('tierId') tierId: string, + @Query('adultCount') adultCount: string, + @Query('childCount') childCount?: string, + ) { + return this.service.getBookingContext(id, tierId, parseInt(adultCount), childCount ? parseInt(childCount) : 0); + } + @Get(':id') @IsPublic() @ApiOperation({ summary: 'Get package details' }) @@ -149,12 +186,4 @@ export class PackagesController { return this.service.deleteTier(tierId); } - @Post('book') - @IsPublic() - @UseGuards(OptionalJwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Book a package (public or authenticated)' }) - book(@Body() dto: BookPackageDto, @Request() req: any) { - return this.service.book(dto, req.user?.passengerId); - } } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index b4dac126d..0b7a25bba 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator'; +import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID, IsPositive } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -93,6 +93,12 @@ export class BookPackagePassengerDto { @ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string; } +export class PackageBookingContextDto { + @ApiProperty() @IsUUID() tierId: string; + @ApiProperty({ example: 1 }) @IsInt() @IsPositive() adultCount: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() @Min(0) childCount?: number; +} + export class BookPackageDto { @ApiProperty() @IsUUID() packageId: string; @ApiProperty() @IsUUID() priceTierId: string; @@ -112,4 +118,10 @@ export class BookPackageDto { @ApiProperty({ type: [BookPackagePassengerDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto) passengers: BookPackagePassengerDto[]; + + /** Number of adult passengers (≥5 years). Derived from passengers array if omitted. */ + @ApiPropertyOptional({ example: 2 }) @IsOptional() @IsInt() @Min(1) adultCount?: number; + + /** Number of child passengers (<5 years). Derived from passengers array if omitted. */ + @ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number; } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.module.ts b/apps/edr-passenger-api/src/modules/packages/packages.module.ts index f84a23781..32aec44fc 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.module.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.module.ts @@ -3,9 +3,10 @@ import { PrismaModule } from '../../common/prisma.module'; import { PackagesController } from './packages.controller'; import { PackagesService } from './packages.service'; import { CurrencyModule } from '../currency/currency.module'; +import { BookingsModule } from '../bookings/bookings.module'; @Module({ - imports: [PrismaModule, CurrencyModule], + imports: [PrismaModule, CurrencyModule, BookingsModule], controllers: [PackagesController], providers: [PackagesService], exports: [PackagesService], diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index f6c25611c..3b14af6dd 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -3,6 +3,34 @@ import { PrismaService } from '../../common/prisma.service'; import { CurrencyService } from '../currency/currency.service'; import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto'; import { Currency } from '@prisma/client'; +import { BookingsService } from '../bookings/bookings.service'; +import { GuestBookingService } from '../bookings/guest-booking.service'; + +/** Package-specific fare rules */ +const PKG_MAX_ADULTS = 5; +const PKG_MAX_CHILDREN = 2; +const PKG_CHILD_FARE_RATIO = 0.1; + +function calculatePackageFareBreakdown( + priceMinor: number, + isRoundTrip: boolean, + adultCount: number, + childCount: number, +) { + const multiplier = isRoundTrip ? 2 : 1; + const adultFareMinor = priceMinor * multiplier; + const childFareMinor = Math.round(adultFareMinor * PKG_CHILD_FARE_RATIO); + const totalMinor = adultCount * adultFareMinor + childCount * childFareMinor; + return { adultFareMinor, childFareMinor, totalMinor, multiplier }; +} + +function deriveAge(dateOfBirth: string | Date): number { + const today = new Date(); + const dob = new Date(dateOfBirth); + let age = today.getFullYear() - dob.getFullYear(); + if (today < new Date(today.getFullYear(), dob.getMonth(), dob.getDate())) age--; + return age; +} function generateRef(): string { return 'PKG-' + Array.from({ length: 6 }, () => @@ -15,8 +43,102 @@ export class PackagesService { constructor( private readonly prisma: PrismaService, private readonly currencyService: CurrencyService, + private readonly bookingsService: BookingsService, + private readonly guestBookingService: GuestBookingService, ) {} + async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) { + const pkg = await this.prisma.travelPackage.findUnique({ + where: { id: packageId }, + include: { + priceTiers: true, + outboundSchedule: { + include: { + originStation: true, + destinationStation: true, + coachAssignments: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } }, + }, + }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, + }); + if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available'); + const tier = pkg.priceTiers.find((t) => t.id === tierId); + if (!tier) throw new NotFoundException('Price tier not found'); + + if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); + if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); + if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); + + const passengerCount = adultCount + childCount; + const remaining = tier.availableSeats - tier.bookedSeats; + if (passengerCount > remaining) + throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); + + const isRoundTrip = !!pkg.returnScheduleId; + const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( + tier.priceMinor, isRoundTrip, adultCount, childCount, + ); + + // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches + let seatClassId: string | null = null; + let coachTypeId: string | null = null; + for (const a of pkg.outboundSchedule.coachAssignments) { + const sc = a.coach.coachType?.seatClasses?.find( + (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || + tier.seatType.toLowerCase().includes(s.name.toLowerCase()), + ); + if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } + } + if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) { + const first = pkg.outboundSchedule.coachAssignments[0]; + coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null; + } + + return { + packageId: pkg.id, + packageName: pkg.name, + priceTierId: tier.id, + tierLabel: tier.label, + seatType: tier.seatType, + seatClassId, + coachTypeId, + adultCount, + childCount, + passengerCount, + isRoundTrip, + pricePerAdultMinor: adultFareMinor, + pricePerChildMinor: childFareMinor, + childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, + maxAdults: PKG_MAX_ADULTS, + maxChildren: PKG_MAX_CHILDREN, + totalMinor, + currency: tier.currency, + remainingSeats: remaining, + outboundSchedule: { + scheduleId: pkg.outboundScheduleId, + originStationId: pkg.outboundSchedule.originStationId, + destinationStationId: pkg.outboundSchedule.destinationStationId, + departureAt: pkg.outboundSchedule.departureAt, + arrivalAt: pkg.outboundSchedule.arrivalAt, + originStation: pkg.outboundSchedule.originStation, + destinationStation: pkg.outboundSchedule.destinationStation, + }, + returnSchedule: pkg.returnSchedule ? { + scheduleId: pkg.returnScheduleId, + originStationId: pkg.returnSchedule.originStationId, + destinationStationId: pkg.returnSchedule.destinationStationId, + departureAt: pkg.returnSchedule.departureAt, + arrivalAt: pkg.returnSchedule.arrivalAt, + originStation: pkg.returnSchedule.originStation, + destinationStation: pkg.returnSchedule.destinationStation, + } : null, + includedServices: pkg.includedServices, + busTransferIncluded: pkg.busTransferIncluded, + busTransferRoute: pkg.busTransferRoute, + }; + } + async createInquiry(dto: CreateInquiryDto) { return this.prisma.packageInquiry.create({ data: { @@ -74,7 +196,7 @@ export class PackagesService { returnSchedule: { include: { originStation: true, destinationStation: true } }, }, orderBy: { validFrom: 'asc' }, - }); + }).then(pkgs => pkgs.map(p => ({ ...p, journeyType: p.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' }))); } async getById(id: string) { @@ -82,12 +204,19 @@ export class PackagesService { where: { id }, include: { priceTiers: true, - outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, + outboundSchedule: { + include: { + originStation: true, + destinationStation: true, + train: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + }, + }, returnSchedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); if (!pkg) throw new NotFoundException('Package not found'); - return pkg; + return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' }; } create(dto: CreatePackageDto) { @@ -209,13 +338,30 @@ export class PackagesService { const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId); if (!tier) throw new NotFoundException('Price tier not found'); - const passengerCount = dto.passengers.length; + // Derive adult/child counts from the passengers array (dateOfBirth-based) + let adultCount = 0, childCount = 0; + for (const p of dto.passengers) { + if (p.dateOfBirth && deriveAge(p.dateOfBirth) < 5) childCount++; + else adultCount++; + } + // Allow explicit override from mobile app (e.g. when dateOfBirth is not provided per passenger) + if (dto.adultCount !== undefined) adultCount = dto.adultCount; + if (dto.childCount !== undefined) childCount = dto.childCount; + + if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); + if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); + if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); + + const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; if (passengerCount > remaining) { throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); } - const totalMinor = tier.priceMinor * passengerCount; + const isRoundTrip = !!pkg.returnScheduleId; + const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( + tier.priceMinor, isRoundTrip, adultCount, childCount, + ); const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -266,7 +412,21 @@ export class PackagesService { }), ]); - return booking; + return { + ...booking, + fareBreakdown: { + isRoundTrip, + adultCount, + adultFareMinor, + childCount, + childFareMinor, + childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }, + }; } getMyBookings(passengerId: string) { @@ -296,6 +456,29 @@ export class PackagesService { return booking; } + async listBookings({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) { + const where: any = {}; + if (packageId) where.packageId = packageId; + if (status) where.status = status; + const skip = (page - 1) * pageSize; + const [items, total] = await Promise.all([ + this.prisma.packageBooking.findMany({ + where, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.packageBooking.count({ where }), + ]); + return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }; + } + async listAll(page = 1, pageSize = 20) { const skip = (page - 1) * pageSize; const [items, total] = await Promise.all([ diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index ea952d98c..d41fcbfed 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -433,39 +433,49 @@ export class PassengersService { } async deletePassenger(id: string) { - const passenger = await this.prisma.passenger.findUnique({ + // id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id + let passenger = await this.prisma.passenger.findUnique({ where: { id }, - include: { - user: true - } + include: { user: true }, }); - if (!passenger) throw new NotFoundException('Passenger not found'); + + if (!passenger) { + const profile = await this.prisma.travelerProfile.findUnique({ where: { id } }); + if (!profile?.passengerId) throw new NotFoundException('Passenger not found'); + passenger = await this.prisma.passenger.findUnique({ + where: { id: profile.passengerId }, + include: { user: true }, + }); + if (!passenger) throw new NotFoundException('Passenger not found'); + } + + const passengerId = passenger.id; // Check usage before allowing deletion - const usage = await this.checkPassengerUsage(id); + const usage = await this.checkPassengerUsage(passengerId); if (usage.isInUse && usage.constraints) { - const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`; + const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`; throw new DeleteOperationException('Passenger', passengerName, usage.constraints); } await this.prisma.$transaction([ - this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }), - this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }), - this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }), - this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }), - this.prisma.notification.deleteMany({ where: { passengerId: id } }), - this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }), - this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }), - this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }), - this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }), - this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }), - this.prisma.booking.deleteMany({ where: { passengerId: id } }), - this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }), - this.prisma.journey.deleteMany({ where: { passengerId: id } }), - this.prisma.passenger.delete({ where: { id } }), + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }), + this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }), + this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }), + this.prisma.walletAccount.deleteMany({ where: { passengerId } }), + this.prisma.notification.deleteMany({ where: { passengerId } }), + this.prisma.travelerProfile.deleteMany({ where: { passengerId } }), + this.prisma.savedRoute.deleteMany({ where: { passengerId } }), + this.prisma.packageBooking.deleteMany({ where: { passengerId } }), + this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }), + this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }), + this.prisma.booking.deleteMany({ where: { passengerId } }), + this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }), + this.prisma.journey.deleteMany({ where: { passengerId } }), + this.prisma.passenger.delete({ where: { id: passengerId } }), ]); - return { deleted: true, passengerId: id }; + return { deleted: true, passengerId }; } async checkPassengerUsage(id: string) { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 280ef2752..5be11434a 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, HttpStatus, Param, @@ -42,6 +43,14 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; export class PaymentsController { constructor(private service: PaymentsService) {} + @Delete(":id") + @PassengerStaff([PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Delete a payment intent record (admin only)" }) + deletePayment(@Param("id") id: string) { + return this.service.deletePayment(id); + } + @Get("all") @PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index af496a5a2..b79079c39 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -55,6 +55,13 @@ export class PaymentsService { private currencyService: CurrencyService, ) {} + async deletePayment(id: string) { + const intent = await this.prisma.paymentIntent.findUnique({ where: { id } }); + if (!intent) throw new NotFoundException('Payment intent not found'); + await this.prisma.paymentIntent.delete({ where: { id } }); + return { deleted: true, id }; + } + async getAll(filters: { search?: string; status?: string; diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 044768e90..80f3ad13b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -50,8 +50,8 @@ export class CreateScheduleDto { { sequence: 6, plannedArrivalAt: '2026-06-15T20:00:00Z' }, ], }) - @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) - plannedTimes: PlannedStopTimeDto[]; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) + plannedTimes?: PlannedStopTimeDto[]; } export class UpdateScheduleDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 2d194fd7b..19aa5ed0e 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -153,7 +153,7 @@ export class SchedulesService { }); } - const providedSeqs = new Set(plannedTimes.map(t => t.sequence)); + const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq)); if (missingSeqs.length > 0) { throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`); diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index 6bb9d1960..384592dc9 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -1,8 +1,8 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Body, Controller, Post, Get, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SearchService } from './search.service'; -import { SearchTripsDto, FareQuoteDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto'; @ApiTags('Search') @Controller('search') @@ -66,4 +66,29 @@ Nationality-Based: getFareQuote(@Body() dto: FareQuoteDto) { return this.service.getFareQuote(dto); } + + @Get('fare-breakdown') + @ApiOperation({ + summary: 'Per-passenger fare breakdown for booking review page', + description: `Calculates a line-item fare for each individual passenger based on their date of birth, nationality, and chosen seat class. + +- Age is derived from dateOfBirth at request time (ADULT ≥5 yrs, CHILD <5 yrs) +- First CHILD in the list travels free (pays only premium + insurance fees) +- Each passenger can have a different seat class and nationality +- Returns per-passenger lines plus subtotal, discount, and grand total + +**passengers** must be a URL-encoded JSON array, e.g.: +\`[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]\``, + }) + @ApiQuery({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'originStationId', description: 'Origin station UUID' }) + @ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' }) + @ApiQuery({ name: 'passengers', description: 'URL-encoded JSON array of passengers: [{passengerName, dateOfBirth, seatClassId, nationality?}]' }) + @ApiQuery({ name: 'promoCode', required: false }) + @ApiQuery({ name: 'displayCurrency', required: false, enum: ['ETB', 'DJF', 'USD'] }) + @ApiResponse({ status: 200, description: 'Per-passenger fare lines with grand total' }) + @ApiResponse({ status: 404, description: 'Schedule not found' }) + getFareBreakdown(@Query() dto: FareBreakdownRequestDto) { + return this.service.getFareBreakdown(dto); + } } diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index 9eb035ef2..cfb1075ca 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -75,6 +75,43 @@ export class CoachTypeOptionClass { @ApiProperty({ example: 35000 }) baseFareMinor: number; } +export class FareBreakdownPassengerDto { + @ApiProperty({ example: 'Abebe Kebede', description: 'Passenger name (for display only)' }) + @IsString() passengerName: string; + + @ApiProperty({ example: '1985-03-15', description: 'Date of birth — determines ADULT (≥5 yrs) or CHILD (<5 yrs)' }) + @IsDateString() dateOfBirth: string; + + @ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID for this passenger' }) + @IsString() seatClassId: string; + + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality — affects billing currency and seat class variant' }) + @IsOptional() @IsString() nationality?: string; +} + +export class FareBreakdownRequestDto { + @ApiProperty({ example: 'schedule-uuid' }) + @IsString() scheduleId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the schedule)' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) + @IsString() destinationStationId: string; + + @ApiProperty({ + example: '[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]', + description: 'URL-encoded JSON array of passengers. Each entry: { passengerName, dateOfBirth (YYYY-MM-DD), seatClassId, nationality? }', + }) + @IsString() passengers: string; + + @ApiPropertyOptional({ example: 'WEEKEND15' }) + @IsOptional() @IsString() promoCode?: string; + + @ApiPropertyOptional({ example: 'USD', enum: Currency }) + @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; +} + export class CoachTypeOption { @ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string; @ApiProperty({ example: 'Economy' }) coachTypeName: string; diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 6f3bc5a67..621e5487d 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { SearchTripsDto, FareQuoteDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto } from './search.dto'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; @@ -477,6 +477,124 @@ export class SearchService { }; } + async getFareBreakdown(dto: FareBreakdownRequestDto) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { routeId: true, originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); + + const now = new Date(); + const displayCurrency = dto.displayCurrency ?? Currency.ETB; + + let parsedPassengers: FareBreakdownPassengerDto[]; + try { + parsedPassengers = JSON.parse(dto.passengers as unknown as string); + } catch { + throw new NotFoundException('passengers must be a valid JSON array'); + } + + // Categorise passengers by age + const categorised = parsedPassengers.map(p => { + const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime(); + const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25); + return { ...p, category: (ageYears >= 5 ? 'ADULT' : 'CHILD') as 'ADULT' | 'CHILD', ageYears }; + }); + + const adultCount = categorised.filter(p => p.category === 'ADULT').length; + const childCount = categorised.filter(p => p.category === 'CHILD').length; + + // Ask the fare engine for the authoritative free-child count using the full group + // Use the first passenger's seatClassId as a representative — freeChildrenCount + // depends only on adultCount/childCount, not on seat class. + const groupFare = await this.fareEngine.calculate({ + routeId: schedule.routeId!, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: categorised[0].seatClassId, + nationality: categorised[0].nationality, + scheduleId: dto.scheduleId, + adultCount, + childCount, + }); + const freeChildrenAllowed = groupFare.freeChildrenCount; + + // Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup) + let freeChildrenUsed = 0; + const passengerLines = await Promise.all( + categorised.map(async (p) => { + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId!, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: p.seatClassId, + nationality: p.nationality, + scheduleId: dto.scheduleId, + adultCount: 1, + childCount: 0, + }); + + const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed; + if (isFree) freeChildrenUsed++; + + const fareMinor = isFree + ? fare.premiumPerPassenger + fare.insurancePerPassenger + : fare.farePerPassengerMinor; + const displayFareMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency) + : fareMinor; + + return { + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + category: p.category, + ageYears: Math.floor(p.ageYears), + seatClassId: fare.seatClassId, + seatClassName: fare.seatClassName, + nationality: p.nationality ?? null, + baseFareMinor: fare.baseFarePerPassengerMinor, + premiumMinor: fare.premiumPerPassenger, + insuranceFeeMinor: fare.insurancePerPassenger, + fareMinor, + isFree, + displayCurrency, + displayFareMinor, + }; + }), + ); + + let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0); + + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > now) { + discountMinor = promo.percentOff + ? Math.round(subtotalMinor * promo.percentOff / 100) + : (promo.amountOffMinor ?? 0); + } + } + + const totalMinor = subtotalMinor - discountMinor; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + return { + scheduleId: dto.scheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + passengers: passengerLines, + subtotalMinor, + discountMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }; + } + private async calculateFaresForSegment( schedule: ScheduleWithIncludes, originStationId: string, diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 79151bdc9..63f5e1f29 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; +import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; @Injectable() export class SeatClassesService { @@ -45,8 +46,24 @@ export class SeatClassesService { } async deleteSeatClass(id: string) { - const sc = await this.prisma.seatClass.findUnique({ where: { id } }); + const sc = await this.prisma.seatClass.findUnique({ + where: { id }, + include: { + _count: { select: { fareRules: true, routeFareRules: true, segmentFares: true } }, + }, + }); if (!sc) throw new NotFoundException('SeatClass not found'); + + const totalFareRules = + (sc as any)._count.fareRules + + (sc as any)._count.routeFareRules + + (sc as any)._count.segmentFares; + + if (totalFareRules > 0) + throw new DeleteOperationException('Seat Class', sc.name, [ + { entityName: 'fare rule', count: totalFareRules, action: 'delete' }, + ]); + return this.prisma.seatClass.delete({ where: { id } }); } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index a8d5d724c..4a9783101 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -28,6 +28,25 @@ import { IamGuard } from "../../common/iam-adapter"; export class SeatsController { constructor(private service: SeatsService) {} + // ── Coach Availability ──────────────────────────────────────────────────── + @Get('coaches/:scheduleId') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'List coaches with remaining seat counts for a schedule', + description: 'Returns each coach assigned to the schedule with total, available, held, and booked seat counts. Optionally scoped to a specific origin→destination leg.', + }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'originStationId', required: false, description: 'Scope availability to this origin station' }) + @ApiQuery({ name: 'destinationStationId', required: false, description: 'Scope availability to this destination station' }) + @ApiResponse({ status: 200, description: 'Coaches with seat availability counts' }) + getCoachesWithAvailability( + @Param('scheduleId') scheduleId: string, + @Query('originStationId') originStationId?: string, + @Query('destinationStationId') destinationStationId?: string, + ) { + return this.service.getCoachesWithAvailability(scheduleId, originStationId, destinationStationId); + } + // ── Seat Map ────────────────────────────────────────────────────────────── @Get("seatmap/:scheduleId") @SetMetadata('isPublic', true) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 1562a600b..495c394ee 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -61,11 +61,12 @@ export class SeatsService { const resolvedBedPosition = isBedCoach ? this.resolveBedPosition(s.col, s.bedPosition) : s.bedPosition; + const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE'); return { id: s.id, seatNumber: s.seatNumber, label: s.seatNumber, - status: effectiveStatuses.get(s.id) ?? s.status, + status: effectiveStatus, kind: s.kind, row: s.row, col: s.col, @@ -245,11 +246,16 @@ export class SeatsService { holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo); - if (!legsOverlap) continue; - // Check direction conflict const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection); - if (!directionsConflict) continue; + + if (!legsOverlap || !directionsConflict) { + // This hold does not conflict with the requested leg/direction. + // Explicitly mark AVAILABLE so the DB's HELD status (set by the + // opposing-direction hold) does not bleed through via the fallback. + if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE'); + continue; + } statusMap.set(seatId, 'HELD'); } @@ -367,7 +373,24 @@ export class SeatsService { where: { scheduleId: dto.scheduleId }, select: { stationId: true, sequence: true }, }); - const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence; + + // When no stop times exist, fall back to the schedule's own origin/destination + // with synthetic sequences so the hold can still be created. + let effectiveStopTimes = stopTimes; + if (stopTimes.length === 0) { + const sched = await tx.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (sched) { + effectiveStopTimes = [ + { stationId: sched.originStationId, sequence: 0 }, + { stationId: sched.destinationStationId, sequence: 1 }, + ]; + } + } + + const seqOf = (stationId: string) => effectiveStopTimes.find(s => s.stationId === stationId)?.sequence; const reqFrom = seqOf(dto.originStationId); const reqTo = seqOf(dto.destinationStationId); @@ -604,6 +627,56 @@ export class SeatsService { await this.prisma.journey.deleteMany({ where: { bookingId } as any }); } + async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const assignments = await this.prisma.coachAssignment.findMany({ + where: { scheduleId }, + include: { + coach: { + include: { + seats: { select: { id: true, status: true, seatNumber: true } }, + coachType: { include: { seatClasses: { select: { name: true } } } }, + }, + }, + }, + orderBy: { positionNumber: 'asc' }, + }); + + const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id)); + const effectiveStatuses = await this.resolveEffectiveStatuses( + scheduleId, + allSeatIds, + originStationId ?? schedule.originStationId, + destinationStationId ?? schedule.destinationStationId, + ); + + return assignments.map(a => { + const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-')); + const totalSeats = seats.length; + const unavailable = seats.filter(s => { + const status = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE'); + return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED'; + }).length; + + return { + coachId: a.coach.id, + coachNumber: a.coach.number, + positionNumber: a.positionNumber, + coachTypeName: a.coach.coachType?.name ?? '', + seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [], + totalSeats, + availableSeats: totalSeats - unavailable, + heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'HELD').length, + bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'BOOKED').length, + }; + }); + } + async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { const seats = await this.prisma.seat.findMany({ where: { @@ -794,11 +867,21 @@ export class SeatsService { if (expired.length === 0) return; const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]); - // Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched. - await this.prisma.seat.updateMany({ - where: { id: { in: expiredSeatIds }, status: 'HELD' }, - data: { status: 'AVAILABLE' }, + + // Only reset seats that have no remaining active holds + const stillHeld = await this.prisma.seatHold.findMany({ + where: { expiresAt: { gte: new Date() }, seatIds: { hasSome: expiredSeatIds } }, + select: { seatIds: true }, }); + const stillHeldIds = new Set(stillHeld.flatMap(h => h.seatIds as string[])); + const toRelease = expiredSeatIds.filter(id => !stillHeldIds.has(id)); + + if (toRelease.length > 0) { + await this.prisma.seat.updateMany({ + where: { id: { in: toRelease }, status: 'HELD' }, + data: { status: 'AVAILABLE' }, + }); + } await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } }); } } diff --git a/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts b/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts deleted file mode 100644 index 7e912a6e2..000000000 --- a/apps/edr-passenger-api/src/modules/segments/booking-flow-example.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * SEGMENT-BASED SEAT RESERVATION EXAMPLE - * - * Demonstrates the complete flow for booking Addis Ababa → Dire Dawa - * on the Addis Ababa → Djibouti route with segment-based seat management. - * - * Route: Addis Ababa (seq:1) → Adama (seq:2) → Awash (seq:3) → Dire Dawa (seq:4) → Aysha (seq:5) → Djibouti (seq:6) - * Booking: Addis Ababa → Dire Dawa (segments: 1→2, 2→3, 3→4) - */ - -import { PrismaClient } from '@prisma/client'; - -const prisma = new PrismaClient(); - -async function exampleBookingFlow() { - console.log('=== SEGMENT-BASED BOOKING FLOW ===\n'); - - const scheduleId = 'schedule_add_dji_001'; - const passengerId = 'passenger_kelemu'; - const seatIds = ['seat_coach_a_1a', 'seat_coach_a_1b']; - const originStationId = 'st_ADD'; - const destinationStationId = 'st_DRE'; - - try { - console.log('1. Checking seat availability...'); - const segments = await getJourneySegments(scheduleId, originStationId, destinationStationId); - console.log('Journey segments:', segments.map(s => `${s.fromName} → ${s.toName}`)); - - console.log('\n2. Holding seats...'); - const holdResult = await holdSeatsTransaction(scheduleId, seatIds, passengerId, originStationId, destinationStationId); - console.log('Hold created:', holdResult); - - console.log('\n3. Processing payment...'); - await new Promise(resolve => setTimeout(resolve, 5000)); - - console.log('\n4. Confirming booking...'); - const bookingId = 'booking_' + Date.now(); - const confirmResult = await confirmBookingTransaction(holdResult.holdId, bookingId, segments); - console.log('Booking confirmed:', confirmResult); - - console.log('\n5. Simulating trip progress...'); - await simulateTripProgress(scheduleId, segments); - - } catch (error) { - console.error('Booking flow error:', error); - } -} - -async function getJourneySegments(scheduleId: string, originStationId: string, destinationStationId: string) { - const stopTimes = await prisma.tripStopTime.findMany({ - where: { scheduleId }, - include: { station: true }, - orderBy: { sequence: 'asc' }, - }); - - const originStop = stopTimes.find(st => st.stationId === originStationId); - const destinationStop = stopTimes.find(st => st.stationId === destinationStationId); - - if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) { - throw new Error('Invalid origin/destination'); - } - - const segments = []; - for (let i = originStop.sequence; i < destinationStop.sequence; i++) { - const fromStop = stopTimes.find(st => st.sequence === i); - const toStop = stopTimes.find(st => st.sequence === i + 1); - if (fromStop && toStop) { - segments.push({ - fromStationId: fromStop.stationId, - toStationId: toStop.stationId, - fromSequence: fromStop.sequence, - toSequence: toStop.sequence, - fromName: fromStop.station.name, - toName: toStop.station.name, - }); - } - } - return segments; -} - -async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passengerId: string, originStationId: string, destinationStationId: string) { - return prisma.$transaction(async (tx) => { - console.log(' → Starting seat hold transaction...'); - - const seats = await tx.seat.findMany({ where: { id: { in: seatIds } }, include: { coach: true } }); - if (seats.length !== seatIds.length) throw new Error('Some seats not found'); - - for (const seat of seats) { - if (seat.status !== 'AVAILABLE') { - throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`); - } - } - - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); - const seatHold = await tx.seatHold.create({ - data: { scheduleId, seatIds, passengerId, expiresAt }, - }); - - await tx.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } }); - - console.log(' → Seats held successfully'); - return { holdId: seatHold.id, expiresAt, seats: seatIds.length }; - }); -} - -async function confirmBookingTransaction(holdId: string, bookingId: string, segments: any[]) { - return prisma.$transaction(async (tx) => { - console.log(' → Starting booking confirmation transaction...'); - - const hold = await tx.seatHold.findUnique({ where: { id: holdId } }); - if (!hold || hold.expiresAt < new Date()) throw new Error('Hold expired or not found'); - - const booking = await tx.booking.create({ - data: { - id: bookingId, - bookingRef: 'BK' + Date.now().toString().slice(-6), - passengerId: hold.passengerId, - scheduleId: hold.scheduleId, - status: 'CONFIRMED', - totalMinor: 45000, - currency: 'ETB', - }, - }); - - const journey = await tx.journey.create({ - data: { passengerId: hold.passengerId, status: 'CONFIRMED', totalMinor: 45000, currency: 'ETB' }, - }); - - for (const seatId of hold.seatIds) { - for (let i = 0; i < segments.length; i++) { - await tx.journeySegment.create({ - data: { - journeyId: journey.id, - scheduleId: hold.scheduleId, - segmentOrder: i + 1, - seatId, - departureStationId: segments[i].fromStationId, - arrivalStationId: segments[i].toStationId, - }, - }); - } - } - - for (const seatId of hold.seatIds) { - await tx.bookingSeat.create({ data: { bookingId, seatId, passengerName: 'Kelemu Ketsela' } }); - } - - await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); - await tx.seatHold.delete({ where: { id: holdId } }); - - console.log(' → Booking confirmed successfully'); - return { bookingId, bookingRef: booking.bookingRef, confirmedSeats: hold.seatIds.length, segments: segments.length }; - }); -} - -async function simulateTripProgress(scheduleId: string, bookedSegments: any[]) { - console.log(' → Simulating trip progress...'); - - for (const segment of bookedSegments) { - console.log(` → Train approaching ${segment.toName}...`); - - await prisma.tripLiveStatus.upsert({ - where: { scheduleId }, - update: { currentLocationLabel: segment.toName, progressPercent: Math.round((segment.toSequence / 4) * 100) }, - create: { - scheduleId, - state: 'EN_ROUTE', - currentLocationLabel: segment.toName, - progressPercent: Math.round((segment.toSequence / 4) * 100), - delayMinutes: 0, - }, - }); - - if (segment.toName === 'Dire Dawa') { - console.log(' → Passengers reached destination, releasing seats...'); - await releaseSeatsAtStation(scheduleId, segment.toStationId); - } - - await new Promise(resolve => setTimeout(resolve, 2000)); - } -} - -async function releaseSeatsAtStation(scheduleId: string, stationId: string) { - return prisma.$transaction(async (tx) => { - const completedSegments = await tx.journeySegment.findMany({ - where: { scheduleId, arrivalStationId: stationId }, - include: { journey: { include: { journeySegments: { where: { scheduleId } } } } }, - }); - - const seatsToRelease: string[] = []; - - for (const segment of completedSegments) { - const passengerSegments = segment.journey.journeySegments.filter((js: any) => js.seatId === segment.seatId); - const maxOrder = Math.max(...passengerSegments.map((js: any) => js.segmentOrder)); - if (segment.segmentOrder === maxOrder) seatsToRelease.push(segment.seatId!); - } - - if (seatsToRelease.length > 0) { - await tx.seat.updateMany({ where: { id: { in: seatsToRelease } }, data: { status: 'AVAILABLE' } }); - console.log(` → Released ${seatsToRelease.length} seats at station`); - } - - return seatsToRelease; - }); -} - -async function checkOverlappingReservations(tx: any, scheduleId: string, seatId: string, segments: any[]) { - const activeHolds = await tx.seatHold.findMany({ - where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } }, - }); - - const activeBookings = await tx.journeySegment.findMany({ - where: { - scheduleId, - seatId, - journey: { status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } }, - }, - }); - - return [...activeHolds, ...activeBookings]; -} - -if (require.main === module) { - exampleBookingFlow() - .then(() => console.log('\n=== EXAMPLES COMPLETED ===')) - .catch(console.error) - .finally(() => prisma.$disconnect()); -} - -export { - exampleBookingFlow, - getJourneySegments, - holdSeatsTransaction, - confirmBookingTransaction, - simulateTripProgress, - releaseSeatsAtStation, - checkOverlappingReservations, -}; diff --git a/apps/edr-passenger-api/src/modules/segments/segment-fare.controller.ts b/apps/edr-passenger-api/src/modules/segments/segment-fare.controller.ts new file mode 100644 index 000000000..3cd7c5f06 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/segment-fare.controller.ts @@ -0,0 +1,49 @@ +import { + Controller, Get, Post, Put, Delete, + Param, Body, Query, UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { IamGuard } from '../../common/iam-adapter'; +import { Roles } from '../../common/roles.decorator'; +import { SegmentFareService } from './segment-fare.service'; +import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto'; + +@ApiTags('Admin – Segment Fares') +@Controller('admin/segment-fares') +@ApiBearerAuth('IAM-auth') +@UseGuards(IamGuard) +@Roles('ADMIN', 'SUPERVISOR') +export class SegmentFareController { + constructor(private readonly service: SegmentFareService) {} + + @Get() + @ApiOperation({ summary: 'List all segment fare rules, optionally filtered by route' }) + @ApiQuery({ name: 'routeId', required: false }) + findAll(@Query('routeId') routeId?: string) { + return this.service.findAll(routeId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single segment fare rule' }) + findOne(@Param('id') id: string) { + return this.service.findOne(id); + } + + @Post() + @ApiOperation({ summary: 'Create a segment fare rule' }) + create(@Body() dto: CreateSegmentFareDto) { + return this.service.create(dto); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a segment fare rule' }) + update(@Param('id') id: string, @Body() dto: UpdateSegmentFareDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a segment fare rule' }) + remove(@Param('id') id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-passenger-api/src/modules/segments/segment-fare.dto.ts b/apps/edr-passenger-api/src/modules/segments/segment-fare.dto.ts new file mode 100644 index 000000000..2f56f4fe1 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/segment-fare.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, IsInt, IsOptional, IsDateString, IsIn, Min, +} from 'class-validator'; + +export class CreateSegmentFareDto { + @ApiProperty({ example: 'route-uuid' }) + @IsString() + routeId: string; + + @ApiProperty({ example: 1 }) + @IsInt() @Min(0) + originStopSequence: number; + + @ApiProperty({ example: 5 }) + @IsInt() @Min(1) + destinationStopSequence: number; + + @ApiProperty({ example: 'seat-class-uuid' }) + @IsString() + seatClassId: string; + + @ApiProperty({ example: 35000, description: 'Base fare in minor units (e.g. 350.00 ETB = 35000)' }) + @IsInt() @Min(0) + baseFareMinor: number; + + @ApiPropertyOptional({ example: 'LOCAL', enum: ['LOCAL', 'INTERNATIONAL'] }) + @IsOptional() + @IsIn(['LOCAL', 'INTERNATIONAL']) + nationality?: string; + + @ApiPropertyOptional({ example: 'ETB', default: 'ETB' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiProperty({ example: '2025-01-01T00:00:00.000Z' }) + @IsDateString() + validFrom: string; + + @ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' }) + @IsOptional() + @IsDateString() + validUntil?: string; +} + +export class UpdateSegmentFareDto { + @ApiPropertyOptional({ example: 40000 }) + @IsOptional() + @IsInt() @Min(0) + baseFareMinor?: number; + + @ApiPropertyOptional({ example: 'ETB' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ example: '2025-06-01T00:00:00.000Z' }) + @IsOptional() + @IsDateString() + validFrom?: string; + + @ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z' }) + @IsOptional() + @IsDateString() + validUntil?: string; +} diff --git a/apps/edr-passenger-api/src/modules/segments/segment-fare.service.ts b/apps/edr-passenger-api/src/modules/segments/segment-fare.service.ts new file mode 100644 index 000000000..982ee7dd6 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/segments/segment-fare.service.ts @@ -0,0 +1,62 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../common/prisma.service'; +import { CreateSegmentFareDto, UpdateSegmentFareDto } from './segment-fare.dto'; + +@Injectable() +export class SegmentFareService { + constructor(private readonly prisma: PrismaService) {} + + findAll(routeId?: string) { + return this.prisma.segmentFareRule.findMany({ + where: routeId ? { routeId } : undefined, + include: { seatClass: true, route: { select: { id: true, code: true, name: true } } }, + orderBy: [{ routeId: 'asc' }, { originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }], + }); + } + + async findOne(id: string) { + const rule = await this.prisma.segmentFareRule.findUnique({ + where: { id }, + include: { seatClass: true, route: { select: { id: true, code: true, name: true } } }, + }); + if (!rule) throw new NotFoundException(`SegmentFareRule ${id} not found`); + return rule; + } + + create(dto: CreateSegmentFareDto) { + return this.prisma.segmentFareRule.create({ + data: { + routeId: dto.routeId, + originStopSequence: dto.originStopSequence, + destinationStopSequence: dto.destinationStopSequence, + seatClassId: dto.seatClassId, + baseFareMinor: dto.baseFareMinor, + nationality: dto.nationality ?? null, + currency: dto.currency ?? 'ETB', + validFrom: new Date(dto.validFrom), + validUntil: dto.validUntil ? new Date(dto.validUntil) : null, + }, + include: { seatClass: true }, + }); + } + + async update(id: string, dto: UpdateSegmentFareDto) { + await this.findOne(id); + return this.prisma.segmentFareRule.update({ + where: { id }, + data: { + ...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }), + ...(dto.currency !== undefined && { currency: dto.currency }), + ...(dto.validFrom !== undefined && { validFrom: new Date(dto.validFrom) }), + ...(dto.validUntil !== undefined && { validUntil: new Date(dto.validUntil) }), + }, + include: { seatClass: true }, + }); + } + + async remove(id: string) { + await this.findOne(id); + await this.prisma.segmentFareRule.delete({ where: { id } }); + return { deleted: true }; + } +} diff --git a/apps/edr-passenger-api/src/modules/segments/segments.module.ts b/apps/edr-passenger-api/src/modules/segments/segments.module.ts index f82da7fa9..053d1d33f 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.module.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.module.ts @@ -3,20 +3,24 @@ import { SegmentsService } from './segments.service'; import { EnhancedSeatsService } from './enhanced-seats.service'; import { TripProgressService } from './trip-progress.service'; import { SegmentSeatsController } from './segments.controller'; +import { SegmentFareController } from './segment-fare.controller'; +import { SegmentFareService } from './segment-fare.service'; import { PrismaService } from '../../common/prisma.service'; @Module({ - controllers: [SegmentSeatsController], + controllers: [SegmentSeatsController, SegmentFareController], providers: [ SegmentsService, EnhancedSeatsService, TripProgressService, - PrismaService + SegmentFareService, + PrismaService, ], exports: [ SegmentsService, EnhancedSeatsService, - TripProgressService - ] + TripProgressService, + SegmentFareService, + ], }) export class SegmentsModule {} \ No newline at end of file diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index 9e9fc824d..ec2480b21 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -3,6 +3,7 @@ import { REQUEST } from '@nestjs/core'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; import { CreateStationDto } from './stations.dto'; +import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; interface StationFilters { search?: string; @@ -96,7 +97,35 @@ export class StationsService { } async remove(id: string) { - const station = await this.findOne(id); + const station = await this.prisma.station.findUnique({ + where: { id }, + include: { + _count: { select: { stopTimes: true } }, + originSchedules: { take: 1, select: { id: true } }, + destinationSchedules: { take: 1, select: { id: true } }, + }, + }); + if (!station) throw new NotFoundException('Station not found'); + + const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([ + this.prisma.routeStop.count({ where: { stationId: id } }), + this.prisma.trainSchedule.count({ where: { originStationId: id } }), + this.prisma.trainSchedule.count({ where: { destinationStationId: id } }), + (station as any)._count.stopTimes as number, + ]); + + const constraints = []; + if (routeStopCount > 0) + constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const }); + const scheduleCount = originCount + destCount; + if (scheduleCount > 0) + constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const }); + if (stopTimeCount > 0) + constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const }); + + if (constraints.length > 0) + throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints); + const deleted = await this.prisma.station.delete({ where: { id } }); await this.auditService.log({ diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts index 1ecb2edea..8b0c5ed2e 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { WalletService } from './wallet.service'; @@ -11,6 +11,8 @@ import { JwtGuard } from '../../common/jwt.guard'; @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class WalletController { constructor(private service: WalletService) {} - @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } - @Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); } + @Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all wallet accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); } + @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } + @Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); } + @Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete wallet account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); } } diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts index a83d97e0b..ac092ee41 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts @@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service'; export class WalletService { constructor(private prisma: PrismaService) {} + async getAccounts(params: { search?: string; page?: string; pageSize?: string } = {}) { + const { search, page = '1', pageSize = '20' } = params; + const skip = (parseInt(page) - 1) * parseInt(pageSize); + const where: any = {}; + if (search) { + where.passenger = { + OR: [ + { user: { fullName: { contains: search, mode: 'insensitive' } } }, + { user: { email: { contains: search, mode: 'insensitive' } } }, + ], + }; + } + const [items, total] = await Promise.all([ + this.prisma.walletAccount.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { balanceMinor: 'desc' }, + include: { passenger: { include: { user: true } } }, + }), + this.prisma.walletAccount.count({ where }), + ]); + return { + items: items.map(w => ({ + ...w, + passenger: w.passenger ? { + id: w.passenger.id, + fullName: (w.passenger as any).user?.fullName ?? null, + email: (w.passenger as any).user?.email ?? null, + phone: (w.passenger as any).user?.phone ?? null, + } : null, + })), + meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) }, + }; + } + async getWallet(passengerId: string) { const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } }); if (!wallet) throw new NotFoundException('Wallet not found'); @@ -18,4 +54,14 @@ export class WalletService { await this.prisma.walletAccount.update({ where: { passengerId }, data: { balanceMinor: newBalance } }); return this.prisma.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'CREDIT', amountMinor, balanceAfterMinor: newBalance, description } }); } + + async deleteAccount(id: string) { + const wallet = await this.prisma.walletAccount.findUnique({ where: { id } }); + if (!wallet) throw new NotFoundException('Wallet account not found'); + await this.prisma.$transaction([ + this.prisma.walletLedgerEntry.deleteMany({ where: { walletId: id } }), + this.prisma.walletAccount.delete({ where: { id } }), + ]); + return { deleted: true, accountId: id }; + } } diff --git a/apps/edr-passenger-api/src/seed/segment-fare.seeder.ts b/apps/edr-passenger-api/src/seed/segment-fare.seeder.ts new file mode 100644 index 000000000..4ae06b0a1 --- /dev/null +++ b/apps/edr-passenger-api/src/seed/segment-fare.seeder.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../common/prisma.service'; + +const SEED_FLAG = 'SEED_SEGMENT_FARES'; + +/** + * Seeds SegmentFareRule rows for every origin→destination pair on the + * Addis Ababa–Djibouti route across all active seat classes. + * + * Skip-if-loaded: uses Prisma upsert on the unique constraint + * (routeId, originStopSequence, destinationStopSequence, seatClassId, nationality). + * Re-running is safe — existing rows are updated in-place. + */ +@Injectable() +export class SegmentFareSeeder { + private readonly logger = new Logger(SegmentFareSeeder.name); + + constructor(private readonly prisma: PrismaService) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping segment fare seed — set ${SEED_FLAG}=true to enable`); + return; + } + + const route = await this.prisma.route.findFirst({ + where: { active: true }, + include: { stops: { orderBy: { sequence: 'asc' } } }, + }); + + if (!route) { + this.logger.warn('No active route found — skipping segment fare seed'); + return; + } + + const seatClasses = await this.prisma.seatClass.findMany({ + where: { isActive: true }, + }); + + if (!seatClasses.length) { + this.logger.warn('No active seat classes found — skipping segment fare seed'); + return; + } + + const stops = route.stops; + const validFrom = new Date('2025-01-01T00:00:00.000Z'); + + // Rate table: ETB minor units per km, keyed by (nationalityType, bedPosition) + // null bedPosition = regular seat + const rateTable: Record> = { + LOCAL: { null: 3000, UPPER: 4000, MIDDLE: 5500, LOWER: 6000 }, + INTERNATIONAL: { null: 6000, UPPER: 8000, MIDDLE: 11000, LOWER: 12000 }, + }; + + let upserted = 0; + + for (const seatClass of seatClasses) { + const natType = seatClass.nationalityType ?? 'LOCAL'; + const bedPos = seatClass.bedPosition ?? 'null'; + const ratePerKm = rateTable[natType]?.[bedPos] ?? rateTable['LOCAL']['null']; + + for (let i = 0; i < stops.length - 1; i++) { + for (let j = i + 1; j < stops.length; j++) { + const origin = stops[i]; + const dest = stops[j]; + + // Approximate distance: sum of per-stop distanceKm if available, + // otherwise fall back to sequence-gap × 50 km. + let distanceKm = 0; + for (let k = i; k < j; k++) { + distanceKm += stops[k + 1].distanceKm ?? 50; + } + + const baseFareMinor = Math.round(distanceKm * ratePerKm); + + await this.prisma.segmentFareRule.upsert({ + where: { + routeId_originStopSequence_destinationStopSequence_seatClassId_nationality: { + routeId: route.id, + originStopSequence: origin.sequence, + destinationStopSequence: dest.sequence, + seatClassId: seatClass.id, + nationality: natType, + }, + }, + update: { baseFareMinor, validFrom }, + create: { + routeId: route.id, + originStopSequence: origin.sequence, + destinationStopSequence: dest.sequence, + seatClassId: seatClass.id, + nationality: natType, + baseFareMinor, + currency: 'ETB', + validFrom, + }, + }); + + upserted++; + } + } + } + + this.logger.log( + `Segment fare seed complete — ${upserted} rules upserted ` + + `(${stops.length} stops × ${seatClasses.length} seat classes)`, + ); + } +} diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx new file mode 100644 index 000000000..c5d5d7fa9 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Pencil, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { appReleasesApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; + +const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' }; + +export default function AppReleasesPage() { + const queryClient = useQueryClient(); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [formError, setFormError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteError, setDeleteError] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); + + const { data, isLoading } = useQuery({ + queryKey: ['app-releases'], + queryFn: () => appReleasesApi.getAll(), + }); + + const flash = (msg: string) => { setSuccessMessage(msg); setTimeout(() => setSuccessMessage(''), 3000); }; + + const saveMutation = useMutation({ + mutationFn: (payload: any) => + editing ? appReleasesApi.update(editing.id, payload) : appReleasesApi.create(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['app-releases'] }); + setFormOpen(false); + setEditing(null); + setForm({ ...EMPTY_FORM }); + setFormError(''); + flash(editing ? 'Release updated.' : 'Release created.'); + }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save.'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => appReleasesApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['app-releases'] }); + setDeleteTarget(null); + setDeleteError(null); + flash('Release deleted.'); + }, + onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete.'), + }); + + const openCreate = () => { setEditing(null); setForm({ ...EMPTY_FORM }); setFormError(''); setFormOpen(true); }; + const openEdit = (r: any) => { + setEditing(r); + setForm({ os: r.os, version: r.version, forceUpdate: r.forceUpdate, storeLink: r.storeLink || '', notes: r.notes || '' }); + setFormError(''); + setFormOpen(true); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!form.version.trim()) { setFormError('Version is required.'); return; } + saveMutation.mutate({ ...form, version: form.version.trim(), storeLink: form.storeLink || undefined, notes: form.notes || undefined }); + }; + + const releases: any[] = Array.isArray(data) ? data : []; + + const columns = [ + { + key: 'os', label: 'OS', + render: (r: any) => ( + + {r.os === 'ios' ? '🍎 iOS' : '🤖 Android'} + + ), + }, + { key: 'version', label: 'Version', render: (r: any) => {r.version} }, + { + key: 'forceUpdate', label: 'Force Update', + render: (r: any) => {r.forceUpdate ? 'Yes' : 'No'}, + }, + { + key: 'storeLink', label: 'Store Link', + render: (r: any) => r.storeLink + ? {r.storeLink} + : , + }, + { key: 'notes', label: 'Notes', render: (r: any) => {r.notes || '—'} }, + { key: 'createdAt', label: 'Created', render: (r: any) => {formatDateTime(r.createdAt)} }, + ]; + + const actions = [ + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Pencil }, + { label: 'Delete', onClick: (r: any) => { setDeleteError(null); setDeleteTarget(r); }, variant: 'danger' as const, icon: Trash2 }, + ]; + + return ( +
+
+
+

App Releases

+

Manage mobile app version release control

+
+ New Release +
+ + {successMessage && ( +
✓ {successMessage}
+ )} + +
+ +
+ + {/* Create / Edit Modal */} + setFormOpen(false)} title={editing ? 'Edit Release' : 'New Release'} size="md"> +
+
+
+ + +
+
+ + setForm({ ...form, version: e.target.value })} /> +
+
+ +
+ +
+ {(['true', 'false'] as const).map((val) => ( + + ))} +
+
+ +
+ + setForm({ ...form, storeLink: e.target.value })} /> +
+ +
+ +