diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index e02391ccd..5bce9de95 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -55,8 +55,31 @@ REDIS_HOST=localhost REDIS_PORT=6379 # --- Notification broker (RabbitMQ) --------------------------------------------- -# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service). -# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker). +# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared +# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely +# (dev without a local broker). RABBITMQ_ENABLED=false RABBITMQ_URL=amqp://localhost:5672 SMS_QUEUE=sms_queue + +# ── VeriFayda 2.0 (eSignet OIDC) identity verification ────────────────────── +# Disabled by default; /fayda/verification/start returns 503 until enabled. +FAYDA_ENABLED=false +FAYDA_CLIENT_ID= +FAYDA_AUTHORIZATION_ENDPOINT= +FAYDA_TOKEN_ENDPOINT= +FAYDA_USERINFO_ENDPOINT= +# Base64-encoded RSA private JWK used for the private_key_jwt client assertion +FAYDA_PRIVATE_KEY_BASE64= +# OAuth redirect_uri for MOBILE clients (must be registered with eSignet) +FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete +# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. +FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +FAYDA_SCOPE=openid profile email phone address +FAYDA_ACR_VALUES=mosip:idp:acr:generated-code +FAYDA_CLAIMS_LOCALES=en am +FAYDA_SESSION_TTL_MINUTES=10 +EXPIRATION_TIME=15 +ALGORITHM=RS256 +EMAIL_QUEUE=email_queue diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index a0a119861..c148dd226 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -63,6 +63,7 @@ "dotenv": "^17.4.2", "dotenv-cli": "^11.0.0", "handlebars": "^4.7.9", + "jose": "^5.10.0", "libphonenumber-js": "^1.13.6", "minio": "7.1.3", "pg": "^8.13.0", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a81a539ba..40161209e 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,29 +64,30 @@ 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 { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { WagonsModule } from './modules/wagons/wagons.module'; -import { ContainersModule } from './modules/container-management/containers.module'; -import { CargoesModule } from './modules/cargoes/cargoes.module'; -import { RoutesModule } from './modules/routes/routes.module'; -import { WarehousesModule } from './modules/warehouses/warehouses.module'; -import { OverviewModule } from './modules/overview/overview.module'; -import { VehiclesModule } from './modules/vehicles/vehicles.module'; -import { DriversModule } from './modules/drivers/drivers.module'; -import { FuelModule } from './modules/fuel/fuel.module'; -import { MaintenanceModule } from './modules/maintenance/maintenance.module'; -import { FirstMileModule } from './modules/first-mile/first-mile.module'; -import { LastMileModule } from './modules/last-mile/last-mile.module'; -import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; -import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; +import { VerifaydaModule } from './modules/verifayda/verifayda.module'; +import { 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(), @@ -142,6 +148,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera LastMileModule, InterchangeDocumentsModule, ImportOperationsModule, + VerifaydaModule, ], providers: [ EdrOrgSeeder, @@ -161,7 +168,6 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, - PaidIndodeDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -180,7 +186,6 @@ export class AppModule implements OnApplicationBootstrap { private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, - private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly govCompaniesSeeder: GovCompaniesSeeder, @@ -202,7 +207,6 @@ export class AppModule implements OnApplicationBootstrap { await this.warehouseDemoSeeder.run(); await this.exportDjiboutiInterchangeDemoSeeder.run(); await this.marshallingDemoTrainsSeeder.run(); - await this.paidIndodeDemoBookingsSeeder.run(); // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, @@ -215,4 +219,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/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 8d55f1dc4..7eae4e94d 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -28,3 +28,7 @@ export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); + +/** Container allocation on a booking (allocate-containers endpoint). */ +export const AllocationManage = () => + BookingStaff(FREIGHT_PERMS.allocation.manage); 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/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts index 65052bad4..08ce634eb 100644 --- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter ); // Create indexes for service_types - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_IS_ACTIVE", - columnNames: ["is_active"], - }), - ); - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", - columnNames: ["display_order"], - }), - ); + const table = await queryRunner.getTable("freight.service_types"); + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + } + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + } // Create cargo_types table if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable( diff --git a/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts new file mode 100644 index 000000000..a98fcd80d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface { + name = "AddBookingWindowGlobalRules1861000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3, + ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24, + ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8, + ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3, + ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30, + ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60, + ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS import_window_lead_days, + DROP COLUMN IF EXISTS export_booking_lead_hours, + DROP COLUMN IF EXISTS window_open_hour, + DROP COLUMN IF EXISTS window_duration_hours, + DROP COLUMN IF EXISTS doc_review_minutes, + DROP COLUMN IF EXISTS payment_window_minutes, + DROP COLUMN IF EXISTS reopen_delay_minutes; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts new file mode 100644 index 000000000..0d5155391 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * arriveSchedule used to release only the primary locomotive of a train set, leaving + * secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out + * on a dispatched train — release every ASSIGNED locomotive that is not attached to a + * currently-DISPATCHED schedule. + */ +export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface { + name = "ReleaseStuckAssignedLocomotives1861000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.locomotives l + SET status = 'AVAILABLE' + WHERE l.status = 'ASSIGNED' + AND NOT EXISTS ( + SELECT 1 + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND loco.locomotive_id = l.id + ); + `); + } + + public async down(): Promise { + // Data fix — not reversible. + } +} diff --git a/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts new file mode 100644 index 000000000..cae33a534 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddScheduleWindowPhases1862000000000 implements MigrationInterface { + name = "AddScheduleWindowPhases1862000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN window_phase varchar(20) NULL, + ADD COLUMN window_opens_at timestamptz NULL, + ADD COLUMN window_closes_at timestamptz NULL, + ADD COLUMN doc_review_ends_at timestamptz NULL, + ADD COLUMN doc_review_completed_at timestamptz NULL, + ADD COLUMN payment_phase_ends_at timestamptz NULL, + ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0; + `); + await queryRunner.query(` + CREATE INDEX idx_train_schedules_window_phase + ON freight.train_schedules (window_phase) + WHERE window_phase IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS window_phase, + DROP COLUMN IF EXISTS window_opens_at, + DROP COLUMN IF EXISTS window_closes_at, + DROP COLUMN IF EXISTS doc_review_ends_at, + DROP COLUMN IF EXISTS doc_review_completed_at, + DROP COLUMN IF EXISTS payment_phase_ends_at, + DROP COLUMN IF EXISTS booking_cycle_no; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts new file mode 100644 index 000000000..779807a57 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateBookingBatchOffers1863000000000 implements MigrationInterface { + name = "CreateBookingBatchOffers1863000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE 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 idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts new file mode 100644 index 000000000..3434631a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add location_id column to vehicles table to track vehicle base location. + */ +export class AddLocationToVehicles1870000000000 implements MigrationInterface { + name = "AddLocationToVehicles1870000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS location_id uuid; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS location_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts new file mode 100644 index 000000000..484a2686b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add FREE and BUSY statuses to vehicle status enum. + */ +export class AddVehicleStatuses1880000000000 implements MigrationInterface { + name = "AddVehicleStatuses1880000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // Create enum type if it doesn't exist + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN + CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE'); + ELSE + -- Add values if enum already exists but doesn't have them + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + END IF; + END $$; + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Note: Postgres cannot drop individual enum values, so the down migration is a no-op + // The enum values FREE and BUSY will remain but will be unused after downgrade + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts new file mode 100644 index 000000000..c0015c3c6 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Split the mixed vehicle status into two fields: + * - status: operational state (ACTIVE, MAINTENANCE, RETIRED, OUT_OF_SERVICE) + * - availability: assignment state (FREE, BUSY) + * + * Existing FREE/BUSY statuses are moved to availability and the status is + * normalized back to ACTIVE. + */ +export class SeparateVehicleAvailability1890000000000 implements MigrationInterface { + name = "SeparateVehicleAvailability1890000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'BUSY' WHERE status = 'BUSY' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL + `); + await queryRunner.query(` + UPDATE freight.vehicles SET status = 'ACTIVE' WHERE status IN ('FREE', 'BUSY') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Fold availability back into status before dropping the column + await queryRunner.query(` + UPDATE freight.vehicles SET status = availability + WHERE status = 'ACTIVE' AND availability IN ('FREE', 'BUSY') + `); + await queryRunner.query(` + ALTER TABLE freight.vehicles DROP COLUMN IF EXISTS availability + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts new file mode 100644 index 000000000..6f9faa1f8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add code, power_plate_no and trailer_plate_no columns to vehicles. + * These fields existed in the DTO and UI form but had no entity columns, + * so submitted values were silently dropped. + */ +export class AddVehicleCodeAndPlates1890000000001 implements MigrationInterface { + name = "AddVehicleCodeAndPlates1890000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS code varchar, + ADD COLUMN IF NOT EXISTS power_plate_no varchar, + ADD COLUMN IF NOT EXISTS trailer_plate_no varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS code, + DROP COLUMN IF EXISTS power_plate_no, + DROP COLUMN IF EXISTS trailer_plate_no + `); + } +} 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/1900000000000-AddEmailToOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts new file mode 100644 index 000000000..1bd3bbc27 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Support email as a second OTP channel alongside phone (e.g. signup lets the + * user choose which one to verify). `phone` becomes nullable since an + * email-channel row has none, and `email` is added as a nullable unique column + * mirroring `phone`'s shape. + */ +export class AddEmailToOtpVerifications1900000000000 + implements MigrationInterface +{ + name = "AddEmailToOtpVerifications1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ADD COLUMN IF NOT EXISTS email varchar UNIQUE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + DROP COLUMN IF EXISTS email + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone SET NOT NULL + `); + } +} 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/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 7da5e2d66..b9f0a74c0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,21 +1,31 @@ -import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Res, +} from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; +import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") -@FreightAdmin() +@BookingView() @ApiBearerAuth() export class BillingController { - constructor(private readonly billingService: BillingService) { } + constructor(private readonly billingService: BillingService) {} @Get("invoices") - @ApiOperation({ summary: "List all invoices" }) - findAll() { - return this.billingService.findAll(); + @ApiOperation({ + summary: "List invoices (paginated, filterable by company/status/search)", + }) + findAll(@Query() query: FilterInvoiceDto) { + return this.billingService.findAllPaginated(query); } @Get("invoices/:id") diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 958279002..536129122 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -125,7 +125,7 @@ export class BillingService { private readonly payment: PaymentService, private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, - ) { } + ) {} // ── Reads ────────────────────────────────────────────────────────────────── @@ -134,9 +134,56 @@ export class BillingService { return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } + /** + * Paginated invoice list for the backoffice — optionally narrowed to a + * company (customer detail "Invoices" tab) and/or status/search (global + * invoices page). + */ + async findAllPaginated( + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + page?: number; + pageSize?: number; + } = {}, + ): Promise<{ items: Invoice[]; total: number }> { + const page = filter.page && filter.page > 0 ? filter.page : 1; + const pageSize = + filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; + + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice") + .leftJoinAndSelect("invoice.company", "company") + .orderBy("invoice.issuedAt", "DESC") + .skip((page - 1) * pageSize) + .take(pageSize); + + if (filter.companyId) { + qb.andWhere("invoice.companyId = :companyId", { + companyId: filter.companyId, + }); + } + if (filter.status) { + qb.andWhere("invoice.status = :status", { status: filter.status }); + } + if (filter.search) { + qb.andWhere( + "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + { search: `%${filter.search}%` }, + ); + } + + const [items, total] = await qb.getManyAndCount(); + return { items, total }; + } + /** Invoice header plus its line items. */ async findById(id: string): Promise { - const invoice = await this.invoices.findById(id); + const invoice = await this.invoices.findById(id, { + relations: { company: true, companyProfile: true }, + }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const lines = await this.invoiceLines.findAll({ where: { invoiceId: id }, @@ -375,7 +422,7 @@ export class BillingService { input.dueAt ?? new Date( Date.now() + - (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); const invoiceNumber = await this.nextInvoiceNumber(mg); @@ -691,7 +738,13 @@ export class BillingService { status: invoice.status, paymentId: invoice.paymentId ?? null, }; - this.events.emit(`${invoice.source}.invoice.${event}`, payload); + this.events + .emitAsync(`${invoice.source}.invoice.${event}`, payload) + .catch((err) => + this.logger.error( + `Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`, + ), + ); } // ── Payment reconciliation (by source) ─────────────────────────────────────── @@ -823,7 +876,10 @@ export class BillingService { ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) }, + where: { + id: invoiceId, + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), + }, }); if (!invoice) return; await mg.update( @@ -881,7 +937,7 @@ export class BillingService { // in the domain via `${source}.invoice.paid`. Neither billing nor the payment // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, - orderRef: invoice.invoiceNumber, + orderRef: invoice.invoiceNumber.replace("-", "_"), amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts new file mode 100644 index 000000000..e8942d586 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -0,0 +1,42 @@ +import { Freight } from "@edr/types"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Min, +} from "class-validator"; + +export class FilterInvoiceDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + pageSize?: number = 20; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + companyId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: Freight.InvoiceStatus }) + @IsOptional() + @IsIn(Object.values(Freight.InvoiceStatus)) + status?: Freight.InvoiceStatus; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts index cfb9887c3..bb159c538 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; import { AllocateContainersDto } from './dto/allocate-containers.dto'; +import { AllocationManage } from '../../common/booking-guards'; @ApiTags('bookings') @Controller('bookings') @@ -10,6 +11,7 @@ export class BookingAllocationController { constructor(private readonly bookingsService: BookingsService) {} @Post(':bookingId/allocate-containers') + @AllocationManage() @ApiOperation({ summary: 'Allocate containers to vehicles' }) async allocateContainers( @Param('bookingId', ParseUUIDPipe) bookingId: string, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 3bfb838b4..ddf09dea6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -97,6 +97,9 @@ export class BookingInvoiceService { */ @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + this.logger.log( + `onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`, + ); switch (payload.type) { case "PREPAID": await this.advanceBookingOnPayment(payload.sourceId); @@ -135,7 +138,7 @@ export class BookingInvoiceService { ); return; } - if (booking.paymentStatus === "PAID") return; + // if (booking.paymentStatus === "PAID") return; await this.dataSource.transaction(async (mg) => { await mg.update( @@ -143,9 +146,16 @@ export class BookingInvoiceService { { id: bookingId }, { paymentStatus: "PAID", status: "PAID" }, ); - await this.firstMile.acceptBooking(bookingId); }); + try { + await this.firstMile.acceptBooking(bookingId); + } catch (err) { + this.logger.error( + `Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`, + ); + } + try { await this.bookingBatch.ensurePaidBookingAllocated(bookingId); } catch (err) { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 1c1b490dd..db6b70eae 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, ratesService as never, exchangeService as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); }); 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 c5e5b710e..e8469e627 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 @@ -17,6 +17,14 @@ import { import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +import { ContainerValidationService } from './container-validation.service'; + +export interface OverweightLine { + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; +} export interface ComputedPriceResult { lineItems: PriceLineItemDto[]; @@ -27,6 +35,7 @@ export interface ComputedPriceResult { priorityScore: number; warnings: string[]; hardBlocked: string[]; + overweightLines: OverweightLine[]; } type StoredPricingBreakdown = { @@ -67,6 +76,7 @@ export class BookingPricingService { private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly exchangeService: ExchangeService, + private readonly containerValidationService: ContainerValidationService, ) {} async generatePrice(bookingId: string): Promise { @@ -94,12 +104,19 @@ export class BookingPricingService { }, } as never); + // 20ft weight-pairing preview: surfaced now so the customer sees the problem + // (and the overweight warning + surcharge) at the confirm step, before submit. + // Submit re-runs this and HARD-BLOCKS on a non-empty result. + const pairing = await this.containerValidationService.validate20ftPairing(booking); + return { bookingId, totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, warnings: computed.warnings, + overweightLines: computed.overweightLines, + pairingErrors: pairing.map((p) => p.message), }; } @@ -169,6 +186,35 @@ export class BookingPricingService { if (rate) usedRatesMap.set(rate.id, rate); } + // Overweight detail for the customer: map the engine's per-line results back + // to the booking's container lines (same order) for code + weights. maxAllowed + // is derived from the line total minus the excess the engine computed. + const overweightLines: OverweightLine[] = []; + const containerLines = (booking.bookingContainers ?? []).filter( + (bc) => bc.containerTypeId != null, + ); + for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { + const wr = ruleResult.containerWeightResults[i]; + if (!wr?.isOverweight) continue; + const line = containerLines[i]; + const totalVgmTons = Number(line?.totalVgmTons ?? 0); + const excessTons = Number(wr.overweightExcessTons ?? 0); + let code = line?.containerSize ?? ''; + if (line?.containerTypeId) { + try { + code = (await this.containerTypesService.findById(line.containerTypeId)).code; + } catch { + // fall back to the container size label + } + } + overweightLines.push({ + containerTypeCode: code, + totalVgmTons, + maxAllowedTons: Math.max(0, totalVgmTons - excessTons), + excessTons, + }); + } + return { lineItems, totalAmount: total, @@ -178,6 +224,7 @@ export class BookingPricingService { priorityScore: ruleResult.priorityScore, warnings: ruleResult.warnings, hardBlocked: ruleResult.hardBlocked, + overweightLines, }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index f9b160672..3c535f450 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -37,6 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, ruleEngineService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index d62883d53..9f9aa5713 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -48,6 +48,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository }; } @@ -132,6 +133,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository }; } @@ -202,6 +204,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 66df02ac4..ea3618a08 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -40,6 +40,7 @@ describe('BookingTransitionService — operation review', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, bookingBatchService }; } 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 898f78cd5..3e1ea3cd4 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 @@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; +import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { clearanceCodesForBooking } from './clearance.util'; @@ -51,6 +52,7 @@ export class BookingTransitionService { @Inject(forwardRef(() => ClearanceWorkflowService)) private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, + private readonly containerValidationService: ContainerValidationService, ) {} @@ -58,6 +60,19 @@ export class BookingTransitionService { return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); } + /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ + private async assert20ftPairable(booking: Booking): Promise { + const violations = + await this.containerValidationService.validate20ftPairing(booking); + if (violations.length) { + throw new BadRequestException( + `Cannot submit — 20ft containers cannot be paired on wagons: ${violations + .map((v) => v.message) + .join(' ')}`, + ); + } + } + async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]); @@ -78,6 +93,11 @@ export class BookingTransitionService { requiresDirectorApproval: false, }); + // 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap. + // If no balanced pairing exists the booking cannot proceed (overweight only + // warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation. + await this.assert20ftPairable(booking); + const stored = booking.pricingBreakdown as { lineItems?: PriceLineItemDto[]; totalAmount?: number; @@ -158,6 +178,7 @@ export class BookingTransitionService { hardBlocked: computed.hardBlocked, requiresDirectorApproval: false, }); + await this.assert20ftPairable(booking); await this.pricingService.createPricingSnapshots( bookingId, @@ -991,6 +1012,17 @@ export class BookingTransitionService { private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + // Export is FCFS: fail the accept up-front (409) when no export train on the + // booking's day still has capacity — nothing below runs and the request stays + // pending for staff to move/decline. (For a consolidated pair this is a rough + // solo pre-check; the real combined-capacity reservation happens after the + // booking is FULLY_EXECUTED, once both partners are ready.) + const isExportTrain = + booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); + if (isExportTrain) { + await this.bookingBatchService.pickExportSchedule(booking); + } + const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); this.logger.log( `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, @@ -1014,7 +1046,16 @@ export class BookingTransitionService { lockedAt: booking.lockedAt ?? now, } as never); - if (booking.scheduledDate) { + if (isExportTrain) { + // FCFS: reserve the slot and send the payment notification immediately; + // 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, @@ -1029,6 +1070,12 @@ export class BookingTransitionService { latestChangeRequestNote?: string | null; contractSummary?: string | null; nextStep: BookingNextStep | null; + activeBatchOffer?: { + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null; } > { const note = await this.bookingsRepository.findLatestReviewNote( @@ -1044,11 +1091,16 @@ export class BookingTransitionService { ? 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; return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, + activeBatchOffer, }; } } 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 688c6ee18..c5574377a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -617,12 +617,14 @@ export class BookingsController { async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, file, resolveAuthUserId(user), + vesselDepartureDate, ); return this.transitionService.enrichBookingResponse(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 2cc23b3fa..92790c273 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -23,6 +23,7 @@ import { BookingsController } from './bookings.controller'; // import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { ContainerValidationService } from './container-validation.service'; import { BookingsService } from './bookings.service'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -33,14 +34,14 @@ import { BookingContractSignature } from './entities/booking-contract-signature. import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; -import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; -import { ContractRendererService } from '../../contracts/contract-renderer.service'; -import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; -import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; -import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; - +import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; +import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { VehiclesModule } from "../vehicles/vehicles.module"; @Module({ imports: [ @@ -62,6 +63,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat forwardRef(() => ContractsModule), FilesModule, MinioModule, + VehiclesModule, CompaniesModule, // CustomersModule, RuleEngineModule, @@ -78,6 +80,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat BookingsService, BookingsRepository, ConsolidationService, + ContainerValidationService, BookingReferenceDataService, BookingPricingService, BookingTransitionService, 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 b99611c35..3f696bc6b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository { /** * Find another booking whose container quantity complements this one to fill whole wagon(s) - * (same route, same container type, partial wagon on both sides). + * (same route, same container type, partial wagon on both sides). Only 20ft lines ever + * reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial. + * + * Partners must also ride the SAME booking day: consolidation shares one physical wagon, + * and the window/batch pool is keyed on the EAT departure day, so a pair that can't board + * the same train is useless. The day filter is applied only when THIS booking already has + * a scheduled_date (draft bookings without a date match on route/type alone until they pick one). */ async findComplementaryConsolidationPartner( booking: Booking, @@ -198,7 +204,7 @@ export class BookingsRepository extends BaseRepository { ): Promise { const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; - return this.repository + const qb = this.repository .createQueryBuilder('b') .innerJoinAndSelect('b.bookingContainers', 'bc') .innerJoin('bc.containerType', 'ct') @@ -224,9 +230,18 @@ export class BookingsRepository extends BaseRepository { .andWhere('((:quantity + bc.quantity) % :perWagon) = 0', { quantity, perWagon, - }) - .orderBy('b.createdAt', 'ASC') - .getOne(); + }); + + // Same EAT booking day, so the pair can share a wagon on one train. Skip only + // when this booking has no date yet (matched again once it picks its day). + if (booking.scheduledDate) { + qb.andWhere( + `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, + { bookingDate: booking.scheduledDate }, + ); + } + + return qb.orderBy('b.createdAt', 'ASC').getOne(); } /** Try each partial-wagon line until a complementary partner booking is found. */ 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 80165cb7e..ac639a4bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -31,6 +31,8 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { assertFreightShape } from './booking-freight.util'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { mapStatusCountsToTabs } from './booking-list-tabs.config'; @@ -94,6 +96,7 @@ export class BookingsService { private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, + private readonly vehiclesService: VehiclesService, private readonly contractPdfService: ContractPdfService, ) {} @@ -1520,6 +1523,16 @@ export class BookingsService { throw new NotFoundException(`Booking ${bookingId} not found`); } + const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, { + where: { + bookingId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(BookingContainerAllocation, { @@ -1536,6 +1549,16 @@ export class BookingsService { } }); + 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/bookings/container-pairing.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts new file mode 100644 index 000000000..6aed9bf1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts @@ -0,0 +1,55 @@ +import { validate20ftWeightPairing } from './container-pairing.util'; + +describe('validate20ftWeightPairing', () => { + const MAX_DIFF = 10; + + it('passes when a balanced pairing exists (adjacent diffs within cap)', () => { + // sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10. + const units = [ + { label: 'A', grossWeightTons: 24 }, + { label: 'B', grossWeightTons: 8 }, + { label: 'C', grossWeightTons: 18 }, + { label: 'D', grossWeightTons: 15 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('flags a pair whose weight difference exceeds the cap', () => { + // sorted: 5, 25 → single pair diff 20 > 10. + const units = [ + { label: 'HEAVY', grossWeightTons: 25 }, + { label: 'LIGHT', grossWeightTons: 5 }, + ]; + const result = validate20ftWeightPairing(units, MAX_DIFF); + expect(result).toHaveLength(1); + expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']); + expect(result[0].diffTons).toBe(20); + }); + + it('allows an odd leftover unit (goes to consolidation, not a violation)', () => { + // sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover. + const units = [ + { label: 'A', grossWeightTons: 10 }, + { label: 'B', grossWeightTons: 12 }, + { label: 'C', grossWeightTons: 30 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => { + // Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail), + // but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation. + const units = [ + { label: 'A', grossWeightTons: 20 }, + { label: 'B', grossWeightTons: 12 }, + { label: 'C', grossWeightTons: 22 }, + { label: 'D', grossWeightTons: 10 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('returns nothing for fewer than two units', () => { + expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]); + expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts new file mode 100644 index 000000000..cf1cfe944 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts @@ -0,0 +1,64 @@ +/** + * Booking-time 20ft weight-pairing rule. + * + * A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the + * same wagon their gross-weight difference must not exceed `maxPairDiffTons` + * (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays + * balanced. 40ft containers occupy a whole wagon alone and never pair. + * + * At booking time the customer enters every 20ft container's weight but not its + * wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent + * (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY + * valid pairing exists this one finds it — a violation here means no balanced + * pairing is possible and the booking must be blocked. An odd leftover 20ft is + * fine: it has no partner in this booking and flows to consolidation. + */ + +export interface Container20ftUnit { + /** Human label for messages, e.g. the container number. */ + label: string; + grossWeightTons: number; +} + +export interface PairingViolation { + message: string; + /** The two container labels whose pairing exceeds the diff cap. */ + labels: [string, string]; + diffTons: number; +} + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Validate that the given 20ft units can all be paired onto wagons within the + * weight-difference cap. Returns one violation per over-cap adjacent pair (empty + * when every wagon pair is balanced or there is nothing to pair). A single + * leftover unit (odd count) is not a violation. + */ +export function validate20ftWeightPairing( + units: Container20ftUnit[], + maxPairDiffTons: number, +): PairingViolation[] { + if (units.length < 2 || maxPairDiffTons == null) return []; + + // Ascending by weight: adjacent pairs have the smallest possible diffs. + const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons); + const violations: PairingViolation[] = []; + + for (let i = 0; i + 1 < sorted.length; i += 2) { + const a = sorted[i]; + const b = sorted[i + 1]; + const diff = Math.abs(a.grossWeightTons - b.grossWeightTons); + if (diff > maxPairDiffTons) { + violations.push({ + message: + `20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` + + `${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` + + `weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`, + labels: [a.label, b.label], + diffTons: round2(diff), + }); + } + } + return violations; +} diff --git a/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts new file mode 100644 index 000000000..de4dca661 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, In } from 'typeorm'; + +import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; +import { Booking } from './entities/booking.entity'; +import { BookingContainerUnit } from './entities/booking-container-unit.entity'; +import { + Container20ftUnit, + PairingViolation, + validate20ftWeightPairing, +} from './container-pairing.util'; + +/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */ +const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10; + +/** + * Booking-time container validations that need the customer-entered per-unit + * weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the + * rule engine (which works on line totals) because pairing is per physical unit. + */ +@Injectable() +export class ContainerValidationService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + private async maxPairDiffTons(): Promise { + const row = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ order: { createdAt: 'ASC' }, take: 1 }) + .then((rows) => rows[0] ?? null) + .catch(() => null); + const v = row?.max20ftPairWeightDiffTons; + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS; + } + + /** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */ + private async load20ftUnits(booking: Booking): Promise { + const lines = (booking.bookingContainers ?? []).filter( + (bc) => (bc.containerSize ?? '').includes('20'), + ); + if (!lines.length) return []; + + const units = await this.dataSource + .getRepository(BookingContainerUnit) + .find({ + where: { bookingContainerId: In(lines.map((l) => l.id)) }, + order: { sortOrder: 'ASC' }, + }); + + return units.map((u) => ({ + label: u.containerNumber || u.id.slice(0, 8), + grossWeightTons: Number(u.vgmTons ?? 0), + })); + } + + /** + * Validate the 20ft weight-pairing rule for a booking. Returns one message per + * pair whose weight difference exceeds the cap; empty when all 20ft can be + * balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine — + * it flows to consolidation. Callers hard-block a non-empty result. + */ + async validate20ftPairing(booking: Booking): Promise { + // Only bookings whose 20ft lines actually carry per-unit weights can be + // checked; contract-drawdown bookings do (units are required there). + const containerLines = booking.bookingContainers ?? []; + const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20')); + if (!has20ft) return []; + + const units = await this.load20ftUnits(booking); + if (units.length < 2) return []; + + const maxDiff = await this.maxPairDiffTons(); + return validate20ftWeightPairing(units, maxDiff); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts index 532d6b1a7..0ee77aeed 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -27,6 +27,20 @@ export class PriceLineItemDto { currency!: string; } +export class OverweightLineDto { + @ApiProperty() + containerTypeCode!: string; + + @ApiProperty() + totalVgmTons!: number; + + @ApiProperty() + maxAllowedTons!: number; + + @ApiProperty() + excessTons!: number; +} + export class GeneratePriceResponseDto { @ApiProperty() bookingId!: string; @@ -42,4 +56,16 @@ export class GeneratePriceResponseDto { @ApiProperty({ type: [String] }) warnings!: string[]; + + /** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */ + @ApiProperty({ type: [OverweightLineDto] }) + overweightLines!: OverweightLineDto[]; + + /** + * 20ft weight-pairing violations. Non-empty means the booking cannot be + * balanced onto wagons and submit is HARD-BLOCKED — the customer must fix + * container weights/quantities. (Overweight, by contrast, only warns.) + */ + @ApiProperty({ type: [String] }) + pairingErrors!: string[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 910d3f103..4b033bab5 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 @@ -291,7 +291,7 @@ export class Booking extends BaseEntity { @Column({ name: 'origin_yard_id', type: 'uuid' }) originYardId!: string; - @ManyToOne(() => Yard) + @ManyToOne(() => Yard) @JoinColumn({ name: 'origin_yard_id' }) originYard?: Yard; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 7ea818e34..2b0126003 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -65,6 +65,16 @@ function makeService(overrides?: { children: [{ value: '2' }], }), }; + const glOperationsService = { + t1State: jest.fn().mockResolvedValue({ + bookingId: 'b-general', + wagonAllocated: false, + trainDepartedAt: null, + trainArrivedAt: null, + closed: false, + closedAt: null, + }), + }; const service = new BookingClearanceService( bookingsRepository as never, @@ -74,6 +84,7 @@ function makeService(overrides?: { workflowService as never, milestoneService as never, dropdownSettingsService as never, + glOperationsService as never, ); return { 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 7bb835df2..3f169c49c 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 @@ -1,5 +1,11 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { ContractDocPhase } from '@edr/types'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -11,6 +17,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; @@ -63,6 +70,23 @@ export interface BookingClearanceView { noticeFile?: { id: string; name: string; url: string } | null; } | null; workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until wagon allocation). */ + t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; } @Injectable() @@ -75,6 +99,7 @@ export class BookingClearanceService { private readonly workflowService: ClearanceWorkflowService, private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, ) {} private async assertPhasedGeneralCustoms(booking: Booking): Promise { @@ -162,6 +187,29 @@ export class BookingClearanceService { booking.tradeDirection ?? 'IMPORT', ); + let t1: ClearanceT1State | null = null; + if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(bookingId); + } catch { + t1 = null; + } + } + + let train: ClearanceTrainState | null = null; + try { + train = await this.glOperationsService.trainState(bookingId); + } catch { + train = null; + } + const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); + const bookingMilestone = (code: string) => + milestones.find((m) => m.milestoneCode === code); + const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState(milestones, files); + return { bookingId, status: booking.status, @@ -192,6 +240,34 @@ export class BookingClearanceService { preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), dutyAdvice, workflowFiles, + t1, + train, + gatepassGranted: gatepassMilestone?.status === 'COMPLETED', + gatepassAt: + gatepassMilestone?.status === 'COMPLETED' + ? (gatepassMilestone.metadata?.gatepassAt ?? + (gatepassMilestone.triggeredAt + ? gatepassMilestone.triggeredAt.toISOString() + : null)) + : null, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', }; } @@ -288,6 +364,12 @@ export class BookingClearanceService { : ContractDocPhase.CustomerDuty, } as never); + // Export: the declaration is the last GL ET pre-operation action — release + // immediately so the customer can proceed without a separate confirm click. + if (tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + } + return this.bookingsService.findById(bookingId); } @@ -421,6 +503,13 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); + // GL Djibouti may have uploaded the DO early (un-gated) — count it now. + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED'); + await this.workflowService.markReadyForOperation(bookingId); + } + return this.bookingsService.findById(bookingId); } @@ -428,21 +517,18 @@ export class BookingClearanceService { bookingId: string, file: Express.Multer.File, userId?: string, + vesselDepartureDate?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Delivery Order applies only to import bookings.'); } - if (!booking.preClearanceFinalizedAt) { - throw new BadRequestException( - 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', - ); - } - - await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED'); if (!file) throw new BadRequestException('No Delivery Order uploaded'); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and operation readiness) still + // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', @@ -450,8 +536,16 @@ export class BookingClearanceService { file, }); - await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); - await this.workflowService.markReadyForOperation(bookingId); + if (vesselDepartureDate?.trim()) { + await this.bookingsRepository.update(bookingId, { + vesselDepartureDate: vesselDepartureDate.trim(), + } as never); + } + + if (booking.preClearanceFinalizedAt) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForOperation(bookingId); + } return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts index ce6f7bea4..648d9666f 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -39,6 +39,16 @@ const IMPORT_DEFS: Record> = { OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false }, T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false }, RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false }, + SECOND_DUTY_ADVISED: { + label: 'Additional Duty and Taxes Advised', + ownerRegion: 'ET', + triggeredByDoc: false, + }, + SECOND_DUTY_PAID: { + label: 'Additional Duty and Tax Paid', + ownerRegion: 'CUST', + triggeredByDoc: true, + }, IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true }, IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true }, STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false }, @@ -68,6 +78,7 @@ const EXPORT_DEFS: Record> = { DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false }, ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false }, GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false }, + T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'DJ', triggeredByDoc: false }, OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true }, }; diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 7d30b1c5b..e033f8e9b 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -91,6 +91,39 @@ export class ClearanceMilestoneService { }); } + /** + * Find-or-create a post-booking milestone row from the catalog. Needed for codes + * added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED). + */ + async ensureForBooking( + bookingId: string, + code: string, + tradeDirection: string, + ): Promise { + const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (existing) return existing; + + const { postBooking } = splitMilestones(tradeDirection); + const idx = postBooking.findIndex((d) => d.code === code); + if (idx < 0) { + throw new NotFoundException( + `Milestone ${code} is not a ${tradeDirection} post-booking milestone`, + ); + } + const def = postBooking[idx]!; + return this.repo.save( + this.repo.create({ + bookingId, + milestoneCode: def.code, + milestoneLabel: def.label, + ownerRegion: def.ownerRegion, + triggeredByDoc: def.triggeredByDoc, + status: 'PENDING', + sortOrder: idx, + }), + ); + } + /** Mark a milestone complete (by code) on a booking. */ async completeForBooking( bookingId: string, 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 b0a5cc636..01b35fc71 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,11 +1,14 @@ import { BadRequestException, ForbiddenException, + Inject, Injectable, Logger, NotFoundException, + forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { ExchangeService } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -13,6 +16,9 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; 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'; @@ -60,6 +66,9 @@ export class ContractBookingService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, + private readonly exchangeService: ExchangeService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, ) {} async createUnderContract( @@ -111,6 +120,20 @@ 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, + }); + } + // Denormalize route/direction/freight onto the booking for the scheduling engine. const booking = await this.bookingsRepository.create({ reference, @@ -563,6 +586,145 @@ 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. + */ + async validateShipment( + contractId: string, + dto: CreateBookingUnderContractDto, + ): Promise<{ + overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }>; + overweightSurchargeAmount: number; + currency: string | null; + pairingErrors: string[]; + }> { + 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: [], + overweightSurchargeAmount: 0, + currency: null, + pairingErrors: [], + }; + } + + // 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). + const resolved = 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 { line, ct, totalVgmTons }; + }), + ); + + const ruleResult = await this.ruleEngineService.evaluate({ + freightType: 'CONTAINER', + cargoTypeId: null, + serviceTypeId: contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + tradeDirection: contract.tradeDirection, + isHazardous: false, + isReefer: contract.isReefer ?? false, + isGovernment: false, + allowConsolidation: false, + 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); + + 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, + }); + } + + // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. + const twentyFtUnits = resolved + .filter((r) => (r.line.containerSize ?? '').includes('20')) + .flatMap((r) => + (r.line.units ?? []).map((u, idx) => ({ + label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + })), + ); + const maxDiff = await this.max20ftPairDiffTons(); + const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map( + (v) => v.message, + ); + + // Real overweight surcharge (same rate the rule engine bills at booking-create + // time) so the confirm-modal total isn't missing the charge the warning refers to. + // Rates are stored in USD; convert to the contract's payment currency the same + // way BookingPricingService does so this preview matches the eventual booking total. + const overweightModifier = ruleResult.appliedModifiers.find( + (m) => m.surchargeCode === 'OVERWEIGHT_PER_TON', + ); + let overweightSurchargeAmount = 0; + if (overweightModifier) { + const isEtb = contract.paymentCurrency === 'ETB'; + const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; + overweightSurchargeAmount = isEtb + ? Math.round(overweightModifier.calculatedAmount * usdToEtb) + : overweightModifier.calculatedAmount; + } + + return { + overweightLines, + overweightSurchargeAmount, + currency: overweightLines.length ? contract.paymentCurrency : null, + pairingErrors, + }; + } + + private async max20ftPairDiffTons(): Promise { + const row = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ order: { createdAt: 'ASC' }, take: 1 }) + .then((rows) => rows[0] ?? null) + .catch(() => null); + const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons); + return Number.isFinite(n) ? n : 10; + } + /** Pick the default container type for a size; prefer reefer when requested. */ private async resolveContainerTypeForSize( size: string, 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 a09de407c..73a4fe117 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 @@ -1,5 +1,11 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; -import { ContractDocPhase } from '@edr/types'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -10,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; @@ -77,6 +84,23 @@ export interface ContractClearanceView { noticeFile?: { id: string; name: string; url: string } | null; } | null; workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until a booking is linked). */ + t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions; null until a booking is linked). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; } @Injectable() @@ -90,6 +114,7 @@ export class ContractClearanceService { private readonly workflowService: ClearanceWorkflowService, private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -208,8 +233,9 @@ export class ContractClearanceService { files, contract.tradeDirection ?? 'IMPORT', ); + let bookingFiles: Awaited> = []; if (cycle?.bookingId) { - const bookingFiles = await this.filesService.findByResource( + bookingFiles = await this.filesService.findByResource( cycle.bookingId, 'bookings', ); @@ -224,11 +250,41 @@ export class ContractClearanceService { workflowFiles = [...byCode.values()]; } - let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); - if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { - const bookingMilestones = await this.workflowService.listMilestonesForBooking( + let t1: ClearanceT1State | null = null; + if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(cycle.bookingId); + } catch { + t1 = null; // linked booking missing — view stays usable + } + } + + let train: ClearanceTrainState | null = null; + let bookingMilestones: ClearanceMilestone[] = []; + let finalInvoice: ClearanceFinalInvoiceSummary | null = null; + if (cycle?.bookingId) { + try { + train = await this.glOperationsService.trainState(cycle.bookingId); + } catch { + train = null; + } + bookingMilestones = await this.workflowService.listMilestonesForBooking( cycle.bookingId, ); + finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId); + } + const bookingMilestone = (code: string) => + bookingMilestones.find((m) => m.milestoneCode === code); + const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState( + bookingMilestones, + bookingFiles, + ); + + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); + if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { nextAction = this.workflowService.computeNextActionForBooking( @@ -272,6 +328,34 @@ export class ContractClearanceService { linkedBookingId: cycle?.bookingId ?? null, dutyAdvice, workflowFiles, + t1, + train, + gatepassGranted: gatepassMilestone?.status === 'COMPLETED', + gatepassAt: + gatepassMilestone?.status === 'COMPLETED' + ? (gatepassMilestone.metadata?.gatepassAt ?? + (gatepassMilestone.triggeredAt + ? gatepassMilestone.triggeredAt.toISOString() + : null)) + : null, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', }; } @@ -859,6 +943,12 @@ export class ContractClearanceService { }); } + // Export: the declaration is the last GL ET pre-booking action — release + // immediately so booking creation unlocks without a separate confirm click. + if (contract.tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleased(contractId, userId); + } + return this.contractsService.findById(contractId); } @@ -1011,6 +1101,13 @@ export class ContractClearanceService { currentPhase: ContractDocPhase.GlDjCollection, }); + // GL Djibouti may have uploaded the DO early (un-gated) — count it now. + const files = await this.filesService.findByResource(contractId, 'contracts'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED'); + await this.workflowService.markReadyForBooking(contractId); + } + return this.contractsService.findById(contractId); } @@ -1018,6 +1115,7 @@ export class ContractClearanceService { contractId: string, file: Express.Multer.File, userId?: string, + vesselDepartureDate?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); @@ -1025,17 +1123,11 @@ export class ContractClearanceService { throw new BadRequestException('Delivery Order applies only to import contracts.'); } - const cycle = await this.contractsRepository.currentCycle(contractId); - if (!cycle?.preClearanceFinalizedAt) { - throw new BadRequestException( - 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', - ); - } - - await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED'); - if (!file) throw new BadRequestException('No Delivery Order uploaded'); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and booking readiness) still waits + // for GL Ethiopia to finalize pre-clearance so the workflow order holds. await this.filesService.upsertByCode({ resourceId: contractId, resource: 'contracts', @@ -1043,8 +1135,16 @@ export class ContractClearanceService { file, }); - await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); - await this.workflowService.markReadyForBooking(contractId); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle && vesselDepartureDate?.trim()) { + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate: vesselDepartureDate.trim(), + }); + } + if (cycle?.preClearanceFinalizedAt) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForBooking(contractId); + } return this.contractsService.findById(contractId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e87112bc2..9bb4b2de6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -19,6 +19,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; +import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; @@ -63,6 +64,7 @@ export class ContractTransitionService { private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, + private readonly otpService: OtpService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -520,6 +522,12 @@ export class ContractTransitionService { if (existing) { throw new BadRequestException('Customer has already signed this contract'); } + // Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone) + // must be verified before the signature is applied. + if (!dto.otpPhone || !dto.otp) { + throw new BadRequestException('OTP verification is required to sign the contract'); + } + await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', 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 5e712a177..09e4a7ffd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -77,6 +77,7 @@ import { } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, + GatepassDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @@ -610,9 +611,15 @@ export class ContractsController { uploadDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user)); + return this.clearanceService.uploadDeliveryOrder( + id, + file, + resolveAuthUserId(user), + vesselDepartureDate, + ); } @Post(':id/clearance/release-order') @@ -681,6 +688,30 @@ 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') @@ -757,6 +788,18 @@ 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).', + }) + validateShipment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateBookingUnderContractDto, + ) { + return this.contractBookingService.validateShipment(id, dto); + } + @Get(':id/capacity') @ApiOperation({ summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)', @@ -873,6 +916,147 @@ export class ContractsController { return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); } + @Post('bookings/:bookingId/t1-documents') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs', + }) + uploadT1Documents( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); + } + + @Post('bookings/:bookingId/t1-close') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + ]) + @ApiOperation({ + summary: + 'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)', + }) + closeT1( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + 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')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)', + }) + createFinalInvoice( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('amount') amountRaw: string, + @Body('currency') currency: string | undefined, + @Body('description') description: string | undefined, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.createFinalInvoice( + bookingId, + { + amount: Number(amountRaw), + currency: currency?.trim() || 'ETB', + description, + }, + file, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/final-invoice-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' }) + uploadFinalInvoiceSlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file); + } + + @Post('bookings/:bookingId/final-invoice/confirm') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceDjActions, + FREIGHT_PERMS.contracts.clearanceEtActions, + ]) + @ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' }) + confirmFinalInvoicePaid( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.confirmFinalInvoicePaid( + bookingId, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)', + }) + adviseSecondDuty( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.adviseSecondDuty( + bookingId, + { + dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1', + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }, + attachment, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' }) + uploadSecondDutySlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadSecondDutySlip(bookingId, file); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) 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 94f469112..a9f9dcf5d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { BillingModule } from '../billing/billing.module'; import { CompaniesModule } from '../companies/companies.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; @@ -10,7 +11,9 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; 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'; @@ -64,16 +67,22 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum Booking, BookingContainerUnit, ]), + BillingModule, RuleEngineModule, FileUploadSettingsModule, DropdownSettingsModule, FilesModule, MinioModule, SignaturesModule, + OtpModule, CompaniesModule, // 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/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts index f48653822..6b784073b 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 @@ -35,3 +35,12 @@ export class RoAmendmentDto { @IsString() 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/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index febe7a83b..f0676b629 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; export class SignContractDto { @ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] }) @@ -26,4 +26,19 @@ export class SignContractDto { @IsOptional() @IsString() consentText?: string; + + // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code + // SMS'd to the signer's phone, verified server-side before the signature is + // applied. `otpPhone` is the number the code was sent to (the signed-in + // customer's registered phone). + @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) + otp?: string; + + @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + otpPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index 502afaf6a..d4676b8cd 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -23,6 +23,8 @@ export interface MilestoneMetadata { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string; + /** When the gate pass was physically granted (GL DJ captures the time). */ + gatepassAt?: string; } /** 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 73ca3a65d..8fed3a8ff 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 @@ -1,14 +1,29 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; +import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; +import { BillingService } from '../billing/billing.service'; +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; 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 { persistExportTransportUploads } from './phased-clearance.util'; +import { + persistExportTransportUploads, + persistT1TransportUploads, +} from './phased-clearance.util'; /** * Maps a GL post-booking document `code` to the milestone it auto-completes when @@ -18,7 +33,8 @@ import { persistExportTransportUploads } from './phased-clearance.util'; const DOC_CODE_TO_MILESTONE: Record = { release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ delivery_order: 'DO_COLLECTED', // import — GL DJ - t1_transport_document: 'T1_CLOSED', // import — GL ET + // t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only + // when GL Ethiopia accepts the T1 set after the train arrives (closeT1). import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET full_in_interchange: 'OFFLOADED', // export — GL DJ final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET @@ -37,6 +53,7 @@ export class GlOperationsService { private readonly dataSource: DataSource, private readonly filesService: FilesService, private readonly milestoneService: ClearanceMilestoneService, + private readonly billingService: BillingService, ) {} private get bookings() { @@ -161,6 +178,629 @@ export class GlOperationsService { return { uploaded: files.length, completedMilestones }; } + /** Wagon-allocation + train-schedule actuals for a booking (both directions). */ + async trainState(bookingId: string): Promise { + const booking = await this.getBooking(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + + const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); + const wagonAllocated = + wagonMilestone?.status === 'COMPLETED' || + booking.schedulingStatus === 'SCHEDULED' || + booking.schedulingStatus === 'DISPATCHED' || + Boolean(booking.trainScheduleId); + + let schedule: TrainSchedule | null = null; + if (booking.trainScheduleId) { + schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: booking.trainScheduleId } }); + } + + return { + wagonAllocated, + departedAt: schedule?.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + arrivedAt: schedule?.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, + }; + } + + /** + * T1 transit-document lifecycle state for an import shipment booking. Wagon + * allocation opens the upload window; train departure locks it; train arrival + * lets GL Ethiopia close (accept) the T1 set. + */ + async t1State(bookingId: string): Promise { + const train = await this.trainState(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + + const closedMilestone = milestones.find( + (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED', + ); + + return { + bookingId, + wagonAllocated: train.wagonAllocated, + trainDepartedAt: train.departedAt, + trainArrivedAt: train.arrivedAt, + closed: Boolean(closedMilestone), + closedAt: closedMilestone?.triggeredAt + ? new Date(closedMilestone.triggeredAt).toISOString() + : null, + }; + } + + /** + * GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation. + * Replaces the previous batch; locked once the train departs or T1 is closed. + */ + async uploadT1Documents( + bookingId: string, + files: Express.Multer.File[], + ): Promise<{ uploaded: number }> { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('T1 transport documents apply to import shipments only.'); + } + + const state = await this.t1State(bookingId); + if (!state.wagonAllocated) { + throw new BadRequestException( + 'Wagons must be allocated before T1 transport documents can be uploaded.', + ); + } + if (state.closed) { + throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); + } + if (state.trainDepartedAt) { + throw new BadRequestException( + 'The train has departed — T1 transport documents can no longer be changed.', + ); + } + + await persistT1TransportUploads(this.filesService, bookingId, files); + return { uploaded: files.length }; + } + + /** + * Close (accept) the T1/transport document set. + * Import: GL Ethiopia closes once the train has arrived (T1 files required). + * Export: GL Djibouti closes after the gate pass (transport document required). + */ + async closeT1( + bookingId: string, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + + const state = await this.t1State(bookingId); + if (state.closed) return state; + + if (tradeDirection === 'IMPORT') { + if (!state.trainArrivedAt) { + throw new BadRequestException( + 'The train has not arrived yet — T1 can be closed only after arrival.', + ); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasT1 = files.some((f) => isT1TransportFileCode(f.code)); + if (!hasT1) { + throw new BadRequestException( + 'No T1 transport documents on file — GL Djibouti must upload them first.', + ); + } + } else { + const milestones = await this.milestoneService.listForBooking(bookingId); + const done = (code: string) => + milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED'; + if (!done('EXPORT_TRANSPORT_ISSUED')) { + throw new BadRequestException( + '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.'); + } + // Export bookings seeded before T1_CLOSED joined the catalog lack the row. + await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); + } + + await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId); + return this.t1State(bookingId); + } + + /** Milestones GL DJ implicitly confirms when granting an export gate pass. */ + private static readonly EXPORT_ARRIVAL_CHAIN = [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + 'DEPARTED_TO_DJIBOUTI', + '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 + + * attached invoice document. The customer pays offline and attaches a slip; + * GL (ET or DJ) then confirms to settle it. + */ + async createFinalInvoice( + bookingId: string, + input: { amount: number; currency: string; description?: string }, + file: Express.Multer.File, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Final invoice applies to customs bookings only.'); + } + if (!(input.amount > 0)) { + throw new BadRequestException('Invoice amount must be greater than zero.'); + } + if (!file) throw new BadRequestException('Attach the invoice document.'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const offloaded = milestones.find( + (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', + ); + if (!offloaded) { + throw new BadRequestException( + 'Cargo must be offloaded before the final invoice can be raised.', + ); + } + + const existing = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if ( + existing && + existing.status !== Freight.InvoiceStatus.Cancelled && + existing.status !== Freight.InvoiceStatus.Expired + ) { + throw new ConflictException('A final invoice already exists for this shipment.'); + } + + const description = input.description?.trim() || 'Post-offload charges (Djibouti)'; + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: bookingId, + type: GL_FINAL_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: input.currency, + lines: [ + { + chargeType: GL_FINAL_INVOICE_TYPE, + description, + quantity: 1, + unitRate: input.amount, + amount: input.amount, + }, + ], + status: Freight.InvoiceStatus.Issued, + }); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice', + file, + }); + + // Export clearance is administratively done once the final invoice goes out. + await this.dataSource + .getRepository(ContractClearanceCycle) + .update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() }); + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice could not be created.'); + return summary; + } + + /** Customer attaches the payment slip for the final invoice. */ + async uploadFinalInvoiceSlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ uploaded: boolean }> { + await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('The final invoice is already paid.'); + } + if ( + invoice.status === Freight.InvoiceStatus.Cancelled || + invoice.status === Freight.InvoiceStatus.Expired + ) { + throw new BadRequestException('The final invoice is no longer payable.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice_slip', + file, + }); + return { uploaded: true }; + } + + /** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */ + async confirmFinalInvoicePaid( + bookingId: string, + userId?: string, + ): Promise { + await this.getBooking(bookingId); + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status !== Freight.InvoiceStatus.Paid) { + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (!files.some((f) => f.code === 'final_invoice_slip')) { + throw new BadRequestException( + 'The customer has not attached a payment slip yet.', + ); + } + await this.billingService.markInvoiceAsPaid(invoice.id); + } + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice not found.'); + return summary; + } + + /** + * GL ET advises (or skips) the post-arrival additional duty/tax round (import). + * Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload. + */ + async adviseSecondDuty( + bookingId: string, + input: { + dutyRequired: boolean; + amount?: number; + currency?: string; + declarationSerial?: string; + }, + attachment?: Express.Multer.File, + userId?: string, + ): Promise<{ advised: boolean; skipped: boolean }> { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Additional duty applies to customs bookings only.'); + } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + if (tradeDirection !== 'IMPORT') { + throw new BadRequestException('Additional duty applies to import shipments only.'); + } + + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection); + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection); + + if (!input.dutyRequired) { + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED'); + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { advised: false, skipped: true }; + } + + if (!input.amount || input.amount <= 0) { + throw new BadRequestException('Duty amount must be greater than zero.'); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2'); + if (!attachment && !hasNotice) { + throw new BadRequestException('Attach the additional duty/tax notice.'); + } + if (attachment) { + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_notice_2', + file: attachment, + }); + } + + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'SECOND_DUTY_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency ?? 'ETB', + declarationSerial: input.declarationSerial, + }, + userId, + ); + return { advised: true, skipped: false }; + } + + /** Customer attaches the payment slip for the additional duty round. */ + async uploadSecondDutySlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + if (advised?.status !== 'COMPLETED') { + throw new BadRequestException('No additional duty has been advised for this shipment.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt_2', + file, + }); + + await this.milestoneService.ensureForBooking( + bookingId, + 'SECOND_DUTY_PAID', + booking.tradeDirection ?? 'IMPORT', + ); + await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { milestoneCompleted: true }; + } + + /** Second duty round state for clearance views. */ + secondDutyState( + milestones: Array<{ + milestoneCode: string; + status: string; + metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null; + }>, + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + ): Freight.ClearanceSecondDuty | null { + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID'); + if (!advised && !paid) return null; + + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + + return { + advised: advised?.status === 'COMPLETED', + skipped: advised?.status === 'SKIPPED', + amount: advised?.metadata?.dutyAmount ?? null, + currency: advised?.metadata?.dutyCurrency ?? null, + declarationSerial: advised?.metadata?.declarationSerial ?? null, + noticeFile: toRef('duty_tax_notice_2'), + slipFile: toRef('duty_tax_receipt_2'), + paid: paid?.status === 'COMPLETED', + }; + } + + /** Final-invoice state joined with its document + slip files, for clearance views. */ + async finalInvoiceSummary( + bookingId: string, + ): Promise { + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) return null; + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + const line = await this.dataSource + .getRepository(InvoiceLine) + .findOne({ where: { invoiceId: invoice.id } }); + + return { + id: invoice.id, + invoiceNumber: invoice.invoiceNumber, + status: invoice.status, + totalAmount: Number(invoice.totalAmount), + currency: invoice.currency, + description: line?.description ?? null, + invoiceFile: toRef('final_invoice'), + slipFile: toRef('final_invoice_slip'), + confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null, + }; + } + /** * GL ET uploads export transport document after wagon allocation (export ONE_TIME). */ diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts index cf044d6a9..40646ac47 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts @@ -107,8 +107,12 @@ describe('belongsOnDjClearanceQueue', () => { ).toBe(true); }); - it('excludes import contracts still on Ethiopia-side clearance only', () => { - expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false); + it('keeps import contracts from the start — DO upload is un-gated', () => { + expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(true); + }); + + it('excludes export contracts with no DJ activity or RO hold', () => { + expect(belongsOnDjClearanceQueue('EXPORT', null, [])).toBe(false); }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index 89fbf797e..2e8682951 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -5,7 +5,9 @@ import { isDeclarationFileCode, isImportTransitPermitFileCode, isExportTransportFileCode, + isT1TransportFileCode, exportTransportFileLabel, + t1TransportFileLabel, transitPermitFileLabel, type ClearanceWorkflowFile, } from '@edr/types'; @@ -160,6 +162,50 @@ export async function persistExportTransportUploads( ); } +/** Require at least one T1 transport document in the upload batch. */ +export function assertT1TransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No T1 transport documents uploaded'); + } +} + +export function normalizeT1TransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `t1_transport_document_${index}`, + })); +} + +/** Replace all T1 transport documents on a booking with a new multi-file batch. */ +export async function persistT1TransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeT1TransportFieldNames(files); + assertT1TransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isT1TransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `t1_transport_document_${index}`, + file, + }), + ), + ); +} + export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { if (typeof value === 'boolean') return value; if (value === undefined || value === '') return false; @@ -194,9 +240,9 @@ export function belongsOnDjClearanceQueue( ); if (hasDjActivity) return true; - const preFinalized = - cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null; - if (tradeDirection === 'IMPORT' && preFinalized) return true; + // Import DO upload is un-gated — Djibouti GL must see import customs items from + // the start, not only after Ethiopia finalizes pre-clearance. + if (tradeDirection === 'IMPORT') return true; return false; } @@ -295,6 +341,22 @@ export function buildWorkflowFiles( file: { id: file.id, name: file.name, url: file.url }, }); }); + + const extraT1 = files + .filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraT1.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: t1TransportFileLabel(file.code, index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); } if (tradeDirection === 'EXPORT') { 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 d5176d14b..cbfb6787d 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -1,4 +1,4 @@ -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'; @@ -13,6 +13,12 @@ export class DriversService { ) {} 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,6 +39,17 @@ 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); } @@ -48,12 +65,10 @@ export class DriversService { const qb = this.driverRepo.createQueryBuilder('d'); if (query.search) { - const searchTerm = `%${query.search}%`; - qb.where('d.firstName ILIKE :search', { search: searchTerm }) - .orWhere('d.lastName ILIKE :search', { search: searchTerm }) - .orWhere('d.email ILIKE :search', { search: searchTerm }) - .orWhere('d.licenseNumber ILIKE :search', { search: searchTerm }) - .orWhere('d.phoneNumber ILIKE :search', { search: searchTerm }); + qb.where( + '(d.firstName ILIKE :search OR d.lastName ILIKE :search OR d.email ILIKE :search OR d.licenseNumber ILIKE :search OR d.phoneNumber ILIKE :search)', + { search: `%${query.search}%` }, + ); } if (query.status) { @@ -108,7 +123,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/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 680bb5d09..d5f53ae0c 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 @@ -65,6 +65,12 @@ export class FirstMileController { return this.firstMileService.findById(id); } + @Get('acceptitem/:id') + @ApiOperation({ summary: 'Get a first-mile accep by ID' }) + acceptItem(@Param('id', ParseUUIDPipe) id: string) { + return this.firstMileService.acceptBooking(id); + } + @Post('accept/:reference') @TrainSchedulingManage() @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) 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 ae0ada831..40d163220 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,19 +1,19 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere, In } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } 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 { 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 { FirstMileRepository } from './first-mile.repository'; -import { OnEvent } from '@nestjs/event-emitter'; -import { InvoiceEventPayload } from '../billing/billing.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; +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 { 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 { FirstMileRepository } from "./first-mile.repository"; +import { OnEvent } from "@nestjs/event-emitter"; +import { InvoiceEventPayload } from "../billing/billing.service"; type FirstMileListFilter = { status?: FirstMileStatus; @@ -26,10 +26,10 @@ type FirstMileListFilter = { }; const SORTABLE_FIELDS: (keyof FirstMile)[] = [ - 'status', - 'advancedPayment', - 'remainingPayment', - 'createdAt', + "status", + "advancedPayment", + "remainingPayment", + "createdAt", ]; @Injectable() @@ -43,26 +43,28 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, - ) {} + ) { } /** * 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 * unknown or the booking has not reached PAID status. */ - async acceptBooking(bookingId: string): Promise { + async acceptBooking(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId, { relations: { serviceType: true }, }); if (!booking) { - throw new NotFoundException(`Booking ${bookingId} not found`); + return null; } return this.acceptEligibleBooking(booking); } - async acceptBookingByReference(bookingReference: string): Promise { + async acceptBookingByReference( + bookingReference: string, + ): Promise { const [booking] = await this.bookingsRepository.findAll({ where: { reference: bookingReference }, relations: { serviceType: true }, @@ -89,20 +91,17 @@ export class FirstMileService { tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; - }): Promise { - const label = booking.reference ?? booking.id; - - if (booking.paymentStatus !== 'PAID') { - throw new BadRequestException(`Booking ${label} is not paid`); + }): Promise { + if (booking.paymentStatus !== "PAID") { + return null; } - if (!this.bookingRequestsFirstMile(booking)) { - throw new BadRequestException(`Booking ${label} does not require a first mile`); + return null; } const existing = await this.findByBookingId(booking.id); if (existing) { - throw new ConflictException(`Booking ${label} already has a first-mile assignment`); + return null; } return this.create({ @@ -118,8 +117,9 @@ export class FirstMileService { const pageSize = filter.pageSize ?? 50; const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof FirstMile) ? (filter.sortBy as keyof FirstMile) - : 'createdAt'; - const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + : "createdAt"; + const sortOrder = + filter.sortOrder?.toUpperCase() === "ASC" ? "ASC" : "DESC"; const where: FindOptionsWhere = {}; if (filter.status) where.status = filter.status; @@ -129,7 +129,13 @@ export class FirstMileService { const [data, total] = await this.firstMileRepository.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, + }, vehicle: true, }, order: { [sortBy]: sortOrder }, @@ -151,8 +157,12 @@ export class FirstMileService { @OnEvent("firstmile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { - await this.firstMileRepository.update(payload.sourceId, { paid: true } as any); - this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + await this.firstMileRepository.update(payload.sourceId, { + paid: true, + } as any); + this.logger.log( + `Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`, + ); } catch (err) { this.logger.error( `Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`, @@ -163,7 +173,13 @@ export class FirstMileService { async findById(id: string): Promise { const record = await this.firstMileRepository.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, + }, vehicle: true, }, }); @@ -181,9 +197,9 @@ export class FirstMileService { return existing; } - return this.firstMileRepository.create({ + const record = await this.firstMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'READY_TO_TRANSIT', + status: dto.status ?? "READY_TO_TRANSIT", advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? null, @@ -191,13 +207,25 @@ export class FirstMileService { vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, }); + + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + + return record; } private async findByBookingId(bookingId: string): Promise { const [records] = await this.firstMileRepository.findAndCount({ where: { bookingId }, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, take: 1, @@ -210,12 +238,10 @@ export class FirstMileService { firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): boolean { - // Export bookings always need a first mile (pickup → origin yard); the - // pickup address is captured at assignment time, not required upfront. return Boolean( - booking.tradeDirection === 'EXPORT' || - booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile, + booking.tradeDirection === 'EXPORT' && + (booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile), ); } @@ -226,9 +252,15 @@ export class FirstMileService { const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), - ...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}), - ...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}), - ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), + ...(dto.advancedPayment !== undefined + ? { advancedPayment: dto.advancedPayment } + : {}), + ...(dto.remainingPayment !== undefined + ? { remainingPayment: dto.remainingPayment } + : {}), + ...(dto.estimatedKm !== undefined + ? { estimatedKm: dto.estimatedKm } + : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), @@ -238,55 +270,115 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + // Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE + if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + if (existing.vehicleId) { + await this.vehiclesService.releaseIfUnused([existing.vehicleId]); + } + } + // Notify assigned driver on every explicit vehicle assignment or reassignment if (dto.vehicleId) { void this.notifyDriverAssignment(dto.vehicleId, existing); } + // Trip finished — release the vehicles it was holding + if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { + await this.releaseVehicles(updated); + } + return updated; } async updateStatus(id: string, status: FirstMileStatus): Promise { + const existing = await this.findById(id); const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); } + if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { + await this.releaseVehicles(updated); + } + return updated; } + /** + * Free every vehicle held by this record (direct assignment + container + * 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); + } + await this.vehiclesService.releaseIfUnused(vehicleIds); + } + private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); if (!vehicle.assignedDriverId) { - this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`); + this.logger.warn( + `Vehicle ${vehicleId} has no assigned driver — skipping SMS`, + ); return; } - const driver = await this.driversService.findById(vehicle.assignedDriverId); + const driver = await this.driversService.findById( + vehicle.assignedDriverId, + ); if (!driver.phoneNumber) { - this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`); + this.logger.warn( + `Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`, + ); return; } - const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking; + const booking = ( + record as FirstMile & { + booking?: { + reference?: string; + firstMilePickupAddress?: string | null; + originYard?: { label?: string } | null; + }; + } + ).booking; - const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const driverName = + `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim(); const message = `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + - (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') + - (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : ''); + (booking?.firstMilePickupAddress + ? `Pickup: ${booking.firstMilePickupAddress}. ` + : "") + + (booking?.originYard?.label + ? `Destination: ${booking.originYard.label}.` + : ""); void this.smsClient.sendSms({ to: driver.phoneNumber, message, }); - this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log( + `SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`, + ); } catch (err) { - this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); + this.logger.error( + `Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`, + ); } } @@ -304,6 +396,16 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${firstMileId} not found`); } + 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)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(FirstMileContainerAllocation, { @@ -314,12 +416,20 @@ export class FirstMileService { firstMileId, containerId: allocation.containerId, vehicleId: allocation.vehicleId, - containerType: 'CONTAINER', + containerType: "CONTAINER", quantity: 1, }); } }); + 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/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..0ba0f7d06 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -0,0 +1,30 @@ +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; +} 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.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index df439f28f..69eec29ae 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 @@ -71,6 +71,7 @@ export class LastMileService { return null; } + return this.create({ bookingId: booking.id, advancedPayment: 0, @@ -131,12 +132,6 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { - const [existing] = await this.lastMileRepository.findAll({ - where: { bookingId: dto.bookingId }, - take: 1, - }); - if (existing) return existing; - return this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', diff --git a/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts new file mode 100644 index 000000000..79a6547bc --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class SendEmailDto { + @ApiProperty({ + description: "Recipient email address", + example: "customer@example.com", + }) + @IsEmail() + @IsNotEmpty() + to!: string; + + @ApiProperty({ + description: "Email subject", + example: "Your EDR Freight verification code", + }) + @IsString() + @IsNotEmpty() + subject!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + text?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + html?: string; +} diff --git a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts new file mode 100644 index 000000000..161b2486a --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts @@ -0,0 +1,51 @@ +import { + Inject, + Injectable, + Logger, + OnApplicationBootstrap, +} from "@nestjs/common"; +import { ClientProxy } from "@nestjs/microservices"; +import { SendEmailDto } from "./dtos/email.dto"; + +@Injectable() +export class EmailClientService implements OnApplicationBootstrap { + private readonly logger = new Logger(EmailClientService.name); + + constructor( + @Inject("EMAIL_SERVICE") + private readonly emailClient: ClientProxy, + ) {} + + private readonly enabled = process.env.RABBITMQ_ENABLED !== "false"; + + async onApplicationBootstrap() { + if (!this.enabled) return; + this.emailClient + .connect() + .then(() => this.logger.log("connected to Email service")) + .catch((err) => { + console.error("Error happened at Email service", err); + }); + } + + async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> { + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`); + return { queued: false }; + } + this.emailClient.emit("send-email", { + to: dto.to, + subject: dto.subject, + text: dto.text, + html: dto.html, + appKey: "IFHCRS-LICENSE-MANAGEMENT", + }); + // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery. + this.logger.log( + `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, + ); + // Recipient + content are PII — debug only. + this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`); + return { queued: true }; + } +} 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 663f931ef..4e56c8b70 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices"; import { NotificationsService } from "./notifications.service"; import { SmsClientService } from "./sms-client.service"; +import { EmailClientService } from "./email-client.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; @@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" queueOptions: { durable: true }, }, }, + { + name: "EMAIL_SERVICE", + transport: Transport.RMQ, + options: { + urls: [process.env.RABBITMQ_URL as string], + queue: process.env.EMAIL_QUEUE ?? "email_queue", + queueOptions: { durable: true }, + }, + }, ]), ], controllers: [], - providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService], - exports: [NotificationsService, SmsClientService], + providers: [ + EmailNotificationStrategy, + SmsNotificationStrategy, + NotificationsService, + SmsClientService, + EmailClientService, + ], + exports: [NotificationsService, SmsClientService, EmailClientService], }) export class NotificationsModule {} diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 5850cbb1a..155657a74 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -1,15 +1,24 @@ // otp.controller.ts import { + BadRequestException, Body, Controller, Post, } from "@nestjs/common"; -import { OtpService } from "./otp.service"; +import { OtpService, OtpTarget } from "./otp.service"; import { Public } from "@edr/api-common"; +// Exactly one of phone/email must be present per request — the channel the +// code is sent through / checked against. +function toTarget(phone?: string, email?: string): OtpTarget { + if (email) return { email }; + if (phone) return { phone }; + throw new BadRequestException("phone or email is required"); +} + @Controller("otp") @Public() export class OtpController { @@ -24,9 +33,12 @@ export class OtpController { @Post("send") async sendOtp( @Body("phone") - phone: string + phone?: string, + + @Body("email") + email?: string ) { - return this.otpService.sendOtp(phone); + return this.otpService.sendOtp(toTarget(phone, email)); } // --------------------------------------------------------------------------- @@ -36,13 +48,16 @@ export class OtpController { @Post("verify") async verifyOtp( @Body("phone") - phone: string, + phone: string | undefined, + + @Body("email") + email: string | undefined, @Body("otp") otp: string ) { return this.otpService.verifyOtp( - phone, + toTarget(phone, email), otp ); } diff --git a/apps/edr-freight-api/src/modules/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts index f5900f6b8..022bbf767 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.entity.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common"; name: "otp_verifications", }) export class OtpVerification extends BaseEntity{ + // Exactly one of phone/email is set per row — the channel the code was sent + // through. @Column({ unique: true, + nullable: true, }) - phone!: string; + phone?: string; + + @Column({ + unique: true, + nullable: true, + }) + email?: string; @Column() otp!: string; diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts index ec1d9f9ed..511fe4bbb 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.module.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module"; exports: [ OtpRepository, + OtpService, ], }) export class OtpModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.repository.ts b/apps/edr-freight-api/src/modules/otp/otp.repository.ts index 8aa69dcd6..7abd434d8 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.repository.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.repository.ts @@ -31,17 +31,44 @@ export class OtpRepository { }); } + // --------------------------------------------------------------------------- + // Find By Email + // --------------------------------------------------------------------------- + + async findByEmail( + email: string + ) { + return this.repository.findOne({ + where: { + email, + }, + }); + } + + // --------------------------------------------------------------------------- + // Find By Target (either channel) + // --------------------------------------------------------------------------- + + async findByTarget( + target: { phone?: string; email?: string } + ) { + return target.email + ? this.findByEmail(target.email) + : this.findByPhone(target.phone!); + } + // --------------------------------------------------------------------------- // Create OTP // --------------------------------------------------------------------------- async createOtp( - phone: string, + target: { phone?: string; email?: string }, otp: string ) { const entity = this.repository.create({ - phone, + phone: target.phone, + email: target.email, otp, verified: false, }); @@ -70,10 +97,10 @@ export class OtpRepository { } // --------------------------------------------------------------------------- - // Verify Phone + // Mark Verified // --------------------------------------------------------------------------- - async verifyPhone( + async markVerified( otpVerification: OtpVerification ) { otpVerification.verified = @@ -83,4 +110,18 @@ export class OtpRepository { otpVerification ); } + + // --------------------------------------------------------------------------- + // Delete OTP (single-use consume) + // --------------------------------------------------------------------------- + + // Hard delete so the unique `phone` row is freed and a fresh code can be + // requested for the same number on the next action. + async deleteOtp( + otpVerification: OtpVerification + ) { + return this.repository.remove( + otpVerification + ); + } } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index ffa9c4e68..67fbdec9b 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -1,80 +1,80 @@ // otp.service.ts -import { - BadRequestException, - Injectable, -} from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { OtpRepository } from "./otp.repository"; import { SmsClientService } from "../notifications/sms-client.service"; +import { EmailClientService } from "../notifications/email-client.service"; + +// Exactly one of phone/email is set — enforced by the controller before it +// reaches here. +export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { + logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, - private readonly smsClient: SmsClientService - ) {} + private readonly smsClient: SmsClientService, + private readonly emailClient: EmailClientService, + ) { } // --------------------------------------------------------------------------- // Generate OTP // --------------------------------------------------------------------------- generateOtp(): string { - return Math.floor( - 100000 + Math.random() * 900000 - ).toString(); + return Math.floor(100000 + Math.random() * 900000).toString(); } // --------------------------------------------------------------------------- // Send OTP // --------------------------------------------------------------------------- - async sendOtp(phone: string) { + async sendOtp(target: OtpTarget) { try { // The verification code is generated server-side — never supplied by the // caller — so the OTP stays a secret known only to the server and the - // recipient of the SMS. + // recipient of the SMS/email. const otp = this.generateOtp(); - // find existing phone - const existingPhone = - await this.otpRepository.findByPhone( - phone - ); + // find existing row for this channel + const existing = await this.otpRepository.findByTarget(target); // update existing otp - if (existingPhone) { - await this.otpRepository.updateOtp( - existingPhone, - otp - ); + if (existing) { + await this.otpRepository.updateOtp(existing, otp); } else { // create new otp - await this.otpRepository.createOtp( - phone, - otp - ); + await this.otpRepository.createOtp(target, otp); } - // send sms (queued to RabbitMQ via the shared SMS service) - await this.smsClient.sendSms({ - to: phone, - message: `Your verification code is ${otp}`, - }); + if (target.email) { + // send email (queued to RabbitMQ via the shared Email service) + await this.emailClient.sendEmail({ + to: target.email, + subject: "Your EDR Freight verification code", + text: `Your verification code is ${otp}`, + }); + } else { + // send sms (queued to RabbitMQ via the shared SMS service) + await this.smsClient.sendSms({ + to: target.phone as string, + message: `Your verification code is ${otp}`, + }); + } + this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`); return { success: true, - message: - "OTP sent successfully", + message: "OTP sent successfully", }; } catch (error) { console.log(error); - throw new BadRequestException( - "Failed to send OTP" - ); + throw new BadRequestException("Failed to send OTP"); } } @@ -82,40 +82,70 @@ export class OtpService { // Verify OTP // --------------------------------------------------------------------------- - async verifyOtp( - phone: string, - otp: string - ) { - // find phone - const otpData = - await this.otpRepository.findByPhone( - phone - ); + async verifyOtp(target: OtpTarget, otp: string) { + // find the channel's row + const otpData = await this.otpRepository.findByTarget(target); - // phone not found + // not found if (!otpData) { throw new BadRequestException( - "Phone number not found" + target.email ? "Email address not found" : "Phone number not found", ); } // invalid otp if (otpData.otp !== otp) { - throw new BadRequestException( - "Invalid OTP" - ); + throw new BadRequestException("Invalid OTP"); } - // verify phone - await this.otpRepository.verifyPhone( - otpData - ); + // mark verified + await this.otpRepository.markVerified(otpData); return { success: true, - message: - "Phone verified successfully", + message: target.email + ? "Email verified successfully" + : "Phone verified successfully", }; } -} \ No newline at end of file + + // --------------------------------------------------------------------------- + // Verify OTP for a sensitive action (sudo mode) + // --------------------------------------------------------------------------- + + // Fresh, single-use challenge gating a sensitive action (e.g. applying a + // contract signature). Unlike verifyOtp above — which marks a phone verified + // and leaves the code in place — this enforces a short TTL and consumes the + // code on success so it can never be replayed. + private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; + + async verifyOtpForAction(phone: string, otp: string) { + const otpData = await this.otpRepository.findByPhone(phone); + + if (!otpData) { + throw new BadRequestException( + "No verification code was requested for this phone", + ); + } + + const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); + + if (ageMs > this.ACTION_OTP_TTL_MS) { + await this.otpRepository.deleteOtp(otpData); + + throw new BadRequestException( + "Verification code has expired. Request a new one.", + ); + } + + if (otpData.otp !== otp) { + throw new BadRequestException("Invalid verification code"); + } + + // single-use: consume on success + await this.otpRepository.deleteOtp(otpData); + + return { success: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 57c95eab3..0fc5a6ba5 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -1,9 +1,10 @@ import { - Body, - Controller, - HttpCode, - HttpStatus, - Post, + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { Public } from "@edr/api-common"; @@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service"; @Public() @Controller("internal/payments") export class InternalPaymentController { - constructor(private readonly paymentService: PaymentService) { } + private readonly logger = new Logger(InternalPaymentController.name); + constructor(private readonly paymentService: PaymentService) { } - @Post("mark-paid") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", - }) - async markPaid(@Body() event: PaymentEventDto): Promise { - return this.paymentService.handlePaymentEvent(event); - } + @Post("mark-paid") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", + }) + async markPaid(@Body() event: PaymentEventDto): Promise { + this.logger.log(`Marking payment ${event} as PAID`); + return this.paymentService.handlePaymentEvent(event); + } } diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts index 25c3bdd6b..96a4994ea 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -93,9 +93,8 @@ export class PaymentRepository { p.paid_at, p.created_at FROM freight.payments p - JOIN freight.bookings b ON b.id = p.ref_id + JOIN freight.bookings b ON b.id = p.ref_id::uuid WHERE b.company_id = $1 - AND p.deleted_at IS NULL AND b.deleted_at IS NULL ORDER BY p.created_at DESC`, [companyId], 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 d92af7a3e..d773ebe1f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -189,48 +189,53 @@ export class PaymentService { * has stored the intent id, avoiding a settle-before-correlation race. */ async initiate(input: InitiateIntentInput): Promise { - const snapshot = await this.paymentClient.initiate({ - service: PaymentServiceEnum.FREIGHT, - referenceType: PaymentReferenceType.SHIPMENT, - referenceId: input.referenceId, - orderRef: input.orderRef, - amountMinor: input.amountMinor, - currency: input.currency, - provider: input.method as ProviderMethod, - platform: input.platform, - payerAccount: input.payerAccount, - returnUrl: - input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", - failureUrl: - input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", - }); + try { + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + orderRef: input.orderRef, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + returnUrl: + input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + failureUrl: + input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", + }); - const immediateSuccess = - snapshot.status === ProviderPaymentStatus.SUCCEEDED; - const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; + const immediateSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED; + const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; - const intent = await this.upsertIntent(input, snapshot); + const intent = await this.upsertIntent(input, snapshot); - if (immediateSuccess) { - // Settle the projection but DO NOT notify billing — billing settles - // inline once it has stored intentId on the invoice (see payInvoice), - // avoiding a settle-before-correlation race. - await this.markIntentSucceeded(intent.id, { + if (immediateSuccess) { + // Settle the projection but DO NOT notify billing — billing settles + // inline once it has stored intentId on the invoice (see payInvoice), + // avoiding a settle-before-correlation race. + await this.markIntentSucceeded(intent.id, { + providerTxnId: snapshot.providerTxnId, + paidAt, + notify: false, + }); + } + + return { + intentId: intent.id, + // `intent` still reflects the projection status ("processing" on immediate + // success — settlement is applied by the caller, not shown synchronously). + response: this.formatIntentResponse(intent), + immediateSuccess, providerTxnId: snapshot.providerTxnId, paidAt, - notify: false, - }); + }; + } catch (err) { + console.log(err); + throw err; } - - return { - intentId: intent.id, - // `intent` still reflects the projection status ("processing" on immediate - // success — settlement is applied by the caller, not shown synchronously). - response: this.formatIntentResponse(intent), - immediateSuccess, - providerTxnId: snapshot.providerTxnId, - paidAt, - }; } /** Create or update the local intent projection from a provider snapshot. */ 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..eea223ae3 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 { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsIn, IsNumber, IsUUID, Min } from 'class-validator'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; @@ -21,13 +21,4 @@ export class CreateWeightLimitRuleDto { @Min(0) @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' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } 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..b6b87b285 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; @@ -19,10 +18,4 @@ 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; } 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/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 0d49a0bf3..a7260f7f1 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); } 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..87d2febba 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); } 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..bbd042296 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,4 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { 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 +30,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 +44,51 @@ 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.', + ); + } + } + /** Create a new weight limit rule. */ async create(dto: CreateWeightLimitRuleDto): Promise { + await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection); 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, }); } /** 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); + + // 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 d1ed23ef7..d67d58811 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 @@ -83,6 +83,34 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; + /** + * Booking-window lifecycle for the one-booking-day cycle + * (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → reopen | CLOSED_FOR_DAY | DONE). + * NULL on legacy and DOMESTIC schedules — the window engine ignores those. + */ + @Column({ name: 'window_phase', type: 'varchar', length: 20, nullable: true }) + windowPhase?: string | null; + + @Column({ name: 'window_opens_at', type: 'timestamptz', nullable: true }) + windowOpensAt?: Date | null; + + @Column({ name: 'window_closes_at', type: 'timestamptz', nullable: true }) + windowClosesAt?: Date | null; + + @Column({ name: 'doc_review_ends_at', type: 'timestamptz', nullable: true }) + docReviewEndsAt?: Date | null; + + /** Staff finished document review early — starts the batch/payment phase immediately. */ + @Column({ name: 'doc_review_completed_at', type: 'timestamptz', nullable: true }) + docReviewCompletedAt?: Date | null; + + @Column({ name: 'payment_phase_ends_at', type: 'timestamptz', nullable: true }) + paymentPhaseEndsAt?: Date | null; + + /** 1-based count of open→settle cycles run on the booking day. */ + @Column({ name: 'booking_cycle_no', type: 'int', default: 0 }) + bookingCycleNo!: number; + @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 7316e1610..6d295c8fd 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 @@ -121,6 +121,66 @@ function windowFromEatStart( }; } +/** Build a UTC Date for an EAT wall-clock time on a `yyyy-MM-dd` EAT calendar day. */ +export function eatDayToUtc(day: string, hour: number, minute = 0): Date { + const [year, month, dayOfMonth] = day.split('-').map(Number); + return eatToUtc(year, month, dayOfMonth, hour, minute); +} + +/** Shift a `yyyy-MM-dd` EAT day key by whole days. */ +export function shiftEatDay(day: string, deltaDays: number): string { + // Noon UTC keeps the +3h EAT offset from crossing a day boundary. + const [year, month, dayOfMonth] = day.split('-').map(Number); + const shifted = new Date(Date.UTC(year, month - 1, dayOfMonth + deltaDays, 12)); + return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String( + shifted.getUTCDate(), + ).padStart(2, '0')}`; +} + +export interface InitialWindowTimes { + windowOpensAt: Date; + windowClosesAt: Date; +} + +/** + * Import booking-day window: opens at `windowOpenHour` EAT on departure-day minus + * `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its + * computed window has fully passed gets a same-day window starting now instead, + * capped at departure. + */ +export function computeImportWindowTimes( + departure: Date, + cfg: { + importWindowLeadDays: number; + windowOpenHour: number; + windowDurationHours: number; + }, + now: Date, +): InitialWindowTimes { + const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); + let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); + let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); + if (closesAt.getTime() <= now.getTime()) { + opensAt = now; + closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000); + } + if (closesAt.getTime() > departure.getTime()) { + closesAt = departure; + } + return { windowOpensAt: opensAt, windowClosesAt: closesAt }; +} + +/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */ +export function computeExportWindowTimes( + departure: Date, + cfg: { exportBookingLeadHours: number }, +): InitialWindowTimes { + return { + windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000), + windowClosesAt: departure, + }; +} + /** 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); @@ -170,14 +230,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`. */ @@ -186,6 +245,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', @@ -197,119 +265,124 @@ 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. */ -export function listBoardWindowsForRange( - openDate: Date, - departureDate: Date, +export function listConfigBookingWindows( + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, ): 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 = 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 = 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', ): Map { + const windows = listConfigBookingWindows(direction, departure, cfg); 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.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 5c9cb6119..d8dc6b116 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -1,20 +1,14 @@ /** * Tunables for the demand-batching booking → allocation flow. - * Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock. + * Times run in EAT so window boundaries match the local operating clock. + * + * Cadence and pay-window durations moved to the train_scheduling_global_rules + * table (TrainSchedulingService.getWindowConfig) — the window engine + * (BookingWindowService) drives all timing off that config. */ -/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */ -// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *'; -// export const BATCH_CRON = '*/3 * * * *'; -export const BATCH_CRON = '*/5 * * * *'; -// export const BATCH_CRON = '0 */3 * * *';// - export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; -/** How long a selected commercial customer has to pay before their slot expires. */ -// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour -export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode) - /** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ export const DEFAULT_WAGONS_PER_BOOKING = 1; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 9e974b31e..f112d16d7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -35,6 +35,7 @@ describe('BookingBatchService — PAID reconcile', () => { let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; getBookableSchedules: jest.Mock; + getWindowConfig: jest.Mock; }; let dataSource: { getRepository: jest.Mock; @@ -77,6 +78,15 @@ describe('BookingBatchService — PAID reconcile', () => { violations: [], }), getBookableSchedules: jest.fn().mockResolvedValue([]), + getWindowConfig: jest.fn().mockResolvedValue({ + importWindowLeadDays: 3, + exportBookingLeadHours: 24, + windowOpenHour: 8, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + reopenDelayMinutes: 90, + }), }; const bookingRepo = { @@ -187,17 +197,24 @@ describe('BookingBatchService — PAID reconcile', () => { }) as unknown as Booking; beforeEach(() => { - // Two OPEN trains on the same route + day, train A earlier than train B. - trainSchedulingService.getBookableSchedules.mockResolvedValue([ + // Two OPEN legacy trains on the same route + day, train A earlier than train B. + // fillRouteDay now selects fillable schedules straight from the repository. + trainSchedulesRepository.findAll.mockResolvedValue([ { id: trainA, - scheduleDate: '2026-06-20T06:00:00.000Z', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), bookingWindowStatus: 'OPEN', + windowPhase: null, }, { id: trainB, - scheduleDate: '2026-06-20T09:00:00.000Z', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'), bookingWindowStatus: 'OPEN', + windowPhase: null, }, ]); trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) => @@ -252,5 +269,56 @@ describe('BookingBatchService — PAID reconcile', () => { }), ); }); + + it('reserves both partners of a consolidated pair together on one train', async () => { + // Two 20ft bookings, 1 container each — a shared wagon. Both in the pool. + const consol = (id: string, partnerId: string, priority: number): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + consolidationPartnerId: partnerId, + bookingContainers: [{ quantity: 1 }], + }) as unknown as Booking; + + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + consol('a', 'b', 30), + consol('b', 'a', 20), + ]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // Both reserved on the same (first) train; neither reported unplaced. + const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); + expect(reservedIds.sort()).toEqual(['a', 'b']); + expect(notifier.unplaced).not.toHaveBeenCalled(); + }); + + it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => { + const lonely = { + id: 'a', + reference: 'a', + isGovernment: false, + priorityScore: 30, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + consolidationPartnerId: 'missing-partner', + bookingContainers: [{ quantity: 1 }], + } as unknown as Booking; + + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // Never reserved — waits for its partner in a later cycle. + expect(notifier.payNow).not.toHaveBeenCalled(); + }); }); }); 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 218a20436..efa6b3259 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 @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, Injectable, Logger, NotFoundException, @@ -7,8 +8,8 @@ import { Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; -import { Cron, SchedulerRegistry } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { DataSource, In } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; @@ -22,17 +23,14 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; -import { Freight } from "@edr/types"; +import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types"; import { BillingService } from "../billing/billing.service"; import { - BATCH_CRON, - BATCH_TIMEZONE, DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_WAGONS_PER_BOOKING, - PAYMENT_WINDOW_MS, } from "./booking-batch.constants"; import { bookingTrainLengthMeters, @@ -41,6 +39,8 @@ import { } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { BookingSplitService } from './booking-split.service'; +import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -90,6 +90,9 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking { selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; allocationIssue: string | null; + /** Set when this booking shares a wagon with a consolidation partner. */ + consolidationPartnerId: string | null; + consolidationPartnerRef: string | null; } export interface BatchWindowGroup { @@ -121,6 +124,13 @@ export interface BatchBoardScheduleDetail { scheduleDate: string | null; status: string; bookingWindowStatus: string; + direction: string | null; + windowPhase: string | null; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo: number; locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; @@ -138,6 +148,13 @@ export interface BatchBoardSchedule { scheduleDate: string | null; status: string; bookingWindowStatus: string; + direction: string | null; + windowPhase: string | null; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo: number; locomotive: { code: string; name: string | null; @@ -189,6 +206,7 @@ export class BookingBatchService implements OnModuleInit { private readonly billing: BillingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, + @Optional() private readonly splitService?: BookingSplitService, ) {} @@ -284,11 +302,17 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } - /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ + /** + * Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules — + * schedules with a `windowPhase` are driven exclusively by the window engine + * (BookingWindowService), never by the periodic legacy fill. + */ private async openRouteDayGroups(): Promise { - const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: "OPEN" }, - }); + const open = ( + await this.trainSchedulesRepository.findAll({ + where: { bookingWindowStatus: "OPEN" }, + }) + ).filter((s) => s.windowPhase == null); const groups = new Map(); for (const s of open) { if (!s.scheduledDepartureDate) continue; @@ -340,6 +364,12 @@ export class BookingBatchService implements OnModuleInit { .update(bookingId, { paymentStatus: "PAID" }); } + // Paying inside the window accepts an open partial offer — reduce the booking + // to the offered part before it boards (remainder returns to the contract cap). + if (this.splitService) { + await this.splitService.applySplit(bookingId); + } + const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { @@ -381,6 +411,128 @@ export class BookingBatchService implements OnModuleInit { await this.ensurePaidBookingAllocated(bookingId); } + /** Open partial-capacity offer summary for booking detail payloads (null when none). */ + async getOpenOfferSummary(bookingId: string): Promise<{ + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null> { + if (!this.splitService) return null; + const offer = await this.splitService.findOpenOffer(bookingId); + if (!offer) return null; + return { + offeredWagons: offer.offeredWagons, + totalWagons: offer.totalWagons, + offeredAmount: Number(offer.offeredAmount), + paymentDeadline: offer.paymentDeadline, + }; + } + + // ---- export FCFS ----------------------------------------------------------- + + /** + * Export is first-come-first-serve: no window cycle, no priority, no batch. + * Pick the earliest open export train on the booking's corridor/day that still + * fits the booking. Throws ConflictException when every train is full — the + * staff accept fails and no more export bookings are taken. + */ + async pickExportSchedule(booking: Booking, need?: Capacity): Promise { + if (!booking.scheduledDate) { + throw new BadRequestException('Booking has no scheduled date'); + } + const day = eatDay(new Date(booking.scheduledDate)); + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + this.isFillable(s), + ) + .sort( + (a, b) => + a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), + ); + if (!candidates.length) { + throw new ConflictException( + 'No export train is accepting bookings for this day', + ); + } + + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + const required = need ?? this.needFor(booking, wagonLengths); + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive, rules); + const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + if (this.fits(required, budget)) return schedule.id; + } + throw new ConflictException('Train is full — no export capacity left for this day'); + } + + /** + * Accept an export booking into the FCFS flow. Solo bookings reserve immediately. + * A consolidated booking reserves as a pair only once BOTH partners are ready + * (FULLY_EXECUTED): the second partner's accept triggers the pair reservation + * against the combined shared-wagon need; the first partner's accept just waits. + * Throws ConflictException (before this booking is persisted-ready) when there is + * no export capacity for the day, so staff accept fails. + */ + async acceptExportBooking(booking: Booking): Promise { + const partnerId = booking.consolidationPartnerId ?? null; + if (!partnerId) { + const scheduleId = await this.pickExportSchedule(booking); + await this.reserveOnExport([booking], scheduleId); + return; + } + + const partner = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } }); + // Partner not yet accepted → this booking is now FULLY_EXECUTED and simply + // waits; the partner's later accept will reserve the pair. + if (!partner || partner.status !== 'FULLY_EXECUTED') { + return; + } + const wagonLengths = await this.loadWagonLengths(); + const need = this.combinedNeed(booking, partner, wagonLengths); + const scheduleId = await this.pickExportSchedule(booking, need); + await this.reserveOnExport([booking, partner], scheduleId); + } + + /** Reserve one or two (consolidated) export bookings on a train and open pay windows. */ + private async reserveOnExport( + bookings: Booking[], + scheduleId: string, + ): Promise { + for (const b of bookings) await this.reserve(b, scheduleId); + this.armSettle(scheduleId); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (schedule && (await this.remainingWagons(schedule)) <= 0) { + await this.setWindow(scheduleId, 'FULL'); + } + } + /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ async reconcilePaidUnlinked(scheduleId: string): Promise { const unlinked = @@ -393,9 +545,13 @@ export class BookingBatchService implements OnModuleInit { } } - // ---- cron entry point ----------------------------------------------------- + // ---- legacy fill entry point ---------------------------------------------- - @Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE }) + /** + * Legacy periodic fill for schedules without a window phase (DOMESTIC and + * pre-migration trains). Invoked by BookingWindowService's tick — the old + * standalone cron was replaced by the window engine. + */ async runBatchFill(): Promise { const groups = await this.openRouteDayGroups(); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); @@ -434,6 +590,9 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; for (const s of schedules) { if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; + // Batch board is IMPORT-only: export is FCFS with no batch/priority calc, + // and domestic/legacy schedules run the legacy fill, not the window batch. + if (s.direction !== "IMPORT") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -474,6 +633,12 @@ export class BookingBatchService implements OnModuleInit { if (s.status === "ARRIVED" || s.status === "CANCELLED") { throw new BadRequestException("Schedule is no longer active"); } + // Batch board is IMPORT-only (export is FCFS, no batch/priority calc). + if (s.direction !== "IMPORT") { + throw new BadRequestException( + "The batch board only covers import schedules", + ); + } const wagonLengths = await this.loadWagonLengths(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); @@ -499,6 +664,27 @@ export class BookingBatchService implements OnModuleInit { allocationPreview.issues.map((i) => [i.bookingId, i]), ); + // Resolve consolidation-partner references for the shared-wagon badge. Most + // partners are on this same schedule; look up any that aren't in one query. + const refById = new Map( + bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]), + ); + const missingPartnerIds = [ + ...new Set( + bookings + .map((b) => b.consolidationPartnerId) + .filter((id): id is string => Boolean(id) && !refById.has(id!)), + ), + ]; + if (missingPartnerIds.length) { + const partners = await this.dataSource + .getRepository(Booking) + .find({ where: { id: In(missingPartnerIds) } }); + for (const p of partners) { + refById.set(p.id, p.reference ?? p.id.slice(0, 8)); + } + } + const items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonLengths); const alloc = allocationByBooking.get(b.id); @@ -524,20 +710,27 @@ export class BookingBatchService implements OnModuleInit { : null, allocationStatus: alloc?.status ?? "NOT_ATTEMPTED", allocationIssue: alloc?.issue ?? null, + consolidationPartnerId: b.consolidationPartnerId ?? null, + consolidationPartnerRef: b.consolidationPartnerId + ? (refById.get(b.consolidationPartnerId) ?? null) + : null, }; }); 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 from the global-rules + // config (import: opens at windowOpenHour EAT importWindowLeadDays before + // departure, lasts windowDurationHours, reopens per reopenDelayMinutes; + // export: single FCFS lead window) — not a fixed clock grid. + const windowCfg = await this.trainSchedulingService.getWindowConfig(); const departureDate = s.scheduledDepartureDate ?? new Date(); const windowBuckets = groupBookingsIntoBoardWindows( items, (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), - openDate, + s.direction ?? null, departureDate, + windowCfg, ); const emptyCounts = () => ({ @@ -595,6 +788,15 @@ export class BookingBatchService implements OnModuleInit { : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, + direction: s.direction ?? null, + windowPhase: s.windowPhase ?? null, + windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, + windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, + docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, + paymentPhaseEndsAt: s.paymentPhaseEndsAt + ? s.paymentPhaseEndsAt.toISOString() + : null, + bookingCycleNo: s.bookingCycleNo ?? 0, locomotive: loco ? { code: loco.code, @@ -679,6 +881,15 @@ export class BookingBatchService implements OnModuleInit { : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, + direction: s.direction ?? null, + windowPhase: s.windowPhase ?? null, + windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, + windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, + docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, + paymentPhaseEndsAt: s.paymentPhaseEndsAt + ? s.paymentPhaseEndsAt.toISOString() + : null, + bookingCycleNo: s.bookingCycleNo ?? 0, locomotive: loco ? { code: loco.code, @@ -722,11 +933,27 @@ export class BookingBatchService implements OnModuleInit { // ---- core fill ------------------------------------------------------------ + /** + * Whether the batch engine may reserve/allocate onto this schedule right now. + * Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate. + * Import window cycle: the engine fills while the customer window is CLOSED — + * during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups). + * Export: FCFS while the booking window is open. + */ + isFillable(schedule: TrainSchedule): boolean { + if (schedule.bookingWindowStatus === "FULL") return false; + if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN"; + if (schedule.direction === "EXPORT") { + return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN"; + } + return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT"; + } + /** Fill one schedule from its priority-ordered pool until full. */ async fillSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || schedule.bookingWindowStatus !== "OPEN") return; + if (!schedule || !this.isFillable(schedule)) return; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { this.logger.warn( @@ -746,13 +973,19 @@ export class BookingBatchService implements OnModuleInit { } const pool = await this.bookingsRepository.findBatchPool(scheduleId); + const units = this.groupConsolidatedPool(pool); let armed = false; - for (const booking of pool) { - const need = this.needFor(booking, wagonLengths); + for (const unit of units) { + const { primary: booking, partner } = unit; + const isPair = partner != null; + const need = isPair + ? this.combinedNeed(booking, partner, wagonLengths) + : this.needFor(booking, wagonLengths); + const isGov = booking.isGovernment || (partner?.isGovernment ?? false); if (!this.fits(need, budget)) { - if (booking.isGovernment) { + if (isGov) { budget = await this.preemptForGovernment( scheduleId, need, @@ -761,14 +994,16 @@ export class BookingBatchService implements OnModuleInit { ); if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt } else { - continue; // skip a booking that exceeds weight/length/wagons, try the next + continue; // skip a unit that exceeds weight/length/wagons, try the next } } - if (booking.isGovernment) { + if (isGov) { await this.allocate(scheduleId, booking, "gov"); + if (partner) await this.allocate(scheduleId, partner, "gov"); } else { await this.reserve(booking, scheduleId); + if (partner) await this.reserve(partner, scheduleId); armed = true; } budget = this.subtract(budget, need); @@ -793,22 +1028,33 @@ export class BookingBatchService implements OnModuleInit { destinationYardId: string, day: string, ): Promise { - // The day's OPEN bookable schedules on this exact corridor, earliest first. - const bookable = await this.trainSchedulingService.getBookableSchedules( - originYardId, - destinationYardId, - ); - const scheduleIds = bookable + // The day's fillable schedules on this exact corridor, earliest first. Fillable + // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT — + // the batch must run while the customer window is closed. + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: originYardId, + destinationStationId: destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: originYardId, + destinationStationId: destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const scheduleIds = corridor .filter( (s) => - s.bookingWindowStatus === "OPEN" && - s.scheduleDate != null && - eatDay(new Date(s.scheduleDate)) === day, + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + this.isFillable(s), ) .sort( (a, b) => - new Date(a.scheduleDate).getTime() - - new Date(b.scheduleDate).getTime(), + a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), ) .map((s) => s.id); @@ -845,15 +1091,23 @@ export class BookingBatchService implements OnModuleInit { destinationYardId, day, ); + // Consolidated partners collapse into one atomic unit (both-or-neither); a + // consolidated booking whose partner isn't ready this cycle is skipped. + const units = this.groupConsolidatedPool(pool); - for (const booking of pool) { - const need = this.needFor(booking, wagonLengths); + for (const unit of units) { + const { primary: booking, partner } = unit; + const isPair = partner != null; + const need = isPair + ? this.combinedNeed(booking, partner, wagonLengths) + : this.needFor(booking, wagonLengths); + const isGov = booking.isGovernment || (partner?.isGovernment ?? false); - // First train (earliest departure) that fits this booking as-is. + // First train (earliest departure) that fits this unit as-is. let target = trains.find((t) => this.fits(need, t.budget)); - if (!target && booking.isGovernment) { - // Government booking fits nowhere on its own — try to preempt commercial + if (!target && isGov) { + // Government fits nowhere on its own — try to preempt commercial // on each train (earliest first) until one frees enough room. for (const t of trains) { t.budget = await this.preemptForGovernment( @@ -870,15 +1124,45 @@ export class BookingBatchService implements OnModuleInit { } if (!target) { - // Fits no train this day — stays in the pool, retried next batch. + // A consolidated pair is placed whole or not at all — never split. + if (!isPair) { + // Fits no train whole. Import GENERAL-contract commercial bookings get a + // partial-capacity offer on the train with the most free wagons. + const partialTarget = [...trains] + .filter((t) => t.budget.wagons >= 1) + .sort((a, b) => b.budget.wagons - a.budget.wagons)[0]; + if ( + partialTarget && + !booking.isGovernment && + booking.tradeDirection === "IMPORT" && + booking.contractKind === "GENERAL" && + this.splitService + ) { + const offered = await this.tryPartialOffer( + booking, + partialTarget.id, + partialTarget.budget, + need, + ); + if (offered) { + partialTarget.budget = this.subtract(partialTarget.budget, offered); + partialTarget.armed = true; + continue; + } + } + } + // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); + if (partner) this.notifier.unplaced(partner, day); continue; } - if (booking.isGovernment) { + if (isGov) { await this.allocate(target.id, booking, "gov"); + if (partner) await this.allocate(target.id, partner, "gov"); } else { await this.reserve(booking, target.id); + if (partner) await this.reserve(partner, target.id); target.armed = true; } target.budget = this.subtract(target.budget, need); @@ -893,29 +1177,127 @@ export class BookingBatchService implements OnModuleInit { return trains.map((t) => t.id); } - /** Durable settle: allocate paid / expire overdue reservations, then top up. */ - async settleDueReservations(scheduleId: string): Promise { + /** + * Offer the largest fitting part of an over-capacity booking as a partial + * (split-on-payment). Returns the capacity the offer consumes, or null when no + * meaningful partial fits / an offer is already open. + */ + private async tryPartialOffer( + booking: Booking, + scheduleId: string, + budget: Capacity, + need: Capacity, + ): Promise { + if (!this.splitService) return null; + // A consolidated booking is already half of a shared wagon — never split it. + if (booking.consolidationPartnerId) return null; + if (await this.splitService.findOpenOffer(booking.id)) return null; + + const wagonLengths = await this.loadWagonLengths(); + const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); + const sized = await this.splitService.sizeOffer( + booking, + budget.wagons, + need.wagons, + bulkCapacityTons, + ); + if (!sized) return null; + + const offeredNeed: Capacity = { + wagons: sized.offeredWagons, + weightTons: sized.offeredWeightTons, + lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + if (!this.fits(offeredNeed, budget)) return null; + + const deadline = new Date(Date.now() + (await this.paymentWindowMs())); + await this.splitService.createOffer(booking, scheduleId, sized, deadline); + // Reserve like a normal batch selection, but the partial invoice + partial + // pay-now notification were already produced by createOffer. + await this.bookingsRepository.update(booking.id, { + trainScheduleId: scheduleId, + status: "SELECTED_FOR_BATCH", + selectedForBatchAt: new Date(), + paymentDeadline: deadline, + } as never); + booking.trainScheduleId = scheduleId; + return offeredNeed; + } + + private async loadBulkWagonCapacityTons(): Promise { + const cw3 = await this.dataSource + .getRepository(WagonType) + .findOne({ where: { code: "CW3" } }); + const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60; + return capacity > 0 ? capacity : 60; + } + + /** + * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides + * how to treat a reservation with no deadline (durable path: leave it; timeout + * path: expire it). Consolidated pairs settle atomically: both allocate only + * when both paid; if either partner expires, both expire (a half-paid shared + * wagon must not ship). Returns whether anything changed. + */ + private async settleReserved( + scheduleId: string, + expireUnpaidUnknownDeadline: boolean, + ): Promise { const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); + const byId = new Map(reserved.map((b) => [b.id, b])); + const done = new Set(); let anySettled = false; - for (const booking of reserved) { - const paid = - booking.paymentStatus === "PAID" || booking.status === "PAID"; - const expired = booking.paymentDeadline - ? booking.paymentDeadline.getTime() <= now - : false; + const isPaid = (b: Booking) => + b.paymentStatus === "PAID" || b.status === "PAID"; + const isExpired = (b: Booking) => + b.paymentDeadline + ? b.paymentDeadline.getTime() <= now + : expireUnpaidUnknownDeadline; - if (paid) { + for (const booking of reserved) { + if (done.has(booking.id)) continue; + const partner = booking.consolidationPartnerId + ? (byId.get(booking.consolidationPartnerId) ?? null) + : null; + + if (partner) { + done.add(booking.id); + done.add(partner.id); + // Both-or-neither: allocate the shared wagon only when both partners paid; + // if either lapsed, expire both so no half-paid wagon rides. + if (isPaid(booking) && isPaid(partner)) { + await this.allocate(scheduleId, booking, "paid"); + await this.allocate(scheduleId, partner, "paid"); + anySettled = true; + } else if (isExpired(booking) || isExpired(partner)) { + await this.expire(booking); + await this.expire(partner); + anySettled = true; + } + continue; + } + + done.add(booking.id); + if (isPaid(booking)) { await this.allocate(scheduleId, booking, "paid"); anySettled = true; - } else if (expired) { + } else if (isExpired(booking)) { await this.expire(booking); anySettled = true; } } + return anySettled; + } + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ + async settleDueReservations(scheduleId: string): Promise { + const anySettled = await this.settleReserved(scheduleId, false); if (anySettled) await this.fillSchedule(scheduleId); } @@ -924,25 +1306,7 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - const reserved = - await this.bookingsRepository.findReservedForSchedule(scheduleId); - const now = Date.now(); - - for (const booking of reserved) { - const paid = - booking.paymentStatus === "PAID" || booking.status === "PAID"; - const expired = booking.paymentDeadline - ? booking.paymentDeadline.getTime() <= now - : true; - - if (paid) { - await this.allocate(scheduleId, booking, "paid"); - } else if (expired) { - await this.expire(booking); - } - // else: still within window (rare at settle) → leave for the re-armed timeout - } - + await this.settleReserved(scheduleId, true); await this.fillSchedule(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -1063,7 +1427,7 @@ export class BookingBatchService implements OnModuleInit { */ private async reserve(booking: Booking, scheduleId: string): Promise { const now = new Date(); - const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); + const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, status: "SELECTED_FOR_BATCH", @@ -1136,6 +1500,10 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // An unpaid partial offer dies with the reservation — the booking stays whole. + if (this.splitService) { + await this.splitService.expireOpenOffer(booking.id); + } // Pay window closed before settlement → expire the booking's open invoice too // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays // source-agnostic. @@ -1198,6 +1566,74 @@ export class BookingBatchService implements OnModuleInit { // ---- capacity helpers ----------------------------------------------------- + /** + * Collapse consolidated partners into single pool entries so the fill treats a + * shared-wagon pair as one atomic unit (both-or-neither). For each pool entry: + * - no `consolidationPartnerId` → passes through as a lone booking. + * - consolidated + partner also in this pool → emitted ONCE (at the position of + * whichever partner ranks first) as a pair; the partner is not emitted again. + * - consolidated + partner NOT in this pool → dropped (can't ship half a wagon; + * it waits for the partner to become ready in a later cycle). + * The pool is already priority-ordered, so emitting the pair at the first-seen + * partner's slot ranks it by the stronger (max-priority) partner automatically. + */ + private groupConsolidatedPool( + pool: Booking[], + ): Array<{ primary: Booking; partner: Booking | null }> { + const byId = new Map(pool.map((b) => [b.id, b])); + const emitted = new Set(); + const units: Array<{ primary: Booking; partner: Booking | null }> = []; + for (const booking of pool) { + if (emitted.has(booking.id)) continue; + const partnerId = booking.consolidationPartnerId ?? null; + if (!partnerId) { + emitted.add(booking.id); + units.push({ primary: booking, partner: null }); + continue; + } + const partner = byId.get(partnerId) ?? null; + if (!partner) { + // Both-or-neither: partner not ready in this pool → skip the pair entirely. + emitted.add(booking.id); + continue; + } + emitted.add(booking.id); + emitted.add(partner.id); + units.push({ primary: booking, partner }); + } + return units; + } + + /** + * Combined capacity need of a consolidated pair sharing wagons. The whole point of + * consolidation is that the two partial 20ft counts pack onto the SAME wagons, so + * the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two + * independently-rounded-up needs (that is the capacity consolidation saves). + */ + private combinedNeed( + primary: Booking, + partner: Booking, + wagonLengths: WagonLengths, + ): Capacity { + const containers = (b: Booking): number => + (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); + const totalContainers = containers(primary) + containers(partner); + const sharedWagons = + totalContainers > 0 + ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) + : this.wagonsFor(primary) + this.wagonsFor(partner); + const weightTons = + Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + return { + wagons: sharedWagons, + weightTons, + lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + } + private wagonsFor(booking: Booking): number { if (booking.wagonsRequired && booking.wagonsRequired > 0) { return Math.ceil(booking.wagonsRequired); @@ -1359,7 +1795,7 @@ export class BookingBatchService implements OnModuleInit { return (schedule.maxWagons ?? 0) - used; } - private async setWindow( + async setWindow( scheduleId: string, status: "OPEN" | "FULL" | "CLOSED", ): Promise { @@ -1368,22 +1804,48 @@ export class BookingBatchService implements OnModuleInit { .update(scheduleId, { bookingWindowStatus: status }); } + /** No wagon slots left for allocated + reserved bookings. */ + async isScheduleFull(scheduleId: string): Promise { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return false; + return (await this.remainingWagons(schedule)) <= 0; + } + // ---- timer plumbing ------------------------------------------------------- + /** Configured customer pay window in ms (global rules, with defaults). */ + private async paymentWindowMs(): Promise { + const cfg = await this.trainSchedulingService.getWindowConfig(); + return cfg.paymentWindowMinutes * 60_000; + } + private timeoutName(scheduleId: string): string { return `settle:${scheduleId}`; } + /** + * In-process accelerator only — the durable settle enforcement is the window + * engine's minute tick calling settleDueReservations off `paymentDeadline`. + */ private armSettle(scheduleId: string): void { - this.removeTimeout(scheduleId); - const handle = setTimeout(() => { - void this.settleBatch(scheduleId).catch((err) => - this.logger.error( - `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + void this.paymentWindowMs() + .then((delayMs) => { + this.removeTimeout(scheduleId); + const handle = setTimeout(() => { + void this.settleBatch(scheduleId).catch((err) => + this.logger.error( + `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + ), + ); + }, delayMs); + this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); + }) + .catch((err) => + this.logger.warn( + `armSettle ${scheduleId} skipped: ${(err as Error).message}`, ), ); - }, PAYMENT_WINDOW_MS); - this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); } private removeTimeout(scheduleId: string): void { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e8f272123..49df19758 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; -import { PAYMENT_WINDOW_MS } from './booking-batch.constants'; @Injectable() export class BookingNotifierService { @@ -43,12 +42,32 @@ export class BookingNotifierService { } async payNow(b: Booking, deadline: Date): Promise { - const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000); + const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW'); } + /** + * Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit + * this train. Paying accepts the split; letting the deadline pass keeps the + * booking whole and expires it for this train. + */ + async payNowPartial( + b: Booking, + deadline: Date, + offeredWagons: number, + totalWagons: number, + ): Promise { + const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); + const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const msg = + `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + + `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; + await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + } + secured(b: Booking, reason: 'paid' | 'gov'): void { const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${ reason === 'gov' ? ' (government)' : '' diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts new file mode 100644 index 000000000..2cd2df6d9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -0,0 +1,270 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { Freight } from '@edr/types'; + +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { BookingInvoiceService } from '../bookings/booking-invoice.service'; +import { BillingService } from '../billing/billing.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { + BookingBatchOffer, + OfferedLine, +} from './entities/booking-batch-offer.entity'; +import { BookingNotifierService } from './booking-notifier.service'; + +export interface SizedOffer { + offeredWagons: number; + totalWagons: number; + offeredLines: OfferedLine[] | null; + offeredWeightTons: number; + offeredAmount: number; + offeredPricingBreakdown: Record; +} + +/** + * Partial-capacity booking splits (import batch). The offer is sized and priced + * against an in-memory clone — the booking row is untouched until the customer + * pays, which is the act of accepting the split (applySplit). No payment → + * offer expires and the booking stays whole. + * + * Only GENERAL-contract commercial bookings are offered partials: the remainder + * returns to the contract's quantity cap (derived live from booking_container + * rows, so reducing the lines releases it automatically) and can be rebooked in + * any later window within contract validity. + */ +@Injectable() +export class BookingSplitService { + private readonly logger = new Logger(BookingSplitService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + @Inject(forwardRef(() => BookingPricingService)) + private readonly pricing: BookingPricingService, + @Inject(forwardRef(() => BookingInvoiceService)) + private readonly invoiceService: BookingInvoiceService, + private readonly billing: BillingService, + private readonly notifier: BookingNotifierService, + ) {} + + /** + * Size the largest part of the booking that fits `freeWagons`, priced via an + * in-memory clone. Returns null when nothing meaningful fits (no whole + * container unit / no bulk tonnage, or pricing failed). + */ + async sizeOffer( + booking: Booking, + freeWagons: number, + totalWagons: number, + bulkWagonCapacityTons: number, + ): Promise { + if (freeWagons < 1 || freeWagons >= totalWagons) return null; + + const containers = booking.bookingContainers ?? []; + let offeredLines: OfferedLine[] | null = null; + let offeredWeightTons = 0; + let offeredWagons = 0; + const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking); + clone.adjustedTotalAmount = null; + + if (containers.length) { + offeredLines = []; + let remaining = freeWagons; + const clonedContainers: BookingContainer[] = []; + for (const line of containers) { + const quantity = Number(line.quantity ?? 0); + const lineWagons = Number(line.wagonsRequired ?? 0); + if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue; + const perUnit = lineWagons / quantity; + // Largest unit count whose wagon need still fits the remaining budget. + let take = Math.min(quantity, Math.floor(remaining / perUnit)); + while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1; + if (take <= 0) continue; + const takeWagons = Math.ceil(take * perUnit); + const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0); + offeredLines.push({ + bookingContainerId: line.id, + quantity: take, + wagonsRequired: takeWagons, + totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000, + }); + offeredWeightTons += take * vgmPerUnit; + offeredWagons += takeWagons; + remaining -= takeWagons; + + const clonedLine: BookingContainer = Object.assign( + Object.create(Object.getPrototypeOf(line)), + line, + { + quantity: take, + wagonsRequired: takeWagons, + totalVgmTons: take * vgmPerUnit, + }, + ); + clonedContainers.push(clonedLine); + } + if (!offeredLines.length || offeredWagons <= 0) return null; + clone.bookingContainers = clonedContainers; + } else { + // Bulk: split by weight — the offered part is what freeWagons can carry. + const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0); + if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null; + offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons); + if (offeredWeightTons <= 0) return null; + offeredWagons = Math.min( + freeWagons, + Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)), + ); + } + + offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000; + clone.cargoTotalWeightVgm = offeredWeightTons; + clone.wagonsRequired = offeredWagons; + + try { + const priced = await this.pricing.computePriceForBooking(clone); + return { + offeredWagons, + totalWagons, + offeredLines, + offeredWeightTons, + offeredAmount: priced.totalAmount, + offeredPricingBreakdown: { + lineItems: priced.lineItems, + totalAmount: priced.totalAmount, + currency: priced.currency, + generatedAt: new Date().toISOString(), + partialOfWagons: totalWagons, + }, + }; + } catch (err) { + this.logger.warn( + `Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`, + ); + return null; + } + } + + /** + * Persist the offer and swap the booking's payable to a partial invoice for the + * offered amount. Any previous open offer for the booking is superseded. + */ + async createOffer( + booking: Booking, + scheduleId: string, + sized: SizedOffer, + deadline: Date, + ): Promise { + const repo = this.dataSource.getRepository(BookingBatchOffer); + await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' }); + + // The full-amount invoice must not stay payable next to the partial one. + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID'); + const invoice = await this.invoiceService.ensureInvoiceForBooking( + { ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking, + { dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending }, + ); + + const offer = await repo.save( + repo.create({ + bookingId: booking.id, + trainScheduleId: scheduleId, + offeredWagons: sized.offeredWagons, + totalWagons: sized.totalWagons, + offeredLines: sized.offeredLines, + offeredWeightTons: sized.offeredWeightTons, + offeredAmount: sized.offeredAmount, + offeredPricingBreakdown: sized.offeredPricingBreakdown, + invoiceId: invoice.id, + paymentDeadline: deadline, + status: 'OFFERED', + }), + ); + await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons); + return offer; + } + + /** + * Payment received inside the window — the customer accepted the split. + * Reduce the booking to the offered lines/weight; the remainder returns to the + * contract cap automatically (bookedQuantities derives from live lines). + * Idempotent: no OFFERED offer → no-op. + */ + async applySplit(bookingId: string): Promise { + const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({ + where: { bookingId, status: 'OFFERED' }, + order: { createdAt: 'DESC' }, + }); + if (!offer) return; + + await this.dataSource.transaction(async (manager) => { + if (offer.offeredLines?.length) { + const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l])); + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId }, + }); + for (const line of lines) { + const kept = keptByLine.get(line.id); + if (!kept) { + await manager.getRepository(BookingContainer).softDelete(line.id); + await manager + .getRepository(BookingContainerUnit) + .softDelete({ bookingContainerId: line.id }); + continue; + } + const dropCount = Number(line.quantity) - kept.quantity; + await manager.getRepository(BookingContainer).update(line.id, { + quantity: kept.quantity, + wagonsRequired: kept.wagonsRequired, + totalVgmTons: kept.totalVgmTons, + hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity), + reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity), + }); + if (dropCount > 0) { + // Trim surplus physical units, last-entered first. + const units = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + order: { sortOrder: 'DESC', createdAt: 'DESC' }, + take: dropCount, + }); + if (units.length) { + await manager + .getRepository(BookingContainerUnit) + .softDelete(units.map((u) => u.id)); + } + } + } + } + + await manager.getRepository(Booking).update(bookingId, { + wagonsRequired: offer.offeredWagons, + cargoTotalWeightVgm: offer.offeredWeightTons, + totalAmount: offer.offeredAmount, + pricingBreakdown: offer.offeredPricingBreakdown, + } as never); + + await manager + .getRepository(BookingBatchOffer) + .update(offer.id, { status: 'APPLIED' }); + }); + this.logger.log( + `Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`, + ); + } + + /** Pay window closed without payment — offer dies, booking stays whole. */ + async expireOpenOffer(bookingId: string): Promise { + await this.dataSource + .getRepository(BookingBatchOffer) + .update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' }); + } + + async findOpenOffer(bookingId: string): Promise { + return this.dataSource.getRepository(BookingBatchOffer).findOne({ + where: { bookingId, status: 'OFFERED' }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts new file mode 100644 index 000000000..c694eed11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -0,0 +1,30 @@ +/** + * Booking-window timings sourced from the train_scheduling_global_rules singleton, + * with hardcoded fallbacks when the row is missing (see TrainSchedulingService.getWindowConfig). + */ +export interface BookingWindowConfig { + /** Days before departure the single import booking-window day falls on. */ + importWindowLeadDays: number; + /** Hours before departure an export booking becomes acceptable (FCFS). */ + exportBookingLeadHours: number; + /** Local (Africa/Addis_Ababa) hour at which the import window opens. */ + windowOpenHour: number; + windowDurationHours: number; + /** Max staff document-review time after the window closes. */ + docReviewMinutes: number; + paymentWindowMinutes: number; + /** Delay after window close before reopening when the train is not full. */ + reopenDelayMinutes: number; +} + +/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */ +export const WINDOW_PHASES = [ + 'PRE_WINDOW', + 'OPEN', + 'DOC_REVIEW', + 'PAYMENT', + 'CLOSED_FOR_DAY', + 'DONE', +] as const; + +export type WindowPhase = (typeof WINDOW_PHASES)[number]; 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 new file mode 100644 index 000000000..f2fe5db07 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -0,0 +1,346 @@ +import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +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 { BookingBatchService } from './booking-batch.service'; +import { TrainSchedulingService } from './train-scheduling.service'; +import { BATCH_TIMEZONE } from './booking-batch.constants'; +import { eatDay } from './batch-window.util'; +import { type BookingWindowConfig } from './booking-window.config'; + +/** + * Drives the one-booking-day window cycle for IMPORT schedules and the FCFS + * booking window for EXPORT schedules. All state lives in DB timestamps on the + * 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). + * 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. + */ +@Injectable() +export class BookingWindowService implements OnModuleInit { + private readonly logger = new Logger(BookingWindowService.name); + private ticking = false; + private tickCount = 0; + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly bookingBatchService: BookingBatchService, + private readonly trainSchedulingService: TrainSchedulingService, + ) {} + + async onModuleInit(): Promise { + await this.tick().catch((err) => + this.logger.warn(`Boot window tick failed: ${(err as Error).message}`), + ); + } + + @Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) + async tick(): Promise { + if (this.ticking) return; + this.ticking = true; + try { + const now = new Date(); + const cfg = await this.trainSchedulingService.getWindowConfig(); + + const active = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }) + ).filter( + (s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY', + ); + + for (const schedule of active) { + try { + await this.advanceSchedule(schedule, cfg, now); + } catch (err) { + this.logger.error( + `Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`, + ); + } + } + + await this.settleOverdueReservations(); + + // Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick. + this.tickCount += 1; + if (this.tickCount % 5 === 0) { + await this.bookingBatchService.runBatchFill(); + } + } finally { + this.ticking = false; + } + } + + /** Staff finished document review early — start the batch/payment phase now. */ + async completeDocReview(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.windowPhase !== 'DOC_REVIEW') { + // Idempotent for the whole route-day group: only DOC_REVIEW schedules move. + return schedule; + } + const now = new Date(); + const cfg = await this.trainSchedulingService.getWindowConfig(); + // Stamp the whole route-day group so one staff action releases every train + // sharing this booking day's pool. + const group = ( + await this.trainSchedulesRepository.findAll({ + where: { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }, + }) + ).filter( + (s) => + s.windowPhase === 'DOC_REVIEW' && + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === eatDay(schedule.scheduledDepartureDate), + ); + for (const s of group) { + await this.dataSource + .getRepository(TrainSchedule) + .update(s.id, { docReviewCompletedAt: now }); + s.docReviewCompletedAt = now; + await this.advanceSchedule(s, cfg, now); + } + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + return fresh ?? schedule; + } + + // ---- transitions ------------------------------------------------------------ + + private async advanceSchedule( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + // Apply every transition that is due, in order (fast-forwards after downtime). + for (let guard = 0; guard < 6; guard += 1) { + const advanced = + schedule.direction === 'EXPORT' + ? await this.advanceExport(schedule, now) + : await this.advanceImport(schedule, cfg, now); + if (!advanced) return; + } + } + + /** Export: PRE_WINDOW → OPEN at opensAt, OPEN → DONE at closesAt (= departure). */ + private async advanceExport(schedule: TrainSchedule, now: Date): Promise { + if ( + schedule.windowPhase === 'PRE_WINDOW' && + schedule.windowOpensAt && + now >= schedule.windowOpensAt + ) { + await this.setPhase(schedule, { + windowPhase: 'OPEN', + bookingCycleNo: schedule.bookingCycleNo + 1, + }); + if (schedule.bookingWindowStatus !== 'FULL') { + await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); + schedule.bookingWindowStatus = 'OPEN'; + } + this.logger.log(`Export booking window opened for schedule ${schedule.id}`); + return true; + } + if ( + schedule.windowPhase === 'OPEN' && + schedule.windowClosesAt && + now >= schedule.windowClosesAt + ) { + await this.setPhase(schedule, { windowPhase: 'DONE' }); + if (schedule.bookingWindowStatus === 'OPEN') { + await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); + schedule.bookingWindowStatus = 'CLOSED'; + } + return true; + } + return false; + } + + private async advanceImport( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + const { windowPhase, windowOpensAt, windowClosesAt } = schedule; + + if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) { + await this.setPhase(schedule, { + windowPhase: 'OPEN', + bookingCycleNo: schedule.bookingCycleNo + 1, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + if (schedule.bookingWindowStatus !== 'FULL') { + await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); + schedule.bookingWindowStatus = 'OPEN'; + } + this.logger.log( + `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, + ); + return true; + } + + if (windowPhase === 'OPEN' && windowClosesAt && now >= windowClosesAt) { + const docReviewEndsAt = new Date( + windowClosesAt.getTime() + cfg.docReviewMinutes * 60_000, + ); + await this.setPhase(schedule, { windowPhase: 'DOC_REVIEW', docReviewEndsAt }); + if (schedule.bookingWindowStatus === 'OPEN') { + await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); + schedule.bookingWindowStatus = 'CLOSED'; + } + this.logger.log( + `Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`, + ); + return true; + } + + if ( + windowPhase === 'DOC_REVIEW' && + (schedule.docReviewCompletedAt != null || + (schedule.docReviewEndsAt != null && now >= schedule.docReviewEndsAt)) + ) { + const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000); + await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); + // Run the batch: priority fill over the route-day pool, reserving pay windows + // (or allocating government) — skipped automatically for everyone who fits + // is handled inside the fill (all fit → all reserved → all notified). + await this.bookingBatchService.processRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }); + this.logger.log( + `Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`, + ); + return true; + } + + if ( + windowPhase === 'PAYMENT' && + schedule.paymentPhaseEndsAt != null && + now >= schedule.paymentPhaseEndsAt + ) { + await this.bookingBatchService.settleDueReservations(schedule.id); + await this.concludeCycle(schedule, cfg, now); + return true; + } + + return false; + } + + /** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */ + private async concludeCycle( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + const full = await this.bookingBatchService.isScheduleFull(schedule.id); + if (full) { + await this.bookingBatchService.setWindow(schedule.id, 'FULL'); + await this.setPhase(schedule, { windowPhase: 'DONE' }); + await this.tryAutoFinalize(schedule.id); + return; + } + + const closesAt = schedule.windowClosesAt ?? now; + const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000); + const nextOpensAt = reopenAt > now ? reopenAt : now; + let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000); + if (nextClosesAt > schedule.scheduledDepartureDate) { + nextClosesAt = schedule.scheduledDepartureDate; + } + + const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt); + const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate; + if (sameBookingDay && beforeDeparture) { + await this.setPhase(schedule, { + windowPhase: 'PRE_WINDOW', + windowOpensAt: nextOpensAt, + windowClosesAt: nextClosesAt, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + this.logger.log( + `Schedule ${schedule.id} not full — window reopens at ${nextOpensAt.toISOString()}`, + ); + } else { + await this.setPhase(schedule, { windowPhase: 'CLOSED_FOR_DAY' }); + this.logger.log( + `Booking day over for schedule ${schedule.id} — remaining capacity is staff-managed`, + ); + } + } + + private async tryAutoFinalize(scheduleId: string): Promise { + try { + await this.trainSchedulingService.finalizeSchedule(scheduleId); + this.logger.log(`Schedule ${scheduleId} is full — auto-finalized`); + } catch (err) { + // Not DRAFT / no linked bookings yet — staff finalize manually. + this.logger.warn( + `Auto-finalize skipped for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + + /** Durable settle backstop: expire/allocate reservations whose deadline passed. */ + private async settleOverdueReservations(): Promise { + const overdue = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .select('DISTINCT b.train_schedule_id', 'scheduleId') + .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere('b.payment_deadline <= now()') + .andWhere('b.train_schedule_id IS NOT NULL') + .getRawMany<{ scheduleId: string }>(); + for (const { scheduleId } of overdue) { + try { + await this.bookingBatchService.settleDueReservations(scheduleId); + } catch (err) { + this.logger.warn( + `Overdue settle failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + } + + private async setPhase( + schedule: TrainSchedule, + patch: Partial< + Pick< + TrainSchedule, + | 'windowPhase' + | 'windowOpensAt' + | 'windowClosesAt' + | 'docReviewEndsAt' + | 'docReviewCompletedAt' + | 'paymentPhaseEndsAt' + | 'bookingCycleNo' + > + >, + ): Promise { + await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch); + Object.assign(schedule, patch); + } +} 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 d47195976..1171b0c90 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 @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsNumber, IsOptional, Min } from 'class-validator'; +import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; export class UpdateTrainSchedulingGlobalRulesDto { @ApiPropertyOptional({ example: 760 }) @@ -37,4 +37,55 @@ export class UpdateTrainSchedulingGlobalRulesDto { @IsNumber() @Min(0) max20ftPairWeightDiffTons?: number; + + @ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importWindowLeadDays?: number; + + @ApiPropertyOptional({ example: 24, description: 'Hours before departure an export booking becomes acceptable' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportBookingLeadHours?: number; + + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowOpenHour?: number; + + @ApiPropertyOptional({ example: 3 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.25) + @Max(12) + windowDurationHours?: number; + + @ApiPropertyOptional({ example: 30 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + docReviewMinutes?: number; + + @ApiPropertyOptional({ example: 60 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + paymentWindowMinutes?: number; + + @ApiPropertyOptional({ example: 90 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + reopenDelayMinutes?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts new file mode 100644 index 000000000..4b6f7c685 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts @@ -0,0 +1,75 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +export const BOOKING_BATCH_OFFER_STATUSES = ['OFFERED', 'APPLIED', 'EXPIRED'] as const; +export type BookingBatchOfferStatus = (typeof BOOKING_BATCH_OFFER_STATUSES)[number]; + +/** One reduced container line of a partial offer (per original booking_container row). */ +export interface OfferedLine { + bookingContainerId: string; + /** Units of this line that ride the offered train (≤ original quantity). */ + quantity: number; + wagonsRequired: number; + totalVgmTons: number; +} + +/** + * A partial-capacity payment offer made by the batch when a booking needs more + * wagons than the train has left (e.g. needs 20, 3 free). The booking itself is + * NOT mutated at offer time — paying inside the window accepts the split + * (BookingSplitService.applySplit reduces the booking to the offered lines and + * the remainder returns to the contract's quantity cap); letting the deadline + * pass expires the offer and the booking stays whole. + */ +@Entity({ schema: 'freight', name: 'booking_batch_offers' }) +@Index(['bookingId']) +@Index(['trainScheduleId']) +@Index(['status']) +export class BookingBatchOffer extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'offered_wagons', type: 'int' }) + offeredWagons!: number; + + /** Booking's full wagon need at offer time (for messaging / audit). */ + @Column({ name: 'total_wagons', type: 'int' }) + totalWagons!: number; + + /** Reduced container lines (null for bulk offers — bulk splits by weight). */ + @Column({ name: 'offered_lines', type: 'jsonb', nullable: true }) + offeredLines?: OfferedLine[] | null; + + @Column({ name: 'offered_weight_tons', type: 'numeric', precision: 12, scale: 3 }) + offeredWeightTons!: number; + + @Column({ name: 'offered_amount', type: 'numeric', precision: 14, scale: 2 }) + offeredAmount!: number; + + @Column({ name: 'offered_pricing_breakdown', type: 'jsonb', nullable: true }) + offeredPricingBreakdown?: Record | null; + + /** The partial PREPAID invoice generated for the offered part. */ + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + @Column({ name: 'payment_deadline', type: 'timestamptz' }) + paymentDeadline!: Date; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'OFFERED' }) + status!: BookingBatchOfferStatus; +} 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 326915933..7b8d9b26a 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 @@ -41,4 +41,36 @@ export class TrainSchedulingGlobalRules extends BaseEntity { default: 10, }) max20ftPairWeightDiffTons!: number; + + /** Days before departure the single import booking-window day falls on. */ + @Column({ name: 'import_window_lead_days', type: 'int', default: 3 }) + importWindowLeadDays!: number; + + /** Hours before departure an export booking becomes acceptable (FCFS, no window cycle). */ + @Column({ name: 'export_booking_lead_hours', type: 'int', default: 24 }) + exportBookingLeadHours!: number; + + /** Local (Africa/Addis_Ababa) hour at which the import window opens on its window day. */ + @Column({ name: 'window_open_hour', type: 'int', default: 8 }) + windowOpenHour!: number; + + @Column({ + name: 'window_duration_hours', + type: 'numeric', + precision: 4, + scale: 2, + default: 3, + }) + windowDurationHours!: number; + + /** Max time staff have to accept booking documents after the window closes. */ + @Column({ name: 'doc_review_minutes', type: 'int', default: 30 }) + docReviewMinutes!: number; + + @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) + paymentWindowMinutes!: number; + + /** Delay after window close before the window reopens when the train is not yet full. */ + @Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 }) + reopenDelayMinutes!: 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 1c15ab801..d0595c0da 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"; @@ -43,6 +44,8 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; +import { BookingWindowService } from "./booking-window.service"; +import { BillingService } from "../billing/billing.service"; @ApiTags("train-scheduling") @ApiBearerAuth() @@ -51,8 +54,23 @@ export class TrainSchedulingController { constructor( private readonly trainSchedulingService: TrainSchedulingService, private readonly bookingBatchService: BookingBatchService, + private readonly bookingWindowService: BookingWindowService, + private readonly billingService: BillingService, ) { } + @Get("my-booking-windows") + @ApiOperation({ + summary: + "Upcoming/open booking windows on the signed-in customer's active contract lanes", + }) + async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) { + const companyId = await this.billingService.resolveCompanyId( + resolveAuthUserId(user), + ); + if (!companyId) return []; + return this.trainSchedulingService.getBookingWindowsForCompany(companyId); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) @@ -308,6 +326,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" }) @@ -457,6 +497,17 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Post("schedules/:id/doc-review-complete") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", + }) + async completeDocReview(@Param("id", ParseUUIDPipe) id: string) { + await this.bookingWindowService.completeDocReview(id); + return this.bookingBatchService.getBatchBoardDetail(id); + } + @Post("bookings/:bookingId/mark-paid") @TrainSchedulingManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 0f2e22076..7e281a72f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -25,6 +25,9 @@ import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingWindowService } from './booking-window.service'; +import { BookingSplitService } from './booking-split.service'; +import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { NotificationsModule } from '../notifications/notifications.module'; import { ContractsModule } from '../contracts/contracts.module'; @@ -42,6 +45,7 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainSchedulingGlobalRules, TrainCheckpointEvent, ImportDjiboutiOperation, + BookingBatchOffer, ]), forwardRef(() => BookingsModule), BillingModule, @@ -60,7 +64,9 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainCheckpointEventsRepository, BookingBatchService, BookingNotifierService, + BookingWindowService, + BookingSplitService, ], - exports: [TrainSchedulingService, BookingBatchService], + exports: [TrainSchedulingService, BookingBatchService, BookingWindowService], }) export class TrainSchedulingModule {} 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 e87dbdd88..2c678ea44 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, @@ -13,7 +14,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In } from 'typeorm'; +import { DataSource, EntityManager, In, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -45,6 +46,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'; @@ -58,6 +60,7 @@ import { UploadImportDjiboutiDocumentDto, } from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; +import { type BookingWindowConfig } from './booking-window.config'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -98,7 +101,11 @@ import { DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, } from './booking-batch.constants'; -import { eatDay } from './batch-window.util'; +import { + computeExportWindowTimes, + computeImportWindowTimes, + eatDay, +} from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; @@ -236,9 +243,37 @@ export class TrainSchedulingService { if (dto.max20ftPairWeightDiffTons != null) { row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; } + if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; + if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; + if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; + if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; + 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); } + /** + * Booking-window timings with hardcoded fallbacks for a missing/legacy config row. + * Numeric columns come back from pg as strings — normalize every field. + */ + async getWindowConfig(): Promise { + const row = await this.loadGlobalRulesRow(); + const num = (v: unknown, fallback: number) => { + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : fallback; + }; + return { + importWindowLeadDays: num(row?.importWindowLeadDays, 3), + exportBookingLeadHours: num(row?.exportBookingLeadHours, 24), + windowOpenHour: num(row?.windowOpenHour, 8), + windowDurationHours: num(row?.windowDurationHours, 3), + docReviewMinutes: num(row?.docReviewMinutes, 30), + paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), + reopenDelayMinutes: num(row?.reopenDelayMinutes, 90), + }; + } + async previewTrainSchedule(dto: PreviewTrainScheduleDto) { const limits = await this.resolveTrainLimitConfig(dto); return this.buildPreviewResponse( @@ -310,8 +345,12 @@ export class TrainSchedulingService { throw new BadRequestException('A train must be pulled by at least two locomotives'); } + const scheduleWarnings: string[] = []; const createdScheduleId = await this.dataSource.transaction(async (manager) => { - // Lock and validate every locomotive: all must be AVAILABLE and at the origin yard. + // Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on + // multiple future schedules and does not need to be at the origin yard yet — staff + // plan around its arrival. Only decommissioned locomotives are hard-blocked; + // everything else surfaces as a warning. const lockedLocomotives: Locomotive[] = []; for (const locomotiveId of locomotiveIds) { const locked = await manager.getRepository(Locomotive).findOne({ @@ -321,12 +360,17 @@ export class TrainSchedulingService { if (!locked) { throw new NotFoundException(`Locomotive ${locomotiveId} not found`); } + if (locked.status === 'OUT_OF_SERVICE') { + throw new ConflictException(`Locomotive ${locked.code} is out of service`); + } if (locked.status !== 'AVAILABLE') { - throw new ConflictException(`Locomotive ${locked.code} is not available`); + scheduleWarnings.push( + `Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`, + ); } if (locked.currentYardId !== route.originYardId) { - throw new ConflictException( - `Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`, + scheduleWarnings.push( + `Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`, ); } lockedLocomotives.push(locked); @@ -340,27 +384,46 @@ export class TrainSchedulingService { const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); // 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). + const windowCfg = await this.getWindowConfig(); + const windowFields = + direction === 'IMPORT' + ? { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...computeImportWindowTimes(departure, windowCfg, new Date()), + } + : direction === 'EXPORT' + ? { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...computeExportWindowTimes(departure, windowCfg), + } + : {}; const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, routeId: route.id, originStationId: route.originYardId, destinationStationId: route.destinationYardId, - scheduledDepartureDate: new Date(dto.scheduleDate), + scheduledDepartureDate: departure, status: TrainScheduleStatusEnum.Draft, direction, maxWagons: ( await this.resolveTrainLimitConfig(dto, limitLoco) ).maxWagonsPerTrain, + ...windowFields, }); const saved = await manager.getRepository(TrainSchedule).save(schedule); - await manager.getRepository(Locomotive).update( - { id: In(lockedLocomotives.map((l) => l.id)) }, - { status: 'ASSIGNED' }, - ); + // Locomotives stay in their current status until dispatch — advance scheduling + // must not block the locomotive from serving earlier trains. return saved.id; }); - return this.getTrainScheduleById(createdScheduleId); + const created = await this.getTrainScheduleById(createdScheduleId); + return { ...created, warnings: scheduleWarnings }; } async assignBookingsToSchedule( @@ -683,6 +746,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) { @@ -778,10 +922,19 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // A locomotive may sit on many future schedules, but it can only pull one train + // at a time — block dispatch while any set locomotive is out on a dispatched train. + const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId); const now = new Date(); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); + if (setLocomotiveIds.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' }); + } await this.trainSchedulesRepository.updateStatus( scheduleId, @@ -1461,6 +1614,12 @@ export class TrainSchedulingService { /** Open or close a schedule's booking window (staff override). */ async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise { + if (status === 'OPEN') { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (schedule?.bookingWindowStatus === 'FULL') { + throw new ConflictException('Train is full — the booking window cannot be reopened'); + } + } await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); @@ -1685,16 +1844,14 @@ export class TrainSchedulingService { [scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched], ); - if (schedule.trainSet?.locomotiveId) { - const loco = await manager - .getRepository(Locomotive) - .findOne({ where: { id: schedule.trainSet.locomotiveId } }); - if (loco) { - await manager.getRepository(Locomotive).update(loco.id, { - status: 'AVAILABLE', - currentYardId: schedule.destinationStationId, - }); - } + // Release every locomotive of the set (not just the legacy primary) and move it + // to the destination yard where it physically arrived. + const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + if (arrivedLocoIds.length) { + await manager.getRepository(Locomotive).update( + { id: In(arrivedLocoIds) }, + { status: 'AVAILABLE', currentYardId: schedule.destinationStationId }, + ); } for (const slot of schedule.trainSet?.wagons ?? []) { @@ -1769,11 +1926,21 @@ export class TrainSchedulingService { if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); } + // Locomotives are only ASSIGNED while out on a dispatched train. Release ours, + // but never stomp a locomotive that is currently pulling another dispatched train. const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); if (cancelledLocoIds.length) { - await manager - .getRepository(Locomotive) - .update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' }); + const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere( + cancelledLocoIds, + id, + manager, + ); + const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId)); + if (releasable.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' }); + } } for (const wagon of schedule.trainSet?.wagons ?? []) { if (wagon.physicalWagonId) { @@ -2016,15 +2183,17 @@ export class TrainSchedulingService { } if (assignedLocomotives.length) { - // Every locomotive of the set must sit at the origin yard, and the weakest - // one must still be able to pull the train (min limits across the set). + // Advance scheduling: a locomotive that hasn't reached the origin yard yet is a + // warning (it must arrive before dispatch), but a set too weak to pull the train + // is a hard violation. const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId); const setLimits = minLocomotiveLimits(assignedLocomotives); if (offYard) { - violations.push( - `Locomotive ${offYard.code} is not at the schedule origin yard`, + warnings.push( + `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, ); - } else if ( + } + if ( setLimits && (setLimits.maxPullWeightTons < totalWeightTons || setLimits.maxTrainLengthMeters < totalLengthMeters) @@ -2034,21 +2203,22 @@ export class TrainSchedulingService { ); } } else { - const availableLocomotives = ( - await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE' }, - }) - ).filter((l) => l.currentYardId === originYardId); - if (!availableLocomotives.length) { - violations.push('No available locomotive at the schedule origin yard'); - } else if ( - !availableLocomotives.some( + const inServiceLocomotives = await this.locomotivesRepository.findAll({ + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, + }); + if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) { + warnings.push( + 'No locomotive is at the schedule origin yard yet; one must arrive before dispatch', + ); + } + if ( + !inServiceLocomotives.some( (l) => Number(l.maxPullWeightTons) >= totalWeightTons && Number(l.maxTrainLengthMeters) >= totalLengthMeters, ) ) { - violations.push('No available locomotive can support the total train weight and length'); + violations.push('No locomotive can support the total train weight and length'); } } @@ -2596,6 +2766,58 @@ export class TrainSchedulingService { return trainSet.locomotive ? [trainSet.locomotive] : []; } + /** + * Locomotive ids (among the given ones) that are attached to a DISPATCHED train + * other than `excludeScheduleId`. Covers both the multi-loco link rows and the + * legacy single-locomotive column on the train set. + */ + private async findLocomotiveIdsDispatchedElsewhere( + locomotiveIds: string[], + excludeScheduleId: string, + manager?: EntityManager, + ): Promise> { + if (!locomotiveIds.length) return new Set(); + const runner = manager ?? this.dataSource; + const rows: { locomotive_id: string }[] = await runner.query( + `SELECT DISTINCT loco.locomotive_id + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND ts.id <> $1 + AND loco.locomotive_id = ANY($2)`, + [excludeScheduleId, locomotiveIds], + ); + return new Set(rows.map((r) => r.locomotive_id)); + } + + private async assertLocomotivesNotDispatchedElsewhere( + locomotiveIds: string[], + excludeScheduleId: string, + ): Promise { + const busy = await this.findLocomotiveIdsDispatchedElsewhere( + locomotiveIds, + excludeScheduleId, + ); + if (!busy.size) return; + const locos = await this.dataSource + .getRepository(Locomotive) + .find({ where: { id: In([...busy]) } }); + const codes = locos.map((l) => l.code).join(', '); + throw new ConflictException( + `Locomotive(s) ${codes} are currently out on another dispatched train`, + ); + } + async selectOrValidateLocomotive( locomotiveId: string, totalWeightTons: number, @@ -2732,15 +2954,113 @@ export class TrainSchedulingService { } /** AVAILABLE locomotives at the route's origin yard. */ - async getAvailableLocomotivesForRoute(routeId: string): Promise { + /** + * All in-service locomotives, annotated for the schedule-creation picker. + * Advance scheduling means nothing is filtered out — staff see status, whether the + * locomotive is at the origin yard yet, and how many future schedules it already has. + */ + async getAvailableLocomotivesForRoute(routeId: string) { const route = await this.getSchedulableRoute(routeId); const locomotives = await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE', currentYardId: route.originYardId }, + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, order: { code: 'ASC' }, }); - return locomotives; + const counts: { locomotive_id: string; future_count: string }[] = locomotives.length + ? await this.dataSource.query( + `SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.deleted_at IS NULL + AND loco.locomotive_id = ANY($1) + GROUP BY loco.locomotive_id`, + [locomotives.map((l) => l.id)], + ) + : []; + const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)])); + + return locomotives.map((loco) => ({ + ...loco, + atOriginYard: loco.currentYardId === route.originYardId, + futureScheduleCount: futureCounts.get(loco.id) ?? 0, + })); + } + + /** + * 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. + */ + 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, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_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.deleted_at IS NULL + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.company_id = $1 + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + 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`, + [companyId], + ); + return rows.map((r) => ({ + scheduleId: r.schedule_id, + 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, + 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). @@ -2937,6 +3257,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, ) { diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index b5f9abcb8..9158ecca0 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,5 +1,5 @@ import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator'; -import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity'; +import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; export class CreateVehicleDto { @IsString() @@ -26,6 +26,10 @@ export class CreateVehicleDto { @IsEnum(VehicleStatus) status!: VehicleStatus; + @IsOptional() + @IsEnum(VehicleAvailability) + availability?: VehicleAvailability; + @IsOptional() @IsString() description?: string; @@ -57,4 +61,8 @@ export class CreateVehicleDto { @IsOptional() @IsNumber() actualDistanceKm?: number; + + @IsOptional() + @IsUUID() + locationId?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index fef078ef8..416cddee6 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -25,11 +25,25 @@ export enum VehicleStatus { OUT_OF_SERVICE = 'OUT_OF_SERVICE', } +export enum VehicleAvailability { + FREE = 'FREE', + BUSY = 'BUSY', +} + @Entity({ name: 'vehicles', schema: 'freight' }) export class Vehicle extends BaseEntity { + @Column({ nullable: true }) + code?: string; + @Column({ name: 'plate_number', unique: true, nullable: true }) plateNumber?: string; + @Column({ name: 'power_plate_no', nullable: true }) + powerPlateNo?: string; + + @Column({ name: 'trailer_plate_no', nullable: true }) + trailerPlateNo?: string; + @Column({ name: 'registration_number', unique: true, nullable: true }) registrationNumber?: string; @@ -54,6 +68,9 @@ export class Vehicle extends BaseEntity { @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE, nullable: true }) status?: VehicleStatus; + @Column({ name: 'availability', type: 'varchar', default: VehicleAvailability.FREE, nullable: true }) + availability?: VehicleAvailability; + @Column({ type: 'text', nullable: true }) description?: string | null; @@ -68,4 +85,7 @@ export class Vehicle extends BaseEntity { @Column({ name: 'actual_distance_km', type: 'numeric', nullable: true }) actualDistanceKm?: number; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; } 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 24ff2d022..8e6d8a0a8 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -34,6 +34,7 @@ export class VehiclesController { findAll( @Query('search') search?: string, @Query('status') status?: string, + @Query('availability') availability?: string, @Query('page') page?: string, @Query('limit') limit?: string, @Query('sortBy') sortBy?: string, @@ -42,6 +43,7 @@ export class VehiclesController { return this.vehiclesService.findAll({ search, status: status as any, + availability: availability as any, page: page ? parseInt(page) : undefined, limit: limit ? parseInt(limit) : undefined, sortBy, 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 2bc882591..69568e483 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -1,9 +1,14 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Not, Repository } from 'typeorm'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; -import { Vehicle, VehicleStatus } from './entities/vehicle.entity'; +import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity'; +import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity'; +import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity'; +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'; @Injectable() export class VehiclesService { @@ -35,6 +40,7 @@ export class VehiclesService { async findAll(query: { search?: string; status?: VehicleStatus | string; + availability?: VehicleAvailability | string; page?: number; limit?: number; sortBy?: string; @@ -44,7 +50,7 @@ export class VehiclesService { if (query.search) { qb = qb.where( - 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search', + '(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search OR v.code ILIKE :search OR v.trailerPlateNo ILIKE :search)', { search: `%${query.search}%` }, ); } @@ -53,6 +59,10 @@ export class VehiclesService { qb = qb.andWhere('v.status = :status', { status: query.status }); } + if (query.availability) { + qb = qb.andWhere('v.availability = :availability', { availability: query.availability }); + } + const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes( query.sortBy ?? '', ) @@ -91,6 +101,48 @@ export class VehiclesService { return this.vehicleRepo.save(vehicle); } + async setAvailability(id: string, availability: VehicleAvailability): Promise { + await this.vehicleRepo.update(id, { availability }); + } + + /** + * Set vehicles back to FREE, but only when no active (non-completed) + * first/last-mile record or container allocation still references them. + * First-mile trips ending in RECEIVED_TO_PORT and last-mile trips ending + * in DELIVERED no longer hold the vehicle. + */ + async releaseIfUnused(vehicleIds: string[]): Promise { + const manager = this.vehicleRepo.manager; + for (const vehicleId of [...new Set(vehicleIds)]) { + const [fmRecords, lmRecords, fmAllocations, lmAllocations, bookingAllocations] = await Promise.all([ + manager.count(FirstMile, { + where: { vehicleId, status: Not('RECEIVED_TO_PORT') }, + }), + manager.count(LastMile, { + where: { vehicleId, status: Not('DELIVERED') }, + }), + manager + .createQueryBuilder(FirstMileContainerAllocation, 'alloc') + .innerJoin(FirstMile, 'fm', 'fm.id = alloc.firstMileId') + .where('alloc.vehicleId = :vehicleId', { vehicleId }) + .andWhere('fm.status != :done', { done: 'RECEIVED_TO_PORT' }) + .andWhere('fm.deletedAt IS NULL') + .getCount(), + manager + .createQueryBuilder(LastMileContainerAllocation, 'alloc') + .innerJoin(LastMile, 'lm', 'lm.id = alloc.lastMileId') + .where('alloc.vehicleId = :vehicleId', { vehicleId }) + .andWhere('lm.status != :done', { done: 'DELIVERED' }) + .andWhere('lm.deletedAt IS NULL') + .getCount(), + manager.count(BookingContainerAllocation, { where: { vehicleId } }), + ]); + if (fmRecords + lmRecords + fmAllocations + lmAllocations + bookingAllocations === 0) { + await this.setAvailability(vehicleId, VehicleAvailability.FREE); + } + } + } + async remove(id: string): Promise { await this.findById(id); await this.vehicleRepo.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts b/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts new file mode 100644 index 000000000..3435acabc --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts @@ -0,0 +1,49 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * One row per started Fayda verification. Mirrors the passenger-api Prisma + * model `FaydaVerificationSession`, but stored in the freight schema via + * TypeORM. `state` is the single-use CSRF token that links the eSignet + * redirect back to this session. + */ +@Entity({ name: 'fayda_verification_sessions', schema: 'freight' }) +@Index(['expiresAt']) +@Index(['iamUserId']) +export class FaydaVerificationSession extends BaseEntity { + @Column({ name: 'state', unique: true }) + state!: string; + + @Column({ name: 'code_verifier' }) + codeVerifier!: string; + + /** VERIFY | LOGIN */ + @Column({ name: 'purpose', default: 'VERIFY' }) + purpose!: string; + + /** WEB | MOBILE — recorded for audit */ + @Column({ name: 'platform', default: 'WEB' }) + platform!: string; + + @Column({ name: 'save_to_account', type: 'boolean', default: false }) + saveToAccount!: boolean; + + /** PENDING | COMPLETED | FAILED */ + @Column({ name: 'status', default: 'PENDING' }) + status!: string; + + @Column({ name: 'error_code', type: 'varchar', nullable: true }) + errorCode?: string | null; + + @Column({ name: 'error_description', type: 'text', nullable: true }) + errorDescription?: string | null; + + @Column({ name: 'iam_user_id', type: 'uuid', nullable: true }) + iamUserId?: string | null; + + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt!: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts new file mode 100644 index 000000000..1c5569750 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { VerifaydaCallbackDto } from './verifayda.dto'; + +/** + * Plain acknowledgement endpoint for the Fayda redirect_uri when it points at + * the API instead of the web app (e.g. MOBILE clients or connectivity checks). + * Registered at /callback (excluded from the global /api prefix in main.ts). + * It does NOT consume the verification session — the client must still call + * GET /api/fayda/verification/complete with the echoed code+state. + */ +@ApiTags('Fayda Verification') +@Controller('callback') +export class FaydaCallbackController { + @Get() + @IsPublic() + @ApiOperation({ summary: 'Acknowledge a Fayda redirect (returns OK, echoes code/state)' }) + @ApiOkResponse({ + schema: { example: { status: 'ok', code: '...', state: '...' } }, + }) + ok(@Query() query: VerifaydaCallbackDto) { + return { + status: 'ok', + ...(query.code ? { code: query.code } : {}), + ...(query.state ? { state: query.state } : {}), + ...(query.error ? { error: query.error } : {}), + ...(query.error_description ? { error_description: query.error_description } : {}), + }; + } +} + +// return res.redirect(url.toString()); diff --git a/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts b/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts new file mode 100644 index 000000000..8673aa60e --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts @@ -0,0 +1,30 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { DataSource } from 'typeorm'; + +/** + * Like the IAM JwtGuard, but never rejects the request. + * + * When a valid IAM bearer token is present, `request.user` is populated with + * the package `TCurrentUser`. Missing or invalid tokens continue as guests. + */ +@Injectable() +export class OptionalJwtGuard extends IamJwtGuard implements CanActivate { + constructor( + reflector: Reflector, + @InjectDataSource() dataSource: DataSource, + ) { + super(reflector, dataSource); + } + + async canActivate(context: ExecutionContext): Promise { + try { + await super.canActivate(context); + } catch { + context.switchToHttp().getRequest().user = undefined; + } + return true; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts new file mode 100644 index 000000000..9b4316fc7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts @@ -0,0 +1,71 @@ +import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose'; +import { generateClientAssertion } from './client-assertion.util'; + +describe('generateClientAssertion', () => { + let privateJwk: JWK; + let publicJwk: JWK; + + beforeAll(async () => { + const kp = await generateKeyPair('RS256', { extractable: true }); + privateJwk = await exportJWK(kp.privateKey); + publicJwk = await exportJWK(kp.publicKey); + }); + + it('produces a JWT verifiable with the matching public key', async () => { + const jwt = await generateClientAssertion({ + clientId: 'edr-passenger-test', + audience: 'https://esignet.example.com/token', + privateJwk, + }); + + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload, protectedHeader } = await jwtVerify(jwt, verifier, { + issuer: 'edr-passenger-test', + subject: 'edr-passenger-test', + audience: 'https://esignet.example.com/token', + }); + + expect(protectedHeader.alg).toBe('RS256'); + expect(protectedHeader.typ).toBe('JWT'); + expect(payload.iss).toBe('edr-passenger-test'); + expect(payload.sub).toBe('edr-passenger-test'); + expect(payload.aud).toBe('https://esignet.example.com/token'); + expect(typeof payload.iat).toBe('number'); + expect(typeof payload.exp).toBe('number'); + }); + + it('defaults exp to 120 seconds after iat', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + }); + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload } = await jwtVerify(jwt, verifier); + expect(payload.exp! - payload.iat!).toBe(120); + }); + + it('honors a custom expiresIn', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + expiresIn: '5m', + }); + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload } = await jwtVerify(jwt, verifier); + expect(payload.exp! - payload.iat!).toBe(300); + }); + + it('fails verification against a wrong audience', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + }); + const verifier = await importJWK(publicJwk, 'RS256'); + await expect( + jwtVerify(jwt, verifier, { audience: 'https://other/token' }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts new file mode 100644 index 000000000..dc3558ccc --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -0,0 +1,22 @@ +import { SignJWT, importJWK, type JWK } from 'jose'; + +export interface GenerateClientAssertionInput { + clientId: string; + audience: string; + privateJwk: JWK; + expiresIn?: string; +} + +export async function generateClientAssertion( + input: GenerateClientAssertionInput, +): Promise { + const privateKey = await importJWK(input.privateJwk, 'RS256'); + return new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(input.clientId) + .setSubject(input.clientId) + .setAudience(input.audience) + .setIssuedAt() + .setExpirationTime(input.expiresIn ?? '2m') + .sign(privateKey); +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts new file mode 100644 index 000000000..d359a07f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts @@ -0,0 +1,65 @@ +import { createHash } from 'crypto'; +import { + base64Url, + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './pkce.util'; + +describe('pkce.util', () => { + describe('base64Url', () => { + it('strips padding and replaces + and / with - and _', () => { + const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]); + const out = base64Url(input); + expect(out).not.toMatch(/[+/=]/); + }); + }); + + describe('generateCodeVerifier', () => { + it('returns a base64url-safe string', () => { + expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces unique values across calls', () => { + const a = generateCodeVerifier(); + const b = generateCodeVerifier(); + expect(a).not.toEqual(b); + }); + + it('produces at least 43 characters (RFC 7636 minimum)', () => { + expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43); + }); + }); + + describe('generateCodeChallenge', () => { + it('equals base64url(sha256(verifier))', () => { + const verifier = 'fixed-test-verifier'; + const expected = createHash('sha256') + .update(verifier) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + expect(generateCodeChallenge(verifier)).toBe(expected); + }); + + it('is deterministic for the same verifier', () => { + const verifier = generateCodeVerifier(); + expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier)); + }); + + it('differs for different verifiers', () => { + expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b')); + }); + }); + + describe('generateState', () => { + it('returns a base64url-safe string', () => { + expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces unique values across calls', () => { + expect(generateState()).not.toEqual(generateState()); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts new file mode 100644 index 000000000..89e9437d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts @@ -0,0 +1,21 @@ +import { createHash, randomBytes } from 'crypto'; + +export function base64Url(buffer: Buffer): string { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +export function generateCodeVerifier(): string { + return base64Url(randomBytes(64)); +} + +export function generateCodeChallenge(codeVerifier: string): string { + return base64Url(createHash('sha256').update(codeVerifier).digest()); +} + +export function generateState(): string { + return base64Url(randomBytes(32)); +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts new file mode 100644 index 000000000..977e677f8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { OptionalJwtGuard } from './optional-jwt.guard'; +import { + CompleteVerificationResultDto, + StartVerificationDto, + VerifaydaCallbackDto, + VerificationStatusDto, +} from './verifayda.dto'; +import { VerifaydaService } from './verifayda.service'; + +/** Minimal slices of the Express req we touch (avoids a hard dependency on + * `@types/express`, which isn't resolved in this package). */ +interface RequestWithOptionalUser { + user?: TCurrentUser; +} +interface RequestWithUser { + user: TCurrentUser; +} + +@ApiTags('Fayda Verification') +@Controller('fayda/verification') +export class VerifaydaController { + constructor(private readonly service: VerifaydaService) {} + + @Post('start') + @IsPublic() + @HttpCode(HttpStatus.OK) + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Start a VeriFayda 2.0 verification session', + description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to. + +- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user. +- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender). +- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT. +- The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`, + }) + @ApiOkResponse({ + description: 'Authorize URL the frontend should redirect the user to.', + schema: { + example: { + authorizationUrl: + 'https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...', + }, + }, + }) + async start( + @Body() dto: StartVerificationDto, + @Req() req: RequestWithOptionalUser, + ): Promise<{ authorizationUrl: string }> { + const authorizationUrl = await this.service.startVerification({ + purpose: dto.purpose ?? 'VERIFY', + platform: dto.platform ?? 'WEB', + userId: req.user?.id, + wantsPasswordSetup: dto.wantsPasswordSetup ?? false, + }); + return { authorizationUrl }; + } + + @Get('complete') + @IsPublic() + @ApiOperation({ + summary: 'Complete a verification (Fayda redirect / client callback lands here)', + description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``, + }) + @ApiOkResponse({ type: CompleteVerificationResultDto }) + async complete( + @Query() dto: VerifaydaCallbackDto, + ): Promise { + return this.service.completeVerification(dto); + } + + @Get('status') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: "Get the current user's Fayda verification status", + description: + 'Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.', + }) + @ApiOkResponse({ type: VerificationStatusDto }) + async status( + @Req() req: RequestWithUser, + ): Promise { + return this.service.getVerificationStatus(req.user.id); + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts new file mode 100644 index 000000000..1885b11e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts @@ -0,0 +1,105 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export class StartVerificationDto { + @ApiPropertyOptional({ + enum: ['LOGIN', 'VERIFY'], + default: 'VERIFY', + description: + 'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.', + }) + @IsOptional() + @IsIn(['LOGIN', 'VERIFY']) + purpose?: 'LOGIN' | 'VERIFY'; + + @ApiPropertyOptional({ + enum: ['WEB', 'MOBILE'], + default: 'WEB', + description: + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', + }) + @IsOptional() + @IsIn(['WEB', 'MOBILE']) + platform?: 'WEB' | 'MOBILE'; + + @ApiPropertyOptional({ + type: Boolean, + default: false, + description: + 'Set to true when the user opts in to full account registration (checkbox). ' + + 'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' + + 'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.', + }) + @IsOptional() + wantsPasswordSetup?: boolean; +} + +export class CompleteVerificationResultDto { + @ApiProperty({ enum: ['LOGIN', 'VERIFY'] }) + purpose!: 'LOGIN' | 'VERIFY'; + + @ApiProperty() verified!: boolean; + + @ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' }) + token?: string; + + @ApiPropertyOptional() + refreshToken?: string; + + @ApiPropertyOptional({ + description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).', + }) + user?: { + id: string; + email: string; + role: string; + passengerId?: string; + agentId?: string; + }; + + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) + fullName?: string; + + @ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' }) + email?: string; + + @ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' }) + phoneNumber?: string; + + @ApiPropertyOptional({ + description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).', + }) + birthdate?: string; + + @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) + gender?: string; + + @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) + userDataSaved?: boolean; + + @ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' }) + iamUserId?: string; + + @ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' }) + requiresPassword?: boolean; + + @ApiPropertyOptional({ + description: + 'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' + + 'AND they have not yet set a password. Frontend should navigate to the set-password screen.', + }) + promptPasswordSetup?: boolean; +} + +export class VerifaydaCallbackDto { + @ApiPropertyOptional() @IsOptional() @IsString() code?: string; + @ApiPropertyOptional() @IsOptional() @IsString() state?: string; + @ApiPropertyOptional() @IsOptional() @IsString() error?: string; + @ApiPropertyOptional() @IsOptional() @IsString() error_description?: string; +} + +export class VerificationStatusDto { + @ApiProperty() verified!: boolean; + @ApiPropertyOptional() verifiedAt?: Date; + @ApiPropertyOptional() fullName?: string; +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts new file mode 100644 index 000000000..a7d531102 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts @@ -0,0 +1,19 @@ +import { BadGatewayException, ConflictException } from '@nestjs/common'; + +export class FaydaTokenExchangeException extends BadGatewayException { + constructor(message = 'Fayda token exchange failed') { + super({ code: 'FAYDA_TOKEN_EXCHANGE_FAILED', message }); + } +} + +export class FaydaUserInfoException extends BadGatewayException { + constructor(message = 'Fayda userinfo fetch failed') { + super({ code: 'FAYDA_USERINFO_FAILED', message }); + } +} + +export class FaydaIdentityConflictException extends ConflictException { + constructor(message = 'This Fayda identity is already linked to another account') { + super({ code: 'FAYDA_IDENTITY_CONFLICT', message }); + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts new file mode 100644 index 000000000..82fb9435c --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { VerifaydaController } from './verifayda.controller'; +import { FaydaCallbackController } from './fayda-callback.controller'; +import { VerifaydaService } from './verifayda.service'; +import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([FaydaVerificationSession])], + controllers: [VerifaydaController, FaydaCallbackController], + providers: [VerifaydaService], + exports: [VerifaydaService], +}) +export class VerifaydaModule {} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts new file mode 100644 index 000000000..6e3e2e095 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -0,0 +1,597 @@ +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token'; +import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; +import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; +import { + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './utils/pkce.util'; +import { generateClientAssertion } from './utils/client-assertion.util'; +import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto'; +import { + FaydaTokenExchangeException, + FaydaUserInfoException, +} from './verifayda.errors'; +import { + FaydaTokenResponse, + FaydaUserInfo, + NormalizedFaydaUserInfo, + VerifaydaPurpose, +} from './verifayda.types'; + +export interface StartVerificationInput { + purpose: VerifaydaPurpose; + platform?: FaydaPlatform; + userId?: string; // iamUserId of the authenticated user, if any + wantsPasswordSetup?: boolean; +} + +export interface FaydaUserSummary { + id: string; + email: string; + role: string; +} + +/** + * Result of completing a verification. `verified` is always true on success. + * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity + * attributes (name, email, phone, dob, gender) for the caller to consume. + */ +export interface CompleteVerificationResult { + purpose: VerifaydaPurpose; + verified: boolean; + token?: string; + refreshToken?: string; + requiresPassword?: boolean; + promptPasswordSetup?: boolean; + iamUserId?: string; + user?: FaydaUserSummary; + fullName?: string; + email?: string; + phoneNumber?: string; + birthdate?: string; + gender?: string; + userDataSaved?: boolean; +} + +@Injectable() +export class VerifaydaService { + private readonly logger = new Logger(VerifaydaService.name); + + private readonly faydaConfig: FaydaConfig; + + constructor( + private readonly config: ConfigService, + @InjectRepository(FaydaVerificationSession) + private readonly sessionRepo: Repository, + @InjectDataSource() private readonly dataSource: DataSource, + ) { + const fayda = this.config.get('fayda'); + if (!fayda) { + throw new Error('Fayda config namespace not registered'); + } + this.faydaConfig = fayda; + } + + // ========================================================================== + // OIDC flow + // ========================================================================== + + async startVerification(input: StartVerificationInput): Promise { + if (!this.faydaConfig.enabled) { + throw new ServiceUnavailableException({ + code: 'FAYDA_DISABLED', + message: 'Fayda integration is not enabled', + }); + } + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const expiresAt = new Date( + Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000, + ); + + await this.sessionRepo.save( + this.sessionRepo.create({ + state, + codeVerifier, + purpose: input.purpose, + platform: input.platform ?? 'WEB', + saveToAccount: input.wantsPasswordSetup ?? false, + iamUserId: input.userId ?? null, + expiresAt, + }), + ); + + this.logger.log( + `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, + ); + + return this.buildAuthorizationUrl({ + state, + codeChallenge, + redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'), + }); + } + + /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + private redirectUriForPlatform(platform?: FaydaPlatform): string { + return platform === 'MOBILE' + ? this.faydaConfig.redirectUri + : this.faydaConfig.webRedirectUri; + } + + async completeVerification( + query: VerifaydaCallbackDto, + ): Promise { + if (query.error) { + this.logger.warn(`Fayda callback returned error: ${query.error}`); + if (query.state) { + await this.markSessionFailed( + query.state, + query.error, + query.error_description, + ); + } + throw new BadRequestException({ + code: 'FAYDA_AUTH_ERROR', + message: query.error, + description: query.error_description, + }); + } + + if (!query.code || !query.state) { + throw new BadRequestException({ + code: 'FAYDA_MISSING_PARAMETERS', + message: 'code and state are required', + }); + } + + const session = await this.sessionRepo.findOne({ + where: { state: query.state }, + }); + if (!session || session.status !== 'PENDING') { + this.logger.warn('Fayda complete with unknown or non-pending state'); + throw new BadRequestException({ + code: 'FAYDA_INVALID_STATE', + message: 'Verification session is invalid or already used', + }); + } + if (session.expiresAt.getTime() < Date.now()) { + await this.markSessionFailed(query.state, 'session_expired'); + throw new BadRequestException({ + code: 'FAYDA_SESSION_EXPIRED', + message: 'Verification session has expired; start again', + }); + } + + try { + const tokens = await this.exchangeCodeForTokens( + query.code, + session.codeVerifier, + this.redirectUriForPlatform(session.platform as FaydaPlatform), + ); + const userInfo = await this.fetchUserInfo(tokens.access_token); + const normalized = this.normalizeUserInfo(userInfo); + + if (!normalized.sub) { + throw new FaydaUserInfoException('Fayda userinfo missing required sub'); + } + + let result: CompleteVerificationResult; + if (session.purpose === 'LOGIN') { + const { userId } = await this.handleLoginSuccess(normalized); + const login = await this.issueLoginToken(userId); + result = { purpose: 'LOGIN', verified: true, ...login }; + } else { + // VERIFY — prove identity, save to IAM, return verified attributes + short-lived token. + const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized); + + let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined; + if (iamUserId) { + try { + sessionToken = await this.createFaydaSession(iamUserId); + } catch (err) { + this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`); + } + } + + result = { + purpose: 'VERIFY', + verified: true, + fullName: normalized.fullName, + email: normalized.email, + phoneNumber: normalized.phoneNumber, + birthdate: normalized.birthdate, + gender: normalized.gender, + userDataSaved, + iamUserId: iamUserId ?? undefined, + token: sessionToken?.token, + refreshToken: sessionToken?.refreshToken, + requiresPassword: sessionToken?.requiresPassword, + promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false), + }; + } + + await this.sessionRepo.update(session.id, { + status: 'COMPLETED', + completedAt: new Date(), + codeVerifier: '', + }); + + this.logger.log( + `Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`, + ); + return result; + } catch (err) { + const reason = this.classifyFailureReason(err); + this.logger.error( + `Fayda verification failed: reason=${reason} message=${(err as Error).message}`, + ); + await this.markSessionFailed( + query.state, + reason, + (err as Error).message, + ); + throw err; + } + } + + private async issueLoginToken( + _userId: string, + ): Promise<{ token: string; user: FaydaUserSummary }> { + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package auth endpoints.', + }); + } + + async getVerificationStatus(iamUserId: string): Promise { + const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>( + `SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + const iam = rows[0] ?? null; + const faydaVerified = iam?.verified_by === 'fayda'; + const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined; + const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined; + return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName }; + } + + // ========================================================================== + // OIDC internals + // ========================================================================== + + private buildAuthorizationUrl(args: { + state: string; + codeChallenge: string; + redirectUri: string; + }): string { + const params = new URLSearchParams({ + client_id: this.faydaConfig.clientId, + response_type: 'code', + redirect_uri: args.redirectUri, + scope: this.faydaConfig.scope, + state: args.state, + code_challenge: args.codeChallenge, + code_challenge_method: 'S256', + acr_values: this.faydaConfig.acrValues, + claims_locales: this.faydaConfig.claimsLocales, + }); + + // Every claim is marked essential so eSignet shows them locked/pre-checked + // on the consent screen — the user cannot toggle any off; they either + // consent to all of them or the whole flow is cancelled (?error=...). + const claims = { + userinfo: { + name: { essential: true }, + phone_number: { essential: true }, + email: { essential: true }, + birthdate: { essential: true }, + gender: { essential: true }, + address: { essential: true }, + nationality: { essential: true }, + picture: { essential: true }, + }, + id_token: {}, + }; + params.set('claims', JSON.stringify(claims)); + + return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`; + } + + private async exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string, + ): Promise { + const clientAssertion = await generateClientAssertion({ + clientId: this.faydaConfig.clientId, + audience: this.faydaConfig.tokenEndpoint, + privateJwk: this.faydaConfig.privateJwk, + }); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: this.faydaConfig.clientId, + client_assertion_type: + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: clientAssertion, + code_verifier: codeVerifier, + }); + + const response = await fetch(this.faydaConfig.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!response.ok) { + let detail = ''; + try { + detail = await response.text(); + } catch { + // ignore + } + throw new FaydaTokenExchangeException( + `Fayda token endpoint returned ${response.status}${detail ? `: ${detail}` : ''}`, + ); + } + + return (await response.json()) as FaydaTokenResponse; + } + + private async fetchUserInfo(accessToken: string): Promise { + const response = await fetch(this.faydaConfig.userInfoEndpoint, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + throw new FaydaUserInfoException( + `Fayda userinfo endpoint returned ${response.status}`, + ); + } + + const contentType = response.headers.get('content-type') ?? ''; + const raw = await response.text(); + + if (contentType.includes('application/json')) { + return JSON.parse(raw) as FaydaUserInfo; + } + + // Signed JWT response — decode payload (signature verification = production TODO) + if (raw.split('.').length === 3) { + const payloadB64 = raw.split('.')[1]; + const normalizedB64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/'); + const json = Buffer.from(normalizedB64, 'base64').toString('utf8'); + return JSON.parse(json) as FaydaUserInfo; + } + + throw new FaydaUserInfoException( + 'Unsupported Fayda userinfo response format', + ); + } + + private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo { + const nameEn = raw['name#en'] as string | undefined; + const nameAm = raw['name#am'] as string | undefined; + const genderEn = raw['gender#en'] as string | undefined; + const genderAm = raw['gender#am'] as string | undefined; + const addressEn = raw['address#en'] as string | undefined; + const addressAm = raw['address#am'] as string | undefined; + const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined; + + return { + sub: raw.sub, + fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm, + phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined, + rawPhoneNumber: rawPhone, + email: raw.email as string | undefined, + gender: genderEn ?? genderAm ?? (raw.gender as string | undefined), + birthdate: raw.birthdate as string | undefined, + picture: raw.picture as string | undefined, + nameEn, + nameAm, + genderEn, + genderAm, + addressEn, + addressAm, + }; + } + + private standardizePhoneNumber(phone: string): string { + const digits = phone.replace(/\D/g, ''); + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + return `+${digits}`; + } + + // LOGIN via Fayda is handled entirely by the IAM package's own OIDC flow. + // This method is kept as a stub so completeVerification() still compiles; + // it throws immediately without touching the database. + private async handleLoginSuccess( + _normalized: NormalizedFaydaUserInfo, + ): Promise<{ userId: string }> { + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.', + }); + } + + private async upsertIamUser( + normalized: NormalizedFaydaUserInfo, + ): Promise<{ iamUserId: string | null; userDataSaved: boolean }> { + try { + const iamMetadata = { + sub: normalized.sub, + address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' }, + email: normalized.email ?? '', + gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' }, + name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }, + phoneNumber: normalized.rawPhoneNumber ?? '', + }; + + // Step 1 — already linked to this Fayda sub; ensure verified_by is set + const bySub = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`, + [normalized.sub], + ); + if (bySub.length > 0) { + await this.dataSource.query( + `UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`, + [bySub[0].id], + ); + return { iamUserId: bySub[0].id, userDataSaved: true }; + } + + // Step 2 — existing user by phone or email, not yet Fayda-verified + const conditions: string[] = []; + const params: unknown[] = []; + if (normalized.phoneNumber) { + params.push(normalized.phoneNumber); + conditions.push(`phone_number = $${params.length}`); + } + if (normalized.email) { + params.push(normalized.email); + conditions.push(`email = $${params.length}`); + } + if (conditions.length > 0) { + const byContact = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`, + params, + ); + if (byContact.length > 0) { + const existingId = byContact[0].id; + await this.dataSource.query( + `UPDATE iam.users + SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb, + verified_by = 'fayda', + updated_at = NOW() + WHERE id = $2`, + [JSON.stringify(iamMetadata), existingId], + ); + return { iamUserId: existingId, userDataSaved: true }; + } + } + + // Step 3 — new user + const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }; + const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub; + const inserted = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.users ( + id, name, username, email, phone_number, metadata, + user_type, status, is_active, has_set_password, + is_phone_number_verified, verified_by, + created_at, updated_at + ) VALUES ( + gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb, + 'individual', 'submitted', true, false, + false, 'fayda', + NOW(), NOW() + ) RETURNING id`, + [ + JSON.stringify(name), + username, + normalized.email ?? null, + normalized.phoneNumber ?? null, + JSON.stringify(iamMetadata), + ], + ); + return { iamUserId: inserted[0].id, userDataSaved: true }; + } catch (err) { + this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`); + return { iamUserId: null, userDataSaved: false }; + } + } + + private async createFaydaSession( + iamUserId: string, + ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> { + const rows = await this.dataSource.query<{ + id: string; + email: string; + name: { en: string; am: string } | null; + username: string; + phone_number: string | null; + has_set_password: boolean; + status: string; + }[]>( + `SELECT id, email, name, username, phone_number, has_set_password, status + FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`); + const u = rows[0]; + + const userInfo = { + id: u.id, + email: u.email ?? '', + name: u.name ?? { en: '', am: '' }, + userType: 'individual', + status: u.status, + hasSetPassword: u.has_set_password, + isPhoneNumberVerified: false, + hasFinishedRegistration: false, + hasFinishedDMSOnboarding: false, + username: u.username, + phoneNumber: u.phone_number ?? '', + roles: [], + permissions: [], + employee: [], + }; + + const sessions = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.sessions + (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) + VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3) + ON CONFLICT (user_id, device) DO UPDATE + SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo", + expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW() + RETURNING id`, + [u.email ?? '', JSON.stringify(userInfo), iamUserId], + ); + + const sessionId = sessions[0].id; + const token = generateToken({ id: sessionId }); + const refreshToken = generateRefreshToken({ id: sessionId }); + + return { token, refreshToken, requiresPassword: !u.has_set_password }; + } + + private async markSessionFailed( + state: string, + errorCode: string, + errorDescription?: string, + ): Promise { + await this.sessionRepo.update( + { state, status: 'PENDING' }, + { + status: 'FAILED', + errorCode, + errorDescription: errorDescription ?? null, + completedAt: new Date(), + codeVerifier: '', + }, + ); + } + + private classifyFailureReason(err: unknown): string { + if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed'; + if (err instanceof FaydaUserInfoException) return 'userinfo_failed'; + return 'verification_failed'; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts new file mode 100644 index 000000000..442a22d2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts @@ -0,0 +1,45 @@ +export type VerifaydaPurpose = 'LOGIN' | 'VERIFY'; + +export interface FaydaTokenResponse { + access_token: string; + id_token?: string; + token_type: string; + expires_in?: number; + scope?: string; +} + +export interface FaydaUserInfo { + sub: string; + name?: string; + 'name#en'?: string; + 'name#am'?: string; + phone_number?: string; + 'phone_number#en'?: string; + 'phone_number#am'?: string; + phone?: string; + email?: string; + gender?: string; + birthdate?: string; + picture?: string; + address?: Record; + [key: string]: unknown; +} + +export interface NormalizedFaydaUserInfo { + sub: string; + // Convenience / display fields + fullName?: string; + phoneNumber?: string; // standardized e.g. +251911234567 + email?: string; + gender?: string; + birthdate?: string; + picture?: string; + // Raw localized fields — preserved for IAM-identical writes + nameEn?: string; + nameAm?: string; + genderEn?: string; + genderAm?: string; + addressEn?: string; + addressAm?: string; + rawPhoneNumber?: string; // unstandardized, stored in IAM metadata +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts index 43cd6f61a..8d18d2a1f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts @@ -1,9 +1,10 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, Matches } from 'class-validator'; export class CreateAllocationRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiPropertyOptional({ default: 100 }) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 788c798bf..5a8025948 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; @@ -7,6 +7,7 @@ export class CreateWarehouseDto { @ApiProperty() @IsString() @MaxLength(160) + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index e2c20901d..22e9f9491 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator'; import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; @@ -25,6 +25,7 @@ export class FeeRuleTierDto { export class CreateFeeRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty({ enum: FEE_RULE_TYPES }) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 9f89e7c2c..49fb22fde 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -52,6 +52,11 @@ export class FilterWarehouseInventoryDto { @IsEnum(WAREHOUSE_INVENTORY_STATUSES) status?: WarehouseInventoryStatus; + @ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT'] }) + @IsOptional() + @IsEnum(['IMPORT', 'EXPORT']) + direction?: 'IMPORT' | 'EXPORT'; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 11f34c892..ac6b71c89 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -430,6 +430,7 @@ export class WarehouseInventoryService { ...(filter.status ? { status: filter.status } : {}), ...(createdAt ? { createdAt } : {}), ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), + ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; const search = filter.search?.trim(); @@ -2032,6 +2033,22 @@ export class WarehouseInventoryService { const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); + + if (item.bookingId) { + const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = + await this.dataSource.query( + `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { + throw new BadRequestException( + 'Customer must approve delivery (sign the handover) before the exit paper can be generated', + ); + } + } } const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index f40153dad..45591638a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,30 +1,41 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { Freight } from '@edr/types'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { Freight } from "@edr/types"; +import { DataSource } from "typeorm"; -import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; -import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; -import { Invoice } from '../billing/entities/invoice.entity'; -import { InvoiceLine } from '../billing/entities/invoice-line.entity'; +import { + BillingService, + InvoiceEventPayload, + InvoiceLineInput, +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { InvoiceLine } from "../billing/entities/invoice-line.entity"; + +import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, -} from '../billing/documents/invoice-document.service'; -import { NotificationsService } from '../notifications/notifications.service'; -import { WarehouseFeeService } from './warehouse-fee.service'; +} from "../billing/documents/invoice-document.service"; +import { NotificationsService } from "../notifications/notifications.service"; +import { WarehouseFeeService } from "./warehouse-fee.service"; import { WarehouseFeeInvoiceView, WarehouseFeeType, WarehouseInvoiceItemView, WarehouseInvoiceStatus, WarehouseInvoiceType, -} from './warehouse-invoice.types'; +} from "./warehouse-invoice.types"; interface GenerateOptions { confirmZero?: boolean; performedBy?: string; - billingCurrency?: 'ETB' | 'USD'; + billingCurrency?: "ETB" | "USD"; } export interface PayInvoiceDto { @@ -46,7 +57,10 @@ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Overdue, ]; /** Global statuses considered an "active" invoice for per-inventory dedup. */ -const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid]; +const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [ + ...BLOCKING_STATUSES, + Freight.InvoiceStatus.Paid, +]; export interface InvoiceDocumentDetails { bookingReference: string | null; @@ -121,10 +135,13 @@ export class WarehouseInvoiceService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, - ) {} + ) { } // ── Generation ─────────────────────────────────────────────────────────── - async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + async generateForInventory( + inventoryId: string, + opts: GenerateOptions = {}, + ): Promise { const [item] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt", @@ -137,47 +154,53 @@ export class WarehouseInvoiceService { WHERE inv.id = $1 AND inv.deleted_at IS NULL`, [inventoryId], ); - if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + if (!item) + throw new NotFoundException(`Inventory item ${inventoryId} not found`); // Routing through the global invoice requires a billable company + profile, // both of which come from the inventory's booking. if (!item.companyId || !item.companyProfileId) { throw new BadRequestException( - 'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).', + "Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).", ); } // Dedup: only one active (non-cancelled) invoice per inventory item. if (await this.hasActiveInvoice(inventoryId)) { throw new ConflictException( - 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', + "An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.", ); } - const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD'; - const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency); - const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + const billingCurrency = opts.billingCurrency === "ETB" ? "ETB" : "USD"; + const previews = await this.feeService.previewForInventory( + inventoryId, + billingCurrency, + ); + const isContainer = (item.freightType ?? "").toUpperCase() === "CONTAINER"; const items = previews .filter((p) => p.amount > 0) .map((p) => { const feeType: WarehouseFeeType = - p.ruleType === 'STORAGE_FEE' - ? 'STORAGE_FEE' + p.ruleType === "STORAGE_FEE" + ? "STORAGE_FEE" : isContainer - ? 'CONTAINER_DEMURRAGE' - : 'BULK_DEMURRAGE'; + ? "CONTAINER_DEMURRAGE" + : "BULK_DEMURRAGE"; return { feeRuleId: p.ruleId, feeType, description: - p.ruleType === 'STORAGE_FEE' - ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${ - p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free` - }` - : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${ - p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free` - }`, + p.ruleType === "STORAGE_FEE" + ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length + ? " using tiered tariff" + : ` after ${p.freeDays} free` + }` + : `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length + ? " using tiered tariff" + : ` after ${p.freeDays} free` + }`, quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, @@ -189,13 +212,19 @@ export class WarehouseInvoiceService { const total = items.reduce((s, i) => s + i.amount, 0); if (total <= 0 && !opts.confirmZero) { - throw new BadRequestException('No payable warehouse fee found for this item.'); + throw new BadRequestException( + "No payable warehouse fee found for this item.", + ); } - const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE'); - const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE'); + const hasDemurrage = items.some((i) => i.feeType !== "STORAGE_FEE"); + const hasStorage = items.some((i) => i.feeType === "STORAGE_FEE"); const invoiceType: WarehouseInvoiceType = - hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; + hasDemurrage && hasStorage + ? "MIXED_WAREHOUSE_FEES" + : hasStorage + ? "STORAGE_FEE" + : "DEMURRAGE"; const lines: InvoiceLineInput[] = items.map((it) => ({ chargeType: it.feeType, @@ -237,18 +266,23 @@ export class WarehouseInvoiceService { } listForInventory(inventoryId: string): Promise { - return this.queryViews('AND i.source_id = $1', [inventoryId]); + return this.queryViews("AND i.source_id = $1", [inventoryId]); } listForBooking(bookingId: string): Promise { - return this.queryViews('AND inv.booking_id = $1', [bookingId]); + return this.queryViews("AND inv.booking_id = $1", [bookingId]); } async findAll( filter: Partial< Pick< WarehouseFeeInvoiceView, - 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId' + | "status" + | "invoiceType" + | "warehouseId" + | "facilityId" + | "customerId" + | "bookingId" > >, ): Promise { @@ -259,41 +293,56 @@ export class WarehouseInvoiceService { conditions.push(sql(`$${params.length}`)); }; - if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus)); + if (filter.status) + add( + (p) => `i.status::text = ${p}`, + this.toGlobalStatus(filter.status as WarehouseInvoiceStatus), + ); if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType); if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId); - if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); - if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId); + if (filter.warehouseId) + add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); + if (filter.facilityId) + add((p) => `w.facility_id = ${p}`, filter.facilityId); if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId); - return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params); + return this.queryViews(conditions.map((c) => `AND ${c}`).join(" "), params); } async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE')); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "INVOICE"), + ); } async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); if (Number(invoice.paidAmount) <= 0) { - throw new BadRequestException('A receipt is available only after payment is recorded.'); + throw new BadRequestException( + "A receipt is available only after payment is recorded.", + ); } - return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "RECEIPT"), + ); } // ── State changes ──────────────────────────────────────────────────────── async cancel(id: string): Promise { const invoice = await this.loadWarehouseInvoice(id); if (invoice.status === Freight.InvoiceStatus.Paid) { - throw new BadRequestException('A paid invoice cannot be cancelled.'); + throw new BadRequestException("A paid invoice cannot be cancelled."); } await this.billing.cancelInvoice(id); return this.findById(id); } /** Record a payment against the invoice (delegates settlement to billing). */ - async pay(id: string, dto: PayInvoiceDto): Promise { + async pay( + id: string, + dto: PayInvoiceDto, + ): Promise { // Guard that this is a warehouse invoice before recording (404 otherwise). await this.loadWarehouseInvoice(id); await this.billing.recordPayment(id, { @@ -302,7 +351,10 @@ export class WarehouseInvoiceService { reference: dto.reference ?? null, metadata: dto.driverName || dto.driverPhone - ? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null } + ? { + driverName: dto.driverName ?? null, + driverPhone: dto.driverPhone ?? null, + } : null, }); const detail = await this.findById(id); @@ -314,12 +366,12 @@ export class WarehouseInvoiceService { async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) { const invoice = await this.loadWarehouseInvoice(id); if (invoice.status === Freight.InvoiceStatus.Paid) { - throw new BadRequestException('Invoice is already fully paid.'); + throw new BadRequestException("Invoice is already fully paid."); } - return this.billing.payInvoice(invoice.source as Freight.InvoiceSource, invoice.sourceId, { - method: dto.method ?? (invoice.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'), - platform: dto.platform ?? 'web', + return this.billing.payInvoice(invoice.id, { + method: dto.method ?? (invoice.currency === "USD" ? "WAAFI" : "TELEBIRR"), + platform: dto.platform ?? "web", payerAccount: dto.payerAccount, returnUrl: dto.returnUrl, failureUrl: dto.failureUrl, @@ -334,16 +386,20 @@ export class WarehouseInvoiceService { * counter settlement leaves it null. Skipping null-`paymentId` events avoids * double-notifying a counter payment that already sent its SMS. */ - @OnEvent('warehouse.invoice.paid') + @OnEvent("warehouse.invoice.paid") async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise { if (!payload.paymentId) return; const detail = await this.findById(payload.invoiceId); - await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) }); + await this.notifyWarehouseFeePayment(detail, { + amount: Number(detail.totalAmount), + }); } // ── Release blocking ────────────────────────────────────────────────────── /** Returns the first unpaid invoice that blocks terminal release, or null. */ - async findBlockingInvoice(inventoryId: string): Promise { + async findBlockingInvoice( + inventoryId: string, + ): Promise { const blocking = await this.queryViews( `AND i.source_id = $1 AND i.status::text = ANY($2::text[])`, [inventoryId, BLOCKING_STATUSES], @@ -352,21 +408,31 @@ export class WarehouseInvoiceService { } async assertClearanceAllowed(inventoryId: string): Promise { - const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]); - const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); + const invoices = await this.queryViews("AND i.source_id = $1", [ + inventoryId, + ]); + const blocking = invoices.find( + (inv) => inv.status === "ISSUED" || inv.status === "PARTIALLY_PAID", + ); if (blocking) { throw new BadRequestException( `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, ); } - if (invoices.some((inv) => inv.status === 'PAID')) return; + if (invoices.some((inv) => inv.status === "PAID")) return; - const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); - const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); + const previews = await this.feeService.previewForInventory( + inventoryId, + "USD", + ); + const payableAmount = previews.reduce( + (sum, fee) => sum + Number(fee.amount || 0), + 0, + ); if (payableAmount > 0) { throw new BadRequestException( - 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', + "Generate and fully pay the warehouse demurrage/storage invoice before terminal release.", ); } } @@ -374,7 +440,9 @@ export class WarehouseInvoiceService { // ── Internal: loading & projection ───────────────────────────────────────── /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */ - private async loadWarehouseInvoice(id: string): Promise { + private async loadWarehouseInvoice( + id: string, + ): Promise { const invoice = await this.billing.findById(id); if (invoice.source !== SOURCE) { throw new NotFoundException(`Invoice ${id} not found`); @@ -397,7 +465,10 @@ export class WarehouseInvoiceService { * Project warehouse-source global invoices into the historical view, joined to * their inventory item for the typed FKs. Powers every list/filter read. */ - private async queryViews(extraWhere: string, params: unknown[]): Promise { + private async queryViews( + extraWhere: string, + params: unknown[], + ): Promise { const rows = await this.dataSource.query( `SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId", i.source_id AS "sourceId", i.type, i.status, @@ -410,7 +481,7 @@ export class WarehouseInvoiceService { inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", w.facility_id AS "facilityId" FROM freight.invoices i - LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouse_inventory inv ON inv.id::text = i.source_id AND inv.deleted_at IS NULL LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere} ORDER BY i.created_at DESC`, @@ -430,7 +501,10 @@ export class WarehouseInvoiceService { } /** Reshape a global invoice (+ derived inventory context) into the warehouse view. */ - private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView { + private buildView( + inv: ViewSource, + ctx: InventoryContext, + ): WarehouseFeeInvoiceView { const status = this.toWarehouseStatus(inv.status); return { id: inv.id, @@ -457,7 +531,7 @@ export class WarehouseInvoiceService { issuedAt: inv.issuedAt ?? null, dueDate: inv.dueAt ?? null, paidAt: inv.paidAt ?? null, - cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null, + cancelledAt: status === "CANCELLED" ? inv.updatedAt : null, payments: (inv.payments ?? []).map((p) => ({ amount: Number(p.amount), method: p.method ?? null, @@ -479,7 +553,7 @@ export class WarehouseInvoiceService { return { feeRuleId: meta.feeRuleId ?? null, feeType: line.chargeType as WarehouseFeeType, - description: line.description ?? '', + description: line.description ?? "", quantity: Number(line.quantity), unitRate: Number(line.unitRate), amount: Number(line.amount), @@ -489,32 +563,36 @@ export class WarehouseInvoiceService { }; } - private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus { + private toWarehouseStatus( + status: Freight.InvoiceStatus | string, + ): WarehouseInvoiceStatus { switch (status) { case Freight.InvoiceStatus.Draft: - return 'DRAFT'; + return "DRAFT"; case Freight.InvoiceStatus.PartiallyPaid: - return 'PARTIALLY_PAID'; + return "PARTIALLY_PAID"; case Freight.InvoiceStatus.Paid: - return 'PAID'; + return "PAID"; case Freight.InvoiceStatus.Cancelled: case Freight.InvoiceStatus.Refunded: - return 'CANCELLED'; + return "CANCELLED"; default: // Issued / Pending / Overdue → an issued, still-owed invoice. - return 'ISSUED'; + return "ISSUED"; } } - private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus { + private toGlobalStatus( + status: WarehouseInvoiceStatus, + ): Freight.InvoiceStatus { switch (status) { - case 'DRAFT': + case "DRAFT": return Freight.InvoiceStatus.Draft; - case 'PARTIALLY_PAID': + case "PARTIALLY_PAID": return Freight.InvoiceStatus.PartiallyPaid; - case 'PAID': + case "PAID": return Freight.InvoiceStatus.Paid; - case 'CANCELLED': + case "CANCELLED": return Freight.InvoiceStatus.Cancelled; default: return Freight.InvoiceStatus.Issued; @@ -524,39 +602,54 @@ export class WarehouseInvoiceService { /** Map a warehouse fee invoice view onto the shared document model. */ private toDocumentModel( invoice: WarehouseFeeInvoiceDetail, - kind: 'INVOICE' | 'RECEIPT', + kind: "INVOICE" | "RECEIPT", ): InvoiceDocumentModel { const lastPayment = [...(invoice.payments ?? [])].pop(); const date = (value: unknown) => - value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; + value + ? new Date(value as string | Date).toLocaleDateString("en-GB") + : null; return { kind, - title: 'Warehouse Fee', + title: "Warehouse Fee", documentNumber: invoice.invoiceNumber, issuedAt: invoice.issuedAt ?? invoice.createdAt, status: invoice.status, currency: invoice.currency, summary: [ - { label: 'Status', value: invoice.status.replace(/_/g, ' ') }, - { label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') }, - { label: 'Booking reference', value: invoice.bookingReference ?? null }, - { label: 'Customer', value: invoice.customerName ?? null }, - { label: 'Inventory reference', value: invoice.inventoryReference ?? null }, - { label: 'Inventory info', value: invoice.inventoryInfo ?? null }, - { label: 'Clearance', value: invoice.clearanceStatus ?? null }, - { label: 'Warehouse', value: invoice.warehouseName ?? null }, + { label: "Status", value: invoice.status.replace(/_/g, " ") }, { - label: 'Yard / Zone', - value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null, + label: "Invoice type", + value: invoice.invoiceType.replace(/_/g, " "), }, - { label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` }, + { label: "Booking reference", value: invoice.bookingReference ?? null }, + { label: "Customer", value: invoice.customerName ?? null }, { - label: 'Payment', - value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null, + label: "Inventory reference", + value: invoice.inventoryReference ?? null, + }, + { label: "Inventory info", value: invoice.inventoryInfo ?? null }, + { label: "Clearance", value: invoice.clearanceStatus ?? null }, + { label: "Warehouse", value: invoice.warehouseName ?? null }, + { + label: "Yard / Zone", + value: + [invoice.yardName, invoice.zoneName].filter(Boolean).join(" / ") || + null, + }, + { + label: "Period", + value: `${date(invoice.periodStart) ?? "-"} - ${date(invoice.periodEnd) ?? "-"}`, + }, + { + label: "Payment", + value: lastPayment + ? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}` + : null, }, ], - categoryHeader: 'Fee type', + categoryHeader: "Fee type", lines: invoice.items.map((item) => ({ description: item.description ?? null, category: item.feeType ?? null, @@ -566,17 +659,19 @@ export class WarehouseInvoiceService { currency: item.currency ?? invoice.currency, })), totals: [ - { label: 'Subtotal', amount: Number(invoice.subtotalAmount) }, - { label: 'Tax', amount: Number(invoice.taxAmount) }, - { label: 'Total', amount: Number(invoice.totalAmount), grand: true }, - { label: 'Paid', amount: Number(invoice.paidAmount) }, - { label: 'Balance', amount: Number(invoice.balanceAmount) }, + { label: "Subtotal", amount: Number(invoice.subtotalAmount) }, + { label: "Tax", amount: Number(invoice.taxAmount) }, + { label: "Total", amount: Number(invoice.totalAmount), grand: true }, + { label: "Paid", amount: Number(invoice.paidAmount) }, + { label: "Balance", amount: Number(invoice.balanceAmount) }, ], }; } /** Warehouse-specific display details, derived from the linked inventory item. */ - private async getInvoiceDocumentDetails(invoice: ViewSource): Promise { + private async getInvoiceDocumentDetails( + invoice: ViewSource, + ): Promise { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", @@ -612,12 +707,12 @@ export class WarehouseInvoiceService { [invoice.sourceId], ); - const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID'; + const fullyPaid = this.toWarehouseStatus(invoice.status) === "PAID"; const clearanceStatus = row?.releaseDate - ? 'RELEASE ISSUED' + ? "RELEASE ISSUED" : fullyPaid - ? 'FEE PAID - READY FOR RELEASE' - : 'PENDING PAYMENT'; + ? "FEE PAID - READY FOR RELEASE" + : "PENDING PAYMENT"; return { bookingReference: row?.bookingReference ?? null, @@ -634,7 +729,9 @@ export class WarehouseInvoiceService { }; } - private async getInventoryContext(inventoryId: string): Promise { + private async getInventoryContext( + inventoryId: string, + ): Promise { const [row] = await this.dataSource.query( `SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", @@ -722,55 +819,89 @@ export class WarehouseInvoiceService { }; } - private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise { + private async sendSms( + recipient: string | null | undefined, + message: string, + context: string, + ): Promise { const phone = recipient?.trim(); if (!phone) return; try { - await this.notifications.directSend('sms', phone, message); + await this.notifications.directSend("sms", phone, message); } catch (error) { - this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`); + this.logger.error( + `Failed to send ${context} SMS to ${phone}: ${String(error)}`, + ); } } - private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); - const customerName = contacts.customerName?.trim() || 'Customer'; - const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + private async notifyWarehouseFeeIssued( + invoice: WarehouseFeeInvoiceView, + ): Promise { + const contacts = await this.getInvoiceNotificationContacts( + invoice.inventoryId, + ); + const customerName = contacts.customerName?.trim() || "Customer"; + const bookingReference = contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : ""; const cargo = contacts.containerNumber || contacts.cargoDescription; - const cargoText = cargo ? ` Cargo: ${cargo}.` : ''; + const cargoText = cargo ? ` Cargo: ${cargo}.` : ""; const message = - `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` + + `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ` + `${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`; - await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); + await this.sendSms( + contacts.customerPhone, + message, + `warehouse fee invoice ${invoice.invoiceNumber}`, + ); } - private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); - const customerName = contacts.customerName?.trim() || 'Customer'; - const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + private async notifyWarehouseFeePayment( + invoice: WarehouseFeeInvoiceView, + dto: PayInvoiceDto, + ): Promise { + const contacts = await this.getInvoiceNotificationContacts( + invoice.inventoryId, + ); + const customerName = contacts.customerName?.trim() || "Customer"; + const bookingReference = contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : ""; const statusText = - invoice.status === 'PAID' - ? 'fully paid and ready for pickup release' + invoice.status === "PAID" + ? "fully paid and ready for pickup release" : `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`; const customerMessage = `Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` + `was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`; - await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`); + await this.sendSms( + contacts.customerPhone, + customerMessage, + `warehouse fee payment ${invoice.invoiceNumber}`, + ); - if (invoice.status !== 'PAID') return; + if (invoice.status !== "PAID") return; const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone; - const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver'; + const driverName = + dto.driverName?.trim() || contacts.driverName || "Driver"; const cargo = contacts.containerNumber || contacts.cargoDescription; const driverMessage = `Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` + - (contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') + - (cargo ? ` Cargo: ${cargo}.` : '') + - ' Proceed with pickup after gate verification.'; + (contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : "") + + (cargo ? ` Cargo: ${cargo}.` : "") + + " Proceed with pickup after gate verification."; - await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`); + await this.sendSms( + driverPhone, + driverMessage, + `warehouse pickup driver ${invoice.invoiceNumber}`, + ); } } diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 3c295c9d0..e6ea89fbe 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,6 +1,7 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, + POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; @@ -10,6 +11,13 @@ export type FreightSeedRole = { permissionKeys: string[]; }; +export type FreightSeedPosition = { + key: string; + name: { en: string }; + rank: number; + permissionKeys: string[]; +}; + const IAM_PERMISSION_KEYS = { activateEmployee: "can:activateEmployee", activateUser: "can:activateUser", @@ -282,3 +290,18 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ permissionKeys: [], }, ]; + +/** + * Operational positions (positions-as-roles). Seeded as Position + + * PositionPermission rows (NOT Role/RolePermission). Users get their access by + * being assigned to a Position via EmployeePosition. + */ +export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ + { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] }, + { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] }, + { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] }, + { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] }, + { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, + { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, + { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, +]; diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 8243ef267..cb379890a 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -3,14 +3,28 @@ import { Organization, OrganizationConfiguration, Permission, + Position, + PositionPermission, + PositionType, Role, RolePermission, + Unit, } from "@tria-plc/iamapi-common"; import { DataSource, EntityManager, In } from "typeorm"; import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; -import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; +import { + EDR_FREIGHT_POSITIONS, + EDR_FREIGHT_ROLES, + type FreightSeedPosition, + type FreightSeedRole, +} from "./edr-freight.seed"; + +const EDR_UNIT_KEY = "edr_freight_hq"; +const EDR_UNIT_NAME = { en: "EDR Freight HQ" }; +const EDR_POSITION_TYPE_KEY = "edr_freight_role"; +const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" }; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; @@ -40,6 +54,19 @@ export class EdrOrgSeeder { await this.ensureRoles(manager, EDR_FREIGHT_ROLES); await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); await this.ensureSuperAdminPermissions(manager); + + // Positions-as-roles: seed operational positions and grant their + // permissions via PositionPermission (not Role/RolePermission). + const unit = await this.ensureDefaultUnit(manager, organization.id); + const positionType = await this.ensureDefaultPositionType(manager, unit.id); + await this.ensurePositions( + manager, + organization.id, + unit.id, + positionType.id, + EDR_FREIGHT_POSITIONS, + ); + await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS); }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -205,4 +232,146 @@ export class EdrOrgSeeder { `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, ); } + + private async ensureDefaultUnit( + manager: EntityManager, + organizationId: string, + ): Promise<{ id: string }> { + const unitRepository = manager.getRepository(Unit); + + let unit = await unitRepository.findOne({ + where: { key: EDR_UNIT_KEY, organizationId }, + select: { id: true }, + }); + + if (!unit) { + const insertResult = await unitRepository.insert({ + key: EDR_UNIT_KEY, + name: EDR_UNIT_NAME, + organizationId, + }); + this.logger.log(`Seeded EDR unit '${EDR_UNIT_KEY}'`); + return { id: insertResult.identifiers[0]?.id as string }; + } + + this.logger.log(`Ensured EDR unit '${EDR_UNIT_KEY}'`); + return { id: unit.id }; + } + + private async ensureDefaultPositionType( + manager: EntityManager, + unitId: string, + ): Promise<{ id: string }> { + const positionTypeRepository = manager.getRepository(PositionType); + + // PositionType has no unique constraint on (key, unitId); find-then-insert. + let positionType = await positionTypeRepository.findOne({ + where: { key: EDR_POSITION_TYPE_KEY, unitId }, + select: { id: true }, + }); + + if (!positionType) { + const insertResult = await positionTypeRepository.insert({ + key: EDR_POSITION_TYPE_KEY, + name: EDR_POSITION_TYPE_NAME, + isSystem: true, + unitId, + }); + this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`); + return { id: insertResult.identifiers[0]?.id as string }; + } + + this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`); + return { id: positionType.id }; + } + + private async ensurePositions( + manager: EntityManager, + organizationId: string, + unitId: string, + positionTypeId: string, + seedPositions: FreightSeedPosition[], + ) { + await manager.getRepository(Position).upsert( + seedPositions.map(({ key, name, rank }) => ({ + key, + name, + rank, + organizationId, + unitId, + positionTypeId, + })), + { + conflictPaths: { key: true, unitId: true }, + }, + ); + + this.logger.log( + `Ensured ${seedPositions.length} EDR positions '${seedPositions + .map((position) => position.key) + .join("', '")}'`, + ); + } + + private async ensurePositionPermissions( + manager: EntityManager, + unitId: string, + seedPositions: FreightSeedPosition[], + ) { + const permissionKeys = [ + ...new Set(seedPositions.flatMap((position) => position.permissionKeys)), + ]; + + if (!permissionKeys.length) { + this.logger.log( + "No EDR position permissions configured; skipping position-permission links", + ); + return; + } + + const positions = await manager.getRepository(Position).find({ + where: { key: In(seedPositions.map((position) => position.key)), unitId }, + select: { id: true, key: true }, + }); + const seededPermissions = await manager.getRepository(Permission).find({ + where: { key: In(permissionKeys) }, + select: { id: true, key: true }, + }); + + const positionByKey = new Map( + positions.map((position) => [position.key, position]), + ); + const permissionByKey = new Map( + seededPermissions.map((permission) => [permission.key, permission]), + ); + + const positionPermissions = seedPositions.flatMap((position) => { + const seededPosition = positionByKey.get(position.key); + + if (!seededPosition) { + throw new Error(`missing_position:${position.key}`); + } + + return position.permissionKeys.map((permissionKey) => { + const seededPermission = permissionByKey.get(permissionKey); + + if (!seededPermission) { + throw new Error(`missing_permission:${permissionKey}`); + } + + return { + positionId: seededPosition.id as string, + permissionId: seededPermission.id, + }; + }); + }); + + await manager.getRepository(PositionPermission).upsert(positionPermissions, { + conflictPaths: { positionId: true, permissionId: true }, + }); + + this.logger.log( + `Ensured ${positionPermissions.length} EDR position-permission links`, + ); + } } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 56ca98626..d70cb5fb4 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -109,10 +109,19 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESO }, ); +/** + * Container-allocation permission for the previously-unguarded + * booking allocate-containers endpoint. + */ +export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'), +]; + export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...BOOKING_PERMISSIONS, ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, + ...GAP_CONTROLLER_PERMISSIONS, ]; export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( @@ -171,6 +180,9 @@ export const FREIGHT_PERMS = { manage: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, }, + allocation: { + manage: 'edr_freight_app:allocation:manage', + }, } as const; const allRuleEngineViewKeys = () => @@ -286,6 +298,34 @@ export const ROLE_PERMISSION_PRESETS = { orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; +/** + * Position permission presets (positions-as-roles). Grants flow to users via + * Position → PositionPermission (NOT Role/RolePermission). Each reuses the + * matching ROLE_PERMISSION_PRESETS key-array as a building block and adds the + * gap-controller keys the position needs. Deduped via Set. + */ +const dedupe = (keys: string[]): string[] => [...new Set(keys)]; + +export const POSITION_PERMISSION_PRESETS = { + // Chief: senior operational role — intake/line-staff approval + director + // approval + scheduling/ops, plus container allocation. + chief: dedupe([ + ...ROLE_PERMISSION_PRESETS.lineStaff, + ...ROLE_PERMISSION_PRESETS.director, + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + FREIGHT_PERMS.allocation.manage, + ]), + director: dedupe([...ROLE_PERMISSION_PRESETS.director]), + ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), + ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), + djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]), + marketer: dedupe([...ROLE_PERMISSION_PRESETS.marketing]), + operation: dedupe([ + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + FREIGHT_PERMS.allocation.manage, + ]), +} as const; + export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ key: p.key, label: p.name.en, diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts index b6d83c138..a8c16ef05 100644 --- a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -3,8 +3,11 @@ import { hashPassword } from '@tria-plc/api-common/utils/argon'; import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; import { Employee, + EmployeePosition, Organization, + Position, Role, + Unit, User, UserCredential, UserRole, @@ -13,13 +16,19 @@ import { DataSource } from 'typeorm'; const SEED_FLAG = 'SEED_FREIGHT_STAFF'; const EDR_ORG_KEY = 'edr_freight'; +const EDR_UNIT_KEY = 'edr_freight_hq'; +// roleKey is kept only for backwards compatibility with existing UserRole rows; +// access is granted via the assigned position (positionKey) + PositionPermission. const STAFF_USERS = [ - { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' }, - { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, - { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, - { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' }, - { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' }, + { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff', positionKey: 'operation' }, + { email: 'chief@edr.local', username: 'chief', roleKey: 'edr_org_manager', positionKey: 'chief' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director', positionKey: 'director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo', positionKey: 'ceo' }, + { email: 'marketer@edr.local', username: 'marketer', roleKey: 'edr_marketing', positionKey: 'marketer' }, + { email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' }, + { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' }, + { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' }, ] as const; @Injectable() @@ -47,11 +56,22 @@ export class FreightStaffUsersSeeder { throw new Error(`missing_organization:${EDR_ORG_KEY}`); } + const unit = await manager.getRepository(Unit).findOne({ + where: { key: EDR_UNIT_KEY, organizationId: organization.id }, + select: { id: true }, + }); + + if (!unit) { + throw new Error(`missing_unit:${EDR_UNIT_KEY}`); + } + const roleRepository = manager.getRepository(Role); const userRepository = manager.getRepository(User); const userCredentialRepository = manager.getRepository(UserCredential); const userRoleRepository = manager.getRepository(UserRole); const employeeRepository = manager.getRepository(Employee); + const positionRepository = manager.getRepository(Position); + const employeePositionRepository = manager.getRepository(EmployeePosition); const hashedPassword = await hashPassword(password); @@ -105,20 +125,50 @@ export class FreightStaffUsersSeeder { { conflictPaths: { userId: true, roleId: true } }, ); - const employeeExists = await employeeRepository.exists({ + let employee = await employeeRepository.findOne({ where: { userId: user.id, organizationId: organization.id, isCurrent: true, }, + select: { id: true }, }); - if (!employeeExists) { - await employeeRepository.insert({ - userId: user.id, - organizationId: organization.id, + if (!employee) { + employee = await employeeRepository.save( + employeeRepository.create({ + userId: user.id, + organizationId: organization.id, + unitId: unit.id, + isCurrent: true, + name: { en: staff.username }, + }), + ); + } + + // Grant access via the assigned position (positions-as-roles). + const position = await positionRepository.findOne({ + where: { key: staff.positionKey, unitId: unit.id }, + select: { id: true, key: true }, + }); + + if (!position) { + throw new Error(`missing_position:${staff.positionKey}`); + } + + const employeePositionExists = await employeePositionRepository.exists({ + where: { + employeeId: employee.id as string, + positionId: position.id as string, + }, + }); + + if (!employeePositionExists) { + await employeePositionRepository.insert({ + employeeId: employee.id as string, + positionId: position.id as string, + unitId: unit.id, isCurrent: true, - name: { en: staff.username }, }); } } diff --git a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts deleted file mode 100644 index e33baa7fd..000000000 --- a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts +++ /dev/null @@ -1,1131 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { CargoUnitOfMeasure, TrainScheduleStatus, WagonStatus } from '@edr/types'; -import { randomUUID } from 'crypto'; -import { DataSource, EntityManager, In } from 'typeorm'; - -import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; -import { Booking } from '../modules/bookings/entities/booking.entity'; -import { - Company, - CompanyKind, - CompanyNationality, - CompanyStatus, - CompanyType, -} from '../modules/companies/entities/company.entity'; -import { - CompanyProfile, - ProfileStatus, - ProfileType, -} from '../modules/companies/entities/company-profile.entity'; -import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; -import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; -import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; -import { CargoType } from '../modules/rule-engine/entities/cargo-type.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'; -import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; -import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; -import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; -import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; -import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; -import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; -import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; -import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; -import { Wagon } from '../modules/wagons/entities/wagon.entity'; -import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; -import { WarehouseActivityLog } from '../modules/warehouses/entities/warehouse-activity-log.entity'; -import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; -import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; -import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; -import { Driver, DriverStatus } from '../modules/drivers/entities/driver.entity'; -import { FuelType, Vehicle, VehicleStatus, VehicleType } from '../modules/vehicles/entities/vehicle.entity'; - -const CUSTOMER_TIN = 'US12DEMO01'; - -const DEMO_TRAINS = [ - { - trainNumber: 'US12-DJI-IND-01', - direction: 'IMPORT', - originCode: 'NAGAD', - destinationCode: 'INDODE', - departureHoursAgo: 30, - arrivalHoursAgo: 14, - }, - { - trainNumber: 'US12-IND-DJI-01', - direction: 'EXPORT', - originCode: 'INDODE', - destinationCode: 'NAGAD', - departureHoursAgo: 28, - arrivalHoursAgo: 12, - }, - { - trainNumber: 'US12-DJI-IND-LM-02', - direction: 'IMPORT', - originCode: 'NAGAD', - destinationCode: 'INDODE', - departureHoursAgo: 24, - arrivalHoursAgo: 8, - }, - { - trainNumber: 'US12-IND-DJI-LM-02', - direction: 'EXPORT', - originCode: 'INDODE', - destinationCode: 'NAGAD', - departureHoursAgo: 22, - arrivalHoursAgo: 6, - }, -] as const; - -const TRAIN_DEMO_BOOKINGS = [ - { - reference: 'US12-IMP-FM-001', - trainNumber: 'US12-DJI-IND-01', - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: true, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 27, - totalAmount: 18450, - pickupAddress: 'Doraleh Container Terminal, Djibouti', - pickupLat: 11.5881, - pickupLng: 43.1372, - deliveryAddress: 'Indode bonded warehouse gate, Ethiopia', - deliveryLat: 8.7566, - deliveryLng: 38.9846, - }, - { - reference: 'US12-IMP-NOFM-001', - trainNumber: 'US12-DJI-IND-01', - tradeDirection: 'IMPORT', - freightType: 'BULK', - withFirstMile: false, - withLastMile: false, - containerCode: null, - cargoCode: 'BULK', - weightTons: 42, - totalAmount: 22100, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-EXP-FM-001', - trainNumber: 'US12-IND-DJI-01', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: true, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 19, - totalAmount: 15680, - pickupAddress: 'Indode export truck gate, Ethiopia', - pickupLat: 8.7566, - pickupLng: 38.9846, - deliveryAddress: 'Nagad Terminal customer handover yard, Djibouti', - deliveryLat: 11.5536, - deliveryLng: 43.1103, - }, - { - reference: 'US12-EXP-NOFM-001', - trainNumber: 'US12-IND-DJI-01', - tradeDirection: 'EXPORT', - freightType: 'BULK', - withFirstMile: false, - withLastMile: false, - containerCode: null, - cargoCode: 'BULK', - weightTons: 55, - totalAmount: 29800, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-IMP-LM-TRAIN-001', - trainNumber: 'US12-DJI-IND-LM-02', - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: true, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 31, - totalAmount: 20300, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: 'Indode last-mile customer delivery bay, Ethiopia', - deliveryLat: 8.7581, - deliveryLng: 38.9834, - }, - { - reference: 'US12-EXP-LM-TRAIN-001', - trainNumber: 'US12-IND-DJI-LM-02', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: true, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 21, - totalAmount: 17600, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: 'Nagad last-mile consignee handover yard, Djibouti', - deliveryLat: 11.5549, - deliveryLng: 43.1121, - }, -] as const; - -const CUSTOMER_TRUCK_DEMO_BOOKINGS = [ - { - reference: 'US12-EXP-FM-TRUCK-001', - trainNumber: null, - originCode: 'INDODE', - destinationCode: 'NAGAD', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: false, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 24, - totalAmount: 14800, - pickupAddress: 'Customer factory gate, Addis Ababa', - pickupLat: 8.9806, - pickupLng: 38.8736, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-EXP-NOFM-TRUCK-001', - trainNumber: null, - originCode: 'INDODE', - destinationCode: 'NAGAD', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: false, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 18, - totalAmount: 11200, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - customerTruckPlateNumber: 'ET-CUS-2046', - customerTruckDriverName: 'Dawit Customer Carrier', - customerTruckType: 'Container Chassis', - customerTruckContainerNumber: 'USDU1234567', - }, -] as const; - -const DEMO_BOOKINGS = [...TRAIN_DEMO_BOOKINGS, ...CUSTOMER_TRUCK_DEMO_BOOKINGS] as const; - -@Injectable() -export class PaidIndodeDemoBookingsSeeder { - private readonly logger = new Logger(PaidIndodeDemoBookingsSeeder.name); - - constructor(private readonly dataSource: DataSource) {} - - async run(): Promise { - try { - await this.dataSource.transaction(async (manager) => { - const refs = await this.ensureReferenceData(manager); - const schedules = await this.ensureArrivedTrains(manager, refs); - const bookings = await this.ensureBookings(manager, refs, schedules); - await this.ensureTrainLinks(manager, refs, schedules, bookings); - await this.ensureGatepasses(manager, schedules); - await this.ensureImportWarehouseInventory(manager, bookings); - }); - - this.logger.log( - `US12 paid Indode demo bookings ready: ${DEMO_BOOKINGS.length} booking(s), ${DEMO_TRAINS.length} arrived train(s)`, - ); - } catch (error) { - this.logger.error( - `PaidIndodeDemoBookingsSeeder failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - private async ensureReferenceData(manager: EntityManager) { - await manager.getRepository(Yard).upsert( - [ - { - code: 'INDODE', - label: 'Indode Terminal', - country: 'Ethiopia', - isActive: true, - displayOrder: 1, - }, - { - code: 'NAGAD', - label: 'Nagad Terminal, Djibouti', - country: 'Djibouti', - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(ServiceType).upsert( - [ - { - code: 'RAIL_CONTAINER_FIRST_LAST', - serviceName: 'Rail Freight with First and Last Mile', - description: 'Rail movement with first-mile pickup and last-mile delivery', - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: true, - includesCustoms: false, - priorityBonusPoints: 15, - isActive: true, - displayOrder: 3, - }, - { - code: 'RAIL_CONTAINER_LAST_MILE', - serviceName: 'Rail Freight with Last Mile', - description: 'Rail movement with last-mile delivery from terminal', - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: true, - includesCustoms: false, - priorityBonusPoints: 8, - isActive: true, - displayOrder: 4, - }, - { - code: 'RAIL_CONTAINER', - serviceName: 'Rail Freight', - description: 'Rail movement without first-mile pickup', - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 0, - isActive: true, - displayOrder: 1, - }, - { - code: 'RAIL_CONTAINER_FIRST_MILE', - serviceName: 'Rail Freight with First Mile', - description: 'Rail movement with first-mile pickup to terminal', - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 10, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(ContainerType).upsert( - [ - { - code: '20FT', - label: '20FT Standard', - sizeFt: 20, - wagonsPerUnit: 1, - isReefer: false, - isOpenTop: false, - isActive: true, - displayOrder: 1, - }, - { - code: '40FT', - label: '40FT Standard', - sizeFt: 40, - wagonsPerUnit: 1, - isReefer: false, - isOpenTop: false, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(CargoType).upsert( - [ - { - code: 'GENERAL_CARGO', - cargoTypeName: 'General Cargo', - showFreeTextBox: true, - unitOfMeasure: null, - requiresDirectorApproval: false, - isActive: true, - displayOrder: 1, - }, - { - code: 'BULK', - cargoTypeName: 'Bulk Cargo', - showFreeTextBox: true, - unitOfMeasure: CargoUnitOfMeasure.PerTon, - requiresDirectorApproval: false, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(WagonType).upsert( - { - code: 'US12-DEMO', - name: 'US12 Demo Flat/Bulk Wagon', - capacityTons: 70, - lengthMeters: 14, - maxWagonsPerTrain: 53, - supportedLoadTypes: ['CONTAINER', 'BULK'], - isActive: true, - equatedLengthM: 14, - tareWeightTons: 14, - supportsContainer: true, - maxContainerGrossT: 40, - }, - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(Company).upsert( - { - name: 'US12 Indode Demo Customer PLC', - type: CompanyType.Customer, - kind: CompanyKind.Commercial, - status: CompanyStatus.Active, - tin: CUSTOMER_TIN, - vatNumber: 'VAT-US12-001', - fanNumber: 'US12000000000001', - country: 'Ethiopia', - nationality: CompanyNationality.Ethiopian, - address: 'Bole Road, Addis Ababa, Ethiopia', - phone: '251911120012', - email: 'us12.indode.demo@edr.local', - website: 'https://edr.local/us12-demo', - contactPersonName: 'Aster Bekele', - contactPersonPhone: '251911120013', - generalManagerName: 'Mekonnen Desta', - generalManagerEmail: 'manager.us12.demo@edr.local', - generalManagerPhone: '251911120014', - licenceNumber: 'LIC-US12-2026', - region: 'Addis Ababa', - zone: 'Bole', - woreda: '03', - kebele: '12', - houseNo: 'US12-01', - attributes: { - seededBy: 'PaidIndodeDemoBookingsSeeder', - note: 'Paid customer with import/export demo bookings for US12.', - } as any, - }, - { conflictPaths: { tin: true } }, - ); - - const company = await manager.getRepository(Company).findOneByOrFail({ tin: CUSTOMER_TIN }); - await manager.getRepository(CompanyProfile).upsert( - [ - { - companyId: company.id, - type: ProfileType.importer, - reference: 'US12-IMP', - status: ProfileStatus.Active, - businessLicense: 'BL-US12-IMP-2026', - attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, - }, - { - companyId: company.id, - type: ProfileType.exporter, - reference: 'US12-EXP', - status: ProfileStatus.Active, - businessLicense: 'BL-US12-EXP-2026', - attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, - }, - ], - { conflictPaths: { reference: true } }, - ); - - const [yards, serviceTypes, containerTypes, cargoTypes, wagonType, importerProfile, exporterProfile] = - await Promise.all([ - manager.getRepository(Yard).find({ where: { code: In(['INDODE', 'NAGAD']) } }), - manager - .getRepository(ServiceType) - .find({ - where: { - code: In([ - 'RAIL_CONTAINER', - 'RAIL_CONTAINER_FIRST_MILE', - 'RAIL_CONTAINER_LAST_MILE', - 'RAIL_CONTAINER_FIRST_LAST', - ]), - }, - }), - manager.getRepository(ContainerType).find({ where: { code: In(['20FT', '40FT']) } }), - manager.getRepository(CargoType).find({ where: { code: In(['GENERAL_CARGO', 'BULK']) } }), - manager.getRepository(WagonType).findOneByOrFail({ code: 'US12-DEMO' }), - manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-IMP' }), - manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-EXP' }), - ]); - - return { - company, - importerProfile, - exporterProfile, - yards: new Map(yards.map((yard) => [yard.code, yard])), - serviceTypes: new Map(serviceTypes.map((serviceType) => [serviceType.code, serviceType])), - containerTypes: new Map(containerTypes.map((containerType) => [containerType.code, containerType])), - cargoTypes: new Map(cargoTypes.map((cargoType) => [cargoType.code, cargoType])), - wagonType, - }; - } - - private async ensureArrivedTrains( - manager: EntityManager, - refs: Awaited>, - ): Promise> { - const schedules = new Map(); - const now = new Date(); - - for (const demo of DEMO_TRAINS) { - const origin = refs.yards.get(demo.originCode); - const destination = refs.yards.get(demo.destinationCode); - if (!origin || !destination) { - throw new Error(`US12 demo train missing yard: ${demo.trainNumber}`); - } - - const departure = this.addHours(now, -demo.departureHoursAgo); - const arrival = this.addHours(now, -demo.arrivalHoursAgo); - const locomotive = await this.ensureLocomotive(manager, origin.id); - const trainSet = await this.ensureTrainSet(manager, demo.trainNumber, locomotive.id); - const schedule = await this.ensureTrainSchedule(manager, { - trainNumber: demo.trainNumber, - trainSetId: trainSet.id, - originStationId: origin.id, - destinationStationId: destination.id, - scheduledDepartureDate: departure, - scheduledArrivalDate: arrival, - actualDepartureAt: departure, - actualArrivalAt: arrival, - direction: demo.direction, - }); - - await manager.getRepository(TrainSet).update(trainSet.id, { - totalWeightTons: TRAIN_DEMO_BOOKINGS.filter((booking) => booking.trainNumber === demo.trainNumber) - .reduce((sum, booking) => sum + booking.weightTons, 0), - totalLengthMeters: 28, - wagonCount: 2, - status: 'COMPLETED', - }); - schedules.set(demo.trainNumber, schedule); - } - - return schedules; - } - - private async ensureBookings( - manager: EntityManager, - refs: Awaited>, - schedules: Map, - ): Promise> { - const bookingRepo = manager.getRepository(Booking); - const bookingContainerRepo = manager.getRepository(BookingContainer); - const firstMileRepo = manager.getRepository(FirstMile); - const lastMileRepo = manager.getRepository(LastMile); - const now = new Date(); - const references = DEMO_BOOKINGS.map((booking) => booking.reference); - const existingBookings = await bookingRepo.find({ where: { reference: In(references) } }); - const existingBookingIds = existingBookings.map((booking) => booking.id); - const firstMileVehicle = await this.ensureFirstMileVehicle(manager); - - if (existingBookingIds.length) { - const existingInventory = await manager.getRepository(WarehouseInventory).find({ - where: { bookingId: In(existingBookingIds) }, - select: { id: true }, - }); - const existingInventoryIds = existingInventory.map((item) => item.id); - if (existingInventoryIds.length) { - await manager.getRepository(WarehouseActivityLog).delete({ - inventoryId: In(existingInventoryIds), - }); - await manager.getRepository(WarehouseInventory).delete({ - id: In(existingInventoryIds), - }); - } - await this.deleteBookingTrainChildren(manager, existingBookingIds); - await bookingContainerRepo.delete({ bookingId: In(existingBookingIds) }); - await firstMileRepo.delete({ bookingId: In(existingBookingIds) }); - await lastMileRepo.delete({ bookingId: In(existingBookingIds) }); - } - - for (const demo of DEMO_BOOKINGS) { - const schedule = demo.trainNumber ? schedules.get(demo.trainNumber) : null; - if (demo.trainNumber && !schedule) { - throw new Error(`US12 demo booking missing train: ${demo.reference}`); - } - - const train = demo.trainNumber ? DEMO_TRAINS.find((item) => item.trainNumber === demo.trainNumber) : null; - const originCode = train?.originCode ?? ('originCode' in demo ? demo.originCode : undefined); - const destinationCode = train?.destinationCode ?? ('destinationCode' in demo ? demo.destinationCode : undefined); - const origin = originCode ? refs.yards.get(originCode) : null; - const destination = destinationCode ? refs.yards.get(destinationCode) : null; - const serviceType = refs.serviceTypes.get( - demo.withFirstMile && demo.withLastMile - ? 'RAIL_CONTAINER_FIRST_LAST' - : demo.withFirstMile - ? 'RAIL_CONTAINER_FIRST_MILE' - : demo.withLastMile - ? 'RAIL_CONTAINER_LAST_MILE' - : 'RAIL_CONTAINER', - ); - const cargoType = refs.cargoTypes.get(demo.cargoCode); - const profile = demo.tradeDirection === 'IMPORT' ? refs.importerProfile : refs.exporterProfile; - - if (!origin || !destination || !serviceType || !cargoType) { - throw new Error(`US12 demo booking missing reference data: ${demo.reference}`); - } - - await bookingRepo.upsert( - { - reference: demo.reference, - companyId: refs.company.id, - companyProfileId: profile.id, - isGovernment: false, - status: - 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber - ? 'TRUCK_ASSIGNED' - : demo.trainNumber - ? 'IN_TRANSIT' - : 'PAID', - scheduledDate: schedule?.scheduledDepartureDate ?? now, - estimatedShipmentDate: schedule?.scheduledDepartureDate ?? now, - totalAmount: demo.totalAmount, - paymentStatus: 'PAID', - contractType: 'NEW', - serviceTypeId: serviceType.id, - firstMilePickupAddress: demo.pickupAddress, - firstMilePickupLat: demo.pickupLat, - firstMilePickupLng: demo.pickupLng, - lastMileDeliveryAddress: demo.deliveryAddress, - lastMileDeliveryLat: demo.deliveryLat, - lastMileDeliveryLng: demo.deliveryLng, - customerTruckPlateNumber: - 'customerTruckPlateNumber' in demo ? demo.customerTruckPlateNumber : null, - customerTruckDriverName: - 'customerTruckDriverName' in demo ? demo.customerTruckDriverName : null, - customerTruckType: - 'customerTruckType' in demo ? demo.customerTruckType : null, - customerTruckContainerNumber: - 'customerTruckContainerNumber' in demo ? demo.customerTruckContainerNumber : null, - customerTruckAssignedAt: - 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber - ? this.addHours(now, -2) - : null, - customerTruckArrivedAt: null, - customsClearingEnabled: false, - equipmentReturn: 'WITHOUT_RETURN', - originYardId: origin.id, - destinationYardId: destination.id, - tradeDirection: demo.tradeDirection, - freightType: demo.freightType, - cargoTypeId: cargoType.id, - cargoFreeText: demo.freightType === 'BULK' ? 'Seeded paid bulk cargo' : 'Seeded paid container cargo', - shippingLineId: null, - cargoTotalWeightVgm: demo.weightTons, - isHazardous: false, - isReefer: false, - paymentCurrency: 'ETB', - pnrCode: `PNR-${demo.reference}`, - versionNumber: 1, - approvedByStaffAt: now, - customerSignedAt: now, - fullyExecutedAt: now, - pricingBreakdown: { - paid: true, - source: 'PaidIndodeDemoBookingsSeeder', - firstMileIncluded: demo.withFirstMile, - lastMileIncluded: demo.withLastMile, - }, - priorityScore: demo.withFirstMile ? 30 : demo.withLastMile ? 25 : 20, - wagonsRequired: 1, - schedulingStatus: demo.trainNumber ? 'DISPATCHED' : 'NOT_SCHEDULED', - scheduledAt: demo.trainNumber ? now : null, - trainScheduleId: schedule?.id ?? null, - paymentDeadline: null, - selectedForBatchAt: demo.trainNumber ? now : null, - }, - { conflictPaths: { reference: true } }, - ); - - const booking = await bookingRepo.findOneByOrFail({ reference: demo.reference }); - - if (demo.freightType === 'CONTAINER' && demo.containerCode) { - const containerType = refs.containerTypes.get(demo.containerCode); - if (!containerType) { - throw new Error(`US12 demo booking missing container type: ${demo.reference}`); - } - await bookingContainerRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - containerTypeId: containerType.id, - containerNumber: this.containerNumber(demo.reference), - containerSize: demo.containerCode.startsWith('40') ? '40ft' : '20ft', - quantity: 1, - hazardousQuantity: 0, - reeferQuantity: 0, - vgmPerUnitTons: demo.weightTons, - totalVgmTons: demo.weightTons, - wagonsRequired: 1, - weightLimitRuleId: null, - isOverweight: false, - overweightExcessTons: null, - }); - } - - if (demo.withFirstMile) { - await firstMileRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - status: 'RECEIVED_TO_PORT', - advancedPayment: demo.totalAmount, - remainingPayment: 0, - estimatedKm: demo.tradeDirection === 'IMPORT' ? 12 : 35, - exactKm: demo.tradeDirection === 'IMPORT' ? 11.8 : 34.6, - vehicleId: firstMileVehicle.id, - }); - } - - if (demo.withLastMile) { - await lastMileRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - status: 'DELIVERED', - advancedPayment: demo.totalAmount, - remainingPayment: 0, - estimatedKm: demo.tradeDirection === 'IMPORT' ? 18 : 14, - exactKm: demo.tradeDirection === 'IMPORT' ? 17.5 : 13.8, - vehicleId: null, - }); - } - } - - const savedBookings = await bookingRepo.find({ where: { reference: In(references) } }); - return new Map(savedBookings.map((booking) => [booking.reference, booking])); - } - - private async ensureTrainLinks( - manager: EntityManager, - refs: Awaited>, - schedules: Map, - bookings: Map, - ): Promise { - const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking); - const trainSetWagonRepo = manager.getRepository(TrainSetWagon); - const allocationRepo = manager.getRepository(WagonBookingAllocation); - const containerItemRepo = manager.getRepository(WagonAllocationContainerItem); - const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; - const wagonLength = Number(refs.wagonType.lengthMeters) || 14; - const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; - - for (const demo of TRAIN_DEMO_BOOKINGS) { - const schedule = schedules.get(demo.trainNumber); - const booking = bookings.get(demo.reference); - if (!schedule || !booking) continue; - - const trainBookings = TRAIN_DEMO_BOOKINGS.filter((item) => item.trainNumber === demo.trainNumber); - const sequence = trainBookings.findIndex((item) => item.reference === demo.reference) + 1; - const wagon = await this.ensureWagon(manager, { - wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`, - wagonTypeId: refs.wagonType.id, - yardId: schedule.destinationStationId, - trainScheduleId: schedule.id, - trainSetWagonId: null, - tareWeight, - capacityTons: wagonCapacity, - }); - - let trainSetWagon = await trainSetWagonRepo.findOne({ - where: { trainSetId: schedule.trainSetId, sequenceNo: sequence }, - }); - trainSetWagon = await trainSetWagonRepo.save( - trainSetWagonRepo.create({ - ...(trainSetWagon ? { id: trainSetWagon.id } : {}), - trainSetId: schedule.trainSetId, - wagonTypeId: refs.wagonType.id, - physicalWagonId: wagon.id, - sequenceNo: sequence, - capacityTons: wagonCapacity, - lengthMeters: wagonLength, - assignedWeightTons: demo.weightTons, - status: 'DEPARTED', - }), - ); - - await manager.getRepository(Wagon).update(wagon.id, { - trainSetWagonId: trainSetWagon.id, - currentTrainScheduleId: schedule.id, - currentYardId: schedule.destinationStationId, - status: WagonStatus.Assigned, - }); - - const allocation = await allocationRepo.save( - allocationRepo.create({ - trainSetWagonId: trainSetWagon.id, - bookingId: booking.id, - allocatedWeightTons: demo.weightTons, - loadType: demo.freightType, - status: 'DEPARTED', - confirmedAt: schedule.actualDepartureAt ?? new Date(), - }), - ); - - if (demo.freightType === 'CONTAINER') { - const bookingContainer = await manager.getRepository(BookingContainer).findOne({ - where: { bookingId: booking.id }, - }); - const containerType = demo.containerCode ? refs.containerTypes.get(demo.containerCode) : null; - await containerItemRepo.insert({ - id: randomUUID(), - wagonBookingAllocationId: allocation.id, - bookingContainerId: bookingContainer?.id ?? null, - containerNumber: this.containerNumber(demo.reference), - containerTypeId: containerType?.id ?? null, - positionOnWagon: 1, - sealNumber: `SEAL-${demo.reference}`, - chassisNumber: `CHS-${demo.reference}`, - grossWeightTons: demo.weightTons, - }); - } - - await scheduleBookingRepo.insert({ - id: randomUUID(), - trainScheduleId: schedule.id, - bookingId: booking.id, - }); - } - } - - private async ensureGatepasses( - manager: EntityManager, - schedules: Map, - ): Promise { - const repo = manager.getRepository(ImportDjiboutiOperation); - const securedAt = this.addHours(new Date(), -20); - - for (const schedule of schedules.values()) { - const existing = await repo.findOne({ where: { trainScheduleId: schedule.id } }); - await repo.save( - repo.create({ - ...(existing ? { id: existing.id } : {}), - trainScheduleId: schedule.id, - documents: { - ...(existing?.documents ?? {}), - GATE_PASS: { - reference: `GP-${schedule.trainNumber}`, - uploadedAt: securedAt.toISOString(), - uploadedBy: 'PaidIndodeDemoBookingsSeeder', - notes: 'Seeded secured gate pass for import/export Djibouti port entry testing.', - }, - }, - gatepassGrantedAt: securedAt, - performedBy: 'PaidIndodeDemoBookingsSeeder', - notes: 'Seeded SECURED gate pass for US12 warehouse workflow testing.', - }), - ); - } - } - - private async ensureImportWarehouseInventory( - manager: EntityManager, - bookings: Map, - ): Promise { - const warehouse = await manager.getRepository(Warehouse).findOne({ where: { code: 'INDODE_OPEN' } }); - if (!warehouse) { - this.logger.warn('INDODE_OPEN warehouse missing; skipping US12 import warehouse inventory seed'); - return; - } - - for (const demo of TRAIN_DEMO_BOOKINGS.filter((booking) => booking.tradeDirection === 'IMPORT')) { - const booking = bookings.get(demo.reference); - if (!booking) continue; - - const yard = await this.findWarehouseYard(manager, warehouse.id, demo.freightType); - if (!yard) { - this.logger.warn(`No warehouse yard found for ${warehouse.code}; skipping ${demo.reference}`); - continue; - } - const zone = await manager.getRepository(WarehouseZone).findOne({ where: { yardId: yard.id } }); - if (!zone) { - this.logger.warn(`No warehouse zone found for ${yard.code}; skipping ${demo.reference}`); - continue; - } - - const arrivedAt = this.addHours(new Date(), -Number(demo.trainNumber.includes('LM') ? 7 : 13)); - const grnNumber = `GRN-IMP-${demo.reference.replace(/[^A-Z0-9]/g, '')}`; - const saved = await manager.getRepository(WarehouseInventory).save( - manager.getRepository(WarehouseInventory).create({ - warehouseId: warehouse.id, - yardId: yard.id, - zoneId: zone.id, - bookingId: booking.id, - quantity: demo.freightType === 'CONTAINER' ? 1 : 1, - weight: demo.weightTons, - volume: null, - grnNumber, - status: 'UNLOADED', - inspectionStatus: null, - arrivedAt, - unloadedAt: arrivedAt, - notes: [ - `GRN Number: ${grnNumber}`, - 'Direction: IMPORT', - `Train: ${demo.trainNumber}`, - `Seeded For: ${demo.withLastMile ? 'Import with last mile' : 'Import terminal pickup / no last mile'}`, - 'Seeded by PaidIndodeDemoBookingsSeeder for Receive at Warehouse testing.', - ].join('\n'), - }), - ); - - await manager.getRepository(WarehouseActivityLog).save( - manager.getRepository(WarehouseActivityLog).create({ - inventoryId: saved.id, - warehouseId: warehouse.id, - activityType: 'INVENTORY_UNLOADED', - description: `Seeded import train arrival ${demo.trainNumber} into warehouse queue`, - performedBy: 'PaidIndodeDemoBookingsSeeder', - }), - ); - } - } - - private async findWarehouseYard( - manager: EntityManager, - warehouseId: string, - freightType: string, - ): Promise { - const preferredType = freightType === 'CONTAINER' ? 'CONTAINER_YARD' : 'BULK_YARD'; - return ( - (await manager.getRepository(WarehouseYard).findOne({ - where: { warehouseId, type: preferredType as any }, - })) ?? - (await manager.getRepository(WarehouseYard).findOne({ - where: { warehouseId }, - })) - ); - } - - private async deleteBookingTrainChildren(manager: EntityManager, bookingIds: string[]): Promise { - const allocationRepo = manager.getRepository(WagonBookingAllocation); - const allocations = await allocationRepo.find({ - where: { bookingId: In(bookingIds) }, - select: { id: true }, - }); - const allocationIds = allocations.map((allocation) => allocation.id); - if (allocationIds.length) { - await manager.getRepository(WagonAllocationContainerItem).delete({ - wagonBookingAllocationId: In(allocationIds), - }); - } - await allocationRepo.delete({ bookingId: In(bookingIds) }); - await manager.getRepository(TrainScheduleBooking).delete({ bookingId: In(bookingIds) }); - } - - private async ensureLocomotive( - manager: EntityManager, - currentYardId: string, - ): Promise { - const repo = manager.getRepository(Locomotive); - const existing = await repo.findOne({ where: { code: 'US12-DEMO-LOCO' } }); - if (existing) { - await repo.update(existing.id, { currentYardId, status: 'AVAILABLE' }); - return { ...existing, currentYardId, status: 'AVAILABLE' }; - } - - return repo.save( - repo.create({ - code: 'US12-DEMO-LOCO', - name: 'US12 Demo Locomotive', - locomotiveType: 'DIESEL', - maxPullWeightTons: 4200, - maxTrainLengthMeters: 760, - status: 'AVAILABLE', - currentYardId, - }), - ); - } - - private async ensureTrainSet( - manager: EntityManager, - trainNumber: string, - locomotiveId: string, - ): Promise { - const schedule = await manager.getRepository(TrainSchedule).findOne({ - where: { trainNumber }, - }); - if (schedule) { - const existing = await manager.getRepository(TrainSet).findOneByOrFail({ - id: schedule.trainSetId, - }); - await manager.getRepository(TrainSet).update(existing.id, { - locomotiveId, - status: 'COMPLETED', - }); - return { ...existing, locomotiveId, status: 'COMPLETED' }; - } - - return manager.getRepository(TrainSet).save( - manager.getRepository(TrainSet).create({ - locomotiveId, - totalWeightTons: 0, - totalLengthMeters: 0, - wagonCount: 0, - status: 'COMPLETED', - }), - ); - } - - private async ensureTrainSchedule( - manager: EntityManager, - input: { - trainNumber: string; - trainSetId: string; - originStationId: string; - destinationStationId: string; - scheduledDepartureDate: Date; - scheduledArrivalDate: Date; - actualDepartureAt: Date; - actualArrivalAt: Date; - direction: 'IMPORT' | 'EXPORT'; - }, - ): Promise { - const repo = manager.getRepository(TrainSchedule); - const existing = await repo.findOne({ where: { trainNumber: input.trainNumber } }); - const nextSchedule = repo.create({ - ...(existing ? { id: existing.id } : {}), - trainSetId: input.trainSetId, - originStationId: input.originStationId, - destinationStationId: input.destinationStationId, - scheduledDepartureDate: input.scheduledDepartureDate, - scheduledArrivalDate: input.scheduledArrivalDate, - actualDepartureAt: input.actualDepartureAt, - actualArrivalAt: input.actualArrivalAt, - status: TrainScheduleStatus.Arrived, - trainNumber: input.trainNumber, - direction: input.direction, - maxWagons: 53, - bookingWindowStatus: 'CLOSED', - }); - return repo.save(nextSchedule); - } - - private async ensureWagon( - manager: EntityManager, - input: { - wagonNumber: string; - wagonTypeId: string; - yardId: string; - trainScheduleId: string; - trainSetWagonId: string | null; - tareWeight: number; - capacityTons: number; - }, - ): Promise { - const repo = manager.getRepository(Wagon); - const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); - return repo.save( - repo.create({ - ...(existing ? { id: existing.id } : {}), - wagonNumber: input.wagonNumber, - wagonTypeId: input.wagonTypeId, - currentYardId: input.yardId, - currentTrainScheduleId: input.trainScheduleId, - trainSetWagonId: input.trainSetWagonId, - tareWeight: input.tareWeight, - maxPayloadWeight: input.capacityTons, - status: WagonStatus.Assigned, - notes: 'US12 paid Indode demo seed wagon', - }), - ); - } - - private async ensureFirstMileVehicle(manager: EntityManager): Promise { - const driverRepo = manager.getRepository(Driver); - const vehicleRepo = manager.getRepository(Vehicle); - const licenseNumber = 'US12-FM-LIC-001'; - const plateNumber = 'ET-FM-1201'; - - await driverRepo.upsert( - { - licenseNumber, - firstName: 'Tesfaye', - lastName: 'Firstmile', - email: 'tesfaye.firstmile@edr.local', - phoneNumber: '251911120120', - licenseExpiryDate: this.addHours(new Date(), 24 * 365), - status: DriverStatus.ACTIVE, - vehicleTypesAuthorized: [VehicleType.TRUCK, VehicleType.FLATBED], - notes: 'Seeded first-mile driver for US12 receive-to-warehouse testing', - }, - { conflictPaths: { licenseNumber: true } }, - ); - const driver = await driverRepo.findOneByOrFail({ licenseNumber }); - - await vehicleRepo.upsert( - { - plateNumber, - registrationNumber: 'US12-FM-REG-001', - vehicleType: VehicleType.TRUCK, - manufacturer: 'Sinotruk', - model: 'HOWO Container Carrier', - year: 2024, - fuelType: FuelType.DIESEL, - capacity: 40, - status: VehicleStatus.ACTIVE, - assignedDriverId: driver.id, - assignedDriverName: `${driver.firstName} ${driver.lastName}`, - description: 'Seeded first-mile truck for US12 receive-to-warehouse testing', - estimatedDistanceKm: 35, - actualDistanceKm: 34.6, - }, - { conflictPaths: { plateNumber: true } }, - ); - const vehicle = await vehicleRepo.findOneByOrFail({ plateNumber }); - await manager.query( - `UPDATE freight.vehicles - SET trailer_plate_no = $2, - assigned_driver_id = $3, - assigned_driver_name = $4, - updated_at = NOW() - WHERE id = $1`, - [vehicle.id, 'ET-TRL-1201', driver.id, `${driver.firstName} ${driver.lastName}`], - ); - return vehicleRepo.findOneByOrFail({ plateNumber }); - } - - private containerNumber(reference: string): string { - const suffix = reference.replace(/[^A-Z0-9]/g, '').slice(-7); - return `US12${suffix}`; - } - - private addHours(date: Date, hours: number): Date { - return new Date(date.getTime() + hours * 60 * 60 * 1000); - } -} 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..14ccb3efb 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 @@ -479,7 +449,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/package.json b/apps/edr-freight-web/backoffice/package.json index 48b9bc0a9..72c782e11 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -17,6 +17,7 @@ "@edr/ui-common": "workspace:*", "@hello-pangea/dnd": "^18.0.1", "@mantine/core": "^9.3.0", + "@mantine/dates": "^9.3.0", "@mantine/hooks": "^9.3.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 567ecf074..4c74f6993 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -12,6 +12,7 @@ import { PackageCheck, PackageOpen, Paperclip, + Receipt, Send, Settings, ShieldCheck, @@ -32,7 +33,11 @@ import { useParams, } from "react-router-dom"; -import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import { + FreightDashboardLayout, + type SidebarItem, + type SidebarSection, +} from "@/components/layout"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -53,6 +58,8 @@ import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; +import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; +import InvoicesPage from "./pages/invoices/InvoicesPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; @@ -61,7 +68,13 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import { + FREIGHT_PERMS, + hasPermission as hasFreightPermission, + isDjiboutiGl, + isEthiopianGl, + isSuperAdmin, +} from "./lib/permissions"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; @@ -107,6 +120,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[] => [ { @@ -144,6 +158,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, + { + label: "Invoices", + href: "/dashboard/invoices", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, ...demoItems, ], }, @@ -159,12 +179,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, + { + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, + }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", @@ -309,7 +329,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , }, { @@ -356,7 +376,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , }, ], @@ -419,10 +439,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Contract validity", href: "/dashboard/configuration/contract-validity-periods", }, - // { - // label: "Train scheduling rules", - // href: "/dashboard/configuration/train-scheduling-rules", - // }, + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + }, ], }, { @@ -435,12 +455,35 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ]; -/** Keep only items the user is permitted to see; drop now-empty sections. */ +/** Hrefs of the two document-clearance menu items (stable identifiers). */ +const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; +const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; + +const isEtClearanceItem = (item: SidebarItem): boolean => + item.href === ET_CLEARANCE_HREF; +const isDjClearanceItem = (item: SidebarItem): boolean => + item.href === DJ_CLEARANCE_HREF; +const isClearanceItem = (item: SidebarItem): boolean => + isEtClearanceItem(item) || isDjClearanceItem(item); + +/** + * Keep only items the user is permitted to see; drop now-empty sections. + * + * Position-scoped visibility (super_admin bypasses all of this): + * - Ethiopian GL → sees ONLY the ET document-clearance page. + * - Djibouti GL → sees ONLY the DJ clearance page. + * - Everyone else → sees everything they have permission for, EXCEPT the two + * clearance pages (those are GL-only). + */ const filterSidebarByPermission = ( sections: SidebarSection[], user: ReturnType["user"], ): SidebarSection[] => { - const itemAllowed = (item: SidebarItem): boolean => { + const superAdmin = isSuperAdmin(user); + const etGl = !superAdmin && isEthiopianGl(user); + const djGl = !superAdmin && isDjiboutiGl(user); + + const permissionAllowed = (item: SidebarItem): boolean => { if (!item.permission) return true; const keys = Array.isArray(item.permission) ? item.permission @@ -448,6 +491,19 @@ const filterSidebarByPermission = ( return keys.some((key) => hasFreightPermission(user, key)); }; + const itemAllowed = (item: SidebarItem): boolean => { + if (superAdmin) return true; + + // GL positions are locked to their single clearance page. + if (etGl) return isEtClearanceItem(item); + if (djGl) return isDjClearanceItem(item); + + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + + return permissionAllowed(item); + }; + return sections .map((section) => ({ ...section, @@ -469,6 +525,22 @@ const DashboardShell = () => { ); const displayName = user?.name?.en || user?.username || user?.email || "User"; + // GL positions are locked to their single clearance page: if they navigate + // (or deep-link) anywhere else, send them back to their clearance hub. + // Super admin is exempt. Allow the clearance path + its detail sub-routes. + const superAdmin = isSuperAdmin(user); + const glClearanceHome = !superAdmin + ? isEthiopianGl(user) + ? ET_CLEARANCE_HREF + : isDjiboutiGl(user) + ? DJ_CLEARANCE_HREF + : null + : null; + + if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) { + return ; + } + return ( { } /> } /> + } /> } /> ); @@ -505,8 +578,12 @@ const App = () => { } /> } /> + } /> } /> - } /> + } + /> }> } /> } /> @@ -522,8 +599,27 @@ const App = () => { /> } /> } /> + + + + } + /> + + + + } + /> } /> - } /> + } + /> } @@ -536,7 +632,9 @@ const App = () => { + } @@ -570,7 +668,9 @@ const App = () => { + } @@ -578,7 +678,9 @@ const App = () => { + } @@ -586,7 +688,9 @@ const App = () => { + } @@ -618,12 +722,20 @@ const App = () => { } /> - } /> - } /> + } + /> + } + /> + } @@ -631,7 +743,9 @@ const App = () => { + } @@ -644,7 +758,9 @@ const App = () => { + } @@ -655,155 +771,174 @@ const App = () => { /> } /> } /> - } /> + } + /> } /> } /> } /> } /> } /> } /> - } /> - } /> + } + /> + } + /> } /> } /> - } /> - } /> + } + /> + } + /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> } + element={ + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + } /> { {/* Legacy embedded user management routes */} } /> } /> - } /> + } + /> {/* } /> */} - } /> + } + /> } /> { } + element={ + + } /> { } /> } /> - } /> - } /> + } + /> + } + /> { } + element={ + + } + /> + } /> - } /> } /> } /> @@ -1026,9 +1180,7 @@ const App = () => { /** Redirect removed milestones page to document clearance. */ function BookingMilestonesRedirect() { const { id } = useParams(); - return ( - - ); + return ; } /** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */ diff --git a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx index 950cfd476..8971d4ccb 100644 --- a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx @@ -42,8 +42,8 @@ export function ContainerAllocationTable({ ); const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + queryKey: ["vehicles", "free"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( @@ -99,7 +99,7 @@ export function ContainerAllocationTable({ {vehicles.length === 0 && ( } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. + No free vehicles available. Free up or add vehicles before allocating containers. )} diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx index 85bba1dc4..78c5160ca 100644 --- a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx @@ -42,8 +42,8 @@ export function FirstMileContainerAllocationTable({ ); const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + queryKey: ["vehicles", "free"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( @@ -99,7 +99,7 @@ export function FirstMileContainerAllocationTable({ {vehicles.length === 0 && ( } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. + No free vehicles available. Free up or add vehicles before allocating containers. )} diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx index d11d99a4a..cc619cb9a 100644 --- a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx @@ -42,8 +42,8 @@ export function LastMileContainerAllocationTable({ ); const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + queryKey: ["vehicles", "free"], + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( @@ -99,7 +99,7 @@ export function LastMileContainerAllocationTable({ {vehicles.length === 0 && ( } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. + No free vehicles available. Free up or add vehicles before allocating containers. )} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx new file mode 100644 index 000000000..d7cd9207b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -0,0 +1,1130 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Badge, + Button, + FileInput, + Group, + Modal, + NumberInput, + Paper, + Select, + Stack, + Stepper, + Text, + Textarea, +} from "@mantine/core"; +import { DateInput, DateTimePicker } from "@mantine/dates"; +import { + AlertTriangle, + CheckCircle2, + FileText, + PackageCheck, + Receipt, + Ship, + Train, + Truck, + Upload, +} from "lucide-react"; +import type { Freight } from "@edr/types"; +import toast from "react-hot-toast"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { + TransitPermitMultiUpload, + type TransitPermitUploadedRow, +} from "@/components/contracts/TransitPermitMultiUpload"; +import { + findWorkflowFile, + PhasedUploadedFileRow, +} from "@/components/contracts/PhasedUploadedFileRow"; +import { + DeclarationStep, + StepStatus, + isBookingMilestoneDone, + isMilestoneDone, + type ClearanceViewLike, + type MilestoneRow, +} from "@/components/contracts/PhasedClearanceActionPanel"; +import { contractsService } from "@/services/contracts.service"; +import { bookingsService } from "@/services/bookings.service"; + +/** + * Export customs flow, ordered per the stakeholder process: + * customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET) + * → payment + wagons → transport document (ET) → train to Djibouti → gate pass (DJ) + * → accept T1 (DJ) → final invoice (DJ) + customer slip + GL confirm. + */ +export function computeExportActiveStep( + clearance: ClearanceViewLike, + bookingMilestones: MilestoneRow[], + bookingCreated: boolean, +): number { + const released = Boolean(clearance.bookingReady || clearance.operationReady); + if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0; + if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1; + if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2; + if (!bookingCreated) return 3; + if ( + !isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") || + !( + isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") || + clearance.train?.wagonAllocated + ) + ) { + return 4; + } + if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5; + if (!clearance.train?.arrivedAt) return 6; + if (!clearance.gatepassGranted) return 7; + if (!clearance.t1Closed) return 8; + if (clearance.finalInvoice?.status !== "PAID") return 9; + return 10; +} + +export function exportTransitFilesFromWorkflow( + workflowFiles: Freight.ClearanceWorkflowFile[], +): TransitPermitUploadedRow[] { + return workflowFiles + .filter( + (f) => + f.category === "transit" && + f.code.toLowerCase().startsWith("export_transport_document") && + f.file, + ) + .map((f) => ({ + code: f.code, + label: f.label, + file: f.file!, + })); +} + +export function ExportClearanceStepper({ + contractId, + bookingId, + clearance, + workflowFiles = [], + showEt, + canEt, + showDj, + canDj, + onChanged, + bookingCreateHref, + onViewFile, + onDownloadFile, + useUploadModals = false, + onUploadRoRequest, + bookingCreated = false, + bookingMilestones = [], +}: { + contractId?: string; + bookingId?: string; + clearance: ClearanceViewLike; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + showEt: boolean; + canEt: boolean; + showDj: boolean; + canDj: boolean; + onChanged?: () => void; + bookingCreateHref?: string; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; + useUploadModals?: boolean; + onUploadRoRequest?: () => void; + bookingCreated?: boolean; + bookingMilestones?: MilestoneRow[]; +}) { + // Pre-booking actions (RO, declaration, release) target the contract when one + // is present; GENERAL customs bookings run the same flow keyed on the booking. + const isBooking = Boolean(bookingId) && !contractId; + const entityId = contractId ?? bookingId ?? ""; + // The booking that carries the post-booking steps (gate pass, T1, invoice). + const actionBookingId = clearance.linkedBookingId ?? bookingId ?? null; + const effectiveBookingCreated = + bookingCreated || Boolean(clearance.linkedBookingId) || isBooking; + + const activeStep = useMemo( + () => computeExportActiveStep(clearance, bookingMilestones, effectiveBookingCreated), + [clearance, bookingMilestones, effectiveBookingCreated], + ); + + const released = Boolean(clearance.bookingReady || clearance.operationReady); + const declared = isMilestoneDone(clearance.milestones, "DECLARED"); + const paymentSettled = isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED"); + const wagonAllocated = + isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") || + Boolean(clearance.train?.wagonAllocated); + const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED"); + + return ( + + {clearance.roHold && clearance.roHoldReason ? ( + } title="Release Order on hold"> + {clearance.roHoldReason} + + ) : null} + + {clearance.nextAction ? ( + + + {clearance.nextAction.actor.replace("_", " ")} —{" "} + {clearance.nextAction.action} + + + ) : null} + + + + Export customs clearance + + + + ) : undefined + } + > + + + + } + > + {showDj && canDj ? ( + useUploadModals ? ( + + ) : ( + + ) + ) : ( + + {findWorkflowFile(workflowFiles, "release_order") ? ( + + ) : null} + {clearance.vesselDepartureDate ? ( + + Vessel departure:{" "} + {new Date(clearance.vesselDepartureDate).toLocaleDateString()} + + ) : null} + + + )} + + + : } + > + {showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? ( + + + {declared && !released ? ( + + ) : null} + + ) : ( + + )} + + + } + > + {released && bookingCreateHref && !effectiveBookingCreated && showEt && canEt ? ( + + + Export is released. Create the shipment booking for the customer. + + + + ) : ( + + )} + + + } + > + + + + + + + : } + > + {showEt && canEt && actionBookingId && wagonAllocated ? ( + + ) : ( + + {exportTransitFilesFromWorkflow(workflowFiles).map((row) => ( + + ))} + + + )} + + + } + > + + + + + + + : } + > + + + + : } + > + + + + + ) : ( + + ) + } + > + + + + + + ); +} + +/** Legacy in-flight contracts: declaration done before auto-release existed. */ +function ConfirmExportReleaseFallback({ + entityId, + isBooking, + onChanged, +}: { + entityId: string; + isBooking: boolean; + onChanged?: () => void; +}) { + const [loading, setLoading] = useState(false); + return ( + + ); +} + +function GatepassStep({ + bookingId, + clearance, + canAct, + onChanged, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canAct: boolean; + onChanged?: () => void; +}) { + const [opened, setOpened] = useState(false); + const [at, setAt] = useState(new Date()); + const [loading, setLoading] = useState(false); + + if (clearance.gatepassGranted) { + return ( + + ); + } + + const arrived = Boolean(clearance.train?.arrivedAt); + + return ( + + + {canAct && bookingId ? ( + <> + + setOpened(false)} + title={Grant gate pass} + radius="md" + size="sm" + > + + setAt(v ? new Date(v) : null)} + required + /> + + + + + + + + ) : null} + + ); +} + +function AcceptT1Step({ + bookingId, + clearance, + transportIssued, + workflowFiles = [], + canAct, + onChanged, + onViewFile, + onDownloadFile, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + transportIssued: boolean; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + canAct: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [loading, setLoading] = useState(false); + const files = exportTransitFilesFromWorkflow(workflowFiles); + + return ( + + {files.map((row) => ( + + ))} + {clearance.t1Closed ? ( + + ) : ( + <> + + {canAct && bookingId ? ( + + ) : null} + + )} + + ); +} + +function FinalInvoiceStep({ + bookingId, + clearance, + canDjAct, + canConfirm, + onChanged, + onViewFile, + onDownloadFile, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canDjAct: boolean; + canConfirm: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [opened, setOpened] = useState(false); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [description, setDescription] = useState(""); + const [file, setFile] = useState(null); + const [sending, setSending] = useState(false); + const [confirming, setConfirming] = useState(false); + + const invoice = clearance.finalInvoice ?? null; + const paid = invoice?.status === "PAID"; + + if (!clearance.offloaded && !invoice) { + return ( + + ); + } + + return ( + + {invoice ? ( + + +
+ + {invoice.invoiceNumber} + + + {invoice.totalAmount.toLocaleString()} {invoice.currency} + {invoice.description ? ` — ${invoice.description}` : ""} + +
+ + {invoice.status} + +
+
+ ) : null} + + {invoice?.invoiceFile ? ( + + ) : null} + {invoice?.slipFile ? ( + + ) : null} + + {paid ? ( + + ) : invoice ? ( + <> + + {canConfirm && bookingId && invoice.slipFile ? ( + + ) : null} + + ) : canDjAct && bookingId ? ( + <> + + Cargo offloaded — send the final invoice to the customer. + + + setOpened(false)} + title={Send final invoice} + radius="md" + size="md" + > + + + +