diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..242c1b08a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +**/node_modules +**/dist +**/.turbo +**/.git +**/.github +**/.vscode +**/.idea +**/.env +**/.env.* +!**/.env.example +**/coverage +**/*.tsbuildinfo +**/*.log +.DS_Store diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..dbf44509b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,92 @@ +name: Deploy Stacks + +on: + push: + branches: + - main + - dev + - staging + paths: + - "apps/edr-freight-api/**" + - "apps/edr-freight-web/**" + - "apps/edr-passenger-api/**" + - "apps/edr-passenger-web/**" + - "packages/**" + - "infrastructure/docker/Dockerfile.web" + - "infrastructure/nginx/**" + - "docker-compose.yaml" + - "pnpm-lock.yaml" + - "scripts/deploy/**" + - ".github/workflows/deploy.yml" + workflow_dispatch: + +concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy ${{ matrix.service }} + runs-on: self-hosted + strategy: + fail-fast: false + matrix: + include: + - project: edr-freight + build_env_file: freight-web.build.env + service: freight-api + # - project: edr-freight + # build_env_file: freight-web.build.env + # service: freight-portal + # - project: edr-freight + # build_env_file: freight-web.build.env + # service: freight-backoffice + - project: edr-passenger + build_env_file: passenger-web.build.env + service: passenger-api + - project: edr-passenger + build_env_file: passenger-web.build.env + service: passenger-portal + - project: edr-passenger + build_env_file: passenger-web.build.env + service: passenger-backoffice + env: + PROJECT: ${{ matrix.project }} + BRANCH: ${{ github.ref_name }} + DEPLOY_USER: tria + BUILD_ENV_FILE: ${{ matrix.build_env_file }} + DOCKER_BUILDKIT: "1" + COMPOSE_DOCKER_CLI_BUILD: "1" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Sync environment from server + run: | + chmod +x scripts/deploy/*.sh + ./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" + + - name: Set compose project name + run: | + set -euo pipefail + branch_slug=$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//") + echo "COMPOSE_PROJECT_NAME=${PROJECT}-${branch_slug}" >> "${GITHUB_ENV}" + + - name: Configure npm auth for Docker builds + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: ./scripts/deploy/create-npmrc.sh + + - name: Build ${{ matrix.service }} + run: | + set -euo pipefail + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + + - name: Deploy ${{ matrix.service }} + run: | + set -euo pipefail + docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" + + - name: Remove npm credentials from workspace + if: always() + run: rm -f .npmrc .npmrc_temp diff --git a/.gitignore b/.gitignore index f4b98ebb1..e868b68bf 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ coverage/ .DS_Store .idea/ .vscode/ +.npmrc +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat # emacs cache files *~ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 000000000..00ca421d1 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,187 @@ +# Deployment Runbook + +This document explains how deployments work for the EDR platform using Docker, GitHub Actions, and self-hosted runners. + +## Overview + +- Monorepo contains 6 deployable services: + - `freight-api` + - `freight-portal` + - `freight-backoffice` + - `passenger-api` + - `passenger-portal` + - `passenger-backoffice` +- Deployments run through one workflow: `.github/workflows/deploy.yml` +- Each service is built/deployed independently in parallel (matrix jobs). +- Docker Compose project names are branch-aware to avoid environment collisions on the same host. + +## Prerequisites + +- Docker Engine with Compose plugin on the self-hosted runner. +- GitHub self-hosted runner registered for this repository. +- Repository secret configured: + - `NPM_TOKEN` (for private `@tria-plc/*` package install during Docker build) +- Server-side env files created for each branch/environment. + +## Server Environment Files + +`sync-env-from-server.sh` reads env files from: + +`/home//environment/edr///` + +Where: + +- `` defaults to `tria` (overridable by `DEPLOY_USER`) +- `` is derived from Git branch (lowercase, non-alphanumeric replaced with `-`) +- `` is `edr-freight` or `edr-passenger` + +### Required files per project + +For `edr-freight`: + +- `freight-api.env` +- `freight-portal.env` +- `freight-backoffice.env` +- optional: `freight-web.build.env` + +For `edr-passenger`: + +- `passenger-api.env` +- `passenger-portal.env` +- `passenger-backoffice.env` +- optional: `passenger-web.build.env` + +### Required env key + +Each service env file must contain: + +- `PORT=` + +The sync script validates this and fails if missing. + +### Build env files (optional) + +Used for build-time variables (example: Vite API URLs), with `export` syntax: + +```bash +export FREIGHT_VITE_API_URL=https://freight-api.example.com/api +export PASSENGER_VITE_API_URL=https://passenger-api.example.com +``` + +These are injected into `GITHUB_ENV` during workflow execution. + +## Docker Compose Port Mapping + +`docker-compose.yaml` uses per-service env variables for host/container port mappings: + +- `FREIGHT_API_PORT` +- `PASSENGER_API_PORT` +- `FREIGHT_PORTAL_PORT` +- `FREIGHT_BACKOFFICE_PORT` +- `PASSENGER_PORTAL_PORT` +- `PASSENGER_BACKOFFICE_PORT` + +`scripts/deploy/sync-env-from-server.sh` extracts `PORT` from each synced `.env` and exports the corresponding `*_PORT` variable to `GITHUB_ENV`. + +## GitHub Actions Deployment Flow + +Workflow file: `.github/workflows/deploy.yml` + +### 1) `prepare` job + +- Checks out repository once. +- Creates workspace artifact (`workspace.tgz`) and uploads it. + +### 2) `deploy` matrix job (parallel) + +For each service: + +- Downloads and extracts workspace artifact. +- Syncs that service env file from server path. +- Computes branch slug and sets: + - `COMPOSE_PROJECT_NAME=-` +- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`. +- Runs: + - `docker compose --project-name "$COMPOSE_PROJECT_NAME" build ` + - `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d ` +- Cleans `.npmrc`/`.npmrc_temp`. + +## Branch/Environment Isolation + +Compose project name is generated as: + +`-` + +Examples: + +- `edr-freight-main` +- `edr-freight-staging` +- `edr-passenger-dev` + +This prevents container/network/volume name collisions between branches. + +## Local Manual Deployment (Optional) + +From repo root: + +```bash +DOCKER_BUILDKIT=1 docker compose build +docker compose up -d +``` + +If private packages are required locally, create `.npmrc`: + +```bash +cat < .npmrc +@tria-plc:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken= +always-auth=true +EOF +``` + +## Passenger API Startup Behavior + +Passenger container entrypoint runs on startup: + +1. `npm run prisma:generate` +2. `npm run prisma:migrate` (deploy mode) +3. `npm run prisma:seed` +4. starts API process + +## Troubleshooting + +### Missing env file + +Error: + +- `Missing env file: ...` + +Fix: + +- Create the required file in the server env directory for that project/branch slug. + +### Missing PORT in env file + +Error: + +- `Missing required PORT in env file: ...` + +Fix: + +- Add `PORT=` to that service env file. + +### Private package install fails + +Check: + +- `NPM_TOKEN` exists in repo secrets. +- Workflow created `.npmrc` successfully. + +### Prisma seed/migrate failures (passenger) + +Check: + +- `DATABASE_URL` in `passenger-api.env` +- DB reachability from runner host/container network +- migration history consistency + diff --git a/README.md b/README.md index c974be546..8aba7ec6d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,98 @@ +# EDR Platform - Ethio-Djibouti Railway Passenger API + +Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with TypeScript, PostgreSQL, and Prisma ORM. + +## ๐Ÿš€ Features + +### ๐Ÿ†• NEW: Age-Based Pricing, Verifayda 2.0 & Multi-Currency + +#### Age-Based Pricing +- **ADULT** (โ‰ฅ5 years): Pay 100% of base fare +- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100% +- Automatic age calculation from date of birth +- Example: 2 adults + 3 children = 4ร— base fare (first child free) + +#### Verifayda 2.0 Integration +- Real-time Ethiopian national ID verification +- Retrieves passenger data from government database +- National IDs NOT stored (policy compliant) +- Non-Ethiopians use passport (no verification required) +- Booking fails if verification unsuccessful + +#### Multi-Currency Support +- **Transaction Currency**: ETB (Ethiopian Birr) +- **Display Currencies**: ETB, DJF (Djiboutian Franc), USD (US Dollar) +- Real-time exchange rate conversion +- Prices shown in user's preferred currency +- Exchange rates: ETBโ†’DJF=3.25, ETBโ†’USD=0.018 + +### Core Modules +- **Authentication & Authorization** - Dual authentication system: + - **Passenger Auth**: JWT-based auth with OTP verification, password reset, account lockout + - **Corporate IAM**: Integration with @tria-plc corporate identity system for back-office operations (agents, supervisors, admins) + - Role-based access control (RBAC) with granular permissions +- **Age-Based Pricing** - Smart passenger categorization: + - **ADULT** (โ‰ฅ5 years): Full fare + - **CHILD** (<5 years): First child free, subsequent children full fare + - Automatic age calculation from date of birth +- **Verifayda 2.0 Integration** - Ethiopian national ID verification: + - Real-time verification via government API + - Retrieves passenger data (name, DOB, nationality) + - National IDs NOT stored (policy compliant) + - Non-Ethiopians use passport (no verification) +- **Multi-Currency Support** - Display prices in multiple currencies: + - **ETB** (Ethiopian Birr) - Transaction currency + - **DJF** (Djiboutian Franc) - Display option + - **USD** (US Dollar) - Display option + - Real-time exchange rate conversion +- **Booking Management** - Complete booking lifecycle: + - **Guest Booking**: Book without login, optional account creation + - **Saved Passengers**: Store passenger details for quick rebooking + - Modification, cancellation, refunds, and fare breakdown + - Multi-segment journey support +- **Payment Integration** - Multi-provider support (Telebirr, CBE Birr, eBirr, Card, Wallet) with webhook handling +- **Seat Management** - Real-time seat inventory: + - Seat holds with 5-minute expiry + - Seat releases and blocking with coach/class management + - Segment-based seat availability (partial journey bookings) + - Auto-assign seats with contiguous algorithm + - CSV import/export for seat configurations +- **Ticketing** - QR code and barcode generation, PDF tickets, gate validation with audit logs +- **Agent Operations** - Counter booking, shift management, commission tracking, and reconciliation +- **Passenger Services** - Profile management, traveler profiles, saved routes, and preferences +- **Loyalty Program** - Points accumulation, tier management (Bronze/Silver/Gold/Platinum), and rewards +- **Wallet System** - Balance management, top-up, transaction ledger +- **Live Tracking** - Real-time trip status, location updates, delay notifications, crowd signals +- **Notifications** - Multi-channel (Email, SMS, Push) with templating engine +- **Support System** - FAQ management, live chat conversations +- **Reports & Analytics** - Revenue reports, occupancy analytics, agent sales tracking +- **Route Management** - Route configuration, stops, fare rules, baggage allowance + +### Technical Features +- **Security** - Password hashing (bcrypt), JWT tokens, rate limiting, audit logging +- **Validation** - Request validation with class-validator, DTO transformation +- **Documentation** - Auto-generated Swagger/OpenAPI docs at `/api-docs` +- **Error Handling** - Global exception filters with standardized error responses +- **Database** - PostgreSQL with Prisma ORM, migrations, and comprehensive seeding +- **Scheduling** - Cron jobs for automated tasks (seat release, report generation) +- **Event System** - Event-driven architecture with @nestjs/event-emitter + +## ๐Ÿ“‹ Prerequisites + +- **Node.js** >= 20.x +- **pnpm** >= 9.x (`npm install -g pnpm`) +- **PostgreSQL** >= 15.x +- **Git** + +## ๐Ÿ› ๏ธ Installation & Setup + +### 1. Clone Repository +```bash +git clone +cd edr-platform +``` + +### 2. Install Dependencies # EDR Platform Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines โ€” **Freight Management** and **Passenger Management** โ€” each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library. @@ -185,6 +280,676 @@ Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamap pnpm install ``` +<<<<<<< HEAD +### 3. Environment Configuration +```bash +# Copy environment template +cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env + +# Edit .env file with your configuration +``` + +#### Required Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `NODE_ENV` | Environment mode | `development` | +| `PORT` | HTTP server port | `4000` | +| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/edr_passenger` | +| `JWT_SECRET` | JWT signing secret (change in production) | `your-secret-key` | +| `JWT_EXPIRES_IN` | JWT token expiry | `7d` | +| `PORTAL_URL` | Web app CORS origin | `http://localhost:3000` | +| `BACK_OFFICE_URL` | Admin portal CORS origin | `http://localhost:3001` | +| `SENDGRID_API_KEY` | SendGrid API key (optional) | `SG.xxx` | +| `SENDGRID_FROM_EMAIL` | Email sender address | `noreply@edr-platform.com` | + +#### Verifayda 2.0 Configuration (Ethiopian National ID Verification) + +| Variable | Description | Example | +|----------|-------------|---------| +| `VERIFAYDA_ENABLED` | Enable Verifayda integration | `true` or `false` | +| `VERIFAYDA_API_URL` | Verifayda API endpoint | `https://api.verifayda.gov.et/v2` | +| `VERIFAYDA_API_KEY` | API key for Verifayda service | `your-verifayda-api-key` | + +**Note:** When `VERIFAYDA_ENABLED=false`, verification is skipped (development mode only). + +#### Corporate IAM Configuration (Back-office Authentication) + +| Variable | Description | Example | +|----------|-------------|---------| +| `IAM_ENABLED` | Enable corporate IAM integration | `true` or `false` | +| `IAM_API_URL` | Corporate IAM API endpoint | `https://iam.tria-plc.com/api` | +| `IAM_API_KEY` | API key for IAM service | `your-iam-api-key` | + +**Note:** When `IAM_ENABLED=false`, IAM-protected routes allow access without validation (development mode only). + +#### Optional: Payment Provider Configuration +```bash +# Telebirr Configuration +TELEBIRR_BASE_URL=https://api.telebirr.com +TELEBIRR_MERCHANT_CODE=your-merchant-code +TELEBIRR_APP_SECRET=your-app-secret +# ... see .env.example for complete list +``` + +### 4. Database Setup + +#### Start PostgreSQL +```bash +# Using Docker (recommended) +docker run --name edr-postgres \ + -e POSTGRES_USER=edr \ + -e POSTGRES_PASSWORD=edr_secret \ + -e POSTGRES_DB=edr_passenger \ + -p 5432:5432 \ + -d postgres:15 + +# Or use your local PostgreSQL installation +``` + +#### Generate Prisma Client +```bash +pnpm --filter @edr/passenger-api run prisma:generate +``` + +#### Run Migrations +```bash +pnpm --filter @edr/passenger-api run prisma:migrate:dev +``` + +#### Seed Database +```bash +pnpm --filter @edr/passenger-api run prisma:seed +``` + +**Seed Data Includes:** +- 21 Stations (Complete Ethiopian-Djibouti Railway with country codes) +- 1 Route with 21 stops and fare rules +- 2 Train services with 4 trips +- 360 seats across 12 coaches (Economy Regular, Economy Bed, VIP Bed classes) +- 3 User accounts (Admin, Passenger, Agent) +- Fare rules for ADULT and CHILD passenger categories +- Currency exchange rates (ETB, DJF, USD) +- Baggage allowance rules +- Notification templates +- Promotions and FAQ content +- Menu items and station crowd signals +- Fraud detection rules +- Saved passenger profiles for testing + +### 5. Start Development Server +```bash +pnpm --filter @edr/passenger-api run dev +``` + +**API Server:** http://localhost:4000 +**Swagger Docs:** http://localhost:4000/api-docs + +## ๐Ÿ”‘ Default Credentials + +After seeding, use these credentials to test the API: + +| Role | Email | Password | Description | +|------|-------|----------|-------------| +| **Admin** | `admin@edr-platform.com` | `admin123` | Full system access, reports, agent management | +| **Passenger** | `kelemu@email.com` | `password123` | Regular user with loyalty (Silver) and wallet | +| **Agent** | `agent@edr-platform.com` | `agent123` | Counter booking agent with commission tracking | + +## ๐Ÿ“š API Documentation + +### Swagger UI +Interactive API documentation available at: **http://localhost:4000/api-docs** + +### Authentication Methods + +The API uses two authentication schemes: + +#### 1. JWT Authentication (Passenger-facing) +- **Used for**: Passenger bookings, profile management, wallet, loyalty +- **Header**: `Authorization: Bearer ` +- **Obtain token**: `POST /auth/login` with passenger credentials +- **Swagger Security**: `JWT-auth` + +#### 2. IAM Authentication (Back-office) +- **Used for**: Agent operations, fraud detection, reports, admin functions +- **Header**: `Authorization: Bearer ` +- **Obtain token**: From corporate IAM system (https://iam.tria-plc.com) +- **Swagger Security**: `IAM-auth` +- **Roles**: AGENT, SUPERVISOR, ADMIN, STAFF + +### API Endpoints Overview + +| Module | Base Path | Auth Type | Description | +|--------|-----------|-----------|-------------| +| **Auth** | `/auth` | Public/JWT | Register, login, OTP verification, password reset | +| **Passengers** | `/passengers` | Public/JWT | Verifayda verification, international registration, profiles | +| **Search** | `/search` | Public | Trip search, availability, fare quotes | +| **Stations** | `/stations` | Public/JWT | Station directory and information | +| **Seats** | `/seats` | JWT/IAM | Seat maps, holds, releases, blocking | +| **Bookings** | `/bookings` | Public/JWT | Guest booking, create, modify, cancel bookings | +| **Payments** | `/payments` | JWT/Public | Payment initiation, webhooks, refunds | +| **Tickets** | `/tickets` | JWT/IAM | Ticket generation, QR/barcode, validation | +| **Notifications** | `/notifications` | JWT | In-app notifications, preferences | +| **Loyalty** | `/loyalty` | JWT | Points, tiers, rewards redemption | +| **Wallet** | `/wallet` | JWT | Balance, top-up, transaction history | +| **Promotions** | `/promos` | Public/JWT | Active promotions, promo code validation | +| **Live Tracking** | `/live` | Public/JWT | Real-time trip status, crowd signals | +| **Support** | `/support` | Public/JWT | FAQ, chat conversations | +| **Dashboard** | `/dashboard` | JWT | Home screen aggregated data | +| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops | +| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates | +| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations | +| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration | +| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking | +| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation | +| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking | +| **Reports** | `/reports` | IAM | Revenue, occupancy, agent sales analytics | + +### Example API Calls + +#### 1. Register Passenger +```bash +POST /auth/register +Content-Type: application/json + +{ + "email": "user@example.com", + "phone": "+251911234567", + "fullName": "John Doe", + "password": "SecurePass123" +} +``` + +#### 2. Login (Passenger) +```bash +POST /auth/login +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "SecurePass123" +} + +# Response includes JWT token +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { "id": "uuid", "role": "PASSENGER" } +} +``` + +#### 3. Verify Ethiopian National ID (Verifayda) +```bash +POST /passengers/verify-fayda +Content-Type: application/json + +{ + "nationalId": "ET123456789" +} + +# Response with verified passenger data +{ + "verified": true, + "passengerData": { + "fullName": "Abebe Kebede", + "dateOfBirth": "1985-03-15T00:00:00.000Z", + "gender": "Male", + "nationality": "Ethiopian" + } +} +``` + +#### 4. Universal Passenger Registration (NEW) +```bash +# Guest Ethiopian with Fayda verification +POST /passengers/register +Content-Type: application/json + +{ + "passengerName": "Abebe Kebede", + "dateOfBirth": "1985-03-15", + "nationalId": "ET123456789", + "phone": "+251911234567", + "deviceId": "device-uuid-123" +} + +# Logged-in user with JWT token +POST /passengers/register +Authorization: Bearer +Content-Type: application/json + +{ + "passengerName": "Abebe Kebede", + "dateOfBirth": "1985-03-15", + "nationalId": "ET123456789", + "phone": "+251911234567" +} + +# International passenger (passport) +POST /passengers/register +Content-Type: application/json + +{ + "passengerName": "John Smith", + "dateOfBirth": "1990-07-20", + "passportNumber": "P1234567", + "passportCountry": "Kenya", + "nationality": "Kenyan", + "phone": "+254712345678", + "email": "john@example.com", + "deviceId": "device-uuid-123" +} +``` + +#### 5. Get User Profile (NEW) +```bash +GET /auth/profile +Authorization: Bearer + +# Response includes user, passenger, loyalty, and wallet details +{ + "id": "uuid", + "email": "user@example.com", + "phone": "+251911234567", + "fullName": "John Doe", + "role": "PASSENGER", + "nationality": "Ethiopian", + "faydaVerified": true, + "faydaVerifiedAt": "2024-01-15T10:30:00.000Z", + "passenger": { + "id": "uuid", + "loyalty": { + "tier": "SILVER", + "pointsBalance": 1500, + "lifetimePoints": 3000 + }, + "wallet": { + "balanceMinor": 50000, + "currency": "ETB" + } + } +} +``` + +#### 6. Search Trips +```bash +POST /search +Content-Type: application/json + +{ + "originStationId": "uuid", + "destinationStationId": "uuid", + "date": "2026-06-15", + "adultCount": 2, + "childCount": 1 +} +``` + +#### 7. Get Fare Quote +```bash +POST /search/fare-quote +Content-Type: application/json + +{ + "tripId": "uuid", + "serviceClass": "ECONOMY_REGULAR", + "adultCount": 2, + "childCount": 1, + "displayCurrency": "USD" +} + +# Response includes age-based pricing breakdown +{ + "baseFareMinor": 35000, + "adultCount": 2, + "adultFareMinor": 70000, + "childCount": 1, + "freeChildrenCount": 1, + "paidChildrenCount": 0, + "childFareMinor": 0, + "totalMinor": 73500, + "currency": "ETB", + "displayCurrency": "USD", + "displayTotalMinor": 1323 +} +``` + +#### 8. Guest Booking (No Login Required) +```bash +POST /bookings/guest +Content-Type: application/json + +{ + "tripId": "uuid", + "holdId": "uuid", + "serviceClass": "ECONOMY_REGULAR", + "displayCurrency": "ETB", + "passengers": [ + { + "seatId": "uuid", + "passengerName": "Abebe Kebede", + "dateOfBirth": "1985-03-15", + "idDocumentType": "NATIONAL_ID", + "idDocumentNumber": "ET123456789" + } + ], + "createAccount": false, + "savePassengerDetails": true, + "deviceId": "device-uuid" +} +``` + +#### 9. Agent Booking (IAM Auth) +```bash +POST /agents/bookings +Authorization: Bearer +Content-Type: application/json + +{ + "tripId": "uuid", + "seats": [...], + "paymentMethod": "CASH", + "cashReceived": 50000 +} +``` + +## ๐Ÿ—๏ธ Project Structure + +``` +apps/edr-passenger-api/ +โ”œโ”€โ”€ prisma/ +โ”‚ โ”œโ”€โ”€ schema.prisma # Database schema (40+ models) +โ”‚ โ”œโ”€โ”€ seed.ts # Comprehensive seed script +โ”‚ โ””โ”€โ”€ migrations/ # Database migrations +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ common/ # Shared utilities +โ”‚ โ”‚ โ”œโ”€โ”€ filters/ # Exception filters +โ”‚ โ”‚ โ”œโ”€โ”€ interceptors/ # Response interceptors +โ”‚ โ”‚ โ”œโ”€โ”€ pipes/ # Validation pipes +โ”‚ โ”‚ โ”œโ”€โ”€ i18n/ # Internationalization +โ”‚ โ”‚ โ”œโ”€โ”€ jwt.guard.ts # JWT authentication guard (passengers) +โ”‚ โ”‚ โ”œโ”€โ”€ jwt.strategy.ts # Passport JWT strategy +โ”‚ โ”‚ โ”œโ”€โ”€ iam-adapter.ts # Corporate IAM guard (back-office) +โ”‚ โ”‚ โ”œโ”€โ”€ iam.module.ts # IAM module +โ”‚ โ”‚ โ”œโ”€โ”€ roles.guard.ts # RBAC authorization guard +โ”‚ โ”‚ โ”œโ”€โ”€ roles.decorator.ts # Roles decorator +โ”‚ โ”‚ โ”œโ”€โ”€ prisma.service.ts # Prisma client service +โ”‚ โ”‚ โ””โ”€โ”€ prisma.module.ts # Prisma module +โ”‚ โ”œโ”€โ”€ config/ # Configuration files +โ”‚ โ”‚ โ”œโ”€โ”€ app.config.ts # App configuration +โ”‚ โ”‚ โ”œโ”€โ”€ database.config.ts # Database configuration +โ”‚ โ”‚ โ””โ”€โ”€ telebirr.config.ts # Payment provider config +โ”‚ โ”œโ”€โ”€ modules/ # Feature modules +โ”‚ โ”‚ โ”œโ”€โ”€ auth/ # Authentication & authorization (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ agents/ # Agent operations (IAM-protected) +โ”‚ โ”‚ โ”œโ”€โ”€ bookings/ # Booking management (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ currency/ # Currency conversion service +โ”‚ โ”‚ โ”œโ”€โ”€ dashboard/ # Dashboard aggregations (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ fleet/ # Train fleet management (JWT/IAM) +โ”‚ โ”‚ โ”œโ”€โ”€ fraud/ # Fraud detection (IAM-protected) +โ”‚ โ”‚ โ”œโ”€โ”€ live/ # Live tracking (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ loyalty/ # Loyalty program (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ notifications/ # Notification system (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ passengers/ # Passenger management (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ payments/ # Payment processing (JWT/Webhooks) +โ”‚ โ”‚ โ”œโ”€โ”€ promos/ # Promotions (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ reports/ # Reports & analytics (IAM-protected) +โ”‚ โ”‚ โ”œโ”€โ”€ schedules/ # Trip schedules (JWT/IAM) +โ”‚ โ”‚ โ”œโ”€โ”€ search/ # Trip search (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ seats/ # Seat management (JWT/IAM) +โ”‚ โ”‚ โ”œโ”€โ”€ segments/ # Journey segments (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ stations/ # Station management (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ support/ # Customer support (JWT) +โ”‚ โ”‚ โ”œโ”€โ”€ tickets/ # Ticketing (JWT/IAM) +โ”‚ โ”‚ โ”œโ”€โ”€ verifayda/ # Verifayda 2.0 integration +โ”‚ โ”‚ โ””โ”€โ”€ wallet/ # Wallet system (JWT) +โ”‚ โ”œโ”€โ”€ app.module.ts # Root application module +โ”‚ โ””โ”€โ”€ main.ts # Application entry point +โ”œโ”€โ”€ test/ # E2E tests +โ”œโ”€โ”€ .env.example # Environment template +โ”œโ”€โ”€ Dockerfile # Docker configuration +โ”œโ”€โ”€ nest-cli.json # NestJS CLI configuration +โ”œโ”€โ”€ package.json # Dependencies & scripts +โ”œโ”€โ”€ tsconfig.json # TypeScript configuration +โ””โ”€โ”€ tsconfig.build.json # Build configuration +``` + +## ๐Ÿ—„๏ธ Database Schema + +### Key Models (40+ total) + +**Core Entities:** +- `User`, `Session`, `Passenger`, `Agent` +- `Station`, `Route`, `RouteStop`, `RouteFareRule` +- `TrainService`, `Trip`, `TripStopTime`, `Coach`, `Seat` +- `Booking`, `BookingSeat`, `Ticket` +- `PaymentIntent`, `PaymentRefund`, `PaymentWebhookEvent` + +**Enhanced Features:** +- `OtpCode`, `PasswordResetToken` (Auth) +- `AgentBooking`, `AgentShift`, `AgentCommission` (Agents) +- `BookingModification`, `BookingCancellation` (Booking lifecycle) +- `GateValidationLog` (Ticket validation) +- `BaggageAllowance`, `BaggageBooking` (Baggage) +- `LoyaltyAccount`, `LoyaltyLedgerEntry`, `LoyaltyReward` +- `WalletAccount`, `WalletLedgerEntry` +- `Notification`, `NotificationTemplate` +- `AuditLog`, `OperationalReport` +- `SeatBlock`, `SeatHold` +- `CurrencyExchangeRate` (Multi-currency) +- `VerifaydaVerification` (National ID verification) +- `SavedPassengerProfile` (Guest booking) +- `SeatClass` (Seat class configuration) +- `JourneySegment` (Multi-segment journeys) + +## ๐Ÿ”ง Available Scripts + +```bash +# Development +pnpm --filter @edr/passenger-api run dev # Start with hot-reload + +# Build +pnpm --filter @edr/passenger-api run build # Compile TypeScript + +# Production +pnpm --filter @edr/passenger-api run start # Run compiled code + +# Testing +pnpm --filter @edr/passenger-api run test # Unit tests +pnpm --filter @edr/passenger-api run test:e2e # E2E tests + +# Code Quality +pnpm --filter @edr/passenger-api run lint # ESLint +pnpm --filter @edr/passenger-api run type-check # TypeScript check + +# Database +pnpm --filter @edr/passenger-api run prisma:generate # Generate Prisma client +pnpm --filter @edr/passenger-api run prisma:migrate:dev # Run migrations (local dev) +pnpm --filter @edr/passenger-api run prisma:seed # Seed database +``` + +## ๐Ÿณ Docker Deployment + +All six apps build from Dockerfiles: each API has its own (`apps/edr-freight-api/Dockerfile`, `apps/edr-passenger-api/Dockerfile`); Vite frontends share `infrastructure/docker/Dockerfile.web` and are served with **nginx**. APIs run on **Node 22**. + +**Prerequisites** + +- Docker with BuildKit enabled +- A local [`.npmrc`](.gitignore) with GitHub Packages auth for `@tria-plc/*` (required for **freight** API and web images) +- External Postgres for each API (compose does **not** include databases) +- Copy `apps/edr-freight-api/.env.example` โ†’ `.env` and `apps/edr-passenger-api/.env.example` โ†’ `.env` with real connection strings + +### Build and run (all apps) + +```bash +# From monorepo root +DOCKER_BUILDKIT=1 pnpm docker:build +pnpm docker:up +``` + +Or without pnpm scripts: + +```bash +DOCKER_BUILDKIT=1 docker compose build +docker compose up -d +``` + +| Service | URL (default) | +|---------|----------------| +| Freight API | http://localhost:3001 | +| Passenger API | http://localhost:4000 | +| Freight portal | http://localhost:5173 | +| Freight backoffice | http://localhost:5183 | +| Passenger portal | http://localhost:5174 | +| Passenger backoffice | http://localhost:5184 | + +### Build a single service + +```bash +docker compose build freight-api +docker compose build passenger-portal +``` + +Freight images mount `.npmrc` as a BuildKit secret during `pnpm install`. Passenger web images do not require private packages. + +### `VITE_API_URL` (frontends) + +API URLs are **baked in at image build time** (`import.meta.env.VITE_API_URL`). Defaults in [`docker-compose.yaml`](docker-compose.yaml) use `http://localhost:3001/api` (freight) and `http://localhost:4000` (passenger) for local smoke tests. Override build args for production, e.g.: + +```bash +docker compose build freight-portal \ + --build-arg VITE_API_URL=https://freight-api.example.com/api +``` + +### Migrations + +- **Freight API:** TypeORM migrations are not run on container startup โ€” apply them separately before deploy. +- **Passenger API:** On each container start, the entrypoint runs `npm run prisma:migrate` and `npm run prisma:seed` (same `package.json` scripts as `pnpm run`) before starting the server. Ensure `DATABASE_URL` in `.env` points at a reachable Postgres instance. + +For local development, use `pnpm --filter @edr/passenger-api run prisma:migrate:dev` instead of `prisma:migrate`. + +### GitHub Actions (self-hosted runner) + +Two workflows deploy independently on push to `main`, `develop`, or `staging`: + +| Workflow | Services | Server env root | +|----------|----------|-----------------| +| [`.github/workflows/deploy-freight.yml`](.github/workflows/deploy-freight.yml) | freight-api, freight-portal, freight-backoffice | `/home/user/environmen/edr-freight//` | +| [`.github/workflows/deploy-passenger.yml`](.github/workflows/deploy-passenger.yml) | passenger-api, passenger-portal, passenger-backoffice | `/home/user/environmen/edr-passenger//` | + +**On the runner**, place env files before the first deploy (example for branch `main`): + +```text +/home/user/environmen/edr-freight/main/ + freight-api.env + freight-portal.env # optional runtime env for Vite/nginx + freight-backoffice.env + freight-web.build.env # exports FREIGHT_VITE_API_URL=... + +/home/user/environmen/edr-passenger/main/ + passenger-api.env + passenger-portal.env + passenger-backoffice.env + passenger-web.build.env # exports PASSENGER_VITE_API_URL=... +``` + +Example `freight-web.build.env`: + +```bash +export FREIGHT_VITE_API_URL=https://freight-api.example.com/api +``` + +The workflow copies `*.env` into each app directory, creates `.npmrc` from the `NPM_TOKEN` repository secret, then runs `docker compose build` and `docker compose up -d` for that stack. + +## ๐Ÿ”’ Security Best Practices + +1. **Environment Variables** - Never commit `.env` files. Use secrets management in production. +2. **JWT Secret** - Use strong, randomly generated secrets (min 32 characters). +3. **Password Hashing** - Bcrypt with salt rounds (default: 10). +4. **Rate Limiting** - Implement rate limiting for auth endpoints. +5. **CORS** - Configure allowed origins in production. +6. **HTTPS** - Always use HTTPS in production. +7. **Database** - Use connection pooling and prepared statements (Prisma handles this). +8. **Audit Logging** - All sensitive operations are logged in `AuditLog` table. +9. **Dual Authentication** - Passenger routes use JWT, back-office routes use corporate IAM. +10. **IAM Integration** - Corporate IAM validates tokens against centralized identity service. +11. **Role-Based Access** - Granular permissions enforced via IAM roles (AGENT, SUPERVISOR, ADMIN). +12. **Token Validation** - IAM tokens validated in real-time with 5-second timeout. + +## ๐Ÿ“Š Monitoring & Logging + +- **Application Logs** - NestJS built-in logger +- **Database Queries** - Prisma query logging (enable in development) +- **Audit Trail** - All user actions logged in `AuditLog` table +- **Error Tracking** - Global exception filters with detailed error responses + +## ๐Ÿงช Testing + +```bash +# Unit tests +pnpm --filter @edr/passenger-api run test + +# E2E tests +pnpm --filter @edr/passenger-api run test:e2e + +# Test coverage +pnpm --filter @edr/passenger-api run test:cov +``` + +## ๐Ÿš€ Production Deployment + +### Pre-deployment Checklist +- [ ] Update environment variables (JWT_SECRET, DATABASE_URL, etc.) +- [ ] Configure IAM integration (IAM_ENABLED=true, IAM_API_URL, IAM_API_KEY) +- [ ] Configure Verifayda integration (VERIFAYDA_ENABLED=true, VERIFAYDA_API_KEY) +- [ ] Set up currency exchange rate sync (external API) +- [ ] Set NODE_ENV=production +- [ ] Configure CORS origins (PORTAL_URL, BACK_OFFICE_URL) +- [ ] Set up SSL/TLS certificates +- [ ] Configure database connection pooling +- [ ] Set up monitoring and logging +- [ ] Configure backup strategy +- [ ] Test payment provider integrations +- [ ] Verify IAM token validation endpoint +- [ ] Test Verifayda verification with real national IDs +- [ ] Verify currency conversion accuracy +- [ ] Test age-based pricing calculations +- [ ] Review security settings and audit logs +- [ ] Test both JWT and IAM authentication flows + +### Deployment Steps +```bash +# 1. Build application +pnpm --filter @edr/passenger-api run build + +# 2. Run migrations +pnpm --filter @edr/passenger-api run prisma:migrate + +# 3. Start production server +NODE_ENV=production pnpm --filter @edr/passenger-api run start:prod +``` + +## ๐Ÿค Contributing + +1. Fork the repository +2. Create feature branch (`git checkout -b feature/amazing-feature`) +3. Commit changes (`git commit -m 'Add amazing feature'`) +4. Push to branch (`git push origin feature/amazing-feature`) +5. Open Pull Request + +## ๐Ÿ“ License + +This project is proprietary and confidential. + +## ๐Ÿ“ง Support + +For technical support or questions: +- Email: support@edr-platform.com +- Documentation: http://localhost:4000/api-docs + +--- + +**Built with โค๏ธ for Ethio-Djibouti Railway** +======= ### Start local databases ```bash @@ -257,3 +1022,4 @@ pnpm dev:passenger # passenger API + portal + backoffice - **One DB per domain** โ€” no cross-database joins. See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions. +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 90aa517ff..73e122a1d 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,14 +1,24 @@ -# App -NODE_ENV=development +# Copy to .env for local/docker compose (not committed). PORT=3001 - -# Database DB_HOST=localhost DB_PORT=5433 -DB_NAME=edr_freight DB_USER=postgres DB_PASSWORD= +DB_NAME=edr_freight +# Telebirr payment gateway (freight merchant credentials) +TELEBIRR_BASE_URL= +TELEBIRR_WEB_BASE_URL= +TELEBIRR_FABRIC_APP_ID= +TELEBIRR_APP_SECRET= +TELEBIRR_MERCHANT_APP_ID= +TELEBIRR_MERCHANT_CODE= +TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr +TELEBIRR_RETURN_URL= +TELEBIRR_TIMEOUT_EXPRESS=15m +TELEBIRR_PRIVATE_KEY= +TELEBIRR_PUBLIC_KEY= +TELEBIRR_INSECURE_TLS=false # JWT (used by @tria-plc/api-common SharedAuthModule) JWT_SECRET= JWT_ACCESS_TOKEN_SECRET= diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index 1f4ac2e60..b0850737b 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -1,26 +1,37 @@ -FROM node:20-alpine AS base -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate +# syntax=docker/dockerfile:1 +# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile . + +FROM node:24.15.0-alpine AS base +RUN apk add --no-cache libc6-compat +RUN corepack enable WORKDIR /app -FROM base AS deps -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/ -COPY packages ./packages -RUN pnpm install --frozen-lockfile --filter @edr/freight-api... +FROM base AS pruner +COPY . . +RUN pnpm dlx turbo prune "@edr/freight-api" --docker -FROM deps AS build -COPY apps/edr-freight-api ./apps/edr-freight-api -RUN pnpm --filter @edr/freight-api build +FROM base AS installer +COPY --from=pruner /app/out/json/ . +COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ + pnpm install --frozen-lockfile -FROM node:20-alpine AS runtime -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate -WORKDIR /app/apps/edr-freight-api +FROM base AS builder +COPY --from=installer /app/ . +COPY --from=pruner /app/out/full/ . +RUN pnpm turbo build --filter="@edr/freight-api..." + +FROM base AS deployer +COPY --from=builder /app/ . +RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy + +FROM node:24.15.0-alpine AS runner +RUN apk add --no-cache libc6-compat ENV NODE_ENV=production - -COPY --from=deps /app/node_modules ./../../node_modules -COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules -COPY --from=build /app/apps/edr-freight-api/dist ./dist -COPY --from=build /app/apps/edr-freight-api/package.json ./package.json - +WORKDIR /app +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 --ingroup nodejs nestjs +COPY --from=deployer --chown=nestjs:nodejs /deploy . +USER nestjs EXPOSE 3001 CMD ["node", "dist/main.js"] diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 4eb237a39..c3efffb3d 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -13,16 +13,23 @@ "lint": "eslint src", "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", +<<<<<<< HEAD "seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts", "type-check": "tsc --noEmit" +======= + "type-check": "tsc --noEmit", + "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts" +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db }, "dependencies": { "@edr/api-common": "workspace:*", + "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", + "@nestjs/event-emitter": "^2.0.4", "@nestjs/mapped-types": "^2.1.1", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", @@ -64,7 +71,7 @@ "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typeorm": "^1.0.0", + "typeorm": "^0.3.30", "typescript": "^5.5.4" }, "jest": { diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index eb1f4ae74..a248ca58f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -8,17 +8,19 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth. import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; +import telebirrConfig from "./config/telebirr.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; -//import { TrainsModule } from "./modules/trains/trains.module"; +// import { TrainsModule } from "./modules/trains/trains.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; +import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module"; import { CustomersModule } from "./modules/customers/customers.module"; import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; @@ -48,14 +50,19 @@ 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'; +<<<<<<< HEAD import { WarehousesModule } from './modules/warehouses/warehouses.module'; +======= +import { OverviewModule } from './modules/overview/overview.module'; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig], + load: [appConfig, databaseConfig, telebirrConfig], }), + // EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => @@ -82,6 +89,7 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module'; TrainSetsModule, TrainSchedulesModule, TrainSchedulingModule, + SchedulingRescheduleModule, CustomersModule, CompaniesModule, TrackingModule, @@ -101,7 +109,11 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module'; ContainersModule, CargoesModule, RoutesModule, +<<<<<<< HEAD WarehousesModule, +======= + OverviewModule, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db ], providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], }) diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 2ebae8175..393a97f9f 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -15,3 +15,9 @@ export const BookingStaff = (permission: string | string[]) => ); export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); + +export const TrainSchedulingView = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.view); + +export const TrainSchedulingManage = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.manage); diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index 8fa1ac47d..4f7ec23bb 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -1,7 +1,17 @@ import { registerAs } from "@nestjs/config"; +const numberFromEnv = (key: string, fallback: number): number => { + const value = Number(process.env[key]); + return Number.isFinite(value) && value > 0 ? value : fallback; +}; + export default registerAs("app", () => ({ env: process.env.NODE_ENV ?? "development", port: parseInt(process.env.PORT ?? "3001", 10), apiPrefix: "api", + trainScheduling: { + maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500), + maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), + maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), + }, })); diff --git a/apps/edr-freight-api/src/config/dmoney.config.ts b/apps/edr-freight-api/src/config/dmoney.config.ts new file mode 100644 index 000000000..7922b4aae --- /dev/null +++ b/apps/edr-freight-api/src/config/dmoney.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("dmoney", () => ({ + baseUrl: process.env.DMONEY_BASE_URL ?? "", + appId: process.env.DMONEY_APP_ID ?? "", + appSecret: process.env.DMONEY_APP_SECRET ?? "", + publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", + privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "" +})); diff --git a/apps/edr-freight-api/src/config/telebirr.config.ts b/apps/edr-freight-api/src/config/telebirr.config.ts new file mode 100644 index 000000000..8e5d1712a --- /dev/null +++ b/apps/edr-freight-api/src/config/telebirr.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("telebirr", () => ({ + baseUrl: process.env.TELEBIRR_BASE_URL ?? "", + webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "", + fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "", + appSecret: process.env.TELEBIRR_APP_SECRET ?? "", + merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "", + merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "", + notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "", + returnUrl: process.env.TELEBIRR_RETURN_URL ?? "", + timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m", + privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "", + publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "", + insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true", +})); diff --git a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts index dd961a14b..d5439ff11 100644 --- a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts @@ -61,7 +61,10 @@ export class ContractPricingScheduleBuilder { booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'โ€”', containerLines: (booking.bookingContainers ?? []).map((c) => ({ label: - c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId, + c.containerType?.label ?? + c.containerType?.code ?? + c.containerTypeId ?? + 'โ€”', quantity: c.quantity, vgmPerUnitTons: Number(c.vgmPerUnitTons), })), diff --git a/apps/edr-freight-api/src/migrations/1750400000000-AddSchedulingAllocationEnhancements.ts b/apps/edr-freight-api/src/migrations/1750400000000-AddSchedulingAllocationEnhancements.ts new file mode 100644 index 000000000..a0ca64303 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750400000000-AddSchedulingAllocationEnhancements.ts @@ -0,0 +1,321 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSchedulingAllocationEnhancements1750400000000 + implements MigrationInterface +{ + name = 'AddSchedulingAllocationEnhancements1750400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS wagons_required NUMERIC(6,2) NULL, + ADD COLUMN IF NOT EXISTS scheduling_status VARCHAR(30) NOT NULL DEFAULT 'NOT_SCHEDULED', + ADD COLUMN IF NOT EXISTS hold_started_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS hold_expires_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS train_number VARCHAR(20) NULL, + ADD COLUMN IF NOT EXISTS direction VARCHAR(10) NULL, + ADD COLUMN IF NOT EXISTS actual_departure_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS actual_arrival_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS prepared_by_user_id UUID NULL, + ADD COLUMN IF NOT EXISTS checked_by_user_id UUID NULL, + ADD COLUMN IF NOT EXISTS max_wagons INT NOT NULL DEFAULT 53; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL, + ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED'; + `); + + await queryRunner.query(` + ALTER TABLE freight.wagon_booking_allocations + ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL, + ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED', + ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS confirmed_by_user_id UUID NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.wagon_types + ADD COLUMN IF NOT EXISTS equated_length_m NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS tare_weight_tons NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS supports_container BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS max_container_gross_t NUMERIC(10,3) NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS train_set_wagon_id UUID NULL, + ADD COLUMN IF NOT EXISTS current_train_schedule_id UUID NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.containers + ADD COLUMN IF NOT EXISTS booking_id UUID NULL, + ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL, + ADD COLUMN IF NOT EXISTS booking_container_id UUID NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.cargoes + ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL, + ADD COLUMN IF NOT EXISTS booking_id UUID NULL, + ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.cargoes + ALTER COLUMN container_id DROP NOT NULL; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_allocation_container_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_booking_allocation_id UUID NOT NULL, + booking_container_id UUID NULL, + container_id UUID NULL, + container_number VARCHAR(64) NULL, + container_type_id UUID NOT NULL, + position_on_wagon SMALLINT NULL, + seal_number VARCHAR(64) NULL, + chassis_number VARCHAR(64) NULL, + gross_weight_tons NUMERIC(10,3) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_waci_allocation FOREIGN KEY (wagon_booking_allocation_id) + REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE, + CONSTRAINT fk_waci_booking_container FOREIGN KEY (booking_container_id) + REFERENCES freight.booking_container(id) ON DELETE SET NULL, + CONSTRAINT fk_waci_container FOREIGN KEY (container_id) + REFERENCES freight.containers(id) ON DELETE SET NULL, + CONSTRAINT fk_waci_container_type FOREIGN KEY (container_type_id) + REFERENCES freight.container_types(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_allocation_bulk_loads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_booking_allocation_id UUID NOT NULL UNIQUE, + booking_id UUID NOT NULL, + cargo_type_id UUID NULL, + cargo_description TEXT NULL, + pricing_unit VARCHAR(20) NOT NULL DEFAULT 'PER_TON', + quantity NUMERIC(12,3) NOT NULL DEFAULT 0, + weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0, + truck_plate_number VARCHAR(32) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_wabl_allocation FOREIGN KEY (wagon_booking_allocation_id) + REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE, + CONSTRAINT fk_wabl_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id), + CONSTRAINT fk_wabl_cargo_type FOREIGN KEY (cargo_type_id) + REFERENCES freight.cargo_types(id) ON DELETE SET NULL + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_scheduling_status + ON freight.bookings(scheduling_status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_train_number + ON freight.train_schedules(train_number); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon + ON freight.train_set_wagons(physical_wagon_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_train_set_wagon_id + ON freight.wagons(train_set_wagon_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_current_train_schedule_id + ON freight.wagons(current_train_schedule_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_waci_allocation + ON freight.wagon_allocation_container_items(wagon_booking_allocation_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wabl_booking + ON freight.wagon_allocation_bulk_loads(booking_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.train_set_wagons + ADD CONSTRAINT fk_train_set_wagons_physical_wagon + FOREIGN KEY (physical_wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.wagons + ADD CONSTRAINT fk_wagons_train_set_wagon + FOREIGN KEY (train_set_wagon_id) REFERENCES freight.train_set_wagons(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.wagons + ADD CONSTRAINT fk_wagons_current_train_schedule + FOREIGN KEY (current_train_schedule_id) REFERENCES freight.train_schedules(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT fk_containers_booking + FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT fk_containers_wagon_allocation + FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT fk_containers_booking_container + FOREIGN KEY (booking_container_id) REFERENCES freight.booking_container(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.cargoes + ADD CONSTRAINT fk_cargoes_wagon_allocation + FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.cargoes + ADD CONSTRAINT fk_cargoes_booking + FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.3, + tare_weight_tons = 22.4, + supports_container = true, + max_container_gross_t = 30.48 + WHERE code = 'NW5'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.6, + tare_weight_tons = 25.2, + supports_container = false + WHERE code = 'PW2'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.5, + tare_weight_tons = 25.2, + supports_container = false + WHERE code = 'KW2'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.3, + tare_weight_tons = 23.4, + supports_container = false + WHERE code = 'CW3'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.3, + tare_weight_tons = 24.8, + supports_container = false + WHERE code = 'CW4'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_bulk_loads;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_container_items;`); + + await queryRunner.query(` + ALTER TABLE freight.cargoes + ALTER COLUMN container_id SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS wagons_required, + DROP COLUMN IF EXISTS scheduling_status, + DROP COLUMN IF EXISTS hold_started_at, + DROP COLUMN IF EXISTS hold_expires_at, + DROP COLUMN IF EXISTS scheduled_at; + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS train_number, + DROP COLUMN IF EXISTS direction, + DROP COLUMN IF EXISTS actual_departure_at, + DROP COLUMN IF EXISTS actual_arrival_at, + DROP COLUMN IF EXISTS prepared_by_user_id, + DROP COLUMN IF EXISTS checked_by_user_id, + DROP COLUMN IF EXISTS max_wagons; + `); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP COLUMN IF EXISTS physical_wagon_id, + DROP COLUMN IF EXISTS status; + `); + await queryRunner.query(` + ALTER TABLE freight.wagon_booking_allocations + DROP COLUMN IF EXISTS load_type, + DROP COLUMN IF EXISTS status, + DROP COLUMN IF EXISTS confirmed_at, + DROP COLUMN IF EXISTS confirmed_by_user_id; + `); + await queryRunner.query(` + ALTER TABLE freight.wagon_types + DROP COLUMN IF EXISTS equated_length_m, + DROP COLUMN IF EXISTS tare_weight_tons, + DROP COLUMN IF EXISTS supports_container, + DROP COLUMN IF EXISTS max_container_gross_t; + `); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS train_set_wagon_id, + DROP COLUMN IF EXISTS current_train_schedule_id; + `); + await queryRunner.query(` + ALTER TABLE freight.containers + DROP COLUMN IF EXISTS booking_id, + DROP COLUMN IF EXISTS wagon_booking_allocation_id, + DROP COLUMN IF EXISTS booking_container_id; + `); + await queryRunner.query(` + ALTER TABLE freight.cargoes + DROP COLUMN IF EXISTS wagon_booking_allocation_id, + DROP COLUMN IF EXISTS booking_id, + DROP COLUMN IF EXISTS load_type; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750500000000-AddWagonReadiness.ts b/apps/edr-freight-api/src/migrations/1750500000000-AddWagonReadiness.ts new file mode 100644 index 000000000..17cc23f9a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750500000000-AddWagonReadiness.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWagonReadiness1750500000000 implements MigrationInterface { + name = 'AddWagonReadiness1750500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_readiness + ON freight.wagons (readiness) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS readiness + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750600000000-AddGovernmentBookingFields.ts b/apps/edr-freight-api/src/migrations/1750600000000-AddGovernmentBookingFields.ts new file mode 100644 index 000000000..ce833e4ac --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750600000000-AddGovernmentBookingFields.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddGovernmentBookingFields1750600000000 implements MigrationInterface { + name = 'AddGovernmentBookingFields1750600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS is_government BOOLEAN NOT NULL DEFAULT false + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS government_institution VARCHAR(255) NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN company_id DROP NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_is_government + ON freight.bookings (is_government) + WHERE is_government = true AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_is_government`); + await queryRunner.query(` + UPDATE freight.bookings + SET company_id = '00000000-0000-0000-0000-000000000000' + WHERE company_id IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN company_id SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS government_institution + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS is_government + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750700000000-CreateSchedulingEvents.ts b/apps/edr-freight-api/src/migrations/1750700000000-CreateSchedulingEvents.ts new file mode 100644 index 000000000..9f92f70b4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750700000000-CreateSchedulingEvents.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSchedulingEvents1750700000000 implements MigrationInterface { + name = 'CreateSchedulingEvents1750700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.scheduling_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id UUID NOT NULL, + trigger VARCHAR(40) NOT NULL, + actor_user_id UUID NULL, + reason TEXT NULL, + plan_snapshot JSONB NOT NULL DEFAULT '{}', + displaced_booking_ids JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_scheduling_events_train_schedule_id + ON freight.scheduling_events (train_schedule_id) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_scheduling_events_train_schedule_id`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.scheduling_events`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750800000000-FixContainerWagonsPerUnit.ts b/apps/edr-freight-api/src/migrations/1750800000000-FixContainerWagonsPerUnit.ts new file mode 100644 index 000000000..1bb1a9518 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750800000000-FixContainerWagonsPerUnit.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** 20ft = 0.5 wagon slots (2 per wagon); 40ft = 1.0 wagon slot (1 per wagon). */ +export class FixContainerWagonsPerUnit1750800000000 implements MigrationInterface { + name = 'FixContainerWagonsPerUnit1750800000000'; + + public async up(queryRunner: QueryRunner): Promise { + const hasContainerTypes = await queryRunner.hasTable('freight.container_types'); + if (!hasContainerTypes) { + return; + } + + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = 0.50 + WHERE size_ft = 20 OR code LIKE '20%'; + `); + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = 1.00 + WHERE size_ft = 40 OR code LIKE '40%'; + `); + + const hasBookingContainer = await queryRunner.hasTable('freight.booking_container'); + if (!hasBookingContainer) { + return; + } + + await queryRunner.query(` + UPDATE freight.booking_container bc + SET wagons_required = CEILING(bc.quantity * ct.wagons_per_unit) + FROM freight.container_types ct + WHERE ct.id = bc.container_type_id; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + const hasContainerTypes = await queryRunner.hasTable('freight.container_types'); + if (!hasContainerTypes) { + return; + } + + await queryRunner.query(` + UPDATE freight.container_types SET wagons_per_unit = 1.00; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750900000000-AddContainerNumberToBookingContainer.ts b/apps/edr-freight-api/src/migrations/1750900000000-AddContainerNumberToBookingContainer.ts new file mode 100644 index 000000000..eb28da8f3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750900000000-AddContainerNumberToBookingContainer.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddContainerNumberToBookingContainer1750900000000 implements MigrationInterface { + name = "AddContainerNumberToBookingContainer1750900000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container + ALTER COLUMN container_type_id DROP NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + ADD COLUMN container_number varchar(64); + `); + + await queryRunner.query(` + ALTER TABLE freight.wagon_allocation_container_items + ALTER COLUMN container_type_id DROP NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_allocation_container_items + ALTER COLUMN container_type_id SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + DROP COLUMN container_number; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + ALTER COLUMN container_type_id SET NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1751000000000-CreateTrainSchedulingGlobalRules.ts b/apps/edr-freight-api/src/migrations/1751000000000-CreateTrainSchedulingGlobalRules.ts new file mode 100644 index 000000000..ac9741c5a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1751000000000-CreateTrainSchedulingGlobalRules.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateTrainSchedulingGlobalRules1751000000000 implements MigrationInterface { + name = "CreateTrainSchedulingGlobalRules1751000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE freight.train_scheduling_global_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + max_train_length_meters numeric(10, 2) NOT NULL DEFAULT 760, + max_train_weight_tons numeric(10, 3) NOT NULL DEFAULT 3500, + max_wagons_per_train integer NOT NULL DEFAULT 53, + max_20ft_container_weight_tons numeric(8, 3) NOT NULL DEFAULT 30, + max_20ft_pair_weight_diff_tons numeric(8, 3) NOT NULL DEFAULT 10, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ); + `); + + await queryRunner.query(` + INSERT INTO freight.train_scheduling_global_rules ( + max_train_length_meters, + max_train_weight_tons, + max_wagons_per_train, + max_20ft_container_weight_tons, + max_20ft_pair_weight_diff_tons + ) VALUES (760, 3500, 53, 30, 10); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_scheduling_global_rules;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1751000000001-AddDeletedAtToTrainSchedulingGlobalRules.ts b/apps/edr-freight-api/src/migrations/1751000000001-AddDeletedAtToTrainSchedulingGlobalRules.ts new file mode 100644 index 000000000..fd72f6c3b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1751000000001-AddDeletedAtToTrainSchedulingGlobalRules.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddDeletedAtToTrainSchedulingGlobalRules1751000000001 + implements MigrationInterface +{ + name = "AddDeletedAtToTrainSchedulingGlobalRules1751000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS deleted_at; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index c591a1db4..b79c4ef20 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -14,6 +14,11 @@ export function computeNextStep( const { status } = booking; switch (status) { + case 'PRICE_CHANGED_PENDING_CONFIRM': + return { + action: 'CONFIRM_SUBMIT', + description: 'Price has changed since preview; confirm to submit booking', + }; case 'SUBMITTED': return { action: 'ACCEPT_INTAKE', diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts index ecc410320..07ef6a183 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -4,56 +4,44 @@ import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; import { PaymentService } from '../payment/payment.service'; - +import { PaymentStatus } from '../payment/entities/payment.entity'; export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } +const NON_TERMINAL_STATUSES: PaymentStatus[] = [ + "action-required", + "processing", + "success", +]; + @Injectable() export class BookingPaymentService { - constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { } + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly paymentService: PaymentService, + ) { } - async pay( - bookingId: string, - ): Promise<{ redirectUrl: string }> { + async pay(bookingId: string): Promise<{ redirectUrl: string }> { const booking = await this.requireBooking(bookingId); - assertBookingStatus(booking, ['FULLY_EXECUTED']); + assertBookingStatus(booking, ['FULLY_EXECUTED', '']); - // const receipt = this.buildMockReceipt(booking); - - // const updated = await this.bookingsRepository.update(bookingId, { - // status: 'PAID', - // paymentStatus: 'PAID', - // } as never); - const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => { - return new Promise((resp, _) => { - resp({ - id: booking.id, - type: "booking" - }) - }); - }) - - return { - redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : "" + const existing = await this.paymentService.findBookingById(bookingId); + if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { + if (existing.clientAction) { + const action = existing.clientAction as { type?: string; url?: string }; + if (action.type === "REDIRECT" && action.url) { + return { redirectUrl: action.url }; + } + } } + const resp = await this.paymentService.initBookingTelebirr(bookingId, "web"); + + return { + redirectUrl: + resp.redirectUrl ?? "", + }; } - // private buildMockReceipt(booking: Booking): InAppPaymentReceipt { - // const timestamp = Date.now(); - // const isEtb = booking.paymentCurrency === 'ETB'; - // const prefix = isEtb ? 'TB' : 'CARD'; - // const provider = isEtb ? 'TELEBIRR' : 'CARD'; - - // return { - // success: true, - // provider, - // providerRef: `${prefix}-${booking.reference}-${timestamp}`, - // amount: booking.totalAmount, - // currency: booking.paymentCurrency, - // paidAt: new Date().toISOString(), - // }; - // } - private async requireBooking(id: string): Promise { const booking = await this.bookingsRepository.findById(id); if (!booking) throw new NotFoundException(`Booking ${id} not found`); 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 4e47456e0..b0d4121a9 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 @@ -14,6 +14,24 @@ import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +export interface ComputedPriceResult { + lineItems: PriceLineItemDto[]; + totalAmount: number; + currency: string; + usedRates: Rate[]; + appliedModifiers: AppliedCargoModifier[]; + priorityScore: number; + warnings: string[]; + hardBlocked: string[]; +} + +type StoredPricingBreakdown = { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + currency?: string; + generatedAt?: string; +} | null; + @Injectable() export class BookingPricingService { constructor( @@ -26,22 +44,56 @@ export class BookingPricingService { async generatePrice(bookingId: string): Promise { const booking = await this.requireBooking(bookingId); - assertBookingStatus(booking, ['DRAFT']); + assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + const computed = await this.computePriceForBooking(booking); + this.ruleEngineService.assertNoHardBlocks({ + priorityScore: computed.priorityScore, + appliedModifiers: computed.appliedModifiers, + containerWeightResults: [], + warnings: computed.warnings, + hardBlocked: computed.hardBlocked, + requiresDirectorApproval: false, + }); + + await this.bookingsRepository.update(bookingId, { + totalAmount: computed.totalAmount, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + + return { + bookingId, + totalAmount: computed.totalAmount, + currency: computed.currency, + lineItems: computed.lineItems, + warnings: computed.warnings, + }; + } + + async computePriceForBooking(booking: Booking): Promise { const evalInput = await this.buildEvalInputForBooking(booking); - console.log('evalInput----', evalInput); const ruleResult = await this.ruleEngineService.evaluate(evalInput); - this.ruleEngineService.assertNoHardBlocks(ruleResult); const lineItems: PriceLineItemDto[] = []; let total = 0; - const baseLines = await this.computeBaseRailLines(booking, evalInput); + const { lineItems: baseLines, usedRates: baseRates } = + await this.computeBaseRailLinesWithRates(booking, evalInput); for (const line of baseLines) { lineItems.push(line); total += line.amount; } + const liveRates = await this.ratesService.findLiveRates(); + const rateById = new Map(liveRates.map((r) => [r.id, r])); + const usedRatesMap = new Map(baseRates.map((r) => [r.id, r])); + for (const mod of ruleResult.appliedModifiers) { const item: PriceLineItemDto = { code: mod.surchargeTypeCode, @@ -51,44 +103,76 @@ export class BookingPricingService { }; lineItems.push(item); total += mod.calculatedAmount; + + const rate = rateById.get(mod.rateId); + if (rate) usedRatesMap.set(rate.id, rate); } - await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total); - - await this.bookingsRepository.update(bookingId, { - totalAmount: total, - priorityScore: ruleResult.priorityScore, - pricingBreakdown: { - lineItems, - totalAmount: total, - currency: booking.paymentCurrency, - generatedAt: new Date().toISOString(), - }, - } as never); - return { - bookingId, + lineItems, totalAmount: total, currency: booking.paymentCurrency, - lineItems, + usedRates: [...usedRatesMap.values()], + appliedModifiers: ruleResult.appliedModifiers, + priorityScore: ruleResult.priorityScore, warnings: ruleResult.warnings, + hardBlocked: ruleResult.hardBlocked, }; } + pricesMatch(stored: StoredPricingBreakdown, computed: ComputedPriceResult): boolean { + if (!stored?.lineItems?.length) return false; + if (Number(stored.totalAmount) !== computed.totalAmount) return false; + return ( + this.lineItemsSignature(stored.lineItems) === + this.lineItemsSignature(computed.lineItems) + ); + } + + async createPricingSnapshots( + bookingId: string, + usedRates: Rate[], + appliedModifiers: AppliedCargoModifier[], + ): Promise { + await this.bookingsRepository.clearPricingArtifacts(bookingId); + const snapshots = await this.ruleEngineService.snapshotRates(bookingId, usedRates); + + const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id])); + const rows = appliedModifiers + .map((m) => { + const snapshotId = snapshotByRateId.get(m.rateId); + if (!snapshotId) return null; + return { + bookingId, + surchargeTypeId: m.surchargeTypeId, + triggerValue: m.triggerValue, + calculatedAmount: m.calculatedAmount, + rateSnapshotId: snapshotId, + }; + }) + .filter((r): r is NonNullable => r !== null); + + if (rows.length > 0) { + await this.bookingsRepository.createCargoModifiers(rows); + } + } + async buildEvalInputForBooking(booking: Booking): Promise { const containers = await Promise.all( - (booking.bookingContainers ?? []).map(async (bc) => { - const ct = await this.containerTypesService.findById(bc.containerTypeId); - const vgm = Number(bc.vgmPerUnitTons); - const qty = bc.quantity; - return { - containerTypeId: bc.containerTypeId, - quantity: qty, - vgmPerUnitTons: vgm, - totalVgmTons: qty * vgm, - isReefer: ct.isReefer, - }; - }), + (booking.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map(async (bc) => { + const ct = await this.containerTypesService.findById(bc.containerTypeId); + const vgm = Number(bc.vgmPerUnitTons); + const qty = bc.quantity; + return { + containerTypeId: bc.containerTypeId, + quantity: qty, + vgmPerUnitTons: vgm, + totalVgmTons: qty * vgm, + isReefer: ct.isReefer, + }; + }), ); return { freightType: booking.freightType as 'CONTAINER' | 'BULK', @@ -97,6 +181,7 @@ export class BookingPricingService { paymentCurrency: booking.paymentCurrency, tradeDirection: booking.tradeDirection, isHazardous: booking.isHazardous, + isGovernment: booking.isGovernment, allowConsolidation: booking.allowConsolidation, shippingLineId: booking.shippingLineId, containers, @@ -115,11 +200,7 @@ export class BookingPricingService { totalAmount: number; currency: string; }> { - const stored = booking.pricingBreakdown as { - lineItems?: PriceLineItemDto[]; - totalAmount?: number; - currency?: string; - } | null; + const stored = booking.pricingBreakdown as StoredPricingBreakdown; if (stored?.lineItems?.length) { return { @@ -129,41 +210,28 @@ export class BookingPricingService { }; } - const evalInput = await this.buildEvalInputForBooking(booking); - const ruleResult = await this.ruleEngineService.evaluate(evalInput); - const lineItems: PriceLineItemDto[] = []; - let total = 0; + const computed = await this.computePriceForBooking(booking); - const baseLines = await this.computeBaseRailLines(booking, evalInput); - for (const line of baseLines) { - lineItems.push(line); - total += line.amount; - } - - for (const mod of ruleResult.appliedModifiers) { - lineItems.push({ - code: mod.surchargeTypeCode, - description: `Surcharge: ${mod.surchargeTypeCode}`, - amount: mod.calculatedAmount, - currency: mod.currency, - }); - total += mod.calculatedAmount; - } - - if (lineItems.length === 0) { - total = Number(booking.totalAmount); - lineItems.push({ - code: 'TOTAL', - description: 'Contract total', - amount: total, + if (computed.lineItems.length === 0) { + const total = Number(booking.totalAmount); + return { + lineItems: [ + { + code: 'TOTAL', + description: 'Contract total', + amount: total, + currency: booking.paymentCurrency, + }, + ], + totalAmount: total, currency: booking.paymentCurrency, - }); + }; } return { - lineItems, - totalAmount: total || Number(booking.totalAmount), - currency: booking.paymentCurrency, + lineItems: computed.lineItems, + totalAmount: computed.totalAmount || Number(booking.totalAmount), + currency: computed.currency, }; } @@ -190,14 +258,14 @@ export class BookingPricingService { return score; } - private async computeBaseRailLines( + private async computeBaseRailLinesWithRates( booking: Booking, evalInput: BookingEvaluationInput, - ): Promise { + ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { const liveRates = await this.ratesService.findLiveRates(); const currency = booking.paymentCurrency; const isBulk = booking.freightType === 'BULK'; -console.log('liveRates----', liveRates); + const rateType = booking.tradeDirection === 'IMPORT' ? isBulk @@ -209,18 +277,15 @@ console.log('liveRates----', liveRates); : 'CONTAINER_EXPORT' : 'INTERCITY_CONTAINER'; - - console.log('rateType----', rateType); - const lines: PriceLineItemDto[] = []; + const usedRatesMap = new Map(); const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); for (const container of evalInput.containers) { - console.log('container----', container); const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency); - console.log('rate----', rate); if (!rate) continue; + usedRatesMap.set(rate.id, rate); const amount = this.amountForRate(rate, container.quantity, wagonCount); lines.push({ code: rateType, @@ -235,6 +300,7 @@ console.log('liveRates----', liveRates); (r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE', ); if (fallback) { + usedRatesMap.set(fallback.id, fallback); const amount = this.amountForRate(fallback, 1, wagonCount); lines.push({ code: rateType, @@ -245,7 +311,7 @@ console.log('liveRates----', liveRates); } } - return lines; + return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } private pickRate( @@ -281,31 +347,15 @@ console.log('liveRates----', liveRates); } } - private async persistPriceRun( - bookingId: string, - modifiers: AppliedCargoModifier[], - _total: number, - ): Promise { - await this.bookingsRepository.clearPricingArtifacts(bookingId); - const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId); - - const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id])); - const rows = modifiers - .map((m) => { - const snapshotId = snapshotByRateId.get(m.rateId); - if (!snapshotId) return null; - return { - bookingId, - surchargeTypeId: m.surchargeTypeId, - triggerValue: m.triggerValue, - calculatedAmount: m.calculatedAmount, - rateSnapshotId: snapshotId, - }; - }) - .filter((r): r is NonNullable => r !== null); - - if (rows.length > 0) { - await this.bookingsRepository.createCargoModifiers(rows); - } + private lineItemsSignature(items: PriceLineItemDto[]): string { + return JSON.stringify( + [...items] + .map((item) => ({ + code: item.code, + amount: item.amount, + currency: item.currency, + })) + .sort((a, b) => a.code.localeCompare(b.code)), + ); } } 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 0fdfc5084..9974b807a 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 @@ -8,6 +8,8 @@ import { BookingPricingService } from './booking-pricing.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; +import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; +import { PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { BookingsService } from './bookings.service'; @@ -22,7 +24,7 @@ export class BookingTransitionService { private readonly bookingsService: BookingsService, ) {} - async submit(bookingId: string): Promise { + async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); @@ -32,14 +34,119 @@ export class BookingTransitionService { ); } - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); - await this.ruleEngineService.snapshotLiveRates(bookingId); + const computed = await this.pricingService.computePriceForBooking(booking); + this.ruleEngineService.assertNoHardBlocks({ + priorityScore: computed.priorityScore, + appliedModifiers: computed.appliedModifiers, + containerWeightResults: [], + warnings: computed.warnings, + hardBlocked: computed.hardBlocked, + requiresDirectorApproval: false, + }); + const stored = booking.pricingBreakdown as { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + } | null; + const unchanged = this.pricingService.pricesMatch(stored, computed); + const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + + if (unchanged) { + await this.pricingService.createPricingSnapshots( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SUBMITTED', + priorityScore, + } as never); + + const finalBooking = await this.bookingsService.findById(updated!.id); + return { + bookingId: finalBooking.id, + status: finalBooking.status, + priceChanged: false, + totalAmount: Number(finalBooking.totalAmount), + currency: finalBooking.paymentCurrency, + lineItems: computed.lineItems, + }; + } + + const previousTotalAmount = Number(booking.totalAmount); + await this.bookingsRepository.update(bookingId, { + totalAmount: computed.totalAmount, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + status: 'PRICE_CHANGED_PENDING_CONFIRM', + } as never); + + const updatedBooking = await this.bookingsService.findById(bookingId); + return { + bookingId: updatedBooking.id, + status: updatedBooking.status, + priceChanged: true, + previousTotalAmount, + totalAmount: computed.totalAmount, + currency: computed.currency, + lineItems: computed.lineItems, + message: 'Price has changed since preview. Confirm to submit with the updated price.', + }; + } + + async confirmSubmit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']); + + if (Number(booking.totalAmount) <= 0) { + throw new BadRequestException('No price to confirm'); + } + + const computed = await this.pricingService.computePriceForBooking(booking); + this.ruleEngineService.assertNoHardBlocks({ + priorityScore: computed.priorityScore, + appliedModifiers: computed.appliedModifiers, + containerWeightResults: [], + warnings: computed.warnings, + hardBlocked: computed.hardBlocked, + requiresDirectorApproval: false, + }); + + await this.pricingService.createPricingSnapshots( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); const updated = await this.bookingsRepository.update(bookingId, { status: 'SUBMITTED', priorityScore, + totalAmount: computed.totalAmount, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, } as never); - return this.bookingsService.findById(updated!.id); + + const finalBooking = await this.bookingsService.findById(updated!.id); + return { + bookingId: finalBooking.id, + status: finalBooking.status, + priceChanged: false, + totalAmount: Number(finalBooking.totalAmount), + currency: finalBooking.paymentCurrency, + lineItems: computed.lineItems, + message: 'Booking submitted with confirmed price.', + }; } async requestChanges( @@ -279,6 +386,7 @@ export class BookingTransitionService { assertBookingStatus(booking, [ 'DRAFT', 'SUBMITTED', + 'PRICE_CHANGED_PENDING_CONFIRM', 'CHANGES_REQUESTED', 'PENDING_APPROVAL', 'CONTRACT_READY', @@ -321,4 +429,4 @@ export class BookingTransitionService { nextStep, }; } -} +} \ No newline at end of file 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 ccd598577..12d6e0d5a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -39,6 +39,7 @@ import { CreateBookingDto } from './dto/create-booking.dto'; import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; +import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { ApproveStepDto, CancelBookingDto, @@ -53,6 +54,7 @@ import { type AuthUserPayload, resolveAuthUserId, } from '../../common/resolve-auth-user-id'; +import { assertFreightPermission } from '../../common/freight-permission.util'; @ApiTags('bookings') @Controller('bookings') @@ -74,10 +76,12 @@ export class BookingsController { create( @Body() dto: CreateBookingDto, @UploadedFiles() files: Express.Multer.File[], - @Request() req: { user?: { id?: string; sub?: string } }, + @CurrentUser() user: TCurrentUser, ) { - const userId = req.user?.id ?? req.user?.sub; - return this.bookingsService.create(dto, files ?? [], userId); + if (dto.isGovernment) { + assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + } + return this.bookingsService.create(dto, files ?? [], user?.id); } @Patch(':id') @@ -165,17 +169,36 @@ export class BookingsController { } @Post(':id/generate-price') - @ApiOperation({ summary: 'Generate price preview (DRAFT only)' }) + @ApiOperation({ + summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', + description: + 'Computes and stores a price preview on the booking. Does not create rate snapshots.', + }) @ApiOkResponse({ type: GeneratePriceResponseDto }) generatePrice(@Param('id', ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } @Post(':id/submit') - @ApiOperation({ summary: 'Customer submit booking' }) - async submit(@Param('id', ParseUUIDPipe) id: string) { - const booking = await this.transitionService.submit(id); - return this.transitionService.enrichBookingResponse(booking); + @ApiOperation({ + summary: 'Customer submit booking', + description: + 'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', + }) + @ApiOkResponse({ type: SubmitBookingResponseDto }) + submit(@Param('id', ParseUUIDPipe) id: string) { + return this.transitionService.submit(id); + } + + @Post(':id/confirm-submit') + @ApiOperation({ + summary: 'Confirm submit after price change', + description: + 'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', + }) + @ApiOkResponse({ type: SubmitBookingResponseDto }) + confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { + return this.transitionService.confirmSubmit(id); } @Post(':id/staff/request-changes') @@ -224,6 +247,20 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/government-expedite') + @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) + @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) + async governmentExpedite( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.bookingsService.governmentExpedite( + id, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/approval-steps/:stepId/approve') @BookingStaff([ FREIGHT_PERMS.bookings.approveLineStaff, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts new file mode 100644 index 000000000..7d4ff199c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts @@ -0,0 +1,70 @@ +import { DataSource, Repository } from 'typeorm'; + +import { Booking } from './entities/booking.entity'; +import { BookingsRepository } from './bookings.repository'; + +function mockQueryBuilder() { + const qb = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + return qb; +} + +describe('BookingsRepository', () => { + let repository: jest.Mocked>; + let dataSource: { getRepository: jest.Mock }; + let bookingsRepository: BookingsRepository; + + beforeEach(() => { + repository = { + createQueryBuilder: jest.fn(), + } as unknown as jest.Mocked>; + dataSource = { getRepository: jest.fn() }; + bookingsRepository = new BookingsRepository(repository, dataSource as unknown as DataSource); + }); + + it('findEligibleForScheduling does not filter by schedule date', async () => { + const qb = mockQueryBuilder(); + const bookings = [ + { id: 'b1', scheduledDate: new Date('2026-06-20T08:00:00.000Z') }, + { id: 'b2', scheduledDate: new Date('2026-06-21T14:00:00.000Z') }, + ]; + qb.getMany.mockResolvedValue(bookings); + repository.createQueryBuilder.mockReturnValue(qb as never); + + const result = await bookingsRepository.findEligibleForScheduling({ + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + freightType: 'CONTAINER', + }); + + expect(result).toHaveLength(2); + const dateFilters = qb.andWhere.mock.calls.filter(([clause]) => + String(clause).includes('scheduled_date'), + ); + expect(dateFilters).toHaveLength(0); + }); + + it('applyListFilters excludes assigned bookings when assignedToSchedule is false', async () => { + const qb = mockQueryBuilder(); + repository.createQueryBuilder.mockReturnValue(qb as never); + dataSource.getRepository.mockReturnValue({ find: jest.fn().mockResolvedValue([]) }); + + await bookingsRepository.findAllPaginated({ + page: 1, + pageSize: 10, + assignedToSchedule: 'false', + }); + + expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS')); + }); +}); 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 9c17eff3e..46600b148 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,7 +1,8 @@ import { BaseRepository } from '@edr/api-common'; +import { SchedulingStatus } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm'; +import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; @@ -9,6 +10,7 @@ import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingContainer } from './entities/booking-container.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { Booking } from './entities/booking.entity'; import { BookingContractSignature, @@ -20,6 +22,8 @@ import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; export interface BookingListFilterOptions { statuses?: string[]; status?: string; + schedulingStatuses?: string[]; + assignedToSchedule?: 'true' | 'false'; companyId?: string; contractType?: string; serviceTypeId?: string; @@ -345,6 +349,26 @@ export class BookingsRepository extends BaseRepository { await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId }); } + async hasPricingArtifacts(bookingId: string): Promise { + const snapshotCount = await this.dataSource + .getRepository(BookingRateSnapshot) + .count({ where: { bookingId } }); + const modifierCount = await this.dataSource + .getRepository(BookingCargoModifier) + .count({ where: { bookingId } }); + return snapshotCount > 0 || modifierCount > 0; + } + + async invalidatePricingPreview(bookingId: string): Promise { + if (await this.hasPricingArtifacts(bookingId)) { + await this.clearPricingArtifacts(bookingId); + } + await this.update(bookingId, { + totalAmount: 0, + pricingBreakdown: null, + } as never); + } + /** Queue listing with optional bulk exclusion for LINE_STAFF. */ async findQueue(options: { status: string | string[]; @@ -407,17 +431,37 @@ export class BookingsRepository extends BaseRepository { this.applyListFilters(qb, options); - const sortField = - options.sortBy === 'priorityScore' - ? 'booking.priorityScore' - : 'booking.createdAt'; - qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + if (options.sortBy === 'isGovernment') { + qb.orderBy('booking.isGovernment', 'DESC') + .addOrderBy('booking.priorityScore', 'DESC') + .addOrderBy('booking.scheduledDate', 'ASC'); + } else { + const sortField = + options.sortBy === 'priorityScore' + ? 'booking.priorityScore' + : options.sortBy === 'scheduledDate' + ? 'booking.scheduledDate' + : 'booking.createdAt'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + } const [items, total] = await qb .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount(); + if (items.length) { + const links = await this.dataSource.getRepository(TrainScheduleBooking).find({ + where: { bookingId: In(items.map((item) => item.id)) }, + select: { bookingId: true, trainScheduleId: true }, + }); + const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId])); + for (const item of items) { + (item as Booking & { trainScheduleId?: string | null }).trainScheduleId = + scheduleByBooking.get(item.id) ?? null; + } + } + return { items, total }; } @@ -536,6 +580,26 @@ export class BookingsRepository extends BaseRepository { } else if (options.consolidationPaired === 'false') { qb.andWhere('booking.consolidation_partner_id IS NULL'); } + if (options.schedulingStatuses?.length) { + qb.andWhere('booking.scheduling_status IN (:...schedulingStatuses)', { + schedulingStatuses: options.schedulingStatuses, + }); + } + if (options.assignedToSchedule === 'true') { + qb.andWhere( + `EXISTS ( + SELECT 1 FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL + )`, + ); + } else if (options.assignedToSchedule === 'false') { + qb.andWhere( + `NOT EXISTS ( + SELECT 1 FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL + )`, + ); + } } async findAndCountFiltered(where: FindOptionsWhere, options: { @@ -585,4 +649,99 @@ export class BookingsRepository extends BaseRepository { } return repo.save(repo.create(data)); } + + private bookingRepo(manager?: EntityManager) { + return manager ? manager.getRepository(Booking) : this.repository; + } + + findEligibleForScheduling(options: { + freightType?: string; + originStationId?: string; + destinationStationId?: string; + schedulingStatus?: string; + }): Promise { + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') + .leftJoin( + TrainScheduleBooking, + 'scheduleBooking', + 'scheduleBooking.booking_id = booking.id', + ) + .where('booking.status = :paidStatus', { paidStatus: 'PAID' }) + .andWhere('scheduleBooking.id IS NULL'); + + if (options.freightType) { + qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType }); + } + + if (options.originStationId) { + qb.andWhere('booking.originYardId = :originStationId', { + originStationId: options.originStationId, + }); + } + if (options.destinationStationId) { + qb.andWhere('booking.destinationYardId = :destinationStationId', { + destinationStationId: options.destinationStationId, + }); + } + if (options.schedulingStatus) { + qb.andWhere('booking.scheduling_status = :schedulingStatus', { + schedulingStatus: options.schedulingStatus, + }); + } + + return qb + .orderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.scheduled_date', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + + findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise { + if (!bookingIds.length) return Promise.resolve([]); + return this.bookingRepo(manager).find({ + where: { id: In(bookingIds) }, + relations: { + company: true, + originYard: true, + destinationYard: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + order: { priorityScore: 'DESC', createdAt: 'ASC' }, + }); + } + + async updateSchedulingFields( + bookingId: string, + fields: Partial< + Pick< + Booking, + 'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt' + > + >, + manager?: EntityManager, + ): Promise { + await this.bookingRepo(manager).update(bookingId, fields as never); + } + + async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise { + const now = new Date(); + const expires = new Date(now.getTime() + 3 * 60 * 60 * 1000); + await this.updateSchedulingFields( + bookingId, + { + schedulingStatus: SchedulingStatus.Holding, + holdStartedAt: now, + holdExpiresAt: expires, + }, + manager, + ); + } } 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 87e9298ff..d8e796b36 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -4,6 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { FilesService } from '../files/files.service'; @@ -64,6 +65,7 @@ export class BookingsService { paymentCurrency: string; tradeDirection: string; isHazardous?: boolean; + isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; containers: CreateBookingContainerDto[]; @@ -92,6 +94,7 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, + isGovernment: dto.isGovernment ?? false, allowConsolidation: dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, shippingLineId: dto.shippingLineId, @@ -178,8 +181,15 @@ export class BookingsService { // customerId = customer.id; // } - let companyId = dto.companyId; - if (!companyId) { + const isGovernment = dto.isGovernment === true; + + let companyId: string | null | undefined = dto.companyId; + if (isGovernment) { + if (!dto.governmentInstitution?.trim()) { + throw new BadRequestException('governmentInstitution is required for government bookings'); + } + companyId = dto.companyId ?? null; + } else if (!companyId) { if (!userId) { throw new BadRequestException( 'companyId is required or must be resolvable from auth token', @@ -209,6 +219,7 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous, + isGovernment, allowConsolidation, shippingLineId: dto.shippingLineId, containers, @@ -220,7 +231,9 @@ export class BookingsService { const booking = await this.bookingsRepository.create({ reference, - companyId, + companyId: companyId ?? null, + isGovernment, + governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, trainId: dto.trainId, contractType: dto.contractType, previousContractId: dto.previousContractId, @@ -300,12 +313,13 @@ export class BookingsService { const freightType = (dto.freightType ?? existing.freightType) as FreightType; let containers = dto.containers ?? - existing.bookingContainers?.map((bc) => ({ - containerTypeId: bc.containerTypeId, - quantity: bc.quantity, - vgmPerUnitTons: Number(bc.vgmPerUnitTons), - })) ?? - []; + (existing.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + vgmPerUnitTons: Number(bc.vgmPerUnitTons), + })); let cargoTypeId = dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; @@ -348,6 +362,15 @@ export class BookingsService { this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); + const pricingFieldsChanged = this.pricingRelevantFieldsChanged( + existing, + dto, + freightType, + cargoTypeId, + allowConsolidation, + containers, + ); + const updates: Record = { ...dto, freightType, @@ -375,6 +398,10 @@ export class BookingsService { ); } + if (pricingFieldsChanged) { + await this.bookingsRepository.invalidatePricingPreview(id); + } + if (files.length > 0) { await this.filesService.uploadMany(id, 'bookings', files); } @@ -390,6 +417,19 @@ export class BookingsService { return { booking, warnings }; } + /** Parse comma-separated scheduling status query values. */ + private parseSchedulingStatusFilter(filter: FilterBookingDto): { + schedulingStatuses?: string[]; + } { + const raw = filter.schedulingStatuses; + if (!raw) return {}; + const schedulingStatuses = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return schedulingStatuses.length ? { schedulingStatuses } : {}; + } + /** Parse comma-separated or repeated status query values. */ private parseStatusFilter(filter: FilterBookingDto): { statuses?: string[]; @@ -420,11 +460,14 @@ export class BookingsService { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const statusFilter = this.parseStatusFilter(filter); + const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter); return this.bookingsRepository.findAllPaginated({ page, pageSize, ...statusFilter, + ...schedulingStatusFilter, + assignedToSchedule: filter.assignedToSchedule, companyId: filter.companyId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, @@ -648,4 +691,86 @@ export class BookingsService { ), }; } + + private pricingRelevantFieldsChanged( + existing: Booking, + dto: UpdateBookingDto, + freightType: FreightType, + cargoTypeId: string | null | undefined, + allowConsolidation: boolean, + containers: CreateBookingContainerDto[], + ): boolean { + if (dto.freightType !== undefined && dto.freightType !== existing.freightType) { + return true; + } + if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) { + return true; + } + if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) { + return true; + } + if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) { + return true; + } + if ( + dto.allowConsolidation !== undefined && + dto.allowConsolidation !== existing.allowConsolidation + ) { + return true; + } + if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) { + return true; + } + if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) { + return true; + } + if (dto.containers !== undefined) { + const existingContainers = (existing.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + vgmPerUnitTons: Number(bc.vgmPerUnitTons), + })); + if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) { + return true; + } + } + if ( + freightType !== existing.freightType || + (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) || + allowConsolidation !== existing.allowConsolidation + ) { + return true; + } + return false; + } + + /** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */ + async governmentExpedite(id: string, staffUserId: string): Promise { + const booking = await this.findById(id); + if (!booking.isGovernment) { + throw new BadRequestException('Only government bookings can be expedited'); + } + const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED']; + if (blocked.includes(booking.status)) { + throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`); + } + + await this.bookingsRepository.update(id, { + status: 'PAID', + paymentStatus: 'PAID', + schedulingStatus: SchedulingStatus.Eligible, + holdStartedAt: null, + holdExpiresAt: null, + }); + await this.bookingsRepository.createReviewNote( + id, + `Government booking expedited to PAID by staff (${staffUserId})`, + 'STAFF_NOTE', + staffUserId, + ); + + return this.findById(id); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index 2d805ba8f..541d5d09f 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -76,11 +76,12 @@ export class ConsolidationService { } async slotsFromBooking(booking: Booking): Promise { - const lines = - booking.bookingContainers?.map((bc) => ({ + const lines = (booking.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map((bc) => ({ containerTypeId: bc.containerTypeId, quantity: bc.quantity, - })) ?? []; + })); return this.slotsFromContainerLines(lines); } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 8089d0307..194bd5a83 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -12,6 +12,7 @@ import { IsString, IsUUID, Min, + MinLength, Validate, ValidateIf, ValidateNested, @@ -66,7 +67,21 @@ export class CreateBookingDto { // @IsUUID() // customerId?: string; + @ApiPropertyOptional({ description: 'Staff only: government booking flag' }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isGovernment?: boolean; + + @ApiPropertyOptional({ description: 'Required when isGovernment is true' }) + @ValidateIf((o) => o.isGovernment === true) + @IsString() + @MinLength(2) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + governmentInstitution?: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' }) + @ValidateIf((o) => o.isGovernment !== true) @IsOptional() @IsUUID() companyId?: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 03fe73683..b88c381ae 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -84,8 +84,25 @@ export class FilterBookingDto { @Transform(({ value }) => (value ? parseInt(value, 10) : 20)) pageSize?: number; + @ApiPropertyOptional({ + description: 'Comma-separated scheduling statuses (NOT_SCHEDULED,HOLDING,ELIGIBLE,SCHEDULED)', + }) + @IsOptional() + @Transform(({ value }) => { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value)) return value.map(String).join(','); + return String(value); + }) + schedulingStatuses?: string; + + @ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter by train schedule assignment' }) + @IsOptional() + @IsIn(['true', 'false']) + assignedToSchedule?: 'true' | 'false'; + @ApiPropertyOptional({ default: 'createdAt' }) @IsOptional() + @IsIn(['createdAt', 'priorityScore', 'scheduledDate', 'isGovernment']) sortBy?: string; @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/submit-booking-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/submit-booking-response.dto.ts new file mode 100644 index 000000000..2828f0237 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/submit-booking-response.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +import { PriceLineItemDto } from './generate-price-response.dto'; + +export class SubmitBookingResponseDto { + @ApiProperty() + bookingId!: string; + + @ApiProperty() + status!: string; + + @ApiProperty() + priceChanged!: boolean; + + @ApiPropertyOptional() + previousTotalAmount?: number; + + @ApiProperty() + totalAmount!: number; + + @ApiProperty() + currency!: string; + + @ApiPropertyOptional({ type: [PriceLineItemDto] }) + lineItems?: PriceLineItemDto[]; + + @ApiPropertyOptional() + message?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts index dc7691456..8a09245ea 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -15,12 +15,15 @@ export class BookingContainer extends BaseEntity { @JoinColumn({ name: 'booking_id' }) booking?: Booking; - @Column({ name: 'container_type_id', type: 'uuid' }) - containerTypeId!: string; + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; - @ManyToOne(() => ContainerType) + @ManyToOne(() => ContainerType, { nullable: true }) @JoinColumn({ name: 'container_type_id' }) - containerType?: ContainerType; + containerType?: ContainerType | null; + + @Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true }) + containerNumber?: string | null; @Column({ name: 'quantity', type: 'smallint' }) quantity!: number; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts index af39a469c..91171a793 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Booking } from './booking.entity'; -export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const; +export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const; export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; @Entity({ schema: 'freight', name: 'booking_review_note' }) 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 f0c4ad623..87fdf5a34 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 @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { SchedulingStatus } from '@edr/types'; import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; // import { Customer } from '../../customers/entities/customer.entity'; import { Company } from '../../companies/entities/company.entity'; @@ -17,6 +18,7 @@ import { BookingReviewNote } from './booking-review-note.entity'; export const BOOKING_STATUSES = [ 'DRAFT', 'SUBMITTED', + 'PRICE_CHANGED_PENDING_CONFIRM', 'CHANGES_REQUESTED', 'PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE', @@ -53,6 +55,16 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; export type FreightType = (typeof FREIGHT_TYPES)[number]; +export const SCHEDULING_STATUSES = [ + SchedulingStatus.NotScheduled, + SchedulingStatus.Holding, + SchedulingStatus.Eligible, + SchedulingStatus.Scheduled, + SchedulingStatus.Dispatched, +] as const; + +export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number]; + /** Statuses where the customer may edit booking fields. */ export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [ 'DRAFT', @@ -71,16 +83,24 @@ export class Booking extends BaseEntity { // @JoinColumn({ name: 'customer_id' }) // customer?: Customer; - @Column({ name: 'company_id', type: 'uuid' }) - companyId!: string; + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; - @ManyToOne(() => Company) + @ManyToOne(() => Company, { nullable: true }) @JoinColumn({ name: 'company_id' }) - company?: Company; + company?: Company | null; + @Column({ name: 'is_government', type: 'boolean', default: false }) + isGovernment!: boolean; + + @Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true }) + governmentInstitution?: string | null; + + /** @deprecated Fleet master data link โ€” scheduling uses train_schedule_bookings instead. */ @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId?: string | null; + /** @deprecated Use train_schedule_bookings for operational scheduling. */ @ManyToOne(() => Train, { nullable: true }) @JoinColumn({ name: 'train_id' }) train?: Train | null; @@ -242,6 +262,21 @@ export class Booking extends BaseEntity { @JoinColumn({ name: 'consolidation_partner_id' }) consolidationPartner?: Booking | null; + @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true }) + wagonsRequired?: number | null; + + @Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' }) + schedulingStatus!: string; + + @Column({ name: 'hold_started_at', type: 'timestamptz', nullable: true }) + holdStartedAt?: Date | null; + + @Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true }) + holdExpiresAt?: Date | null; + + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt?: Date | null; + @OneToMany(() => BookingContainer, (bc) => bc.booking) bookingContainers?: BookingContainer[]; diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts index f5940d6a6..6c79f0a76 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts @@ -159,9 +159,12 @@ export class CargoesService { cargo.status = 'DELIVERED'; if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks; - const remaining = await this.cargoRepo.count({ - where: { containerId: cargo.containerId, status: 'LOADED' }, - }); + const remaining = + cargo.containerId != null + ? await this.cargoRepo.count({ + where: { containerId: cargo.containerId, status: 'LOADED' }, + }) + : 0; if (remaining === 0 && cargo.container) { cargo.container.status = 'AVAILABLE'; await this.containerRepo.save(cargo.container); diff --git a/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts index 7c2f752e5..ffc4bb26a 100644 --- a/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts +++ b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts @@ -1,7 +1,9 @@ // apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; +import { Booking } from '../../bookings/entities/booking.entity'; import { Container } from '../../container-management/entities/container.entity'; +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; @Entity({ name: 'cargoes', schema: 'freight' }) export class Cargo extends BaseEntity { @@ -11,8 +13,8 @@ export class Cargo extends BaseEntity { @Column({ name: 'shipment_id', type: 'uuid' }) shipmentId!: string; - @Column({ name: 'container_id', type: 'uuid' }) - containerId!: string; + @Column({ name: 'container_id', type: 'uuid', nullable: true }) + containerId!: string | null; @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) cargoTypeId!: string | null; // optional link to cargo_types table @@ -38,8 +40,24 @@ export class Cargo extends BaseEntity { @Column({ name: 'unloaded_at', type: 'timestamp', nullable: true }) unloadedAt!: Date | null; - // Relationship to Container - @ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' }) + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true }) + wagonBookingAllocationId!: string | null; + + @ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + wagonBookingAllocation?: WagonBookingAllocation | null; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId!: string | null; + + @ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + @Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true }) + loadType!: string | null; + + @ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true }) @JoinColumn({ name: 'container_id' }) - container!: Container; + container!: Container | null; } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts index a5c7ee9c1..e6fdd47ee 100644 --- a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts +++ b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts @@ -1,6 +1,9 @@ // apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { BookingContainer } from '../../bookings/entities/booking-container.entity'; +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; import { Wagon } from '../../wagons/entities/wagon.entity'; import { Cargo } from '../../cargoes/entities/cargoes.entity'; @@ -34,7 +37,27 @@ sealNumber!: string | null; @Column({ type: 'varchar', default: 'AVAILABLE' }) status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED - // Relationship to Wagon + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId!: string | null; + + @ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true }) + wagonBookingAllocationId!: string | null; + + @ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + wagonBookingAllocation?: WagonBookingAllocation | null; + + @Column({ name: 'booking_container_id', type: 'uuid', nullable: true }) + bookingContainerId!: string | null; + + @ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_container_id' }) + bookingContainer?: BookingContainer | null; + @ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'wagon_id' }) wagon!: Wagon | null; diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-query.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-query.dto.ts new file mode 100644 index 000000000..591fb6b6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-query.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +const OVERVIEW_RANGES = ['7d', '30d', '90d'] as const; + +export type OverviewRangeQuery = (typeof OVERVIEW_RANGES)[number]; + +export class OverviewQueryDto { + @ApiPropertyOptional({ + enum: OVERVIEW_RANGES, + default: '30d', + description: 'Time range for trend charts', + }) + @IsOptional() + @IsIn(OVERVIEW_RANGES) + range?: OverviewRangeQuery = '30d'; +} diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts new file mode 100644 index 000000000..767a217a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -0,0 +1,104 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class OverviewBookingKpisDto { + @ApiProperty() totalActive!: number; + @ApiProperty() needsAction!: number; + @ApiProperty() urgent!: number; + @ApiProperty() inApproval!: number; + @ApiProperty() submittedToday!: number; +} + +export class OverviewOperationsKpisDto { + @ApiProperty() trainsActive!: number; + @ApiProperty() wagonsAvailable!: number; + @ApiProperty() containersInTransit!: number; + @ApiProperty() cargoesLoaded!: number; +} + +export class OverviewCustomerKpisDto { + @ApiProperty() totalCustomers!: number; + @ApiProperty() newCustomersThisMonth!: number; +} + +export class OverviewBillingKpisDto { + @ApiProperty() revenueMtdEtb!: number; + @ApiProperty() revenueMtdUsd!: number; + @ApiProperty() pendingPayments!: number; + @ApiProperty() successfulPaymentsMtd!: number; +} + +export class OverviewStaffKpisDto { + @ApiProperty() activeEmployees!: number; + @ApiProperty() activeUsers!: number; +} + +export class OverviewKpisDto { + @ApiProperty({ type: OverviewBookingKpisDto }) + bookings!: OverviewBookingKpisDto; + + @ApiProperty({ type: OverviewOperationsKpisDto }) + operations!: OverviewOperationsKpisDto; + + @ApiProperty({ type: OverviewCustomerKpisDto }) + customers!: OverviewCustomerKpisDto; + + @ApiProperty({ type: OverviewBillingKpisDto }) + billing!: OverviewBillingKpisDto; + + @ApiProperty({ type: OverviewStaffKpisDto }) + staff!: OverviewStaffKpisDto; +} + +export class OverviewTrendPointDto { + @ApiProperty({ example: '2026-06-01' }) date!: string; + @ApiProperty() count!: number; +} + +export class OverviewStatusCountDto { + @ApiProperty() status!: string; + @ApiProperty() count!: number; +} + +export class OverviewPipelineCountDto { + @ApiProperty() stage!: string; + @ApiProperty() count!: number; +} + +export class OverviewPaymentTrendPointDto { + @ApiProperty({ example: '2026-06-01' }) date!: string; + @ApiProperty() amountEtb!: number; + @ApiProperty() amountUsd!: number; +} + +export class OverviewRecentBookingDto { + @ApiProperty() id!: string; + @ApiProperty() reference!: string; + @ApiProperty() customerLabel!: string; + @ApiProperty() status!: string; + @ApiProperty() priorityScore!: number; + @ApiProperty({ nullable: true }) totalAmount!: number | null; + @ApiProperty({ nullable: true }) paymentCurrency!: string | null; + @ApiProperty() createdAt!: string; +} + +export class OverviewResponseDto { + @ApiProperty({ type: OverviewKpisDto }) + kpis!: OverviewKpisDto; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + bookingTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + bookingsByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewPipelineCountDto] }) + bookingsByPipeline!: OverviewPipelineCountDto[]; + + @ApiProperty({ type: [OverviewPaymentTrendPointDto] }) + paymentTrend!: OverviewPaymentTrendPointDto[]; + + @ApiProperty({ type: [OverviewRecentBookingDto] }) + recentBookings!: OverviewRecentBookingDto[]; + + @ApiProperty() generatedAt!: string; +} diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts new file mode 100644 index 000000000..c19a8baee --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts @@ -0,0 +1,131 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { + OverviewBillingKpisDto, + OverviewBookingKpisDto, + OverviewCustomerKpisDto, + OverviewOperationsKpisDto, + OverviewPaymentTrendPointDto, + OverviewPipelineCountDto, + OverviewRecentBookingDto, + OverviewStaffKpisDto, + OverviewStatusCountDto, + OverviewTrendPointDto, +} from './overview-response.dto'; + +export class OverviewLabelCountDto { + @ApiProperty() label!: string; + @ApiProperty() count!: number; +} + +export class OverviewPaymentMethodDto { + @ApiProperty() method!: string; + @ApiProperty() count!: number; + @ApiProperty() amountEtb!: number; + @ApiProperty() amountUsd!: number; +} + +export class OverviewCurrencyAmountDto { + @ApiProperty() currency!: string; + @ApiProperty() amount!: number; +} + +export class OverviewBookingsTabDto { + @ApiProperty({ type: OverviewBookingKpisDto }) + kpis!: OverviewBookingKpisDto; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + bookingTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + bookingsByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewPipelineCountDto] }) + bookingsByPipeline!: OverviewPipelineCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + bookingsByFreightType!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + bookingsByCurrency!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewRecentBookingDto] }) + recentBookings!: OverviewRecentBookingDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewBillingTabDto { + @ApiProperty({ type: OverviewBillingKpisDto }) + kpis!: OverviewBillingKpisDto; + + @ApiProperty({ type: [OverviewPaymentTrendPointDto] }) + paymentTrend!: OverviewPaymentTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + paymentsByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewPaymentMethodDto] }) + paymentsByMethod!: OverviewPaymentMethodDto[]; + + @ApiProperty({ type: [OverviewCurrencyAmountDto] }) + revenueByCurrency!: OverviewCurrencyAmountDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewOperationsTabDto { + @ApiProperty({ type: OverviewOperationsKpisDto }) + kpis!: OverviewOperationsKpisDto; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + trainStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + wagonStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + containerStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + cargoStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewCustomersTabDto { + @ApiProperty({ type: OverviewCustomerKpisDto }) + kpis!: OverviewCustomerKpisDto; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + customerGrowthTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + customersByType!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + topCustomersByBookings!: OverviewLabelCountDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewStaffTabDto { + @ApiProperty({ type: OverviewStaffKpisDto }) + kpis!: OverviewStaffKpisDto; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + usersByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + employeeGrowthTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + activeUsersBreakdown!: OverviewLabelCountDto[]; + + @ApiProperty() + generatedAt!: string; +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.constants.ts b/apps/edr-freight-api/src/modules/overview/overview.constants.ts new file mode 100644 index 000000000..fed9a76c7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.constants.ts @@ -0,0 +1,26 @@ +export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 1000; + +export const OVERVIEW_NEEDS_ACTION_STATUSES = [ + 'SUBMITTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', +] as const; + +export const OVERVIEW_IN_APPROVAL_STATUSES = [ + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', +] as const; + +export const OVERVIEW_CLOSED_STATUSES = [ + 'REJECTED', + 'CANCELLED', + 'COMPLETED', +] as const; + +export const OVERVIEW_RANGE_DAYS = { + '7d': 7, + '30d': 30, + '90d': 90, +} as const; + +export type OverviewRange = keyof typeof OVERVIEW_RANGE_DAYS; diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts new file mode 100644 index 000000000..fe545b452 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; + +import { BookingView } from '../../common/booking-guards'; +import { OverviewQueryDto } from './dto/overview-query.dto'; +import { OverviewResponseDto } from './dto/overview-response.dto'; +import { + OverviewBillingTabDto, + OverviewBookingsTabDto, + OverviewCustomersTabDto, + OverviewOperationsTabDto, + OverviewStaffTabDto, +} from './dto/overview-tab-response.dto'; +import { OverviewService } from './overview.service'; + +@ApiTags('Overview') +@ApiBearerAuth() +@Controller('overview') +export class OverviewController { + constructor(private readonly overviewService: OverviewService) {} + + @Get() + @BookingView() + @ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' }) + @ApiOkResponse({ type: OverviewResponseDto }) + getDashboard(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getDashboard(query.range ?? '30d'); + } + + @Get('bookings') + @BookingView() + @ApiOperation({ summary: 'Bookings tab metrics and charts' }) + @ApiOkResponse({ type: OverviewBookingsTabDto }) + getBookingsTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getBookingsTab(query.range ?? '30d'); + } + + @Get('billing') + @BookingView() + @ApiOperation({ summary: 'Billing tab metrics and charts' }) + @ApiOkResponse({ type: OverviewBillingTabDto }) + getBillingTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getBillingTab(query.range ?? '30d'); + } + + @Get('operations') + @BookingView() + @ApiOperation({ summary: 'Operations tab metrics and charts' }) + @ApiOkResponse({ type: OverviewOperationsTabDto }) + getOperationsTab(): Promise { + return this.overviewService.getOperationsTab(); + } + + @Get('customers') + @BookingView() + @ApiOperation({ summary: 'Customers tab metrics and charts' }) + @ApiOkResponse({ type: OverviewCustomersTabDto }) + getCustomersTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getCustomersTab(query.range ?? '30d'); + } + + @Get('staff') + @BookingView() + @ApiOperation({ summary: 'Staff tab metrics and charts' }) + @ApiOkResponse({ type: OverviewStaffTabDto }) + getStaffTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getStaffTab(query.range ?? '30d'); + } +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts new file mode 100644 index 000000000..50893b626 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Employee } from '@tria-plc/iamapi-common'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { Customer } from '../customers/entities/customer.entity'; +import { PaymentEntity } from '../payment/entities/payment.entity'; +import { Train } from '../trains/entities/train.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { OverviewController } from './overview.controller'; +import { OverviewRepository } from './overview.repository'; +import { OverviewService } from './overview.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Booking, + PaymentEntity, + Customer, + Train, + Wagon, + Container, + Cargo, + Employee, + User, + ]), + ], + controllers: [OverviewController], + providers: [OverviewService, OverviewRepository], +}) +export class OverviewModule {} diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts new file mode 100644 index 000000000..2c49ff8da --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -0,0 +1,553 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { Employee } from '@tria-plc/iamapi-common'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { Freight } from '@edr/types'; +import { Repository, ObjectLiteral } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { Customer } from '../customers/entities/customer.entity'; +import { PaymentEntity } from '../payment/entities/payment.entity'; +import { Train } from '../trains/entities/train.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { + OVERVIEW_CLOSED_STATUSES, + OVERVIEW_IN_APPROVAL_STATUSES, + OVERVIEW_NEEDS_ACTION_STATUSES, + OVERVIEW_URGENT_PRIORITY_THRESHOLD, +} from './overview.constants'; + +export type OverviewBookingKpisRow = { + totalActive: number; + needsAction: number; + urgent: number; + inApproval: number; + submittedToday: number; +}; + +export type OverviewRecentBookingRow = { + id: string; + reference: string; + customerLabel: string; + status: string; + priorityScore: number; + totalAmount: number | null; + paymentCurrency: string | null; + createdAt: Date; +}; + +@Injectable() +export class OverviewRepository { + constructor( + @InjectRepository(Booking) + private readonly bookingRepository: Repository, + @InjectRepository(PaymentEntity) + private readonly paymentRepository: Repository, + @InjectRepository(Customer) + private readonly customerRepository: Repository, + @InjectRepository(Train) + private readonly trainRepository: Repository, + @InjectRepository(Wagon) + private readonly wagonRepository: Repository, + @InjectRepository(Container) + private readonly containerRepository: Repository, + @InjectRepository(Cargo) + private readonly cargoRepository: Repository, + @InjectRepository(Employee) + private readonly employeeRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async getBookingKpis(): Promise { + const row = await this.bookingRepository + .createQueryBuilder('booking') + .select( + `COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`, + 'totalActive', + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`, + 'needsAction', + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`, + 'urgent', + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`, + 'inApproval', + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`, + 'submittedToday', + ) + .where('booking.deleted_at IS NULL') + .setParameters({ + closedStatuses: [...OVERVIEW_CLOSED_STATUSES], + needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], + inApprovalStatuses: [...OVERVIEW_IN_APPROVAL_STATUSES], + urgentThreshold: OVERVIEW_URGENT_PRIORITY_THRESHOLD, + }) + .getRawOne>(); + + return { + totalActive: Number(row?.totalActive ?? 0), + needsAction: Number(row?.needsAction ?? 0), + urgent: Number(row?.urgent ?? 0), + inApproval: Number(row?.inApproval ?? 0), + submittedToday: Number(row?.submittedToday ?? 0), + }; + } + + async getOperationsKpis(): Promise<{ + trainsActive: number; + wagonsAvailable: number; + containersInTransit: number; + cargoesLoaded: number; + }> { + const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = + await Promise.all([ + this.trainRepository + .createQueryBuilder('train') + .where('train.deleted_at IS NULL') + .andWhere('train.status IN (:...statuses)', { + statuses: [ + Freight.TrainStatus.InService, + Freight.TrainStatus.Scheduled, + ], + }) + .getCount(), + this.wagonRepository + .createQueryBuilder('wagon') + .where('wagon.deleted_at IS NULL') + .andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available }) + .getCount(), + this.containerRepository + .createQueryBuilder('container') + .where('container.deleted_at IS NULL') + .andWhere('container.status = :status', { status: 'IN_TRANSIT' }) + .getCount(), + this.cargoRepository + .createQueryBuilder('cargo') + .where('cargo.deleted_at IS NULL') + .andWhere('cargo.status IN (:...statuses)', { + statuses: ['LOADED', 'IN_TRANSIT'], + }) + .getCount(), + ]); + + return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded }; + } + + async getCustomerKpis(): Promise<{ + totalCustomers: number; + newCustomersThisMonth: number; + }> { + const row = await this.customerRepository + .createQueryBuilder('customer') + .select('COUNT(*)::int', 'totalCustomers') + .addSelect( + `COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`, + 'newCustomersThisMonth', + ) + .where('customer.deleted_at IS NULL') + .getRawOne>(); + + return { + totalCustomers: Number(row?.totalCustomers ?? 0), + newCustomersThisMonth: Number(row?.newCustomersThisMonth ?? 0), + }; + } + + async getBillingKpis(): Promise<{ + revenueMtdEtb: number; + revenueMtdUsd: number; + pendingPayments: number; + successfulPaymentsMtd: number; + }> { + const revenueRow = await this.paymentRepository + .createQueryBuilder('payment') + .select( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + 'revenueMtdEtb', + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + 'revenueMtdUsd', + ) + .addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd') + .where('payment.status = :status', { status: 'success' }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, + ) + .getRawOne>(); + + const pendingPayments = await this.paymentRepository + .createQueryBuilder('payment') + .where('payment.status IN (:...statuses)', { + statuses: ['action-required', 'processing'], + }) + .getCount(); + + return { + revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), + revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), + pendingPayments, + successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), + }; + } + + async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> { + const [activeEmployees, activeUsers] = await Promise.all([ + this.employeeRepository.count({ + where: { isCurrent: true }, + }), + this.userRepository.count({ + where: { + isActive: true, + status: EUserStatus.ACCEPTED, + }, + }), + ]); + + return { activeEmployees, activeUsers }; + } + + async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy('booking.created_at::date') + .orderBy('booking.created_at::date', 'ASC') + .getRawMany<{ date: string; count: string }>(); + + return rows.map((row) => ({ + date: row.date, + count: Number(row.count), + })); + } + + async getStatusCounts(): Promise> { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .select('booking.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .groupBy('booking.status') + .getRawMany<{ status: string; count: string }>(); + + return Object.fromEntries( + rows.map((row) => [row.status, Number(row.count)]), + ); + } + + async getPaymentTrend( + days: number, + ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + const rows = await this.paymentRepository + .createQueryBuilder('payment') + .select( + `to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`, + 'date', + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + 'amountEtb', + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + 'amountUsd', + ) + .where('payment.status = :status', { status: 'success' }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) + .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC') + .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); + + return rows.map((row) => ({ + date: row.date, + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + async getRecentBookings(limit: number): Promise { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .leftJoin('booking.company', 'company') + .select('booking.id', 'id') + .addSelect('booking.reference', 'reference') + .addSelect('COALESCE(company.name, \'โ€”\')', 'customerLabel') + .addSelect('booking.status', 'status') + .addSelect('booking.priority_score', 'priorityScore') + .addSelect('booking.total_amount', 'totalAmount') + .addSelect('booking.payment_currency', 'paymentCurrency') + .addSelect('booking.created_at', 'createdAt') + .where('booking.deleted_at IS NULL') + .orderBy('booking.created_at', 'DESC') + .limit(limit) + .getRawMany<{ + id: string; + reference: string; + customerLabel: string; + status: string; + priorityScore: string; + totalAmount: string | null; + paymentCurrency: string | null; + createdAt: Date; + }>(); + + return rows.map((row) => ({ + id: row.id, + reference: row.reference, + customerLabel: row.customerLabel, + status: row.status, + priorityScore: Number(row.priorityScore), + totalAmount: row.totalAmount != null ? Number(row.totalAmount) : null, + paymentCurrency: row.paymentCurrency, + createdAt: row.createdAt, + })); + } + + async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .select('booking.freight_type', 'label') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .andWhere("booking.status != 'DRAFT'") + .groupBy('booking.freight_type') + .orderBy('count', 'DESC') + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .select('booking.payment_currency', 'label') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .andWhere("booking.status != 'DRAFT'") + .groupBy('booking.payment_currency') + .orderBy('count', 'DESC') + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> { + const rows = await this.paymentRepository + .createQueryBuilder('payment') + .select('payment.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('payment.status') + .orderBy('count', 'DESC') + .getRawMany<{ status: string; count: string }>(); + + return rows.map((row) => ({ + status: row.status, + count: Number(row.count), + })); + } + + async getPaymentsByMethod(): Promise< + { method: string; count: number; amountEtb: number; amountUsd: number }[] + > { + const rows = await this.paymentRepository + .createQueryBuilder('payment') + .select('payment.method', 'method') + .addSelect('COUNT(*)::int', 'count') + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`, + 'amountEtb', + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, + 'amountUsd', + ) + .groupBy('payment.method') + .orderBy('count', 'DESC') + .getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>(); + + return rows.map((row) => ({ + method: row.method, + count: Number(row.count), + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> { + const rows = await this.paymentRepository + .createQueryBuilder('payment') + .select('payment.currency', 'currency') + .addSelect('COALESCE(SUM(payment.amount), 0)', 'amount') + .where('payment.status = :status', { status: 'success' }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, + ) + .groupBy('payment.currency') + .getRawMany<{ currency: string; amount: string }>(); + + return rows.map((row) => ({ + currency: row.currency, + amount: Number(row.amount), + })); + } + + async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> { + return this.statusBreakdown(this.trainRepository, 'train'); + } + + async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> { + return this.statusBreakdown(this.wagonRepository, 'wagon'); + } + + async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> { + return this.statusBreakdown(this.containerRepository, 'container'); + } + + async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> { + return this.statusBreakdown(this.cargoRepository, 'cargo'); + } + + private async statusBreakdown( + repository: Repository, + alias: string, + ): Promise<{ status: string; count: number }[]> { + const rows = await repository + .createQueryBuilder(alias) + .select(`${alias}.status`, 'status') + .addSelect('COUNT(*)::int', 'count') + .where(`${alias}.deleted_at IS NULL`) + .groupBy(`${alias}.status`) + .orderBy('count', 'DESC') + .getRawMany<{ status: string; count: string }>(); + + return rows.map((row) => ({ + status: row.status, + count: Number(row.count), + })); + } + + async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { + const rows = await this.customerRepository + .createQueryBuilder('customer') + .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date') + .addSelect('COUNT(*)::int', 'count') + .where('customer.deleted_at IS NULL') + .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy('customer.created_at::date') + .orderBy('customer.created_at::date', 'ASC') + .getRawMany<{ date: string; count: string }>(); + + return rows.map((row) => ({ + date: row.date, + count: Number(row.count), + })); + } + + async getCustomersByType(): Promise<{ label: string; count: number }[]> { + const rows = await this.customerRepository + .createQueryBuilder('customer') + .select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label') + .addSelect('COUNT(*)::int', 'count') + .where('customer.deleted_at IS NULL') + .groupBy('customer.customer_type') + .orderBy('count', 'DESC') + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder('booking') + .leftJoin('booking.company', 'company') + .select(`COALESCE(company.name, 'Unknown')`, 'label') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .andWhere("booking.status != 'DRAFT'") + .groupBy('company.name') + .orderBy('count', 'DESC') + .limit(limit) + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getUsersByStatus(): Promise<{ status: string; count: number }[]> { + const rows = await this.userRepository + .createQueryBuilder('user') + .select('user.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('user.status') + .orderBy('count', 'DESC') + .getRawMany<{ status: string; count: string }>(); + + return rows.map((row) => ({ + status: row.status, + count: Number(row.count), + })); + } + + async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { + const rows = await this.employeeRepository + .createQueryBuilder('employee') + .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date') + .addSelect('COUNT(*)::int', 'count') + .where('employee.is_current = true') + .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy('employee.created_at::date') + .orderBy('employee.created_at::date', 'ASC') + .getRawMany<{ date: string; count: string }>(); + + return rows.map((row) => ({ + date: row.date, + count: Number(row.count), + })); + } + + async getActiveUsersBreakdown(): Promise<{ label: string; count: number }[]> { + const [active, inactive] = await Promise.all([ + this.userRepository.count({ + where: { isActive: true, status: EUserStatus.ACCEPTED }, + }), + this.userRepository + .createQueryBuilder('user') + .where('user.is_active = false OR user.status != :status', { + status: EUserStatus.ACCEPTED, + }) + .getCount(), + ]); + + return [ + { label: 'Active', count: active }, + { label: 'Inactive', count: inactive }, + ]; + } +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts new file mode 100644 index 000000000..feadf2409 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -0,0 +1,210 @@ +import { Injectable } from '@nestjs/common'; + +import { + BOOKING_LIST_TABS, + mapStatusCountsToTabs, +} from '../bookings/booking-list-tabs.config'; +import type { OverviewRangeQuery } from './dto/overview-query.dto'; +import type { OverviewResponseDto } from './dto/overview-response.dto'; +import type { + OverviewBillingTabDto, + OverviewBookingsTabDto, + OverviewCustomersTabDto, + OverviewOperationsTabDto, + OverviewStaffTabDto, +} from './dto/overview-tab-response.dto'; +import { OVERVIEW_RANGE_DAYS } from './overview.constants'; +import { OverviewRepository } from './overview.repository'; + +@Injectable() +export class OverviewService { + constructor(private readonly overviewRepository: OverviewRepository) {} + + private mapStatusCounts(statusCounts: Record) { + const pipelineTabs = mapStatusCountsToTabs(statusCounts); + const bookingsByPipeline = BOOKING_LIST_TABS.filter( + (tab) => tab.key !== 'all', + ).map((tab) => ({ + stage: tab.key, + count: pipelineTabs[tab.key], + })); + + const bookingsByStatus = Object.entries(statusCounts) + .map(([status, count]) => ({ status, count })) + .sort((a, b) => b.count - a.count); + + return { bookingsByPipeline, bookingsByStatus }; + } + + async getDashboard(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [ + bookingKpis, + operationsKpis, + customerKpis, + billingKpis, + staffKpis, + bookingTrend, + statusCounts, + paymentTrend, + recentBookings, + ] = await Promise.all([ + this.overviewRepository.getBookingKpis(), + this.overviewRepository.getOperationsKpis(), + this.overviewRepository.getCustomerKpis(), + this.overviewRepository.getBillingKpis(), + this.overviewRepository.getStaffKpis(), + this.overviewRepository.getBookingTrend(days), + this.overviewRepository.getStatusCounts(), + this.overviewRepository.getPaymentTrend(days), + this.overviewRepository.getRecentBookings(8), + ]); + + const { bookingsByPipeline, bookingsByStatus } = + this.mapStatusCounts(statusCounts); + + return { + kpis: { + bookings: bookingKpis, + operations: operationsKpis, + customers: customerKpis, + billing: billingKpis, + staff: staffKpis, + }, + bookingTrend, + bookingsByStatus, + bookingsByPipeline, + paymentTrend, + recentBookings: recentBookings.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + })), + generatedAt: new Date().toISOString(), + }; + } + + async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [ + kpis, + bookingTrend, + statusCounts, + bookingsByFreightType, + bookingsByCurrency, + recentBookings, + ] = await Promise.all([ + this.overviewRepository.getBookingKpis(), + this.overviewRepository.getBookingTrend(days), + this.overviewRepository.getStatusCounts(), + this.overviewRepository.getBookingsByFreightType(), + this.overviewRepository.getBookingsByCurrency(), + this.overviewRepository.getRecentBookings(8), + ]); + + const { bookingsByPipeline, bookingsByStatus } = + this.mapStatusCounts(statusCounts); + + return { + kpis, + bookingTrend, + bookingsByStatus, + bookingsByPipeline, + bookingsByFreightType, + bookingsByCurrency, + recentBookings: recentBookings.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + })), + generatedAt: new Date().toISOString(), + }; + } + + async getBillingTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] = + await Promise.all([ + this.overviewRepository.getBillingKpis(), + this.overviewRepository.getPaymentTrend(days), + this.overviewRepository.getPaymentsByStatus(), + this.overviewRepository.getPaymentsByMethod(), + this.overviewRepository.getRevenueByCurrency(), + ]); + + return { + kpis, + paymentTrend, + paymentsByStatus, + paymentsByMethod, + revenueByCurrency, + generatedAt: new Date().toISOString(), + }; + } + + async getOperationsTab(): Promise { + const [ + kpis, + trainStatusBreakdown, + wagonStatusBreakdown, + containerStatusBreakdown, + cargoStatusBreakdown, + ] = await Promise.all([ + this.overviewRepository.getOperationsKpis(), + this.overviewRepository.getTrainStatusBreakdown(), + this.overviewRepository.getWagonStatusBreakdown(), + this.overviewRepository.getContainerStatusBreakdown(), + this.overviewRepository.getCargoStatusBreakdown(), + ]); + + return { + kpis, + trainStatusBreakdown, + wagonStatusBreakdown, + containerStatusBreakdown, + cargoStatusBreakdown, + generatedAt: new Date().toISOString(), + }; + } + + async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] = + await Promise.all([ + this.overviewRepository.getCustomerKpis(), + this.overviewRepository.getCustomerGrowthTrend(days), + this.overviewRepository.getCustomersByType(), + this.overviewRepository.getTopCustomersByBookings(8), + ]); + + return { + kpis, + customerGrowthTrend, + customersByType, + topCustomersByBookings, + generatedAt: new Date().toISOString(), + }; + } + + async getStaffTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [kpis, usersByStatus, employeeGrowthTrend, activeUsersBreakdown] = + await Promise.all([ + this.overviewRepository.getStaffKpis(), + this.overviewRepository.getUsersByStatus(), + this.overviewRepository.getEmployeeGrowthTrend(days), + this.overviewRepository.getActiveUsersBreakdown(), + ]); + + return { + kpis, + usersByStatus, + employeeGrowthTrend, + activeUsersBreakdown, + generatedAt: new Date().toISOString(), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/dto/initiate-booking-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/dto/initiate-booking-payment.dto.ts deleted file mode 100644 index 3ff5f1798..000000000 --- a/apps/edr-freight-api/src/modules/payment/dto/initiate-booking-payment.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsString } from "class-validator"; - -export class InitiateBookingPayment { - @IsString() - bookingId!: string; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index cb03ee25d..83b4d00dd 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -4,7 +4,7 @@ import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } type PaymentType = "booking" type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" type Currency = "ETB" | "USD" -type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" +export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @Entity({ schema: 'freight', name: 'payments' }) export class PaymentEntity extends BaseEntity { diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 48907966a..4799a4c37 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -1,53 +1,31 @@ -import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common"; +import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common"; import { PaymentService } from "./payment.service"; import { Public } from "@edr/api-common"; -// import { randomUUID } from "crypto"; import { Response } from "express" @Public() @Controller("payments") export class PaymentController { - constructor(private readonly paymentService: PaymentService,) { } + constructor(private readonly paymentService: PaymentService,) { } + @Post("/initiate") + initiate() { + return this.paymentService.initBookingTelebirr("123", "web") + } - // @Get("/receipts/:orderId/html") - // async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) { - // const filled = await this.paymentService.genReceiptHtml(orderId); - // return res.send(filled) - // } + @Post("/bookings/check-payment/:orderId") + checkPayment(@Param("orderId") orderId: string) { + return this.paymentService.checkStatusAndUpdate(orderId) + } - // @Post("/initiate/booking") - // async initiatePayment() { - - // //Only for testing.. - // const description = "Booking for contact" - // const price = 2000 - // const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => { - // return new Promise((resp, _) => { - // resp({ - // id: randomUUID(), - // type: "booking" - // }) - // }); - // }) - - // return data - // } - - @Post("/bookings/check-payment/:orderId") - checkPayment(@Param("orderId") orderId: string) { - return this.paymentService.checkStatusAndUpdate(orderId) + @Get("/bookings/telebirr/redirect/:orderId") + async pay(@Param("orderId") orderId: string, @Res() res: Response) { + const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr") + if (!payment) { + throw new NotFoundException('payment not found') } - - @Get("/telebirr/:refId") - async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) { - const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr") - if (!payment) { - throw new NotFoundException('payment not found') - } - - return res.send(` + return res.send(` @@ -62,6 +40,5 @@ export class PaymentController { `); - } - + } } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index ff6a50943..ac38503b9 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -1,5 +1,4 @@ import { Module } from "@nestjs/common"; -import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy"; import { PaymentService } from "./payment.service"; import { HttpModule } from "@nestjs/axios"; import { PaymentController } from "./payment.controller"; @@ -7,10 +6,11 @@ import { ConfigModule } from "@nestjs/config"; import { PaymentRepository } from "./payment.repository"; import { WebhookController } from "./webhooks/webhook.controller"; import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service"; +import { TelebirrProvider } from "@edr/payment-providers"; @Module({ imports: [HttpModule, ConfigModule], - providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService], + providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider], controllers: [PaymentController, WebhookController], exports: [PaymentService] }) 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 b9e37a55b..3a713c357 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -14,6 +14,13 @@ export class PaymentRepository { return qr.manager.save(payment) } + async create(data: Pick): Promise { + const payment = this.paymentRepo.create(data) + return this.paymentRepo.save(payment) + } + + + findOneBy(options: FindOptionsWhere | FindOptionsWhere[]): Promise { return this.paymentRepo.findOneBy(options); } @@ -36,4 +43,20 @@ export class PaymentRepository { + + + getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]) { + return this.paymentRepo + .createQueryBuilder('payment') + .where('payment.method = :method', { method }) + .andWhere('payment.merchantOrderId = :orderId', { orderId }) + .andWhere('payment.status IN (:...statuses)', { + statuses: ['action-required'], + }) + .andWhere('payment.expiresAt > :now', { now: new Date() }) + .getOne(); + } + + + } \ No newline at end of file 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 549b3db4f..e4168370c 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -1,83 +1,68 @@ import { - BadRequestException, - Injectable, - InternalServerErrorException, - NotFoundException, + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, } from "@nestjs/common"; -import { DataSource, QueryRunner } from "typeorm"; +import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; -import { PaymentStrategy } from "./strategies/payment.strategy"; -import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy"; import { PaymentRepository } from "./payment.repository"; -import { ClientAction, PaymentPlatform } from "./strategies/payments.types"; -import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; import { ConfigService } from "@nestjs/config"; +import { SchedulingStatus } from "@edr/types"; import { Booking } from "../bookings/entities/booking.entity"; -type PaymentMethod = PaymentEntity["method"]; -type CurrencyType = PaymentEntity["currency"]; +import { + ClientAction, + createMerchantOrderId, + ProviderPaymentStatus, + TelebirrProvider, +} from "@edr/payment-providers"; +import { ProviderInitiationInput } from "@edr/types" +import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto"; + +const DEFAULT_CURRENCY = "ETB"; @Injectable() export class PaymentService { - private strategies: Map; + constructor( + private readonly configService: ConfigService, + private readonly datasource: DataSource, + private readonly paymentRepo: PaymentRepository, + private readonly telebirrProvider: TelebirrProvider, + ) { } - constructor( - private readonly configService: ConfigService, - private readonly datasource: DataSource, - private readonly paymentRepo: PaymentRepository, - private readonly telebirrPaymentStategy: PaymentTelebirrStrategy, - ) { - this.strategies = new Map([ - ["telebirr", this.telebirrPaymentStategy as PaymentStrategy], - ]); - } + async initBookingTelebirr( + bookingId: string, + platform: PaymentPlatformDto, + ): Promise<{ redirectUrl: string }> { + // const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId }); + // if (!booking) throw new NotFoundException("Booking not found"); - async pay( - amount: number, - currency: CurrencyType, - method: PaymentMethod, - reason: string, - type: PaymentEntity["type"], - cb: ( - qr: QueryRunner, - ) => Promise<{ id: string; type: PaymentEntity["type"] }>, - payform: PaymentPlatform = "web", - ): Promise<{ - refId: string; - clientAction: ClientAction; - status: PaymentEntity["status"]; - paidAt?: string; - failureCode?: string; - failureMessage?: string; - }> { - const strategy = this.strategies.get(method); - if (!strategy) { - throw new NotFoundException("strategy not found"); - } + // const booking = new Booking() + // booking.totalAmount = 20 + // booking.id = randomUUID + const amount = 20 + const merchantOrderId = createMerchantOrderId(); + const redirectBase = this.configService.get("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL"); + const redirectUrl = `${redirectBase}/${merchantOrderId}`; + const amountMinor = Math.round(Number(amount) * 100); - const orderId = `${Date.now()}${crypto.randomBytes(4).toString("hex")}`; //todo: make it dynamic - let redirectUrl: string; - switch (type) { - case "booking": - const url = this.configService.get( - "TELEBIRR_SUCCESS_REDIRECT_BASE_URL", - ); - redirectUrl = `${url}/${orderId}`; - break; - } + const input: ProviderInitiationInput = { + merchantOrderId, + orderRef: bookingId, + amountMinor, + currency: DEFAULT_CURRENCY, + platform: platform || "web", + redirectUrl, + }; - const paymentResp = await strategy.pay({ - redirectUrl, - amountMinor: amount, - currency: currency, - merchantOrderId: orderId, - platform: payform, - }); + const result = await this.telebirrProvider.initiate(input); +<<<<<<< HEAD const queryRunner = this.datasource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); @@ -168,22 +153,111 @@ export class PaymentService { const ordersStatus = bizContent.order_status; if (ordersStatus == "PAY_SUCCESS") { await this.datasource.transaction(async (mg) => { - await mg.update(Booking, { id: resp.refId }, { status: "PAID" }); + const now = new Date(); + const holdExpires = new Date(now.getTime() + 3 * 60 * 60 * 1000); + await mg.update(Booking, { id: resp.refId }, { + status: "PAID", + schedulingStatus: SchedulingStatus.Holding, + holdStartedAt: now, + holdExpiresAt: holdExpires, + }); await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }); +======= + const payment = await this.paymentRepo.create({ + amount: amount, + currency: DEFAULT_CURRENCY, + method: "telebirr", + refId: bookingId, + type: "booking", + merchantOrderId, + rawInitiation: result.rawInitiation, + clientAction: result.clientAction as Record, + expiresAt: result.expiresAt, + reason: `Payment for booking`, +>>>>>>> eda21e22d872344b74c0c72308f87ce7435b299f }); - } - return { - status: result.status, - }; - } catch { - // Telebirr API unavailable โ€” fall back to current DB payment status - const dbStatus = - resp.status === "success" - ? "success" - : resp.status === "failed" - ? "failed" - : "processing"; - return { status: dbStatus }; + + return { + redirectUrl: `${this.configService.get("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}` + } + } + + + async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { + return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method) + } + + + async genReceiptHtml(orderId: string) { + const payment = await this.paymentRepo.findOneBy({ + merchantOrderId: orderId, + status: "success" + }) + if (!payment) { + throw new BadRequestException() + } + + const filePath = path.join(__dirname, "templates", "receipt.hbs"); + if (!fs.existsSync(filePath)) { + throw new InternalServerErrorException() + } + const source = fs.readFileSync(filePath, "utf8"); + const template = Handlebars.compile(source); + + const html = template({ + vendorName: "Ethio Djibouti Railway Ticket Booking", + vendorAddress: "Addis Ababa", + receiptDate: payment.paidAt, + paymentMethod: payment?.method, + subtotal: payment?.amount.toString(), + total: payment?.amount.toString(), + currency: payment?.currency, + reason: payment?.reason + }); + + return html; + } + + async checkStatusAndUpdate(orderId: string) { + const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId }) + if (!resp) { + throw new NotFoundException("order id not found") + } + const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId) + + if (result.status === ProviderPaymentStatus.SUCCEEDED) { + await this.datasource.transaction(async (mg) => { + await mg.update(Booking, { id: resp.refId }, { status: "PAID" }) + await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }) + }) + } + return { + status: result.status + } + } + + findBookingById(id: string) { + return this.paymentRepo.findOneBy({ refId: id, type: "booking" }) + } + + formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { + const clientAction = + intent.clientAction && typeof intent.clientAction === "object" + ? (intent.clientAction as unknown as ClientAction) + : undefined; + const statusMap: Record = { + "action-required": ProviderPaymentStatus.REQUIRES_ACTION, + "processing": ProviderPaymentStatus.PROCESSING, + "success": ProviderPaymentStatus.SUCCEEDED, + "failed": ProviderPaymentStatus.FAILED, + "canceled": ProviderPaymentStatus.CANCELLED, + "refunded": ProviderPaymentStatus.CANCELLED, + }; + return { + intentId: intent.id, + status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING, + clientAction, + merchantOrderId: intent.merchantOrderId ?? undefined, + }; } - } } diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts new file mode 100644 index 000000000..a3e3d256e --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -0,0 +1,62 @@ +import { ProviderPaymentStatus } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString } from "class-validator"; + +export type PaymentPlatformDto = "web" | "mobile"; + +export class InitiatePaymentDto { + @ApiProperty({ example: "booking-uuid" }) + @IsString() + bookingId!: string; + + @ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" }) + @IsIn(["TELEBIRR"]) + method!: "TELEBIRR"; + + @ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" }) + @IsOptional() + @IsIn(["web", "mobile"]) + platform?: PaymentPlatformDto; +} + +export class ClientActionDto { + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) + type!: "REDIRECT" | "LAUNCH_APP"; + + @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) + url?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + appId?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + receiveCode?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + shortCode?: string; +} + +export class InitiateResponseDto { + @ApiProperty() + intentId!: string; + + @ApiProperty({ enum: ProviderPaymentStatus }) + status!: ProviderPaymentStatus; + + @ApiPropertyOptional({ type: ClientActionDto }) + clientAction?: ClientActionDto; + + @ApiPropertyOptional() + merchantOrderId?: string; +} + +export class IntentStatusDto extends InitiateResponseDto { + @ApiPropertyOptional() + paidAt?: string; + + @ApiPropertyOptional() + failureCode?: string; + + @ApiPropertyOptional() + failureMessage?: string; +} diff --git a/apps/edr-freight-api/src/modules/payment/strategies/payment.strategy.ts b/apps/edr-freight-api/src/modules/payment/strategies/payment.strategy.ts deleted file mode 100644 index b1daa2770..000000000 --- a/apps/edr-freight-api/src/modules/payment/strategies/payment.strategy.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { ProviderInitiationInput, ProviderInitiationResult } from "./payments.types"; - - -@Injectable() -export abstract class PaymentStrategy { - abstract pay(data: ProviderInitiationInput): Promise -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts b/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts deleted file mode 100644 index de1768bc6..000000000 --- a/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { PaymentStrategy } from "./payment.strategy"; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { AxiosError, AxiosRequestConfig } from 'axios'; -import { firstValueFrom } from 'rxjs'; -import * as https from 'node:https'; -import { PaymentEntity } from "../entities/payment.entity"; -import { ProviderInitiationInput, ProviderInitiationResult, ProviderStatus } from "./payments.types"; -import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse } from "./telebirr/telebirr.types"; -import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject } from "./telebirr/telebirr.crypto"; - - - -// type PaymentCurrency = PaymentEntity["currency"] -type PaymentIntentStatus = PaymentEntity["status"] - -const TELEBIRR_HTTP_TIMEOUT_MS = 10_000; - -@Injectable() -export class PaymentTelebirrStrategy implements PaymentStrategy { - async pay(data: ProviderInitiationInput): Promise { - // const refId = randomUUID() - // const orderId = createMerchantOrderId() - const resp = await this.initiate(data) - return resp; - } - - // readonly method = PaymentMethodType.TELEBIRR; - private readonly logger = new Logger(PaymentTelebirrStrategy.name); - private readonly httpsAgent: https.Agent; - - constructor( - private readonly config: ConfigService, - private readonly http: HttpService, - ) { - const insecure = this.config.get('telebirr.insecureTls'); - if (insecure) { - this.logger.warn('TELEBIRR_INSECURE_TLS=true โ€” TLS verification disabled for Telebirr calls. DEV ONLY.'); - } - this.httpsAgent = new https.Agent({ - rejectUnauthorized: !insecure, - secureProtocol: 'TLSv1_2_method', - }); - } - - async initiate(input: ProviderInitiationInput): Promise { - const fabricToken = await this.applyFabricToken(); - const requestBody = this.buildCreateOrderRequest(input); - const response = await this.requestCreateOrder(fabricToken, requestBody); - - const prepayId = response.biz_content?.prepay_id; - if (!prepayId) { - throw new Error( - `Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`, - ); - } - - const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express); - const platform = input.platform ?? 'web'; - const clientAction = - platform === 'mobile' - ? { - type: 'LAUNCH_APP' as const, - prepayId, - receiveCode: response.biz_content?.receiveCode, - shortCode: this.merchantCode, - } - : { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) }; - - return { - providerOrderId: prepayId, - clientAction, - expiresAt, - rawInitiation: { - request: this.sanitize(requestBody), - response, - }, - }; - } - - async queryStatus(merchantOrderId: string): Promise { - const fabricToken = await this.applyFabricToken(); - const requestBody = this.buildQueryOrderRequest(merchantOrderId); - const response = await this.postJson( - `${this.baseUrl}/payment/v1/merchant/queryOrder`, - requestBody, - { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, - Authorization: fabricToken, - }, - ); - - const tradeStatus = response.biz_content?.trade_status; - const providerTxnId = - response.biz_content?.trans_id ?? response.biz_content?.payment_order_id; - const mapped = this.mapTradeStatus(tradeStatus); - - return { - status: mapped, - providerTxnId, - failureCode: - mapped === "failed" && tradeStatus ? tradeStatus : undefined, - rawResponse: response as Record, - }; - } - - mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus { - switch (tradeStatus) { - case 'PAY_SUCCESS': - return "success"; - case 'PAY_FAILED': - case 'ORDER_CLOSED': - return "failed"; - case 'WAIT_PAY': - return "action-required"; - case 'PAYING': - return "processing"; - default: - return "processing"; - } - } - - mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus { - switch (tradeStatus) { - case 'Completed': - return "success"; - case 'Failure': - case 'Expired': - return "failed"; - case 'Paying': - case 'Pending': - return "processing"; - default: - return "processing"; - } - } - - verifyWebhookSignature(payload: Record): boolean { - if (!this.publicKey) { - this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks'); - return false; - } - return verifyRequestObject(payload, this.publicKey); - } - - private async applyFabricToken(): Promise { - console.log(this.baseUrl, "base url") - const response = await this.postJson( - `${this.baseUrl}/payment/v1/token`, - { appSecret: this.appSecret }, - { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, - }, - ); - if (!response?.token) { - throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`); - } - return response.token; - } - - private async requestCreateOrder( - fabricToken: string, - body: CreateOrderRequest, - ): Promise { - return this.postJson( - `${this.baseUrl}/payment/v1/inapp/createOrder`, - body, - { - 'Content-Type': 'application/json', - 'X-APP-Key': this.fabricAppId, - Authorization: fabricToken, - }, - ); - } - - private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest { - // const totalAmount = String(input.amountMinor / 100); - const totalAmount = String(input.amountMinor) - const req = { - timestamp: createTimestamp(), - nonce_str: createNonceStr(), - method: 'payment.preorder' as const, - version: '1.0' as const, - biz_content: { - notify_url: this.notifyUrl, - appid: this.merchantAppId, - redirect_url: input.redirectUrl, - merch_code: this.merchantCode, - merch_order_id: input.merchantOrderId, - trade_type: 'Checkout' as const, - title: `EDR Booking`, - total_amount: totalAmount, - trans_currency: input.currency, - timeout_express: this.timeoutExpress, - }, - }; - const sign = signRequestObject(req as unknown as Record, this.privateKey); - return { ...req, sign, sign_type: 'SHA256WithRSA' }; - } - - private buildQueryOrderRequest(merchantOrderId: string): Record { - const req = { - timestamp: createTimestamp(), - nonce_str: createNonceStr(), - method: 'payment.queryorder', - version: '1.0', - biz_content: { - appid: this.merchantAppId, - merch_code: this.merchantCode, - merch_order_id: merchantOrderId, - }, - }; - const sign = signRequestObject(req as Record, this.privateKey); - return { ...req, sign, sign_type: 'SHA256WithRSA' }; - } - - private buildCheckoutUrl(prepayId: string): string { - const map: Record = { - appid: this.merchantAppId, - merch_code: this.merchantCode, - nonce_str: createNonceStr(), - prepay_id: prepayId, - timestamp: createTimestamp(), - }; - const sign = signRequestObject(map, this.privateKey); - const rawRequest = [ - `appid=${map.appid}`, - `merch_code=${map.merch_code}`, - `nonce_str=${map.nonce_str}`, - `prepay_id=${map.prepay_id}`, - `timestamp=${map.timestamp}`, - 'sign_type=SHA256WithRSA', - `sign=${sign}`, - 'version=1.0', - 'trade_type=Checkout', - ].join('&'); - return `${this.webBaseUrl}${rawRequest}`; - } - - private computeExpiresAt(timeoutExpress: string): Date { - const match = /^(\d+)([smhd])$/.exec(timeoutExpress); - const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15; - return new Date(Date.now() + minutes * 60_000); - } - - private toMinutes(n: number, unit: string): number { - switch (unit) { - case 's': return Math.max(1, Math.round(n / 60)); - case 'm': return n; - case 'h': return n * 60; - case 'd': return n * 60 * 24; - default: return 15; - } - } - - private async postJson( - url: string, - body: unknown, - headers: Record, - ): Promise { - const config: AxiosRequestConfig = { - headers, - timeout: TELEBIRR_HTTP_TIMEOUT_MS, - httpsAgent: this.httpsAgent, - }; - const started = Date.now(); - try { - const res = await firstValueFrom(this.http.post(url, body, config)); - this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`); - return res.data; - } catch (err) { - if (err instanceof AxiosError) { - this.logger.error( - `Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`, - ); - } else { - this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`); - } - throw err; - } - } - - private sanitize(body: CreateOrderRequest): Record { - const { sign: _sign, ...rest } = body; - return rest; - } - - private get baseUrl(): string { return this.config.get('telebirr.baseUrl') ?? ''; } - private get webBaseUrl(): string { return this.config.get('telebirr.webBaseUrl') ?? ''; } - private get fabricAppId(): string { return this.config.get('telebirr.fabricAppId') ?? ''; } - private get appSecret(): string { return this.config.get('telebirr.appSecret') ?? ''; } - private get merchantAppId(): string { return this.config.get('telebirr.merchantAppId') ?? ''; } - private get merchantCode(): string { return this.config.get('telebirr.merchantCode') ?? ''; } - private get notifyUrl(): string { return this.config.get('telebirr.notifyUrl') ?? ''; } - private get timeoutExpress(): string { return this.config.get('telebirr.timeoutExpress') ?? '15m'; } - private get privateKey(): string { return this.config.get('telebirr.privateKey') ?? ''; } - private get publicKey(): string { - return this.config.get('telebirr.publicKey') ?? ''; - } - -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/strategies/payments.types.ts b/apps/edr-freight-api/src/modules/payment/strategies/payments.types.ts deleted file mode 100644 index 75a1c8bdc..000000000 --- a/apps/edr-freight-api/src/modules/payment/strategies/payments.types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { PaymentEntity } from "../entities/payment.entity"; - -type PaymentIntentStatus = PaymentEntity["status"] -type PaymentMethodType = PaymentEntity["method"] - -export type PaymentPlatform = 'web' | 'mobile'; - -export type ClientAction = - | { type: 'REDIRECT'; url: string } - | { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string }; - -export interface ProviderInitiationInput { - redirectUrl: string; - merchantOrderId: string; - // bookingRef: string; - amountMinor: number; - currency: string; - platform?: PaymentPlatform; -} - -export interface ProviderInitiationResult { - providerOrderId: string; - clientAction: ClientAction; - expiresAt: Date; - rawInitiation: Record; -} - -export interface ProviderStatus { - status: PaymentIntentStatus; - providerTxnId?: string; - failureCode?: string; - failureMessage?: string; - rawResponse: Record; -} - -export interface PaymentProvider { - readonly method: PaymentMethodType; - initiate(input: ProviderInitiationInput): Promise; - queryStatus(merchantOrderId: string): Promise; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.crypto.ts b/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.crypto.ts deleted file mode 100644 index 20319818d..000000000 --- a/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.crypto.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as crypto from 'crypto'; - -const EXCLUDE_FIELDS = new Set([ - 'sign', - 'sign_type', - 'header', - 'refund_info', - 'openType', - 'raw_request', - 'biz_content', -]); - -const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - -export function buildCanonicalString(requestObject: Record): string { - const fieldMap: Record = {}; - - for (const key of Object.keys(requestObject)) { - if (EXCLUDE_FIELDS.has(key)) continue; - fieldMap[key] = requestObject[key]; - } - - const biz = requestObject['biz_content']; - if (biz && typeof biz === 'object') { - for (const key of Object.keys(biz as Record)) { - if (EXCLUDE_FIELDS.has(key)) continue; - fieldMap[key] = (biz as Record)[key]; - } - } - - return Object.keys(fieldMap) - .sort() - .map((k) => `${k}=${fieldMap[k]}`) - .join('&'); -} - -export function signRequestObject( - requestObject: Record, - privateKey: string, -): string { - return signString(buildCanonicalString(requestObject), privateKey); -} - -export function verifyRequestObject( - requestObject: Record, - publicKey: string, -): boolean { - const signature = requestObject['sign']; - if (typeof signature !== 'string' || signature.length === 0) return false; - return verifySignature(buildCanonicalString(requestObject), signature, publicKey); -} - -export function signString(text: string, privateKey: string): string { - const signature = crypto.sign('sha256', Buffer.from(text), { - key: privateKey, - padding: crypto.constants.RSA_PKCS1_PSS_PADDING, - saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, - }); - return signature.toString('base64'); -} - -export function verifySignature( - text: string, - signatureBase64: string, - publicKey: string, -): boolean { - try { - return crypto.verify( - 'sha256', - Buffer.from(text), - { - key: publicKey, - padding: crypto.constants.RSA_PKCS1_PSS_PADDING, - saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, - }, - Buffer.from(signatureBase64, 'base64'), - ); - } catch { - return false; - } -} - -export function createTimestamp(): string { - return Math.round(Date.now() / 1000).toString(); -} - -export function createNonceStr(length = 32): string { - const bytes = crypto.randomBytes(length); - let out = ''; - for (let i = 0; i < length; i++) { - out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length]; - } - return out; -} - -export function createMerchantOrderId(): string { - return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.types.ts b/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.types.ts deleted file mode 100644 index 6cc29e9f4..000000000 --- a/apps/edr-freight-api/src/modules/payment/strategies/telebirr/telebirr.types.ts +++ /dev/null @@ -1,69 +0,0 @@ -export interface FabricTokenResponse { - token: string; - expires_in?: number | string; -} - -export interface CreateOrderBizContent { - notify_url: string; - appid: string; - merch_code: string; - merch_order_id: string; - trade_type: 'Checkout' | 'InApp' | 'MiniApp'; - title: string; - total_amount: string; - trans_currency: string; - timeout_express: string; -} - -export interface CreateOrderRequest { - timestamp: string; - nonce_str: string; - method: 'payment.preorder'; - version: '1.0'; - biz_content: CreateOrderBizContent; - sign: string; - sign_type: 'SHA256WithRSA'; -} - -export interface CreateOrderResponse { - code?: string; - msg?: string; - biz_content?: { - prepay_id?: string; - receiveCode?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -export type TelebirrTradeStatus = - | 'PAY_SUCCESS' - | 'PAY_FAILED' - | 'WAIT_PAY' - | 'ORDER_CLOSED' - | 'PAYING' - | 'ACCEPTED' - | 'REFUNDING' - | 'REFUND_SUCCESS' - | 'REFUND_FAILED'; - -export interface QueryOrderResponse { - result?: 'SUCCESS' | 'FAIL'; - code?: string; - msg?: string; - nonce_str?: string; - sign?: string; - sign_type?: string; - biz_content?: { - merch_order_id?: string; - order_status?: string; - trade_status?: TelebirrTradeStatus | string; - payment_order_id?: string; - trans_id?: string; - trans_time?: string; - trans_currency?: string; - total_amount?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts b/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts index 3e450c2a0..cf89a2d60 100644 --- a/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts +++ b/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts @@ -1,82 +1,53 @@ -import { Injectable, } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as crypto from "crypto" +import { Injectable, Logger } from '@nestjs/common'; import { TelebirrDto } from '../dto/telebirr.dto'; import { PaymentRepository } from '../../payment.repository'; import { DataSource } from 'typeorm'; -import { Booking } from 'src/modules/bookings/entities/booking.entity'; +import { Booking } from '../../../bookings/entities/booking.entity'; +import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers'; + @Injectable() export class TelebirrWebhookService { - // private readonly logger = new Logger(TelebirrWebhookService.name); + private readonly logger = new Logger(TelebirrWebhookService.name); constructor( private readonly datasource: DataSource, - private readonly config: ConfigService, private readonly paymentRepo: PaymentRepository, - + private readonly telebirrProvider: TelebirrProvider, ) { } verifyTelebirrNotification(payload: TelebirrDto) { - // 1. Extract the signature provided by Telebirr - const { sign, ...bizContent } = payload; - - if (!sign) { - throw new Error("Missing 'sign' field from Telebirr payload"); - } - - // 2. Sort the remaining keys alphabetically to rebuild the raw string - const sortedKeys = Object.keys(bizContent).sort(); - const signString = sortedKeys - .map(key => `${key}=${typeof bizContent[key] === 'object' ? JSON.stringify(bizContent[key]) : bizContent[key]}`) - .join('&'); - - // 3. Convert Telebirr's public key into an object specifying RSA-PSS padding - const publicKey = { - key: this.config.get("telebirr.publicKey") ?? "", - padding: crypto.constants.RSA_PKCS1_PSS_PADDING, - saltLength: 32 // Telebirr standard salt length - }; - - // 4. Verify the signature against the sorted string - const isVerified = crypto.verify( - "sha256", - Buffer.from(signString), - publicKey, - Buffer.from(sign, 'base64') - ); - - return isVerified; + return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record); } async handle(payload: TelebirrDto): Promise { const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id }) if (!payment) { - throw new Error("payment not found") + this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`); + return; } - switch (payload.trade_status) { - case "SUCCEEDED": - await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() }) - switch (payment.type) { - case "booking": - await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", }) - // await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", }) - break; + + const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status); + + switch (mapped) { + case ProviderPaymentStatus.SUCCEEDED: + await this.paymentRepo.update( + { id: payment.id }, + { status: "success", paidAt: new Date() }, + ); + if (payment.type === "booking") { + await this.datasource.manager.update( + Booking, + { id: payment.refId }, + { paymentStatus: "PAID" }, + ); } break; - case "FAILED": - await this.paymentRepo.update({ id: payment.id }, { status: "failed" }) + case ProviderPaymentStatus.FAILED: + await this.paymentRepo.update({ id: payment.id }, { status: "failed" }); break; - case "CANCELLED": - await this.paymentRepo.update({ id: payment.id }, { status: "canceled" }) + case ProviderPaymentStatus.PROCESSING: + await this.paymentRepo.update({ id: payment.id }, { status: "processing" }); break; - case "PROCESSING": - await this.paymentRepo.update({ id: payment.id }, { status: "processing" }) - break; - case "REFUNDED": - await this.paymentRepo.update({ id: payment.id }, { status: "refunded" }) - break; - } } - } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts b/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts index d74eb82a0..16473e614 100644 --- a/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts @@ -22,14 +22,12 @@ export class WebhookController { ); try { - // const verified = this.telebirr.verifyTelebirrNotification(payload) - // if (!verified) { - // throw new Error("not valid") - // } - // const merchantOrderId = payload.merch_order_id; + const verified = this.telebirr.verifyTelebirrNotification(payload) + if (!verified) { + throw new Error("Telebirr webhook signature verification failed") + } await this.telebirr.handle(payload); - } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.error(`Telebirr webhook handler threw: ${message}`); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts index 72e35b296..8e13d3ec7 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts @@ -5,6 +5,8 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; import { ApprovalRulesService } from '../services/approval-rules.service'; @@ -35,6 +37,22 @@ export class ApprovalRulesController { return this.service.findChain(flag === 'true'); } + @Post('reorder') + @RuleEngineManage('approval-rules') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder approval steps within a chain' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('approval-rules') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move an approval step up or down within its chain' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + @Get(':id') @RuleEngineView('approval-rules') @ApiOperation({ summary: 'Get an approval rule by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index 4941a5ebb..e2b8425bf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -5,6 +5,8 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoTypesService } from '../services/cargo-types.service'; @@ -32,6 +34,22 @@ export class CargoTypesController { }); } + @Post('reorder') + @RuleEngineManage('cargo-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder cargo types by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('cargo-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a cargo type up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + @Get(':id') @RuleEngineView('cargo-types') @ApiOperation({ summary: 'Get a cargo type by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts index 43dfcec33..624cf4b03 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -5,6 +5,8 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { ContainerTypesService } from '../services/container-types.service'; @@ -25,6 +27,22 @@ export class ContainerTypesController { }); } + @Post('reorder') + @RuleEngineManage('container-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder container types by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('container-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a container type up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + @Get(':id') @RuleEngineView('container-types') @ApiOperation({ summary: 'Get a container type by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts index 3044515fb..18c597b38 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -5,6 +5,8 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; import { ServiceTypesService } from '../services/service-types.service'; @@ -29,6 +31,22 @@ export class ServiceTypesController { }); } + @Post('reorder') + @RuleEngineManage('service-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder service types by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('service-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a service type up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + @Get(':id') @RuleEngineView('service-types') @ApiOperation({ summary: 'Get a service type by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index 88523967e..d18d0b748 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -5,6 +5,8 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateYardDto } from '../dto/create-yard.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateYardDto } from '../dto/update-yard.dto'; import { YardsService } from '../services/yards.service'; @@ -26,6 +28,22 @@ export class YardsController { }); } + @Post('reorder') + @RuleEngineManage('yards') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder yards by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('yards') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a yard up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + @Get(':id') @RuleEngineView('yards') @ApiOperation({ summary: 'Get a yard by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts index 6ccc95384..5861b1ad8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const; @@ -8,10 +8,16 @@ export class CreateApprovalRuleDto { @IsBoolean() requiresDirectorApproval!: boolean; - @ApiProperty({ description: 'Step sequence number (1 = first, 2 = second)', minimum: 1 }) + @ApiPropertyOptional({ description: 'Step sequence number (auto-assigned if omitted)', minimum: 1 }) + @IsOptional() @IsInt() @Min(1) - stepOrder!: number; + stepOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this step ID within the same chain' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; @ApiProperty({ enum: ROLES, description: 'Role required to action this step' }) @IsString() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index ae2e23c33..57fe48fed 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -32,4 +32,9 @@ export class CreateCargoTypeDto { @IsInt() @Min(1) displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index dbfb5ca2b..52cfe274b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateContainerTypeDto { @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @@ -40,4 +40,9 @@ export class CreateContainerTypeDto { @IsInt() @Min(1) displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index b20203e13..4683d448d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateServiceTypeDto { @ApiProperty({ description: 'Service type display name', maxLength: 255 }) @@ -48,4 +48,9 @@ export class CreateServiceTypeDto { @IsInt() @Min(1) displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index f0d9ff012..38f2bc58b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateYardDto { @ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 }) @@ -22,4 +22,9 @@ export class CreateYardDto { @IsInt() @Min(1) displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/move-order.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/move-order.dto.ts new file mode 100644 index 000000000..91eadc0d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/move-order.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +export class MoveOrderDto { + @ApiProperty({ enum: ['up', 'down'] }) + @IsIn(['up', 'down']) + direction!: 'up' | 'down'; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/reorder-items.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/reorder-items.dto.ts new file mode 100644 index 000000000..48a3e6b6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/reorder-items.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsUUID } from 'class-validator'; + +export class ReorderItemsDto { + @ApiProperty({ description: 'Ordered list of record IDs (new display/step order)', type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + ids!: string[]; + + @ApiPropertyOptional({ + description: 'Approval-rules only: scope reorder to this chain', + }) + @IsOptional() + @IsBoolean() + requiresDirectorApproval?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/government-priority.constants.ts b/apps/edr-freight-api/src/modules/rule-engine/government-priority.constants.ts new file mode 100644 index 000000000..690352602 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/government-priority.constants.ts @@ -0,0 +1,2 @@ +/** Ensures government bookings outrank commercial priority (max ~1,500 today). */ +export const GOVERNMENT_PRIORITY_BONUS = 50_000; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 9657e6865..4af5066ce 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -46,6 +46,7 @@ import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.re import { YardsRepository } from './repositories/yards.repository'; import { ApprovalRulesService } from './services/approval-rules.service'; +import { DisplayOrderService } from './services/display-order.service'; import { CargoTypesService } from './services/cargo-types.service'; import { ContainerTypesService } from './services/container-types.service'; import { PriorityRulesService } from './services/priority-rules.service'; @@ -126,6 +127,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ShippingLinesService, RatesService, ApprovalRulesService, + DisplayOrderService, RuleEngineService, ], exports: [ diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 98aeaffbf..9bf3635e9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -36,6 +36,7 @@ import { SHIPPING_LINES_REPOSITORY, } from './interfaces/shipping-lines.repository.interface'; import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults'; +import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants'; export interface BookingContainerEvalInput { containerTypeId: string; @@ -54,6 +55,7 @@ export interface BookingEvaluationInput { paymentCurrency: string; tradeDirection: string; isHazardous: boolean; + isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; containers: BookingContainerEvalInput[]; @@ -180,6 +182,10 @@ export class RuleEngineService { } } + if (input.isGovernment) { + priorityScore += GOVERNMENT_PRIORITY_BONUS; + } + let shippingLineMapped = false; if (input.shippingLineId) { const line = await this.shippingLinesRepo.findById(input.shippingLineId); @@ -315,15 +321,27 @@ export class RuleEngineService { } /** - * Snapshot all LIVE rates into booking_rate_snapshot for a booking. + * Snapshot only the rates used in a booking's final price. */ - async snapshotLiveRates(bookingId: string): Promise { - const liveRates = await this.ratesRepo.findLiveRates(); + async snapshotRates( + bookingId: string, + rates: Array<{ + id: string; + rateType: string; + rateValue: number; + rateUnit: string; + currency: string; + }>, + ): Promise { const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot); const now = new Date(); + const seen = new Set(); const snapshots: BookingRateSnapshot[] = []; - for (const rate of liveRates) { + for (const rate of rates) { + if (seen.has(rate.id)) continue; + seen.add(rate.id); + const snapshot = snapshotRepo.create({ bookingId, rateId: rate.id, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts index 4a4e33442..063cc6439 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts @@ -1,17 +1,20 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; import { ApprovalRule } from '../entities/approval-rule.entity'; import { APPROVAL_RULES_REPOSITORY, IApprovalRulesRepository, } from '../interfaces/approval-rules.repository.interface'; +import { DisplayOrderService } from './display-order.service'; @Injectable() export class ApprovalRulesService { constructor( @Inject(APPROVAL_RULES_REPOSITORY) private readonly repository: IApprovalRulesRepository, + private readonly displayOrder: DisplayOrderService, ) {} /** List approval rules. */ @@ -21,7 +24,7 @@ export class ApprovalRulesService { pageSize?: number; }): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; + const pageSize = filter.pageSize ?? 10; const where: Record = {}; if (filter.requiresDirectorApproval !== undefined) { where.requiresDirectorApproval = filter.requiresDirectorApproval; @@ -50,9 +53,20 @@ export class ApprovalRulesService { /** Create an approval rule step. */ async create(dto: CreateApprovalRuleDto): Promise { + if (dto.stepOrder !== undefined && dto.insertAfterId) { + throw new BadRequestException('Cannot set both stepOrder and insertAfterId'); + } + + const scopeWhere = { requiresDirectorApproval: dto.requiresDirectorApproval }; + const stepOrder = await this.displayOrder.resolveCreateOrder(ApprovalRule, 'stepOrder', { + explicitOrder: dto.stepOrder, + insertAfterId: dto.insertAfterId, + scopeWhere, + }); + return this.repository.create({ requiresDirectorApproval: dto.requiresDirectorApproval, - stepOrder: dto.stepOrder, + stepOrder, requiredRole: dto.requiredRole, actionLabel: dto.actionLabel, blocksRole: dto.blocksRole, @@ -72,4 +86,20 @@ export class ApprovalRulesService { await this.findById(id); await this.repository.softDelete(id); } + + async reorder(dto: ReorderItemsDto): Promise { + if (dto.requiresDirectorApproval === undefined) { + throw new BadRequestException('requiresDirectorApproval is required for approval rule reorder'); + } + await this.displayOrder.reorderByIds(ApprovalRule, 'stepOrder', dto.ids, { + requiresDirectorApproval: dto.requiresDirectorApproval, + }); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + const rule = await this.findById(id); + await this.displayOrder.moveOne(ApprovalRule, 'stepOrder', id, direction, { + requiresDirectorApproval: rule.requiresDirectorApproval, + }); + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 130b1d605..634ac5faa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj import { ILike } from 'typeorm'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoType } from '../entities/cargo-type.entity'; import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, } from '../interfaces/cargo-types.repository.interface'; +import { DisplayOrderService } from './display-order.service'; @Injectable() export class CargoTypesService { constructor( @Inject(CARGO_TYPES_REPOSITORY) private readonly repository: ICargoTypesRepository, + private readonly displayOrder: DisplayOrderService, ) {} /** List cargo types with pagination and optional filtering. */ @@ -28,7 +31,7 @@ export class CargoTypesService { sortOrder?: 'ASC' | 'DESC'; }): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; + const pageSize = filter.pageSize ?? 10; const where: Record = {}; if (filter.isActive !== undefined) where.isActive = filter.isActive; if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval; @@ -66,6 +69,12 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } + + const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + return this.repository.create({ code, cargoTypeName: dto.cargoTypeName, @@ -73,7 +82,7 @@ export class CargoTypesService { showFreeTextBox: dto.showFreeTextBox ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, - displayOrder: dto.displayOrder ?? 1, + displayOrder, }); } @@ -95,4 +104,13 @@ export class CargoTypesService { await this.findById(id); await this.repository.softDelete(id); } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(CargoType, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(CargoType, 'displayOrder', id, direction); + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 9b0209311..38407f36a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -1,18 +1,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { ContainerType } from '../entities/container-type.entity'; import { CONTAINER_TYPES_REPOSITORY, IContainerTypesRepository, } from '../interfaces/container-types.repository.interface'; +import { DisplayOrderService } from './display-order.service'; @Injectable() export class ContainerTypesService { constructor( @Inject(CONTAINER_TYPES_REPOSITORY) private readonly repository: IContainerTypesRepository, + private readonly displayOrder: DisplayOrderService, ) {} /** List container types with pagination. */ @@ -22,7 +25,7 @@ export class ContainerTypesService { pageSize?: number; }): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; + const pageSize = filter.pageSize ?? 10; const where: Record = {}; if (filter.isActive !== undefined) where.isActive = filter.isActive; @@ -47,6 +50,12 @@ export class ContainerTypesService { const code = generateCode(dto.label); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Container type with label "${dto.label}" conflicts with existing code "${code}"`); + + const displayOrder = await this.displayOrder.resolveCreateOrder(ContainerType, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + return this.repository.create({ code, label: dto.label, @@ -55,7 +64,7 @@ export class ContainerTypesService { isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, - displayOrder: dto.displayOrder ?? 1, + displayOrder, }); } @@ -72,4 +81,13 @@ export class ContainerTypesService { await this.findById(id); await this.repository.softDelete(id); } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(ContainerType, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(ContainerType, 'displayOrder', id, direction); + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/display-order.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/display-order.service.ts new file mode 100644 index 000000000..e0ee48106 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/display-order.service.ts @@ -0,0 +1,175 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityTarget, FindOptionsWhere, ObjectLiteral } from 'typeorm'; + +export type OrderField = 'displayOrder' | 'stepOrder'; + +@Injectable() +export class DisplayOrderService { + constructor(private readonly dataSource: DataSource) {} + + async getMaxOrder( + entity: EntityTarget, + field: OrderField, + where?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const qb = repo.createQueryBuilder('e').select(`MAX(e.${field})`, 'max'); + if (where) { + Object.entries(where).forEach(([key, value]) => { + if (value !== undefined) { + qb.andWhere(`e.${key} = :${key}`, { [key]: value }); + } + }); + } + const row = await qb.getRawOne<{ max: string | null }>(); + return row?.max ? Number(row.max) : 0; + } + + async resolveCreateOrder( + entity: EntityTarget, + field: OrderField, + options: { + explicitOrder?: number; + insertAfterId?: string; + scopeWhere?: FindOptionsWhere; + }, + ): Promise { + const { explicitOrder, insertAfterId, scopeWhere } = options; + + if (insertAfterId) { + if (explicitOrder !== undefined) { + throw new BadRequestException('Cannot set both explicit order and insertAfterId'); + } + const repo = this.dataSource.getRepository(entity); + const after = await repo.findOne({ + where: { id: insertAfterId, ...scopeWhere } as unknown as FindOptionsWhere, + }); + if (!after) { + throw new NotFoundException(`Record ${insertAfterId} not found in scope`); + } + const afterOrder = Number((after as Record)[field]); + await this.shiftOrdersFrom(entity, field, afterOrder + 1, 1, scopeWhere); + return afterOrder + 1; + } + + if (explicitOrder !== undefined) { + return explicitOrder; + } + + const max = await this.getMaxOrder(entity, field, scopeWhere); + return max + 1; + } + + async reorderByIds( + entity: EntityTarget, + field: OrderField, + ids: string[], + scopeWhere?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const existing = await repo.find({ + where: scopeWhere, + order: { [field]: 'ASC' } as never, + }); + + const scopedIds = new Set(existing.map((row) => String(row.id))); + if (ids.length !== scopedIds.size) { + throw new BadRequestException('Reorder list must include every item in scope exactly once'); + } + for (const id of ids) { + if (!scopedIds.has(id)) { + throw new BadRequestException(`ID ${id} is not in the reorder scope`); + } + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + for (let i = 0; i < ids.length; i++) { + await queryRunner.manager.update(entity, ids[i], { [field]: -(i + 1) } as never); + } + for (let i = 0; i < ids.length; i++) { + await queryRunner.manager.update(entity, ids[i], { [field]: i + 1 } as never); + } + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + async moveOne( + entity: EntityTarget, + field: OrderField, + id: string, + direction: 'up' | 'down', + scopeWhere?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const items = await repo.find({ + where: scopeWhere, + order: { [field]: 'ASC' } as never, + }); + + const index = items.findIndex((row) => String(row.id) === id); + if (index === -1) { + throw new NotFoundException(`Record ${id} not found in scope`); + } + + const targetIndex = direction === 'up' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= items.length) { + throw new BadRequestException(`Cannot move ${direction}`); + } + + const current = items[index] as Record; + const neighbor = items[targetIndex] as Record; + const currentOrder = Number(current[field]); + const neighborOrder = Number(neighbor[field]); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + await queryRunner.manager.update(entity, String(current.id), { [field]: -1 } as never); + await queryRunner.manager.update(entity, String(neighbor.id), { [field]: -2 } as never); + await queryRunner.manager.update(entity, String(current.id), { [field]: neighborOrder } as never); + await queryRunner.manager.update(entity, String(neighbor.id), { [field]: currentOrder } as never); + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + private async shiftOrdersFrom( + entity: EntityTarget, + field: OrderField, + fromOrder: number, + delta: number, + scopeWhere?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const orderColumn = repo.metadata.findColumnWithPropertyName(field)?.databaseName ?? field; + const qb = repo + .createQueryBuilder() + .update() + .set({ [field]: () => `"${orderColumn}" + ${delta}` } as never) + .where(`"${orderColumn}" >= :fromOrder`, { fromOrder }); + + if (scopeWhere) { + Object.entries(scopeWhere).forEach(([key, value]) => { + if (value !== undefined) { + const col = repo.metadata.findColumnWithPropertyName(key)?.databaseName ?? key; + qb.andWhere(`"${col}" = :scope_${key}`, { [`scope_${key}`]: value }); + } + }); + } + + await qb.execute(); + } +} 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 0202ef44a..525185058 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 @@ -30,7 +30,7 @@ export class RatesService { skip: (page - 1) * pageSize, take: pageSize, }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) } }; } /** Return all currently LIVE rates. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts index 1d54582a1..2ad8753c3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj import { ILike } from 'typeorm'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; import { ServiceType } from '../entities/service-type.entity'; import { IServiceTypesRepository, SERVICE_TYPES_REPOSITORY, } from '../interfaces/service-types.repository.interface'; +import { DisplayOrderService } from './display-order.service'; @Injectable() export class ServiceTypesService { constructor( @Inject(SERVICE_TYPES_REPOSITORY) private readonly repository: IServiceTypesRepository, + private readonly displayOrder: DisplayOrderService, ) {} /** List service types with pagination and optional filtering. */ @@ -27,7 +30,7 @@ export class ServiceTypesService { sortOrder?: 'ASC' | 'DESC'; }): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; + const pageSize = filter.pageSize ?? 10; const where: Record = {}; if (filter.isActive !== undefined) where.isActive = filter.isActive; if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone; @@ -59,6 +62,12 @@ export class ServiceTypesService { const code = generateCode(dto.serviceName); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`); + + const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + return this.repository.create({ code, serviceName: dto.serviceName, @@ -69,7 +78,7 @@ export class ServiceTypesService { includesCustoms: dto.includesCustoms ?? false, priorityBonusPoints: dto.priorityBonusPoints ?? 0, isActive: dto.isActive ?? true, - displayOrder: dto.displayOrder ?? 1, + displayOrder, }); } @@ -87,4 +96,13 @@ export class ServiceTypesService { await this.findById(id); await this.repository.softDelete(id); } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(ServiceType, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(ServiceType, 'displayOrder', id, direction); + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 5e53cb1fd..0c95582af 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -1,15 +1,18 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateYardDto } from '../dto/create-yard.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateYardDto } from '../dto/update-yard.dto'; import { Yard } from '../entities/yard.entity'; import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; +import { DisplayOrderService } from './display-order.service'; @Injectable() export class YardsService { constructor( @Inject(YARDS_REPOSITORY) private readonly repository: IYardsRepository, + private readonly displayOrder: DisplayOrderService, ) {} /** List yards with pagination. */ @@ -20,7 +23,7 @@ export class YardsService { pageSize?: number; }): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; + const pageSize = filter.pageSize ?? 10; const where: Record = {}; if (filter.isActive !== undefined) where.isActive = filter.isActive; if (filter.country) where.country = filter.country; @@ -46,12 +49,18 @@ export class YardsService { const code = generateCode(dto.label); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`); + + const displayOrder = await this.displayOrder.resolveCreateOrder(Yard, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + return this.repository.create({ code, label: dto.label, country: dto.country, isActive: dto.isActive ?? true, - displayOrder: dto.displayOrder ?? 1, + displayOrder, }); } @@ -68,4 +77,13 @@ export class YardsService { await this.findById(id); await this.repository.softDelete(id); } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(Yard, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(Yard, 'displayOrder', id, direction); + } } diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/dto/preview-reschedule.dto.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/dto/preview-reschedule.dto.ts new file mode 100644 index 000000000..d1b6f80f3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/dto/preview-reschedule.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsIn, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +import { RESCHEDULE_TRIGGERS } from '../entities/scheduling-event.entity'; + +export class PreviewRescheduleDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + incomingBookingIds!: string[]; + + @ApiProperty({ enum: RESCHEDULE_TRIGGERS }) + @IsIn([...RESCHEDULE_TRIGGERS]) + trigger!: (typeof RESCHEDULE_TRIGGERS)[number]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reason?: string; + + @ApiPropertyOptional({ example: '2026-06-22T08:00:00.000Z' }) + @IsOptional() + @IsDateString() + newDepartureDate?: string; +} + +export class ExecuteRescheduleDto extends PreviewRescheduleDto { + @ApiProperty({ type: [String], description: 'Booking IDs to assign after reschedule' }) + @IsArray() + @IsUUID('4', { each: true }) + finalBookingIds!: string[]; + + @ApiProperty({ type: [String], description: 'Booking IDs removed from the schedule' }) + @IsArray() + @IsUUID('4', { each: true }) + displacedBookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/entities/scheduling-event.entity.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/entities/scheduling-event.entity.ts new file mode 100644 index 000000000..c8814755c --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/entities/scheduling-event.entity.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const RESCHEDULE_TRIGGERS = [ + 'GOVERNMENT_PREEMPT', + 'TRAIN_MAINTENANCE', + 'MANUAL', + 'CAPACITY_REBALANCE', +] as const; + +export type RescheduleTrigger = (typeof RESCHEDULE_TRIGGERS)[number]; + +@Entity({ schema: 'freight', name: 'scheduling_events' }) +@Index(['trainScheduleId']) +export class SchedulingEvent extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @Column({ name: 'trigger', type: 'varchar', length: 40 }) + trigger!: RescheduleTrigger; + + @Column({ name: 'actor_user_id', type: 'uuid', nullable: true }) + actorUserId?: string | null; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason?: string | null; + + @Column({ name: 'plan_snapshot', type: 'jsonb' }) + planSnapshot!: Record; + + @Column({ name: 'displaced_booking_ids', type: 'jsonb', default: '[]' }) + displacedBookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts new file mode 100644 index 000000000..0145db3db --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts @@ -0,0 +1,65 @@ +import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; + +import { TrainSchedulingManage } from '../../common/booking-guards'; +import { + type AuthUserPayload, + resolveAuthUserId, +} from '../../common/resolve-auth-user-id'; +import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; +import { SchedulingRescheduleService } from './scheduling-reschedule.service'; + +@ApiTags('train-scheduling') +@ApiBearerAuth() +@Controller('train-scheduling/schedules/:id/reschedule') +export class SchedulingRescheduleController { + constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {} + + @Post('preview') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Preview reschedule / government preempt plan' }) + preview( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: PreviewRescheduleDto, + ) { + return this.schedulingRescheduleService.previewReschedule(id, dto); + } + + @Post('execute') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Execute a confirmed reschedule plan' }) + execute( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ExecuteRescheduleDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.schedulingRescheduleService.executeReschedule( + id, + dto, + resolveAuthUserId(user), + ); + } +} + +@ApiTags('train-scheduling') +@ApiBearerAuth() +@Controller('train-scheduling/schedules/:id') +export class SchedulingMaintenanceController { + constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {} + + @Post('maintenance') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' }) + maintenance( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: PreviewRescheduleDto & { newDepartureDate: string }, + @CurrentUser() user: AuthUserPayload, + ) { + return this.schedulingRescheduleService.maintenanceReschedule( + id, + dto, + resolveAuthUserId(user), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.module.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.module.ts new file mode 100644 index 000000000..fa141057f --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { BookingsModule } from '../bookings/bookings.module'; +import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { SchedulingEvent } from './entities/scheduling-event.entity'; +import { + SchedulingMaintenanceController, + SchedulingRescheduleController, +} from './scheduling-reschedule.controller'; +import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; +import { SchedulingRescheduleService } from './scheduling-reschedule.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SchedulingEvent]), + BookingsModule, + TrainSchedulesModule, + TrainSchedulingModule, + ], + controllers: [SchedulingRescheduleController, SchedulingMaintenanceController], + providers: [SchedulingRescheduleRepository, SchedulingRescheduleService], + exports: [SchedulingRescheduleService], +}) +export class SchedulingRescheduleModule {} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.repository.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.repository.ts new file mode 100644 index 000000000..5a328ed0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity'; + +@Injectable() +export class SchedulingRescheduleRepository { + constructor( + @InjectRepository(SchedulingEvent) + private readonly repository: Repository, + ) {} + + /** Persist an audit record for a completed reschedule. */ + async createEvent(data: { + trainScheduleId: string; + trigger: RescheduleTrigger; + actorUserId?: string; + reason?: string; + planSnapshot: Record; + displacedBookingIds: string[]; + }): Promise { + return this.repository.save(this.repository.create(data)); + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts new file mode 100644 index 000000000..905e827e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -0,0 +1,230 @@ +import { BadRequestException } from '@nestjs/common'; + +import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; +import { SchedulingRescheduleService } from './scheduling-reschedule.service'; + +const makeBooking = ( + id: string, + reference: string, + extra: Record = {}, +) => ({ + id, + reference, + freightType: 'CONTAINER', + cargoTotalWeightVgm: 100, + scheduledDate: new Date('2026-06-20T08:00:00.000Z'), + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + status: 'PAID', + isGovernment: false, + priorityScore: 50, + bookingContainers: [ + { + id: `${id}-line`, + wagonsRequired: 5, + quantity: 1, + vgmPerUnitTons: 100, + }, + ], + ...extra, +}); + +describe('compareSchedulingPriority', () => { + it('orders government before commercial', () => { + const sorted = [ + { + isGovernment: false, + priorityScore: 50000, + scheduledDate: new Date('2026-06-20'), + }, + { + isGovernment: true, + priorityScore: 100, + scheduledDate: new Date('2026-06-25'), + }, + ].sort(compareSchedulingPriority); + + expect(sorted[0]?.isGovernment).toBe(true); + }); +}); + +describe('SchedulingRescheduleService', () => { + let service: SchedulingRescheduleService; + let trainSchedulesRepository: Record; + let bookingsRepository: Record; + let trainSchedulingService: Record; + let schedulingRescheduleRepository: Record; + + beforeEach(() => { + trainSchedulesRepository = { + findByIdWithFullGraph: jest.fn(), + updateStatus: jest.fn(), + }; + bookingsRepository = { + findByIdsForScheduling: jest.fn(), + updateSchedulingFields: jest.fn(), + }; + trainSchedulingService = { + previewTrainSchedule: jest.fn(), + unassignBooking: jest.fn(), + assignBookingsToSchedule: jest.fn(), + }; + schedulingRescheduleRepository = { + createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }), + }; + + service = new SchedulingRescheduleService( + trainSchedulesRepository as never, + bookingsRepository as never, + trainSchedulingService as never, + schedulingRescheduleRepository as never, + ); + }); + + it('rejects reschedule on dispatched trains', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'sched-1', + status: 'DISPATCHED', + scheduleBookings: [], + }); + + await expect( + service.previewReschedule('sched-1', { + incomingBookingIds: ['gov-1'], + trigger: 'GOVERNMENT_PREEMPT', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('displaces lower-priority commercial when government incoming exceeds capacity', async () => { + const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10, isGovernment: false }); + const government = makeBooking('g1', 'BKG-GOV', { + isGovernment: true, + priorityScore: 60000, + governmentInstitution: 'Ministry', + }); + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'sched-1', + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduleBookings: [{ bookingId: 'c1', booking: commercial }], + }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]); + + trainSchedulingService.previewTrainSchedule.mockImplementation( + async ({ bookingIds }: { bookingIds: string[] }) => ({ + valid: bookingIds.length <= 1, + violations: bookingIds.length > 1 ? ['Train capacity exceeded'] : [], + warnings: [], + }), + ); + + const plan = await service.previewReschedule('sched-1', { + incomingBookingIds: ['g1'], + trigger: 'GOVERNMENT_PREEMPT', + }); + + expect(plan.retained.map((b) => b.id)).toEqual(['g1']); + expect(plan.displaced.map((b) => b.id)).toEqual(['c1']); + expect(plan.finalBookingIds).toEqual(['g1']); + }); + + it('readmits high-priority commercial when spare capacity remains', async () => { + const low = makeBooking('c-low', 'BKG-LOW', { priorityScore: 5 }); + const high = makeBooking('c-high', 'BKG-HIGH', { priorityScore: 500 }); + const government = makeBooking('g1', 'BKG-GOV', { isGovernment: true, priorityScore: 60000 }); + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'sched-1', + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduleBookings: [ + { bookingId: 'c-low', booking: low }, + { bookingId: 'c-high', booking: high }, + ], + }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]); + + const fitAttempts = new Map(); + trainSchedulingService.previewTrainSchedule.mockImplementation( + async ({ bookingIds }: { bookingIds: string[] }) => { + const key = [...bookingIds].sort().join(','); + const attempt = (fitAttempts.get(key) ?? 0) + 1; + fitAttempts.set(key, attempt); + + const fits = + bookingIds.length === 1 || + (key === 'c-high,g1' && attempt > 1); + + return { + valid: fits, + violations: fits ? [] : ['Train capacity exceeded'], + warnings: [], + }; + }, + ); + + const plan = await service.previewReschedule('sched-1', { + incomingBookingIds: ['g1'], + trigger: 'GOVERNMENT_PREEMPT', + }); + + expect(plan.retained.map((b) => b.id)).toEqual(['g1']); + expect(plan.readmitted.map((b) => b.id)).toEqual(['c-high']); + expect(plan.displaced.map((b) => b.id)).toEqual(['c-low']); + expect(plan.finalBookingIds).toEqual(['g1', 'c-high']); + }); + + it('maintenance reschedule updates departure and rebalances bookings', async () => { + const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10 }); + const schedule = { + id: 'sched-1', + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduleBookings: [{ bookingId: 'c1', booking: commercial }], + }; + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([commercial]); + trainSchedulingService.previewTrainSchedule.mockResolvedValue({ + valid: true, + violations: [], + warnings: [], + }); + trainSchedulesRepository.updateStatus.mockResolvedValue(undefined); + trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' }); + + const result = await service.maintenanceReschedule( + 'sched-1', + { + incomingBookingIds: ['c1'], + trigger: 'TRAIN_MAINTENANCE', + reason: 'Locomotive service', + newDepartureDate: '2026-06-22T10:00:00.000Z', + }, + 'staff-1', + ); + + expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith( + 'sched-1', + 'DRAFT', + { scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') }, + ); + expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: 'TRAIN_MAINTENANCE', + actorUserId: 'staff-1', + reason: 'Locomotive service', + }), + ); + expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE'); + expect(result.plan.finalBookingIds).toEqual(['c1']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts new file mode 100644 index 000000000..7191a203e --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -0,0 +1,230 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { SchedulingStatus, TrainScheduleStatus } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; +import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; + +export interface RescheduleBookingSummary { + id: string; + reference: string; + isGovernment: boolean; + priorityScore: number; + governmentInstitution?: string | null; +} + +export interface ReschedulePlan { + scheduleId: string; + trigger: PreviewRescheduleDto['trigger']; + retained: RescheduleBookingSummary[]; + displaced: RescheduleBookingSummary[]; + readmitted: RescheduleBookingSummary[]; + finalBookingIds: string[]; + warnings: string[]; +} + +@Injectable() +export class SchedulingRescheduleService { + constructor( + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly trainSchedulingService: TrainSchedulingService, + private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository, + ) {} + + /** Preview who is retained, displaced, and readmitted on a schedule. */ + async previewReschedule( + scheduleId: string, + dto: PreviewRescheduleDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status === TrainScheduleStatus.Dispatched) { + throw new BadRequestException('Cannot reschedule a dispatched train'); + } + + const currentOnSchedule = (schedule.scheduleBookings ?? []) + .map((link) => link.booking) + .filter((b): b is Booking => Boolean(b)); + + const incoming = await this.bookingsRepository.findByIdsForScheduling(dto.incomingBookingIds); + if (incoming.length !== dto.incomingBookingIds.length) { + throw new BadRequestException('One or more incoming bookings were not found'); + } + + const mergedMap = new Map(); + for (const booking of [...currentOnSchedule, ...incoming]) { + mergedMap.set(booking.id, booking); + } + const sorted = [...mergedMap.values()].sort(compareSchedulingPriority); + + const warnings: string[] = []; + const retained: Booking[] = []; + + for (const booking of sorted) { + const candidate = [...retained, booking]; + const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId); + if (fits) { + retained.push(booking); + } else if (currentOnSchedule.some((b) => b.id === booking.id)) { + warnings.push(`Booking ${booking.reference} will be displaced from the train`); + } + } + + const retainedIds = new Set(retained.map((b) => b.id)); + const displacedFromCurrent = currentOnSchedule.filter((b) => !retainedIds.has(b.id)); + const readmitted: Booking[] = []; + + const displacedCommercial = displacedFromCurrent + .filter((b) => !b.isGovernment) + .sort(compareSchedulingPriority); + + for (const booking of displacedCommercial) { + const candidate = [...retained, ...readmitted, booking]; + const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId); + if (fits) { + readmitted.push(booking); + warnings.push(`Booking ${booking.reference} readmitted after government placement`); + } + } + + const finalIds = [...retained, ...readmitted].map((b) => b.id); + const displacedIds = new Set(displacedFromCurrent.map((b) => b.id)); + for (const id of readmitted.map((b) => b.id)) { + displacedIds.delete(id); + } + const displaced = displacedFromCurrent.filter((b) => displacedIds.has(b.id)); + + return { + scheduleId, + trigger: dto.trigger, + retained: retained.map((b) => this.toSummary(b)), + displaced: displaced.map((b) => this.toSummary(b)), + readmitted: readmitted.map((b) => this.toSummary(b)), + finalBookingIds: finalIds, + warnings, + }; + } + + /** Execute a confirmed reschedule plan. */ + async executeReschedule( + scheduleId: string, + dto: ExecuteRescheduleDto, + actorUserId?: string, + ) { + const plan = await this.previewReschedule(scheduleId, dto); + const expectedDisplaced = new Set(plan.displaced.map((b) => b.id)); + const providedDisplaced = new Set(dto.displacedBookingIds); + if ( + expectedDisplaced.size !== providedDisplaced.size || + [...expectedDisplaced].some((id) => !providedDisplaced.has(id)) + ) { + throw new BadRequestException('Displaced booking list does not match current preview'); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + if (dto.newDepartureDate && schedule) { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + schedule.status as TrainScheduleStatus, + { scheduledDepartureDate: new Date(dto.newDepartureDate) }, + ); + } + + for (const bookingId of dto.displacedBookingIds) { + try { + await this.trainSchedulingService.unassignBooking(scheduleId, bookingId); + } catch { + await this.bookingsRepository.updateSchedulingFields(bookingId, { + schedulingStatus: SchedulingStatus.Eligible, + wagonsRequired: null, + }); + } + } + + const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, { + bookingIds: dto.finalBookingIds, + forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT', + }); + + await this.schedulingRescheduleRepository.createEvent({ + trainScheduleId: scheduleId, + trigger: dto.trigger, + actorUserId, + reason: dto.reason, + planSnapshot: plan as unknown as Record, + displacedBookingIds: dto.displacedBookingIds, + }); + + return { plan, schedule: assignResult }; + } + + /** Maintenance shortcut: new departure + rebalance. */ + async maintenanceReschedule( + scheduleId: string, + dto: PreviewRescheduleDto & { newDepartureDate: string }, + actorUserId?: string, + ) { + const currentIds = ( + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId) + )?.scheduleBookings?.map((l) => l.bookingId) ?? []; + + const preview = await this.previewReschedule(scheduleId, { + ...dto, + trigger: 'TRAIN_MAINTENANCE', + incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds, + }); + + return this.executeReschedule( + scheduleId, + { + ...dto, + trigger: 'TRAIN_MAINTENANCE', + incomingBookingIds: dto.incomingBookingIds, + finalBookingIds: preview.finalBookingIds, + displacedBookingIds: preview.displaced.map((b) => b.id), + }, + actorUserId, + ); + } + + private async bookingsFitOnSchedule( + bookings: Booking[], + schedule: { scheduledDepartureDate: Date; originStationId: string; destinationStationId: string }, + scheduleId: string, + ): Promise { + if (!bookings.length) return true; + const preview = await this.trainSchedulingService.previewTrainSchedule({ + bookingIds: bookings.map((b) => b.id), + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + targetScheduleId: scheduleId, + }); + return preview.valid; + } + + private toSummary(booking: Booking): RescheduleBookingSummary { + return { + id: booking.id, + reference: booking.reference, + isGovernment: booking.isGovernment, + priorityScore: booking.priorityScore, + governmentInstitution: booking.governmentInstitution, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts new file mode 100644 index 000000000..a7f7350c4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts @@ -0,0 +1,19 @@ +export interface SchedulingPriorityBooking { + isGovernment?: boolean; + priorityScore?: number | null; + scheduledDate: Date | string; +} + +/** Government first, then priority score, then earliest scheduled date. */ +export function compareSchedulingPriority( + a: SchedulingPriorityBooking, + b: SchedulingPriorityBooking, +): number { + const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment)); + if (govDiff !== 0) return govDiff; + + const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); + if (priorityDiff !== 0) return priorityDiff; + + return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); +} 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 746b73248..a449d0487 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 @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; @@ -7,6 +8,7 @@ import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { TrainScheduleBooking } from './train-schedule-booking.entity'; export const TRAIN_SCHEDULE_STATUSES = [ +<<<<<<< HEAD 'DRAFT', 'READY', 'PUBLISHED', @@ -15,6 +17,13 @@ export const TRAIN_SCHEDULE_STATUSES = [ 'ARRIVED', 'COMPLETED', 'CANCELLED', +======= + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + TrainScheduleStatusEnum.Dispatched, + TrainScheduleStatusEnum.Arrived, + TrainScheduleStatusEnum.Cancelled, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db ] as const; export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number]; @@ -60,6 +69,27 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) status!: TrainScheduleStatus; + @Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true }) + trainNumber?: string | null; + + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) + direction?: string | null; + + @Column({ name: 'actual_departure_at', type: 'timestamptz', nullable: true }) + actualDepartureAt?: Date | null; + + @Column({ name: 'actual_arrival_at', type: 'timestamptz', nullable: true }) + actualArrivalAt?: Date | null; + + @Column({ name: 'prepared_by_user_id', type: 'uuid', nullable: true }) + preparedByUserId?: string | null; + + @Column({ name: 'checked_by_user_id', type: 'uuid', nullable: true }) + checkedByUserId?: string | null; + + @Column({ name: 'max_wagons', type: 'int', default: 53 }) + maxWagons!: number; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-bulk-load.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-bulk-load.entity.ts new file mode 100644 index 000000000..b3ecb4618 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-bulk-load.entity.ts @@ -0,0 +1,53 @@ +import { BaseEntity } from '@edr/api-common'; +import { BulkPricingUnit } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; +import { WagonBookingAllocation } from './wagon-booking-allocation.entity'; + +export const BULK_PRICING_UNITS = [ + BulkPricingUnit.PerWagon, + BulkPricingUnit.PerTon, + BulkPricingUnit.PerItem, +] as const; + +@Entity({ schema: 'freight', name: 'wagon_allocation_bulk_loads' }) +@Index(['bookingId']) +export class WagonAllocationBulkLoad extends BaseEntity { + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', unique: true }) + wagonBookingAllocationId!: string; + + @ManyToOne(() => WagonBookingAllocation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + allocation?: WagonBookingAllocation; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'cargo_type_id' }) + cargoType?: CargoType | null; + + @Column({ name: 'cargo_description', type: 'text', nullable: true }) + cargoDescription?: string | null; + + @Column({ name: 'pricing_unit', type: 'varchar', length: 20, default: BulkPricingUnit.PerTon }) + pricingUnit!: string; + + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + quantity!: number; + + @Column({ name: 'weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 }) + weightTons!: number; + + @Column({ name: 'truck_plate_number', type: 'varchar', length: 32, nullable: true }) + truckPlateNumber?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-container-item.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-container-item.entity.ts new file mode 100644 index 000000000..3885e6d15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-container-item.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { BookingContainer } from '../../bookings/entities/booking-container.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { WagonBookingAllocation } from './wagon-booking-allocation.entity'; + +@Entity({ schema: 'freight', name: 'wagon_allocation_container_items' }) +@Index(['wagonBookingAllocationId']) +export class WagonAllocationContainerItem extends BaseEntity { + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid' }) + wagonBookingAllocationId!: string; + + @ManyToOne(() => WagonBookingAllocation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + allocation?: WagonBookingAllocation; + + @Column({ name: 'booking_container_id', type: 'uuid', nullable: true }) + bookingContainerId?: string | null; + + @ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_container_id' }) + bookingContainer?: BookingContainer | null; + + @Column({ name: 'container_id', type: 'uuid', nullable: true }) + containerId?: string | null; + + @ManyToOne(() => Container, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'container_id' }) + container?: Container | null; + + @Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true }) + containerNumber?: string | null; + + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + @Column({ name: 'position_on_wagon', type: 'smallint', nullable: true }) + positionOnWagon?: number | null; + + @Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true }) + sealNumber?: string | null; + + @Column({ name: 'chassis_number', type: 'varchar', length: 64, nullable: true }) + chassisNumber?: string | null; + + @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + grossWeightTons?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts index 4c78256fe..6bec9f74e 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts @@ -1,8 +1,22 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { AllocationLoadType, AllocationStatus } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; +import { WagonAllocationContainerItem } from './wagon-allocation-container-item.entity'; + +export const ALLOCATION_LOAD_TYPES = [ + AllocationLoadType.Container, + AllocationLoadType.Bulk, +] as const; + +export const ALLOCATION_STATUSES = [ + AllocationStatus.Planned, + AllocationStatus.Reserved, + AllocationStatus.Loaded, + AllocationStatus.Departed, +] as const; @Entity({ schema: 'freight', name: 'wagon_booking_allocations' }) @Index(['trainSetWagonId', 'bookingId']) @@ -23,4 +37,19 @@ export class WagonBookingAllocation extends BaseEntity { @Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 }) allocatedWeightTons!: number; + + @Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true }) + loadType?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' }) + status!: string; + + @Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true }) + confirmedAt?: Date | null; + + @Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true }) + confirmedByUserId?: string | null; + + @OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation) + containerItems?: WagonAllocationContainerItem[]; } 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 d7360226f..4ccfcd469 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 @@ -1,7 +1,7 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DeepPartial, EntityManager, In, Repository } from 'typeorm'; import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; @@ -13,4 +13,38 @@ export class TrainScheduleBookingsRepository extends BaseRepository[], + manager?: EntityManager, + ): Promise { + if (!records.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(records)); + } + + async deleteByScheduleAndBooking( + trainScheduleId: string, + bookingId: string, + manager?: EntityManager, + ): Promise { + await this.repo(manager).delete({ trainScheduleId, bookingId }); + } + + async existsForBooking(bookingId: string, manager?: EntityManager): Promise { + const count = await this.repo(manager).count({ where: { bookingId } }); + return count > 0; + } + + findByBookingIds(bookingIds: string[], manager?: EntityManager): Promise { + if (!bookingIds.length) return Promise.resolve([]); + return this.repo(manager).find({ + where: { bookingId: In(bookingIds) }, + select: { id: true, bookingId: true, trainScheduleId: true }, + }); + } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts index 9fa40897a..f9ce84f7d 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts @@ -3,22 +3,38 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; import { TrainSchedule } from './entities/train-schedule.entity'; +import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity'; +import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository'; import { TrainSchedulesRepository } from './train-schedules.repository'; +import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository'; +import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository'; @Module({ - imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])], + imports: [ + TypeOrmModule.forFeature([ + TrainSchedule, + TrainScheduleBooking, + WagonBookingAllocation, + WagonAllocationContainerItem, + WagonAllocationBulkLoad, + ]), + ], providers: [ TrainSchedulesRepository, TrainScheduleBookingsRepository, WagonBookingAllocationsRepository, + WagonAllocationContainerItemsRepository, + WagonAllocationBulkLoadsRepository, ], exports: [ TrainSchedulesRepository, TrainScheduleBookingsRepository, WagonBookingAllocationsRepository, + WagonAllocationContainerItemsRepository, + WagonAllocationBulkLoadsRepository, ], }) export class TrainSchedulesModule {} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index b6f18eaf2..8ec002d49 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -1,9 +1,9 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; -import { TrainSchedule } from './entities/train-schedule.entity'; +import { TrainSchedule, TrainScheduleStatus } from './entities/train-schedule.entity'; @Injectable() export class TrainSchedulesRepository extends BaseRepository { @@ -13,4 +13,48 @@ export class TrainSchedulesRepository extends BaseRepository { ) { super(repository); } + + private repo(manager?: EntityManager) { + return manager ? manager.getRepository(TrainSchedule) : this.repository; + } + + findByIdWithFullGraph(id: string, manager?: EntityManager): Promise { + return this.repo(manager).findOne({ + where: { id }, + relations: { + route: true, + trainSet: { + locomotive: true, + wagons: { + wagonType: true, + physicalWagon: true, + allocations: { + booking: { company: true, bookingContainers: { containerType: true } }, + containerItems: true, + }, + }, + }, + originStation: true, + destinationStation: true, + scheduleBookings: { + booking: { + company: true, + originYard: true, + destinationYard: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + }, + }, + }); + } + + async updateStatus( + id: string, + status: TrainScheduleStatus, + extra?: Partial, + manager?: EntityManager, + ): Promise { + await this.repo(manager).update(id, { status, ...extra } as never); + } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-bulk-loads.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-bulk-loads.repository.ts new file mode 100644 index 000000000..dfaf602fa --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-bulk-loads.repository.ts @@ -0,0 +1,36 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeepPartial, EntityManager, In, Repository } from 'typeorm'; + +import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity'; + +@Injectable() +export class WagonAllocationBulkLoadsRepository extends BaseRepository { + constructor( + @InjectRepository(WagonAllocationBulkLoad) + repository: Repository, + ) { + super(repository); + } + + private repo(manager?: EntityManager) { + return manager + ? manager.getRepository(WagonAllocationBulkLoad) + : this.repository; + } + + async createMany( + items: DeepPartial[], + manager?: EntityManager, + ): Promise { + if (!items.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(items)); + } + + async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise { + if (!allocationIds.length) return; + await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-container-items.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-container-items.repository.ts new file mode 100644 index 000000000..0ff7a0548 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-container-items.repository.ts @@ -0,0 +1,36 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeepPartial, EntityManager, In, Repository } from 'typeorm'; + +import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity'; + +@Injectable() +export class WagonAllocationContainerItemsRepository extends BaseRepository { + constructor( + @InjectRepository(WagonAllocationContainerItem) + repository: Repository, + ) { + super(repository); + } + + private repo(manager?: EntityManager) { + return manager + ? manager.getRepository(WagonAllocationContainerItem) + : this.repository; + } + + async createMany( + items: DeepPartial[], + manager?: EntityManager, + ): Promise { + if (!items.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(items)); + } + + async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise { + if (!allocationIds.length) return; + await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts index 067dddaf7..620fd1343 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts @@ -1,7 +1,7 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DeepPartial, EntityManager, Repository } from 'typeorm'; import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; @@ -13,4 +13,43 @@ export class WagonBookingAllocationsRepository extends BaseRepository[], + manager?: EntityManager, + ): Promise { + if (!records.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(records)); + } + + findByScheduleId(trainScheduleId: string, manager?: EntityManager): Promise { + return this.repo(manager) + .createQueryBuilder('allocation') + .innerJoin('allocation.trainSetWagon', 'wagon') + .innerJoin('wagon.trainSet', 'trainSet') + .innerJoin('trainSet.trainSchedule', 'schedule') + .where('schedule.id = :trainScheduleId', { trainScheduleId }) + .leftJoinAndSelect('allocation.booking', 'booking') + .getMany(); + } + + async deleteByTrainSetId(trainSetId: string, manager?: EntityManager): Promise { + const allocations = await this.repo(manager) + .createQueryBuilder('allocation') + .innerJoin('allocation.trainSetWagon', 'wagon') + .where('wagon.train_set_id = :trainSetId', { trainSetId }) + .select(['allocation.id']) + .getMany(); + + const ids = allocations.map((a) => a.id); + if (ids.length) { + await this.repo(manager).delete(ids); + } + return ids; + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts new file mode 100644 index 000000000..330c4d4e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts @@ -0,0 +1,21 @@ +import { deriveScheduleDirection } from './derive-schedule-direction.util'; + +describe('deriveScheduleDirection', () => { + it('returns IMPORT when origin is Djibouti', () => { + expect( + deriveScheduleDirection({ country: 'Djibouti' }, { country: 'Ethiopia' }), + ).toBe('IMPORT'); + }); + + it('returns EXPORT when destination is Djibouti and origin is not', () => { + expect( + deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Djibouti' }), + ).toBe('EXPORT'); + }); + + it('returns DOMESTIC for intra-Ethiopia routes', () => { + expect( + deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' }), + ).toBe('DOMESTIC'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts new file mode 100644 index 000000000..f66a06c89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts @@ -0,0 +1,19 @@ +import type { ScheduleTradeDirection } from '@edr/types'; + +type YardLike = { country?: string | null }; + +export function deriveScheduleDirection( + originYard: YardLike, + destinationYard: YardLike, +): ScheduleTradeDirection { + const originCountry = originYard.country?.trim(); + const destinationCountry = destinationYard.country?.trim(); + + if (originCountry === 'Djibouti') { + return 'IMPORT'; + } + if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') { + return 'EXPORT'; + } + return 'DOMESTIC'; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts new file mode 100644 index 000000000..b5e93f5da --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +export class ContainerPlacementDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingContainerId!: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + unitIndex!: number; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + sequenceNo!: number; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + containerId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sealNumber?: string; +} + +export class AssignBookingsDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' }) + @IsOptional() + @IsBoolean() + forceAssign?: boolean; + + @ApiPropertyOptional({ type: [ContainerPlacementDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ContainerPlacementDto) + containerPlacements?: ContainerPlacementDto[]; + + @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index fa40e7131..4f3d5ed6c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -1,5 +1,11 @@ +<<<<<<< HEAD import { ApiProperty } from '@nestjs/swagger'; import { ArrayMinSize, IsArray, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; +======= +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db export class CreateContainerTrainScheduleDto { @ApiProperty({ format: 'uuid' }) @@ -19,6 +25,7 @@ export class CreateContainerTrainScheduleDto { @IsUUID() locomotiveId!: string; +<<<<<<< HEAD @ApiProperty({ enum: ['CONTAINER', 'BULK'], default: 'CONTAINER' }) @IsOptional() @IsIn(['CONTAINER', 'BULK']) @@ -37,4 +44,26 @@ export class CreateContainerTrainScheduleDto { @ArrayMinSize(1) @IsUUID('4', { each: true }) wagonIds?: string[]; +======= + @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts new file mode 100644 index 000000000..3660426ed --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; + +export class GetEligibleBookingsDto { + @ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] }) + @IsOptional() + @IsIn(['CONTAINER', 'BULK']) + freightType?: 'CONTAINER' | 'BULK'; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @ApiPropertyOptional() + @IsOptional() + schedulingStatus?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts new file mode 100644 index 000000000..9a2cafa2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class GetEligibleBulkBookingsDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @ApiPropertyOptional({ example: 'HOLDING' }) + @IsOptional() + schedulingStatus?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts index 06eb2886e..cb3dd5b52 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts @@ -1,5 +1,9 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; +<<<<<<< HEAD import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; +======= +import { IsOptional, IsUUID } from 'class-validator'; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db export class GetEligibleContainerBookingsDto { @ApiPropertyOptional({ format: 'uuid' }) @@ -12,8 +16,9 @@ export class GetEligibleContainerBookingsDto { @IsUUID() destinationStationId?: string; - @ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' }) + @ApiPropertyOptional({ example: 'HOLDING' }) @IsOptional() +<<<<<<< HEAD @IsDateString() scheduleDate?: string; @@ -26,4 +31,7 @@ export class GetEligibleContainerBookingsDto { @IsOptional() @IsIn(['IMPORT', 'EXPORT', 'DOMESTIC']) tradeDirection?: 'IMPORT' | 'EXPORT' | 'DOMESTIC'; +======= + schedulingStatus?: string; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/pin-wagons.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/pin-wagons.dto.ts new file mode 100644 index 000000000..54f96e5c0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/pin-wagons.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class PinWagonAssignmentDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + trainSetWagonId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + physicalWagonId!: string; +} + +export class PinWagonsDto { + @ApiProperty({ type: [PinWagonAssignmentDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => PinWagonAssignmentDto) + assignments!: PinWagonAssignmentDto[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-bulk-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-bulk-train-schedule.dto.ts new file mode 100644 index 000000000..d2efe75e4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-bulk-train-schedule.dto.ts @@ -0,0 +1,3 @@ +import { PreviewTrainScheduleDto } from './preview-train-schedule.dto'; + +export class PreviewBulkTrainScheduleDto extends PreviewTrainScheduleDto {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts index 6c5ca70b2..c12b3c157 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts @@ -1,3 +1,4 @@ +<<<<<<< HEAD import { ApiProperty } from '@nestjs/swagger'; import { ArrayMinSize, IsArray, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; @@ -25,3 +26,8 @@ export class PreviewContainerTrainScheduleDto { @IsIn(['CONTAINER', 'BULK']) assignmentType?: 'CONTAINER' | 'BULK'; } +======= +import { PreviewTrainScheduleDto } from './preview-train-schedule.dto'; + +export class PreviewContainerTrainScheduleDto extends PreviewTrainScheduleDto {} +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts new file mode 100644 index 000000000..56cd9592b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsUUID, + Min, +} from 'class-validator'; + +export class PreviewTrainScheduleDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) + @IsDateString() + scheduleDate!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + originStationId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + destinationStationId!: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Allow bookings already assigned to this schedule (re-assign / reschedule)', + }) + @IsOptional() + @IsUUID() + targetScheduleId?: string; + + @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; +} 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 new file mode 100644 index 000000000..d47195976 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsNumber, IsOptional, Min } from 'class-validator'; + +export class UpdateTrainSchedulingGlobalRulesDto { + @ApiPropertyOptional({ example: 760 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ example: 3500 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ example: 53 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ example: 30 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.001) + max20ftContainerWeightTons?: number; + + @ApiPropertyOptional({ example: 10 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + max20ftPairWeightDiffTons?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts new file mode 100644 index 000000000..326915933 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -0,0 +1,44 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' }) +export class TrainSchedulingGlobalRules extends BaseEntity { + @Column({ + name: 'max_train_length_meters', + type: 'numeric', + precision: 10, + scale: 2, + default: 760, + }) + maxTrainLengthMeters!: number; + + @Column({ + name: 'max_train_weight_tons', + type: 'numeric', + precision: 10, + scale: 3, + default: 3500, + }) + maxTrainWeightTons!: number; + + @Column({ name: 'max_wagons_per_train', type: 'int', default: 53 }) + maxWagonsPerTrain!: number; + + @Column({ + name: 'max_20ft_container_weight_tons', + type: 'numeric', + precision: 8, + scale: 3, + default: 30, + }) + max20ftContainerWeightTons!: number; + + @Column({ + name: 'max_20ft_pair_weight_diff_tons', + type: 'numeric', + precision: 8, + scale: 3, + default: 10, + }) + max20ftPairWeightDiffTons!: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts new file mode 100644 index 000000000..76ea00422 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts @@ -0,0 +1,127 @@ +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + computeFleetAvailability, + selectBookingsWithinFleetCap, + sortBookingsForScheduling, + summarizeFleetWarnings, + wagonsRequiredForBooking, +} from './fleet-plan.util'; +import { buildContainerWagonPlan, type WagonPlanSlot } from './wagon-plan.util'; + +const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, +} as WagonType; + +const makeBooking = ( + id: string, + extra: Partial = {}, +): Booking => + ({ + id, + reference: id, + freightType: 'CONTAINER', + isGovernment: false, + priorityScore: 0, + scheduledDate: new Date('2026-06-20T08:00:00.000Z'), + cargoTotalWeightVgm: 50, + bookingContainers: [{ id: `${id}-line`, quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }], + ...extra, + }) as Booking; + +describe('fleet-plan.util', () => { + it('sorts bookings government first, then priority, then date', () => { + const bookings = [ + makeBooking('late', { scheduledDate: new Date('2026-06-22T08:00:00.000Z') }), + makeBooking('gov', { isGovernment: true, priorityScore: 0 }), + makeBooking('prio', { priorityScore: 10 }), + ]; + + const sorted = sortBookingsForScheduling(bookings); + expect(sorted.map((b) => b.id)).toEqual(['gov', 'prio', 'late']); + }); + + it('computes fleet availability with shortfall', () => { + const plan: WagonPlanSlot[] = buildContainerWagonPlan( + [ + makeBooking('b1', { + bookingContainers: [ + { id: 'b1-line', quantity: 4, wagonsRequired: 2, vgmPerUnitTons: 25 } as never, + ], + }), + ], + nw5, + ); + const fleetByTypeId = new Map([[nw5.id, 1]]); + + const rows = computeFleetAvailability(plan, fleetByTypeId, new Map([[nw5.id, 'NW5']])); + const nw5Row = rows.find((r) => r.wagonTypeCode === 'NW5'); + + expect(nw5Row?.needed).toBe(2); + expect(nw5Row?.available).toBe(1); + expect(nw5Row?.shortfall).toBe(1); + }); + + it('defers lower-priority bookings when fleet is insufficient', () => { + const high = makeBooking('high', { + priorityScore: 100, + bookingContainers: [ + { id: 'high-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never, + ], + }); + const low = makeBooking('low', { + priorityScore: 1, + bookingContainers: [ + { id: 'low-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never, + ], + }); + const fleet = new Map([[nw5.id, 2]]); + + const { fitting, deferred } = selectBookingsWithinFleetCap( + [low, high], + fleet, + () => nw5.id, + ); + + expect(fitting.map((b) => b.id)).toEqual(['high']); + expect(deferred).toHaveLength(1); + expect(deferred[0]?.id).toBe('low'); + expect(deferred[0]?.reason).toContain('2'); + }); + + it('summarizes fleet shortage warnings', () => { + const warnings = summarizeFleetWarnings( + [ + { + wagonTypeId: nw5.id, + wagonTypeCode: 'NW5', + needed: 5, + available: 2, + shortfall: 3, + }, + ], + [{ id: 'b1', reference: 'BKG-1', reason: 'No wagons' }], + ); + + expect(warnings.some((w) => w.includes('Fleet shortage'))).toBe(true); + expect(warnings.some((w) => w.includes('deferred'))).toBe(true); + }); + + it('counts wagons required per booking from container lines', () => { + const booking = makeBooking('b1', { + bookingContainers: [ + { id: 'b1-line-0', quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 } as never, + { id: 'b1-line-1', quantity: 1, wagonsRequired: 1, vgmPerUnitTons: 25 } as never, + ], + }); + expect(wagonsRequiredForBooking(booking)).toBe(2); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts new file mode 100644 index 000000000..2e721825e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -0,0 +1,168 @@ +import type { Booking } from '../bookings/entities/booking.entity'; +import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + buildBulkWagonPlan, + buildContainerWagonPlan, + buildMixedWagonPlan, + roundTons, + type WagonPlanSlot, +} from './wagon-plan.util'; + +export type FleetAvailabilityRow = { + wagonTypeId: string; + wagonTypeCode: string; + needed: number; + available: number; + shortfall: number; +}; + +export type DeferredBookingRow = { + id: string; + reference: string; + reason: string; +}; + +export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { + return [...bookings].sort((a, b) => { + const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment)); + if (govDiff !== 0) return govDiff; + + const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); + if (priorityDiff !== 0) return priorityDiff; + + return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + }); +} + +export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number { + if (booking.freightType === 'BULK') { + const weight = Number(booking.cargoTotalWeightVgm ?? 0); + const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1; + return Math.max(1, Math.ceil(weight / capacity)); + } + + const lineSlots = (booking.bookingContainers ?? []).reduce( + (sum, line) => sum + Number(line.wagonsRequired ?? 0), + 0, + ); + return Math.max(1, lineSlots); +} + +export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map { + const map = new Map(); + for (const slot of wagonPlan) { + const existing = map.get(slot.wagonTypeId) ?? { code: slot.wagonTypeCode, count: 0 }; + existing.count += 1; + map.set(slot.wagonTypeId, existing); + } + return map; +} + +export function computeFleetAvailability( + demandPlan: WagonPlanSlot[], + fleetByTypeId: Map, + fleetTypeCodes: Map, +): FleetAvailabilityRow[] { + const neededByType = countSlotsByType(demandPlan); + const typeIds = new Set([...neededByType.keys(), ...fleetByTypeId.keys()]); + + return [...typeIds].map((wagonTypeId) => { + const needed = neededByType.get(wagonTypeId)?.count ?? 0; + const available = fleetByTypeId.get(wagonTypeId) ?? 0; + return { + wagonTypeId, + wagonTypeCode: + neededByType.get(wagonTypeId)?.code ?? + fleetTypeCodes.get(wagonTypeId) ?? + wagonTypeId, + needed, + available, + shortfall: Math.max(0, needed - available), + }; + }).filter((row) => row.needed > 0 || row.available > 0); +} + +export function selectBookingsWithinFleetCap( + bookings: Booking[], + fleetByTypeId: Map, + resolveWagonTypeId: (booking: Booking) => string, + bulkWagonCapacity?: number, +): { fitting: Booking[]; deferred: DeferredBookingRow[] } { + const remaining = new Map(fleetByTypeId); + const fitting: Booking[] = []; + const deferred: DeferredBookingRow[] = []; + + for (const booking of sortBookingsForScheduling(bookings)) { + const typeId = resolveWagonTypeId(booking); + const needed = wagonsRequiredForBooking(booking, bulkWagonCapacity); + const available = remaining.get(typeId) ?? 0; + + if (available >= needed) { + remaining.set(typeId, available - needed); + fitting.push(booking); + continue; + } + + deferred.push({ + id: booking.id, + reference: booking.reference, + reason: + available > 0 + ? `Needs ${needed} wagons but only ${available} available for this type` + : `No available wagons for required type (${needed} needed)`, + }); + } + + return { fitting, deferred }; +} + +export function buildCappedWagonPlan(params: { + bookings: Booking[]; + resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED'; + containerWagonType: WagonType; + bulkWagonType: WagonType; +}): WagonPlanSlot[] { + const { bookings, resolvedMode, containerWagonType, bulkWagonType } = params; + + if (resolvedMode === 'MIXED') { + const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER'); + const bulkBookings = bookings.filter((b) => b.freightType === 'BULK'); + return buildMixedWagonPlan( + containerBookings, + bulkBookings, + containerWagonType, + bulkWagonType, + ); + } + + if (resolvedMode === 'BULK') { + return buildBulkWagonPlan(bookings, bulkWagonType); + } + + return buildContainerWagonPlan(bookings, containerWagonType); +} + +export function summarizeFleetWarnings( + fleetAvailability: FleetAvailabilityRow[], + deferred: DeferredBookingRow[], +): string[] { + const warnings: string[] = []; + + for (const row of fleetAvailability.filter((r) => r.shortfall > 0)) { + warnings.push( + `Fleet shortage: need ${row.needed} ${row.wagonTypeCode}, only ${row.available} available (short ${row.shortfall})`, + ); + } + + if (deferred.length) { + warnings.push( + `${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`, + ); + } + + return warnings; +} + +export function totalAssignedWeight(bookings: Booking[]): number { + return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0)); +} 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 fda32bf09..def3a188d 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 @@ -1,17 +1,27 @@ import { Body, Controller, + Delete, Get, Param, ParseUUIDPipe, + Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards'; +import { AssignBookingsDto } from './dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; +import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; +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 { 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'; +import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { TrainSchedulingService } from './train-scheduling.service'; @ApiTags('train-scheduling') @@ -20,45 +30,183 @@ import { TrainSchedulingService } from './train-scheduling.service'; export class TrainSchedulingController { constructor(private readonly trainSchedulingService: TrainSchedulingService) {} + @Get('global-rules') + @TrainSchedulingView() + @ApiOperation({ summary: 'Get global train scheduling rules (singleton)' }) + getGlobalRules() { + return this.trainSchedulingService.getTrainSchedulingGlobalRules(); + } + + @Patch('global-rules') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Update global train scheduling rules (singleton)' }) + updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) { + return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto); + } + + @Get('eligible-bookings') + @TrainSchedulingView() + @ApiOperation({ summary: 'List eligible bookings (container and/or bulk)' }) + getEligibleBookings(@Query() query: GetEligibleBookingsDto) { + return this.trainSchedulingService.getEligibleBookings(query); + } + @Get('container/eligible-bookings') + @TrainSchedulingView() @ApiOperation({ summary: 'List eligible container bookings' }) getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) { return this.trainSchedulingService.getEligibleContainerBookings(query); } + @Get('bulk/eligible-bookings') + @TrainSchedulingView() + @ApiOperation({ summary: 'List eligible bulk bookings' }) + getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) { + return this.trainSchedulingService.getEligibleBulkBookings(query); + } + + @Post('preview') + @TrainSchedulingView() + @ApiOperation({ summary: 'Preview a mixed-capable train schedule' }) + previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) { + return this.trainSchedulingService.previewTrainSchedule(dto); + } + @Post('container/preview') + @TrainSchedulingView() @ApiOperation({ summary: 'Preview a container train schedule' }) previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) { return this.trainSchedulingService.previewContainerTrainSchedule(dto); } + @Post('bulk/preview') + @TrainSchedulingView() + @ApiOperation({ summary: 'Preview a bulk train schedule' }) + previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) { + return this.trainSchedulingService.previewBulkTrainSchedule(dto); + } + @Post('container/schedules') + @TrainSchedulingManage() @ApiOperation({ summary: 'Create a container train schedule' }) createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { return this.trainSchedulingService.createContainerTrainSchedule(dto); } + @Post('bulk/schedules') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Create a bulk train schedule' }) + createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { + return this.trainSchedulingService.createContainerTrainSchedule(dto); + } + + @Post('schedules/:id/assign-bookings') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Assign bookings to a train schedule (mixed-capable)' }) + assignBookings( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignBookingsDto, + ) { + return this.trainSchedulingService.assignBookingsToSchedule(id, dto); + } + + @Post('container/schedules/:id/assign-bookings') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Assign container bookings to a train schedule' }) + assignContainerBookings( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignBookingsDto, + ) { + return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'CONTAINER'); + } + + @Post('bulk/schedules/:id/assign-bookings') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Assign bulk bookings to a train schedule' }) + assignBulkBookings( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignBookingsDto, + ) { + return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'BULK'); + } + + @Delete('schedules/:id/bookings/:bookingId') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Unassign a booking from a train schedule' }) + unassignBooking( + @Param('id', ParseUUIDPipe) id: string, + @Param('bookingId', ParseUUIDPipe) bookingId: string, + ) { + return this.trainSchedulingService.unassignBooking(id, bookingId); + } + + @Post('schedules/:id/pin-wagons') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Pin physical wagons to train set slots' }) + pinWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) { + return this.trainSchedulingService.pinWagons(id, dto); + } + + @Post('schedules/:id/finalize') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Finalize a draft train schedule' }) + finalizeSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.finalizeSchedule(id); + } + + @Post('schedules/:id/dispatch') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Dispatch a scheduled train' }) + dispatchSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.dispatchSchedule(id); + } + @Get('container/schedules') + @TrainSchedulingView() @ApiOperation({ summary: 'List container train schedules' }) getContainerTrainSchedules() { return this.trainSchedulingService.getContainerTrainSchedules(); } + @Get('bulk/schedules') + @TrainSchedulingView() + @ApiOperation({ summary: 'List bulk train schedules' }) + getBulkTrainSchedules() { + return this.trainSchedulingService.getContainerTrainSchedules(); + } + @Get('container/schedules/:id') + @TrainSchedulingView() @ApiOperation({ summary: 'Get container train schedule detail' }) getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Get('bulk/schedules/:id') + @TrainSchedulingView() + @ApiOperation({ summary: 'Get bulk train schedule detail' }) + getBulkTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post('container/schedules/:id/cancel') + @TrainSchedulingManage() @ApiOperation({ summary: 'Cancel container train schedule' }) cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { return this.trainSchedulingService.cancelTrainSchedule(id); } +<<<<<<< HEAD @Post('container/schedules/:id/publish') @ApiOperation({ summary: 'Publish container train schedule' }) publishTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { return this.trainSchedulingService.publishTrainSchedule(id); +======= + @Post('bulk/schedules/:id/cancel') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Cancel bulk train schedule' }) + cancelBulkTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.cancelTrainSchedule(id); +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db } } 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 1af1cb31f..bb8a8330a 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 @@ -2,13 +2,19 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; -import { Booking } from '../bookings/entities/booking.entity'; -import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { Route } from '../routes/entities/route.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetsModule } from '../train-sets/train-sets.module'; +import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { Wagon } from '../wagons/entities/wagon.entity'; +<<<<<<< HEAD import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSetsModule } from '../train-sets/train-sets.module'; @@ -17,29 +23,31 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; import { Yard } from '../rule-engine/entities/yard.entity'; +======= +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; @Module({ imports: [ TypeOrmModule.forFeature([ - Booking, - BookingContainer, Locomotive, WagonType, Wagon, TrainSet, TrainSetWagon, - TrainSchedule, - TrainScheduleBooking, - WagonBookingAllocation, - Yard, + Route, + Wagon, + Container, + TrainSchedulingGlobalRules, ]), BookingsModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, + RuleEngineModule, ], controllers: [TrainSchedulingController], providers: [TrainSchedulingService], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 73b2e0662..9c664e75b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1,5 +1,10 @@ import { ConflictException } from '@nestjs/common'; +import { WagonReadiness, WagonStatus } from '@edr/types'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from './train-scheduling.service'; const nw5 = { @@ -11,6 +16,7 @@ const nw5 = { maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, + supportsContainer: true, }; const locomotive = { @@ -21,15 +27,29 @@ const locomotive = { status: 'AVAILABLE', }; +const cw3 = { + id: 'wagon-type-bulk', + code: 'CW3', + name: 'Covered Wagon', + capacityTons: 60, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, +}; + const makeBooking = ( id: string, reference: string, weight: number, quantity: number, containerCode: string, + wagonsRequired: number, scheduledDate = '2026-06-20T08:00:00.000Z', originYardId = 'yard-origin', destinationYardId = 'yard-destination', + extra: Record = {}, ) => ({ id, reference, @@ -38,76 +58,183 @@ const makeBooking = ( scheduledDate: new Date(scheduledDate), originYardId, destinationYardId, +<<<<<<< HEAD status: 'APPROVED', customer: { companyName: 'Demo Customer' }, +======= + status: 'PAID', + schedulingStatus: 'HOLDING', + holdExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + company: { companyName: 'Demo Customer' }, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db originYard: { label: 'Djibouti', code: 'DJIBOUTI' }, destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' }, bookingContainers: [ { + id: `${id}-line`, + containerTypeId: 'ct-1', quantity, + wagonsRequired, + vgmPerUnitTons: weight / quantity, + isOverweight: false, containerType: { code: containerCode, label: containerCode }, }, ], + ...extra, }); describe('TrainSchedulingService', () => { let service: TrainSchedulingService; - let dataSource: { - getRepository: jest.Mock; - transaction: jest.Mock; - }; - let locomotivesRepository: { - findById: jest.Mock; - }; - let wagonTypesRepository: { - findAll: jest.Mock; - }; + let dataSource: { getRepository: jest.Mock; transaction: jest.Mock }; + let bookingsRepository: Record; + let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; + let wagonTypesRepository: { findAll: jest.Mock }; + let trainSchedulesRepository: Record; + let trainScheduleBookingsRepository: Record; + let wagonBookingAllocationsRepository: Record; + let wagonAllocationContainerItemsRepository: Record; + let wagonAllocationBulkLoadsRepository: Record; beforeEach(() => { - dataSource = { - getRepository: jest.fn(), - transaction: jest.fn(), + dataSource = { getRepository: jest.fn(), transaction: jest.fn() }; + bookingsRepository = { + findEligibleForScheduling: jest.fn(), + findByIdsForScheduling: jest.fn(), + updateSchedulingFields: jest.fn(), }; - locomotivesRepository = { + locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() }; + wagonTypesRepository = { findAll: jest.fn() }; + trainSchedulesRepository = { findById: jest.fn(), - }; - wagonTypesRepository = { + findByIdWithFullGraph: jest.fn(), findAll: jest.fn(), + updateStatus: jest.fn(), + }; + trainScheduleBookingsRepository = { + findByBookingIds: jest.fn(), + createMany: jest.fn(), + deleteByScheduleAndBooking: jest.fn(), + }; + wagonBookingAllocationsRepository = { + deleteByTrainSetId: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }; + wagonAllocationContainerItemsRepository = { + createMany: jest.fn(), + deleteByAllocationIds: jest.fn(), + findAll: jest.fn().mockResolvedValue([]), + }; + wagonAllocationBulkLoadsRepository = { + createMany: jest.fn(), + deleteByAllocationIds: jest.fn(), + findAll: jest.fn().mockResolvedValue([]), }; service = new TrainSchedulingService( dataSource as never, + bookingsRepository as never, locomotivesRepository as never, wagonTypesRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + wagonBookingAllocationsRepository as never, + wagonAllocationContainerItemsRepository as never, + wagonAllocationBulkLoadsRepository as never, ); + + const defaultFleetWagons = [ + ...Array.from({ length: 100 }, (_, index) => ({ + id: `wagon-nw5-${index}`, + wagonTypeId: nw5.id, + status: WagonStatus.Available, + readiness: WagonReadiness.ImportReady, + currentTrainScheduleId: null, + })), + ...Array.from({ length: 50 }, (_, index) => ({ + id: `wagon-cw3-${index}`, + wagonTypeId: cw3.id, + status: WagonStatus.Available, + readiness: WagonReadiness.ImportReady, + currentTrainScheduleId: null, + })), + ]; + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(defaultFleetWagons) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5, cw3]) }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; + }); }); - it('computes the expected valid preview for Group A', async () => { + it('returns fleet availability and defers bookings when fleet is insufficient', async () => { const bookings = [ - makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'), - makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'), - makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'), + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10), ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); - dataSource.getRepository.mockImplementation((entity: { name?: string }) => { - if (entity?.name === 'Booking') { - return { find: jest.fn().mockResolvedValue(bookings) }; - } - if (entity?.name === 'TrainScheduleBooking') { + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const availableWagons = Array.from({ length: 15 }, (_, index) => ({ + id: `wagon-${index}`, + wagonTypeId: nw5.id, + status: WagonStatus.Available, + readiness: WagonReadiness.ImportReady, + currentTrainScheduleId: null, + })); + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { return { find: jest.fn().mockResolvedValue([]) }; } - if (entity?.name === 'Locomotive') { - return { - count: jest.fn().mockResolvedValue(2), - find: jest.fn().mockResolvedValue([locomotive]), - }; + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(availableWagons) }; } - throw new Error(`Unexpected repository ${entity?.name}`); + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5]) }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; }); const result = await service.previewContainerTrainSchedule({ - bookingIds: bookings.map((booking) => booking.id), + bookingIds: bookings.map((b) => b.id), + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.fleetAvailability?.length).toBeGreaterThan(0); + expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0); + expect(result.deferredBookings?.length).toBeGreaterThan(0); + expect(result.summary.wagonsNeeded).toBeLessThan(30); + expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe( + true, + ); + }); + + it('computes slot-based preview for Group A', async () => { + const bookings = [ + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10), + makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT', 15), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: bookings.map((b) => b.id), scheduleDate: '2026-06-20T08:00:00.000Z', originStationId: 'yard-origin', destinationStationId: 'yard-destination', @@ -115,40 +242,50 @@ describe('TrainSchedulingService', () => { expect(result.valid).toBe(true); expect(result.violations).toEqual([]); - expect(result.summary).toEqual({ - totalBookings: 3, - totalWeightTons: 1250, - wagonType: 'NW5', - wagonsNeeded: 18, - totalLengthMeters: 252, - }); - expect(result.wagonPlan).toHaveLength(18); - expect(result.wagonPlan[0]?.allocations[0]).toEqual({ - bookingId: 'b1', - bookingReference: 'BKG-CONT-001', - allocatedWeightTons: 70, + expect(result.summary.wagonsNeeded).toBe(45); + expect(result.wagonPlan).toHaveLength(45); + }); + + it('returns soft hold warnings without forceAssign', async () => { + const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b7'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', }); + + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toContain('soft hold window'); }); it('flags the overweight booking as invalid', async () => { - const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')]; + const bookings = [ + makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, { + bookingContainers: [ + { + id: 'b6-line', + containerTypeId: 'ct-1', + quantity: 80, + wagonsRequired: 80, + vgmPerUnitTons: 45, + isOverweight: true, + containerType: { code: '40FT', label: '40FT' }, + }, + ], + }), + ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); - dataSource.getRepository.mockImplementation((entity: { name?: string }) => { - if (entity?.name === 'Booking') { - return { find: jest.fn().mockResolvedValue(bookings) }; - } - if (entity?.name === 'TrainScheduleBooking') { - return { find: jest.fn().mockResolvedValue([]) }; - } - if (entity?.name === 'Locomotive') { - return { - count: jest.fn().mockResolvedValue(1), - find: jest.fn().mockResolvedValue([locomotive]), - }; - } - throw new Error(`Unexpected repository ${entity?.name}`); - }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); const result = await service.previewContainerTrainSchedule({ bookingIds: ['b6'], @@ -158,36 +295,77 @@ describe('TrainSchedulingService', () => { }); expect(result.valid).toBe(false); - expect(result.summary.totalWeightTons).toBe(3600); - expect(result.violations).toContain( - 'Total booking weight 3600T exceeds max train weight 3500T', + expect(result.violations.some((v) => v.includes('overweight'))).toBe(true); + }); + + it('allows preview when bookings are already on the target schedule', async () => { + const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([ + { bookingId: 'b1', trainScheduleId: 'sched-target' }, + ]); + trainSchedulesRepository.findById.mockResolvedValue({ + id: 'sched-target', + direction: 'IMPORT', + }); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + targetScheduleId: 'sched-target', + }); + + expect(result.violations).not.toContain( + 'One or more selected bookings are already assigned to a train schedule', ); + expect(result.valid).toBe(true); + }); + + it('allows preview when selected bookings are on different schedule dates', async () => { + const bookings = [ + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: bookings.map((b) => b.id), + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.violations).not.toContain( + 'Selected bookings must share the same schedule date', + ); + expect(result.valid).toBe(true); }); it('rejects bookings that are not in assignable status', async () => { const bookings = [ +<<<<<<< HEAD { ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'), status: 'PAID', }, +======= + { ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' }, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); - dataSource.getRepository.mockImplementation((entity: { name?: string }) => { - if (entity?.name === 'Booking') { - return { find: jest.fn().mockResolvedValue(bookings) }; - } - if (entity?.name === 'TrainScheduleBooking') { - return { find: jest.fn().mockResolvedValue([]) }; - } - if (entity?.name === 'Locomotive') { - return { - count: jest.fn().mockResolvedValue(1), - find: jest.fn().mockResolvedValue([locomotive]), - }; - } - throw new Error(`Unexpected repository ${entity?.name}`); - }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); const result = await service.previewContainerTrainSchedule({ bookingIds: ['b7'], @@ -239,13 +417,16 @@ describe('TrainSchedulingService', () => { }; jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); - dataSource.getRepository.mockImplementation((entity: { name?: string }) => { - if (entity?.name === 'Route') { + dataSource.getRepository.mockImplementation((entity: unknown) => { + if ((entity as { name?: string })?.name === 'Route') { return { findOne: jest.fn().mockResolvedValue(route) }; } - throw new Error(`Unexpected repository ${entity?.name}`); + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`); }); - jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' }); dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => callback(manager), ); @@ -259,7 +440,62 @@ describe('TrainSchedulingService', () => { expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled(); expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); - expect(result).toEqual({ id: 'schedule-1' }); + expect(result.id).toBe('schedule-1'); + }); + + it('previews mixed container and bulk bookings', async () => { + const containerBooking = makeBooking('c1', 'BKG-CONT', 100, 2, '40FT', 2); + const bulkBooking = { + id: 'b1', + reference: 'BKG-BULK', + freightType: 'BULK', + cargoTotalWeightVgm: 120, + scheduledDate: new Date('2026-06-20T08:00:00.000Z'), + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + status: 'PAID', + bookingContainers: [], + cargoType: { code: 'COFFEE' }, + }; + + wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => { + if (where?.code === 'NW5') return [nw5]; + return [nw5, cw3]; + }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([containerBooking, bulkBooking]); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewTrainSchedule({ + bookingIds: ['c1', 'b1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(true); + expect(result.summary.wagonType).toBe('MIXED'); + expect(result.wagonPlan.length).toBeGreaterThan(2); + expect(result.containerUnits).toHaveLength(2); + }); + + it('previews container bookings without requiring placements', async () => { + const bookings = [makeBooking('c2', 'BKG-CONT-2', 50, 1, '40FT', 1)]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewTrainSchedule({ + bookingIds: ['c2'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(true); + expect(result.containerUnits).toHaveLength(1); }); it('rejects create when the locked locomotive is no longer available', async () => { @@ -296,4 +532,48 @@ describe('TrainSchedulingService', () => { }), ).rejects.toBeInstanceOf(ConflictException); }); + + it('rejects pin when wagon readiness does not match schedule direction', async () => { + const scheduleId = 'sched-1'; + const slotId = 'slot-1'; + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: scheduleId, + status: 'DRAFT', + direction: 'IMPORT', + trainSet: { + wagons: [{ id: slotId, physicalWagonId: null }], + }, + }); + + const manager = { + getRepository: jest.fn((entity: { name?: string }) => { + if (entity === Wagon) { + return { + findOne: jest.fn().mockResolvedValue({ + id: 'wagon-1', + wagonNumber: 'WGN-001', + status: WagonStatus.Available, + readiness: WagonReadiness.ExportReady, + currentTrainScheduleId: null, + }), + update: jest.fn(), + }; + } + if (entity === TrainSetWagon) { + return { update: jest.fn() }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }), + }; + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + await expect( + service.pinWagons(scheduleId, { + assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }], + }), + ).rejects.toBeInstanceOf(ConflictException); + }); }); 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 7c27123fe..3ea2d492e 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,14 +1,52 @@ +import { + AllocationLoadType, + SchedulingStatus, + TrainScheduleStatus as TrainScheduleStatusEnum, + WagonStatus, +} from '@edr/types'; import { BadRequestException, ConflictException, Injectable, NotFoundException, -} from "@nestjs/common"; -import { InjectDataSource } from "@nestjs/typeorm"; -import { DataSource, EntityManager, In } from "typeorm"; +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In } from 'typeorm'; -import { Booking } from "../bookings/entities/booking.entity"; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { LocomotivesRepository } from '../locomotives/locomotives.repository'; +import { Route } from '../routes/entities/route.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository'; +import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; +import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { AssignBookingsDto } from './dto/assign-bookings.dto'; +import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; +import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; +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 { 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'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { +<<<<<<< HEAD Locomotive, type LocomotiveStatus, } from "../locomotives/entities/locomotive.entity"; @@ -74,6 +112,45 @@ type ValidationResult = { totalLengthMeters: number; }; wagonPlan: WagonPlanRecord[]; +======= + buildCappedWagonPlan, + computeFleetAvailability, + selectBookingsWithinFleetCap, + summarizeFleetWarnings, + totalAssignedWeight, + type DeferredBookingRow, + type FleetAvailabilityRow, +} from './fleet-plan.util'; +import { + buildBulkWagonPlan, + buildContainerWagonPlan, + buildMixedWagonPlan, + expandBookingContainerUnits, + getContainerSlotSequenceNos, + roundTons, + sumWagonsRequired, + type TrainLimitConfig, + validateContainerPlacements, + validateMixedTrainLimits, + validateTrainLimits, + type ContainerPlacementInput, + type WagonPlanSlot, +} from './wagon-plan.util'; +import { + getDefaultContainerWagonTypeCode, + pickBulkWagonType, +} from './wagon-type-resolver.util'; +import { deriveScheduleDirection } from './derive-schedule-direction.util'; +import { wagonReadinessMatchesSchedule } from './wagon-readiness.util'; + +const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; +const DEFAULT_TRAIN_LIMITS: Required = { + maxWeightTons: 3500, + maxLengthMeters: 760, + maxWagonsPerTrain: 53, + max20ftContainerWeightTons: 30, + max20ftPairWeightDiffTons: 10, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db }; @Injectable() @@ -81,11 +158,29 @@ export class TrainSchedulingService { constructor( @InjectDataSource() private readonly dataSource: DataSource, + private readonly bookingsRepository: BookingsRepository, private readonly locomotivesRepository: LocomotivesRepository, private readonly wagonTypesRepository: WagonTypesRepository, - ) { } + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, + private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository, + private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository, + private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository, + private readonly configService?: ConfigService, + ) {} + + async getEligibleBookings(query: GetEligibleBookingsDto) { + const bookings = await this.bookingsRepository.findEligibleForScheduling({ + freightType: query.freightType, + originStationId: query.originStationId, + destinationStationId: query.destinationStationId, + schedulingStatus: query.schedulingStatus, + }); + return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; + } async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { +<<<<<<< HEAD const bookingRepository = this.dataSource.getRepository(Booking); const queryBuilder = bookingRepository .createQueryBuilder("booking") @@ -107,21 +202,31 @@ export class TrainSchedulingService { queryBuilder.andWhere("booking.status IN (:...assignableStatuses)", { assignableStatuses: ASSIGNABLE_BOOKING_STATUSES, }); +======= + return this.getEligibleBookings({ ...query, freightType: 'CONTAINER' }); + } - if (query.originStationId) { - queryBuilder.andWhere("booking.originYardId = :originStationId", { - originStationId: query.originStationId, - }); - } + async getEligibleBulkBookings(query: GetEligibleBulkBookingsDto) { + return this.getEligibleBookings({ ...query, freightType: 'BULK' }); + } +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db - if (query.destinationStationId) { - queryBuilder.andWhere( - "booking.destinationYardId = :destinationStationId", - { - destinationStationId: query.destinationStationId, - }, - ); + async getTrainSchedulingGlobalRules() { + return this.loadGlobalRulesRow(); + } + + async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) { + const row = await this.loadGlobalRulesRow(); + if (!row) { + throw new NotFoundException('Train scheduling global rules not configured'); } + if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters; + if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons; + if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain; + if (dto.max20ftContainerWeightTons != null) { + row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons; + } +<<<<<<< HEAD if (query.tradeDirection === "IMPORT") { queryBuilder.andWhere( @@ -148,8 +253,15 @@ export class TrainSchedulingService { `DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`, { scheduleDate: this.toUtcDateKey(query.scheduleDate) }, ); +======= + if (dto.max20ftPairWeightDiffTons != null) { + row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db } + return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row); + } +<<<<<<< HEAD const bookings = await queryBuilder .orderBy("booking.scheduled_date", "ASC") .addOrderBy("booking.created_at", "ASC") @@ -191,22 +303,74 @@ export class TrainSchedulingService { count: items.length, items, }; +======= + async previewTrainSchedule(dto: PreviewTrainScheduleDto) { + const limits = await this.resolveTrainLimitConfig(dto); + return this.buildPreviewResponse( + await this.validateBookingsForScheduling( + dto, + null, + false, + [], + false, + limits, + dto.targetScheduleId, + ), + ); +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db } async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { - const validation = await this.validateContainerBookingsForScheduling(dto); + const limits = await this.resolveTrainLimitConfig(dto); + return this.buildPreviewResponse( + await this.validateBookingsForScheduling( + dto, + 'CONTAINER', + false, + [], + false, + limits, + dto.targetScheduleId, + ), + ); + } + async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) { + const limits = await this.resolveTrainLimitConfig(dto); + return this.buildPreviewResponse( + await this.validateBookingsForScheduling( + dto, + 'BULK', + false, + [], + false, + limits, + dto.targetScheduleId, + ), + ); + } + + private buildPreviewResponse(validation: Awaited>) { + const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); return { valid: validation.valid, violations: validation.violations, + warnings: validation.warnings, summary: validation.summary, - bookingIds: validation.bookings.map((booking) => booking.id), + fleetAvailability: validation.fleetAvailability, + deferredBookings: validation.deferredBookings, + bookingIds: validation.bookings.map((b) => b.id), wagonPlan: validation.wagonPlan, + containerUnits: containerBookings.length + ? expandBookingContainerUnits(containerBookings) + : [], + containerSlotSequenceNos: getContainerSlotSequenceNos(validation.wagonPlan), }; } async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { const route = await this.getActiveRoute(dto.routeId); +<<<<<<< HEAD const validation = dto.bookingIds?.length ? await this.validateContainerBookingsForScheduling({ bookingIds: dto.bookingIds, @@ -228,26 +392,286 @@ export class TrainSchedulingService { dto.locomotiveId, validation?.summary.totalWeightTons ?? 0, validation?.summary.totalLengthMeters ?? 0, +======= + const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0); + + const createdScheduleId = await this.dataSource.transaction(async (manager) => { + const lockedLocomotive = await manager.getRepository(Locomotive).findOne({ + where: { id: locomotive.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedLocomotive) { + throw new NotFoundException(`Locomotive ${locomotive.id} not found`); + } + if (lockedLocomotive.status !== 'AVAILABLE') { + throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`); + } + + const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive); + const direction = deriveScheduleDirection( + route.originYard ?? { country: null }, + route.destinationYard ?? { country: null }, + ); + const schedule = manager.getRepository(TrainSchedule).create({ + trainSetId: trainSet.id, + routeId: route.id, + originStationId: route.originYardId, + destinationStationId: route.destinationYardId, + scheduledDepartureDate: new Date(dto.scheduleDate), + status: TrainScheduleStatusEnum.Draft, + direction, + maxWagons: (await this.resolveTrainLimitConfig(dto)).maxWagonsPerTrain, + }); + const saved = await manager.getRepository(TrainSchedule).save(schedule); + await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' }); + return saved.id; + }); + + return this.getTrainScheduleById(createdScheduleId); + } + + async assignBookingsToSchedule( + scheduleId: string, + dto: AssignBookingsDto, + freightType?: 'CONTAINER' | 'BULK', + ) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot assign bookings to schedule in status ${schedule.status}`, + ); + } + if (!schedule.trainSet) { + throw new BadRequestException('Schedule has no train set'); + } + + const previewDto = { + bookingIds: dto.bookingIds, + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + maxTrainWeightTons: dto.maxTrainWeightTons, + maxTrainLengthMeters: dto.maxTrainLengthMeters, + maxWagonsPerTrain: dto.maxWagonsPerTrain ?? schedule.maxWagons, + }; + + const limits = await this.resolveTrainLimitConfig(previewDto); + const validation = await this.validateBookingsForScheduling( + previewDto, + freightType ?? null, + dto.forceAssign, + dto.containerPlacements, + true, + limits, + scheduleId, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db ); - const createdSchedule = await this.dataSource.transaction( - async (manager) => { - const locomotiveRepository = manager.getRepository(Locomotive); - const lockedLocomotive = await locomotiveRepository.findOne({ - where: { id: locomotive.id }, - lock: { mode: "pessimistic_write" }, + if (!validation.valid) { + throw new BadRequestException({ + message: 'Booking validation failed', + violations: validation.violations, + warnings: validation.warnings, + }); + } + + if (!validation.bookings.length) { + throw new BadRequestException({ + message: 'No bookings fit on available fleet wagons', + violations: ['Insufficient fleet wagons for the selected bookings'], + warnings: validation.warnings, + deferredBookings: validation.deferredBookings, + }); + } + + const { bookings, wagonType, wagonPlan, warnings, deferredBookings } = validation; + const totalWeightTons = validation.summary.totalWeightTons; + const totalLengthMeters = validation.summary.totalLengthMeters; + + const locomotive = schedule.trainSet.locomotive; + if (!locomotive) { + throw new BadRequestException('Schedule train set has no locomotive'); + } + if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`, + ); + } + if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, + ); + } + + await this.dataSource.transaction(async (manager) => { + const trainSetId = schedule.trainSetId; + + await this.releasePinnedWagonsForTrainSet(manager, trainSetId); + + const deletedAllocationIds = + await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager); + + if (deletedAllocationIds.length) { + await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds( + deletedAllocationIds, + manager, + ); + await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds( + deletedAllocationIds, + manager, + ); + } + + await manager.getRepository(TrainSetWagon).delete({ trainSetId }); + await manager.getRepository(TrainScheduleBooking).delete({ trainScheduleId: scheduleId }); + + await manager.getRepository(TrainSet).update(trainSetId, { + totalWeightTons, + totalLengthMeters, + wagonCount: wagonPlan.length, + status: 'ASSIGNED', + }); + + const savedWagons = await this.persistTrainSetWagons( + manager, + trainSetId, + wagonType, + wagonPlan, + ); + + const scheduleBookingRecords = bookings.map((booking) => ({ + trainScheduleId: scheduleId, + bookingId: booking.id, + })); + await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager); + + await this.persistAllocationsAndLoads( + manager, + savedWagons, + wagonPlan, + bookings, + dto.containerPlacements ?? [], + ); + + for (const booking of bookings) { + await this.bookingsRepository.updateSchedulingFields( + booking.id, + { + schedulingStatus: SchedulingStatus.Eligible, + wagonsRequired: sumWagonsRequired(booking), + }, + manager, + ); + } + + if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Draft, + {}, + manager, + ); + } + + await this.autoPinWagonsForSchedule( + manager, + scheduleId, + schedule.direction ?? null, + savedWagons, + ); + }); + + const detail = await this.getTrainScheduleById(scheduleId); + return { ...detail, warnings, deferredBookings }; + } + + async unassignBooking(scheduleId: string, bookingId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); + } + + const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId); + if (!link) { + throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`); + } + + await this.dataSource.transaction(async (manager) => { + const allocationIds = (schedule.trainSet?.wagons ?? []) + .flatMap((w) => w.allocations ?? []) + .filter((a) => a.bookingId === bookingId) + .map((a) => a.id); + + if (allocationIds.length) { + await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds( + allocationIds, + manager, + ); + await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager); + await manager.getRepository(WagonBookingAllocation).delete(allocationIds); + } + + await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( + scheduleId, + bookingId, + manager, + ); + + const booking = await this.bookingsRepository.findById(bookingId); + const schedulingStatus = this.resolvePostUnassignStatus(booking); + await this.bookingsRepository.updateSchedulingFields( + bookingId, + { schedulingStatus, wagonsRequired: null }, + manager, + ); + + const remainingBookings = (schedule.scheduleBookings ?? []).filter( + (sb) => sb.bookingId !== bookingId, + ); + if (remainingBookings.length === 0) { + await this.wagonBookingAllocationsRepository.deleteByTrainSetId( + schedule.trainSetId, + manager, + ); + await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId }); + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + totalWeightTons: 0, + totalLengthMeters: 0, + wagonCount: 0, + status: 'DRAFT', }); + } + }); - if (!lockedLocomotive) { - throw new NotFoundException(`Locomotive ${locomotive.id} not found`); - } + return this.getTrainScheduleById(scheduleId); + } - if (lockedLocomotive.status !== "AVAILABLE") { - throw new ConflictException( - `Locomotive ${lockedLocomotive.code} is not available`, + async pinWagons(scheduleId: string, dto: PinWagonsDto) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule'); + } + + const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id)); + + await this.dataSource.transaction(async (manager) => { + for (const assignment of dto.assignments) { + if (!slotIds.has(assignment.trainSetWagonId)) { + throw new BadRequestException( + `Train set wagon ${assignment.trainSetWagonId} does not belong to this schedule`, ); } +<<<<<<< HEAD const selectedPhysicalWagons = validation ? await this.lockSelectedWagonsForSchedule( manager, @@ -279,8 +703,29 @@ export class TrainSchedulingService { scheduledDepartureDate: new Date(dto.scheduleDate), scheduledArrivalDate: dto.arrivalDate ? new Date(dto.arrivalDate) : null, status: validation ? "READY" : "DRAFT", +======= + const physicalWagon = await manager.getRepository(Wagon).findOne({ + where: { id: assignment.physicalWagonId }, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db }); + if (!physicalWagon) { + throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`); + } + if ( + physicalWagon.status !== WagonStatus.Available && + physicalWagon.currentTrainScheduleId !== scheduleId + ) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is not available`, + ); + } + if (!wagonReadinessMatchesSchedule(physicalWagon.readiness, schedule.direction)) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is ${physicalWagon.readiness} but schedule is ${schedule.direction ?? 'unknown'}`, + ); + } +<<<<<<< HEAD const savedSchedule = await manager .getRepository(TrainSchedule) .save(schedule); @@ -324,61 +769,211 @@ export class TrainSchedulingService { await locomotiveRepository.update(lockedLocomotive.id, { status: "ASSIGNED", +======= + await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, { + physicalWagonId: assignment.physicalWagonId, + status: 'RESERVED', +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db }); - - return savedSchedule.id; - }, - ); - - return this.getContainerTrainScheduleById(createdSchedule); - } - - async validateContainerBookingsForScheduling( - dto: PreviewContainerTrainScheduleDto, - ): Promise { - const bookingIds = [...new Set(dto.bookingIds)]; - - if (!bookingIds.length) { - throw new BadRequestException("At least one booking is required"); - } - - const [wagonType] = await this.wagonTypesRepository.findAll({ - where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true }, + await manager.getRepository(Wagon).update(assignment.physicalWagonId, { + trainSetWagonId: assignment.trainSetWagonId, + currentTrainScheduleId: scheduleId, + status: WagonStatus.Assigned, + }); + } }); - if (!wagonType) { - throw new NotFoundException( - `Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`, - ); + return this.getTrainScheduleById(scheduleId); + } + + async finalizeSchedule(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Draft) { + throw new BadRequestException('Only DRAFT schedules can be finalized'); + } + if (!schedule.scheduleBookings?.length) { + throw new BadRequestException('Cannot finalize a schedule with no bookings'); } - const bookings = await this.loadBookingsForScheduling(bookingIds); + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Scheduled, + {}, + manager, + ); + for (const sb of schedule.scheduleBookings ?? []) { + await this.bookingsRepository.updateSchedulingFields( + sb.bookingId, + { schedulingStatus: SchedulingStatus.Scheduled, scheduledAt: now }, + manager, + ); + } + }); + + return this.getTrainScheduleById(scheduleId); + } + + async dispatchSchedule(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { + throw new BadRequestException('Only SCHEDULED trains can be dispatched'); + } + + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Dispatched, + { actualDepartureAt: now }, + manager, + ); + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' }); + } + for (const sb of schedule.scheduleBookings ?? []) { + await this.bookingsRepository.updateSchedulingFields( + sb.bookingId, + { schedulingStatus: SchedulingStatus.Dispatched }, + manager, + ); + } + }); + + return this.getTrainScheduleById(scheduleId); + } + + async getContainerTrainSchedules() { + const schedules = await this.trainSchedulesRepository.findAll({ + relations: { + trainSet: { locomotive: true }, + route: true, + originStation: true, + destinationStation: true, + scheduleBookings: { booking: true }, + }, + order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' }, + }); + return schedules.map((s) => this.mapScheduleListItem(s)); + } + + async getContainerTrainScheduleById(id: string) { + return this.getTrainScheduleById(id); + } + + async cancelTrainSchedule(id: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + id, + TrainScheduleStatusEnum.Cancelled, + {}, + manager, + ); + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); + } + if (schedule.trainSet?.locomotiveId) { + await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, { + status: 'AVAILABLE', + }); + } + for (const wagon of schedule.trainSet?.wagons ?? []) { + if (wagon.physicalWagonId) { + await manager.getRepository(Wagon).update(wagon.physicalWagonId, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: WagonStatus.Available, + }); + } + } + for (const sb of schedule.scheduleBookings ?? []) { + const booking = await this.bookingsRepository.findById(sb.bookingId); + await this.bookingsRepository.updateSchedulingFields( + sb.bookingId, + { schedulingStatus: this.resolvePostUnassignStatus(booking) }, + manager, + ); + } + }); + + return this.getTrainScheduleById(id); + } + + private async getTrainScheduleById(id: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + return this.mapScheduleDetail(schedule); + } + + private async validateBookingsForScheduling( + dto: PreviewContainerTrainScheduleDto | PreviewBulkTrainScheduleDto | PreviewTrainScheduleDto, + freightType: 'CONTAINER' | 'BULK' | null, + forceAssign = false, + containerPlacements: ContainerPlacementInput[] = [], + requireContainerPlacements = false, + trainLimits: Required, + targetScheduleId?: string, + ) { + const bookingIds = [...new Set(dto.bookingIds)]; + if (!bookingIds.length) { + throw new BadRequestException('At least one booking is required'); + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); const violations: string[] = []; + const warnings: string[] = []; if (bookings.length !== bookingIds.length) { - const foundIds = new Set(bookings.map((booking) => booking.id)); - const missing = bookingIds.filter((id) => !foundIds.has(id)); - violations.push(`Bookings not found: ${missing.join(", ")}`); + const foundIds = new Set(bookings.map((b) => b.id)); + violations.push(`Bookings not found: ${bookingIds.filter((id) => !foundIds.has(id)).join(', ')}`); } - const scheduledLinks = await this.dataSource - .getRepository(TrainScheduleBooking) - .find({ - where: { bookingId: In(bookingIds) }, - select: { bookingId: true }, - }); - - if (scheduledLinks.length > 0) { - violations.push( - "One or more selected bookings are already assigned to a train schedule", - ); + const scheduledLinks = await this.trainScheduleBookingsRepository.findByBookingIds(bookingIds); + const conflictingLinks = targetScheduleId + ? scheduledLinks.filter((link) => link.trainScheduleId !== targetScheduleId) + : scheduledLinks; + if (conflictingLinks.length > 0) { + violations.push('One or more selected bookings are already assigned to a train schedule'); } +<<<<<<< HEAD const nonContainerBookings = bookings.filter( (booking) => booking.freightType !== (dto.assignmentType ?? "CONTAINER"), +======= + const bookingTypes = new Set(bookings.map((b) => b.freightType)); + const isMixed = bookingTypes.size > 1; + const resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED' = + freightType ?? (isMixed ? 'MIXED' : ([...bookingTypes][0] as 'CONTAINER' | 'BULK')); + + if (freightType === 'CONTAINER' || freightType === 'BULK') { + const wrongType = bookings.filter((b) => b.freightType !== freightType); + if (wrongType.length) { + violations.push(`Only ${freightType} bookings are supported`); + } + } + + const invalidStatus = bookings.filter( + (b) => !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID'), +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db ); - if (nonContainerBookings.length > 0) { + if (invalidStatus.length) { + const statuses = [...new Set(invalidStatus.map((b) => b.status))]; violations.push( +<<<<<<< HEAD `Only ${dto.assignmentType ?? "CONTAINER"} bookings are supported for this assignment`, ); } @@ -469,84 +1064,561 @@ export class TrainSchedulingService { if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) { violations.push( `Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`, +======= + `Only ${SCHEDULABLE_BOOKING_STATUSES.join(', ')} bookings can be scheduled; received: ${statuses.join(', ')}`, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db ); } if ( +<<<<<<< HEAD wagonPlan.length > (dto.assignmentType === "BULK" ? MAX_BULK_WAGONS : MAX_CONTAINER_WAGONS) ) { violations.push( `Wagon count ${wagonPlan.length} exceeds ${dto.assignmentType === "BULK" ? "bulk" : "container"} limit ${dto.assignmentType === "BULK" ? MAX_BULK_WAGONS : MAX_CONTAINER_WAGONS}`, ); +======= + bookings.some( + (b) => + b.originYardId !== dto.originStationId || + b.destinationYardId !== dto.destinationStationId, + ) + ) { + violations.push('Selected bookings must share the same origin and destination as the schedule'); +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db } - const availableLocomotiveCount = await this.dataSource - .getRepository(Locomotive) - .count({ - where: { status: "AVAILABLE" as LocomotiveStatus }, - }); + if (!forceAssign) { + for (const booking of bookings) { + if (this.isHoldActive(booking)) { + warnings.push( + `Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`, + ); + } + const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight); + if (overweightLines.length) { + violations.push( + `Booking ${booking.reference} has overweight container lines; use forceAssign to override`, + ); + } + } + } - if (availableLocomotiveCount === 0) { - violations.push("No available locomotive exists for scheduling"); - } else { - const capableLocomotives = await this.dataSource - .getRepository(Locomotive) - .find({ - where: { status: "AVAILABLE" }, - }); - const canPull = capableLocomotives.some( - (locomotive) => - Number(locomotive.maxPullWeightTons) >= totalWeightTons && - Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters, + let wagonType: WagonType; + let containerWagonType: WagonType; + let bulkWagonType: WagonType; + let demandPlan: WagonPlanSlot[]; + let fittingBookings = bookings; + let deferredBookings: DeferredBookingRow[] = []; + let fleetAvailability: FleetAvailabilityRow[] = []; + + if (resolvedMode === 'MIXED') { + const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER'); + const bulkBookings = bookings.filter((b) => b.freightType === 'BULK'); + containerWagonType = await this.resolveWagonType('CONTAINER', bookingIds); + bulkWagonType = await this.resolveWagonType('BULK', bookingIds); + wagonType = containerWagonType; + demandPlan = buildMixedWagonPlan( + containerBookings, + bulkBookings, + containerWagonType, + bulkWagonType, ); - if (!canPull) { + } else { + wagonType = await this.resolveWagonType(resolvedMode, bookingIds); + containerWagonType = wagonType; + bulkWagonType = wagonType; + demandPlan = + resolvedMode === 'CONTAINER' + ? buildContainerWagonPlan(bookings, wagonType) + : buildBulkWagonPlan(bookings, wagonType); + } + + const scheduleDirection = await this.resolveScheduleDirection(targetScheduleId, bookings); + const fleetCounts = await this.countFleetAvailability(scheduleDirection, targetScheduleId); + const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available])); + fleetAvailability = computeFleetAvailability( + demandPlan, + fleetByTypeId, + new Map(fleetCounts.map((row) => [row.wagonTypeId, row.wagonTypeCode])), + ); + + const selection = selectBookingsWithinFleetCap( + bookings, + fleetByTypeId, + (booking) => + booking.freightType === 'BULK' ? bulkWagonType.id : containerWagonType.id, + Number(bulkWagonType.capacityTons), + ); + fittingBookings = selection.fitting; + deferredBookings = selection.deferred; + warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings)); + + const wagonPlan = buildCappedWagonPlan({ + bookings: fittingBookings, + resolvedMode, + containerWagonType, + bulkWagonType, + }); + + const placementRules = { + max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons, + max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, + }; + + if (resolvedMode === 'MIXED') { + violations.push( + ...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), + ); + if (requireContainerPlacements) { + const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); violations.push( - 'No available locomotive can support the total train weight and length', + ...validateContainerPlacements( + containerBookings, + wagonPlan, + containerPlacements, + placementRules, + ), + ); + violations.push( + ...(await this.validateFleetContainers(containerPlacements, containerBookings)), ); } + } else { + violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits)); + + if (requireContainerPlacements && resolvedMode === 'CONTAINER') { + violations.push( + ...validateContainerPlacements( + fittingBookings, + wagonPlan, + containerPlacements, + placementRules, + ), + ); + violations.push( + ...(await this.validateFleetContainers(containerPlacements, fittingBookings)), + ); + } + } + + const totalWeightTons = totalAssignedWeight(fittingBookings); + const totalLengthMeters = roundTons( + wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), + ); + if (totalWeightTons > trainLimits.maxWeightTons) { + const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; + if (!violations.includes(message)) { + violations.push(message); + } + } + + const availableLocomotives = await this.locomotivesRepository.findAll({ + where: { status: 'AVAILABLE' }, + }); + if (!availableLocomotives.length) { + violations.push('No available locomotive exists for scheduling'); + } else if ( + !availableLocomotives.some( + (l) => + Number(l.maxPullWeightTons) >= totalWeightTons && + Number(l.maxTrainLengthMeters) >= totalLengthMeters, + ) + ) { + violations.push('No available locomotive can support the total train weight and length'); } return { valid: violations.length === 0, violations, - bookings, + warnings, + bookings: fittingBookings, wagonType, + wagonPlan, + fleetAvailability, + deferredBookings, summary: { - totalBookings: bookings.length, + totalBookings: fittingBookings.length, totalWeightTons, - wagonType: wagonType.code, + wagonType: + resolvedMode === 'MIXED' ? 'MIXED' : wagonType.code, wagonsNeeded: wagonPlan.length, totalLengthMeters, + freightMode: resolvedMode, }, - wagonPlan, }; } - calculateNW5WagonPlan( - totalBookingWeightTons: number, - wagonType: WagonType, - ): WagonPlanRecord[] { - const wagonCapacityTons = Number(wagonType.capacityTons); - const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons); - let remainingWeight = this.roundTons(totalBookingWeightTons); + private async loadGlobalRulesRow(): Promise { + try { + const rows = await this.dataSource.getRepository(TrainSchedulingGlobalRules).find({ + order: { createdAt: 'ASC' }, + take: 1, + }); + return rows[0] ?? null; + } catch { + return null; + } + } - return Array.from({ length: wagonsNeeded }, (_, index) => { - const assignedWeightTons = this.roundTons( - Math.min(wagonCapacityTons, remainingWeight), - ); - remainingWeight = this.roundTons( - Math.max(0, remainingWeight - assignedWeightTons), - ); + private async resolveTrainLimitConfig(dto?: { + maxTrainWeightTons?: number; + maxTrainLengthMeters?: number; + maxWagonsPerTrain?: number; + }): Promise> { + const row = await this.loadGlobalRulesRow(); + const configured = this.configService?.get<{ + maxTrainWeightTons?: number; + maxTrainLengthMeters?: number; + maxWagonsPerTrain?: number; + }>('app.trainScheduling'); - return { - sequenceNo: index + 1, - capacityTons: wagonCapacityTons, - lengthMeters: this.roundTons(Number(wagonType.lengthMeters)), - assignedWeightTons, - allocations: [], - }; + return { + maxWeightTons: this.positiveNumber( + dto?.maxTrainWeightTons, + Number(row?.maxTrainWeightTons) || + configured?.maxTrainWeightTons || + DEFAULT_TRAIN_LIMITS.maxWeightTons, + ), + maxLengthMeters: this.positiveNumber( + dto?.maxTrainLengthMeters, + Number(row?.maxTrainLengthMeters) || + configured?.maxTrainLengthMeters || + DEFAULT_TRAIN_LIMITS.maxLengthMeters, + ), + maxWagonsPerTrain: Math.floor( + this.positiveNumber( + dto?.maxWagonsPerTrain, + Number(row?.maxWagonsPerTrain) || + configured?.maxWagonsPerTrain || + DEFAULT_TRAIN_LIMITS.maxWagonsPerTrain, + ), + ), + max20ftContainerWeightTons: this.positiveNumber( + undefined, + Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons, + ), + max20ftPairWeightDiffTons: this.positiveNumber( + undefined, + Number(row?.max20ftPairWeightDiffTons) || + DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons, + ), + }; + } + + private async resolveScheduleDirection( + targetScheduleId: string | undefined, + bookings: Booking[], + ): Promise { + if (targetScheduleId) { + const schedule = await this.trainSchedulesRepository.findById(targetScheduleId); + if (schedule?.direction) return schedule.direction; + } + + const booking = bookings[0]; + if (!booking) return null; + + return deriveScheduleDirection( + booking.originYard ?? { country: null }, + booking.destinationYard ?? { country: null }, + ); + } + + private async countFleetAvailability( + scheduleDirection: string | null, + targetScheduleId?: string, + ): Promise> { + const [wagons, wagonTypes] = await Promise.all([ + this.dataSource.getRepository(Wagon).find(), + this.dataSource.getRepository(WagonType).find(), + ]); + const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); + const counts = new Map(); + + for (const wagon of wagons) { + const pinnedOnTarget = targetScheduleId + ? wagon.currentTrainScheduleId === targetScheduleId + : false; + if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; + if (!wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection)) continue; + + const typeId = wagon.wagonTypeId; + const code = typeCodeById.get(typeId) ?? typeId; + const existing = counts.get(typeId) ?? { code, available: 0 }; + existing.available += 1; + counts.set(typeId, existing); + } + + return [...counts.entries()].map(([wagonTypeId, value]) => ({ + wagonTypeId, + wagonTypeCode: value.code, + available: value.available, + })); + } + + private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) { + const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } }); + for (const slot of slots) { + if (!slot.physicalWagonId) continue; + await manager.getRepository(Wagon).update(slot.physicalWagonId, { + status: WagonStatus.Available, + trainSetWagonId: null, + currentTrainScheduleId: null, + }); + } + } + + private async autoPinWagonsForSchedule( + manager: EntityManager, + scheduleId: string, + scheduleDirection: string | null, + slots: TrainSetWagon[], + ) { + const wagons = await manager.getRepository(Wagon).find(); + const assignedPhysicalIds = new Set(); + + for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) { + const candidates = wagons.filter((wagon) => { + if (wagon.wagonTypeId !== slot.wagonTypeId) return false; + if (assignedPhysicalIds.has(wagon.id)) return false; + const pinnedOnSchedule = wagon.currentTrainScheduleId === scheduleId; + if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false; + return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection); + }); + + const physical = candidates[0]; + if (!physical) continue; + + await manager.getRepository(TrainSetWagon).update(slot.id, { + physicalWagonId: physical.id, + status: 'RESERVED', + }); + await manager.getRepository(Wagon).update(physical.id, { + trainSetWagonId: slot.id, + currentTrainScheduleId: scheduleId, + status: WagonStatus.Assigned, + }); + assignedPhysicalIds.add(physical.id); + } + } + + private positiveNumber(value: number | undefined, fallback: number): number { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; + } + + private async validateFleetContainers( + placements: ContainerPlacementInput[], + containerBookings: Booking[], + ): Promise { + const violations: string[] = []; + const inventoryIds = [ + ...new Set(placements.map((p) => p.containerId).filter((id): id is string => Boolean(id))), + ]; + if (!inventoryIds.length) return violations; + + const lineById = new Map( + containerBookings.flatMap((b) => + (b.bookingContainers ?? []).map((line) => [line.id, line] as const), + ), + ); + + const containers = await this.dataSource.getRepository(Container).find({ + where: { id: In(inventoryIds) }, }); + const containerById = new Map(containers.map((c) => [c.id, c])); + + for (const placement of placements) { + if (!placement.containerId) continue; + const fleet = containerById.get(placement.containerId); + if (!fleet) { + violations.push(`Fleet container ${placement.containerId} not found`); + continue; + } + if (fleet.status !== 'AVAILABLE') { + violations.push(`Container ${fleet.containerNumber} is not available`); + } + const line = lineById.get(placement.bookingContainerId); + if (line && fleet.containerTypeId !== line.containerTypeId) { + violations.push( + `Container ${fleet.containerNumber} type does not match booking line`, + ); + } + if ( + placement.containerNumber && + fleet.containerNumber.toUpperCase() !== placement.containerNumber.trim().toUpperCase() + ) { + violations.push( + `Container number ${placement.containerNumber} does not match fleet record ${fleet.containerNumber}`, + ); + } + } + + return violations; + } + + private async resolveWagonType( + freightType: 'CONTAINER' | 'BULK', + bookingIds: string[], + ): Promise { + if (freightType === 'CONTAINER') { + const [wagonType] = await this.wagonTypesRepository.findAll({ + where: { code: getDefaultContainerWagonTypeCode(), isActive: true }, + }); + if (!wagonType) { + throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`); + } + return wagonType; + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); + const cargoCode = bookings[0]?.cargoType?.code ?? null; + const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } }); + const picked = pickBulkWagonType(wagonTypes, cargoCode); + if (!picked) { + throw new NotFoundException('No suitable bulk wagon type found'); + } + return picked; + } + + private async persistTrainSetWagons( + manager: EntityManager, + trainSetId: string, + wagonType: WagonType, + wagonPlan: WagonPlanSlot[], + ) { + const wagons = wagonPlan.map((slot) => + manager.getRepository(TrainSetWagon).create({ + trainSetId, + wagonTypeId: slot.wagonTypeId ?? wagonType.id, + sequenceNo: slot.sequenceNo, + capacityTons: slot.capacityTons, + lengthMeters: slot.lengthMeters, + assignedWeightTons: slot.assignedWeightTons, + status: 'PLANNED', + }), + ); + return manager.getRepository(TrainSetWagon).save(wagons); + } + + private async persistAllocationsAndLoads( + manager: EntityManager, + savedWagons: TrainSetWagon[], + wagonPlan: WagonPlanSlot[], + bookings: Booking[], + containerPlacements: ContainerPlacementInput[] = [], + ) { + const bookingById = new Map(bookings.map((b) => [b.id, b])); + const lineById = new Map( + bookings.flatMap((b) => + (b.bookingContainers ?? []).map((line) => [line.id, { line, bookingId: b.id }] as const), + ), + ); + const allocationBySlotBooking = new Map(); + + const containerItems: Array<{ + wagonBookingAllocationId: string; + bookingContainerId: string; + containerTypeId: string | null; + grossWeightTons: number; + positionOnWagon: number | null; + containerId?: string | null; + containerNumber?: string | null; + sealNumber?: string | null; + }> = []; + const bulkLoads: Array<{ + wagonBookingAllocationId: string; + bookingId: string; + cargoTypeId: string | null; + cargoDescription: string | null; + weightTons: number; + quantity: number; + }> = []; + + for (let i = 0; i < savedWagons.length; i += 1) { + const slot = wagonPlan[i]; + const trainSetWagon = savedWagons[i]; + if (!slot || !trainSetWagon) continue; + + for (const alloc of slot.allocations) { + const savedAllocation = await manager.getRepository(WagonBookingAllocation).save( + manager.getRepository(WagonBookingAllocation).create({ + trainSetWagonId: trainSetWagon.id, + bookingId: alloc.bookingId, + allocatedWeightTons: alloc.allocatedWeightTons, + loadType: alloc.loadType, + status: 'PLANNED', + }), + ); + + allocationBySlotBooking.set( + `${slot.sequenceNo}:${alloc.bookingId}`, + savedAllocation.id, + ); + + const booking = bookingById.get(alloc.bookingId); + if (!booking) continue; + + if (alloc.loadType === AllocationLoadType.Bulk) { + bulkLoads.push({ + wagonBookingAllocationId: savedAllocation.id, + bookingId: booking.id, + cargoTypeId: booking.cargoTypeId ?? null, + cargoDescription: booking.cargoFreeText ?? null, + weightTons: alloc.allocatedWeightTons, + quantity: 1, + }); + } + } + } + + for (const placement of containerPlacements) { + const lineEntry = lineById.get(placement.bookingContainerId); + if (!lineEntry) continue; + + // Durably persist the container number on the booking container line first, so it + // survives a refresh regardless of whether a wagon allocation slot can be matched + // below. booking_container is the source of truth re-read into the preview units. + if (placement.containerNumber && placement.containerNumber.trim()) { + await manager.getRepository(BookingContainer).update(placement.bookingContainerId, { + containerNumber: placement.containerNumber.trim(), + }); + } + + const allocationId = allocationBySlotBooking.get( + `${placement.sequenceNo}:${lineEntry.bookingId}`, + ); + if (!allocationId) continue; + + const { line } = lineEntry; + containerItems.push({ + wagonBookingAllocationId: allocationId, + bookingContainerId: placement.bookingContainerId, + containerTypeId: line.containerTypeId ?? null, + grossWeightTons: Number(line.vgmPerUnitTons), + positionOnWagon: placement.unitIndex + 1, + containerId: placement.containerId ?? null, + containerNumber: placement.containerNumber?.trim() ?? null, + sealNumber: placement.sealNumber ?? null, + }); + + if (placement.containerId) { + await manager.getRepository(Container).update(placement.containerId, { + status: 'LOADED', + bookingId: lineEntry.bookingId, + wagonBookingAllocationId: allocationId, + bookingContainerId: placement.bookingContainerId, + }); + } + } + + if (containerItems.length) { + await this.wagonAllocationContainerItemsRepository.createMany(containerItems, manager); + } + if (bulkLoads.length) { + await this.wagonAllocationBulkLoadsRepository.createMany(bulkLoads, manager); + } } async selectOrValidateLocomotive( @@ -555,32 +1627,24 @@ export class TrainSchedulingService { totalLengthMeters: number, ) { const locomotive = await this.locomotivesRepository.findById(locomotiveId); - if (!locomotive) { throw new NotFoundException(`Locomotive ${locomotiveId} not found`); } - - if (locomotive.status !== "AVAILABLE") { - throw new BadRequestException( - `Locomotive ${locomotive.code} is not available`, - ); + if (locomotive.status !== 'AVAILABLE') { + throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); } - if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { - throw new BadRequestException( - `Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`, - ); + throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`); } - if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { throw new BadRequestException( `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, ); } - return locomotive; } +<<<<<<< HEAD async lockSelectedWagonsForSchedule( manager: EntityManager, wagonIds: string[], @@ -710,6 +1774,9 @@ export class TrainSchedulingService { manager: EntityManager, locomotive: Locomotive, ) { +======= + private async buildEmptyTrainSet(manager: EntityManager, locomotive: Locomotive) { +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db const trainSet = manager.getRepository(TrainSet).create({ locomotiveId: locomotive.id, totalWeightTons: 0, @@ -717,10 +1784,10 @@ export class TrainSchedulingService { wagonCount: 0, status: 'DRAFT', }); - return manager.getRepository(TrainSet).save(trainSet); } +<<<<<<< HEAD allocateBookingsToWagons( bookings: Booking[], baseWagonPlan: WagonPlanRecord[], @@ -1025,23 +2092,19 @@ export class TrainSchedulingService { }); } +======= +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db private async getActiveRoute(routeId: string) { const route = await this.dataSource.getRepository(Route).findOne({ where: { id: routeId }, relations: { originYard: true, destinationYard: true }, }); - - if (!route) { - throw new NotFoundException(`Route ${routeId} not found`); - } - - if (!route.isActive) { - throw new BadRequestException(`Route ${route.name} is inactive`); - } - + if (!route) throw new NotFoundException(`Route ${routeId} not found`); + if (!route.isActive) throw new BadRequestException(`Route ${route.name} is inactive`); return route; } +<<<<<<< HEAD private expectedWagonStatusForYard(yard?: { country?: string } | null) { const country = yard?.country?.trim().toLowerCase(); if (country === "ethiopia" || country === "et") return "EXPORT_READY"; @@ -1052,15 +2115,201 @@ export class TrainSchedulingService { private toUtcDateKey(value: Date | string) { const date = value instanceof Date ? value : new Date(value); return date.toISOString().slice(0, 10); +======= + private mapEligibleBooking(booking: Booking) { + return { + id: booking.id, + reference: booking.reference, + freightType: booking.freightType, + customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer', + priorityScore: booking.priorityScore, + schedulingStatus: booking.schedulingStatus, + containerType: + booking.bookingContainers + ?.map((c) => c.containerType?.label ?? c.containerType?.code ?? 'Container') + .join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'), + quantity: + booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0, + weightTons: roundTons(booking.cargoTotalWeightVgm), + origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', + destination: + booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', + preferredDepartureDate: booking.scheduledDate.toISOString(), + status: booking.status, + }; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db } - private roundTons(value: number | string | null | undefined) { - const numericValue = typeof value === "number" ? value : Number(value ?? 0); + private resolveScheduleFreightType( + schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, + ): 'CONTAINER' | 'BULK' | 'MIXED' | null { + const types = new Set( + (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking?.freightType) + .filter((t): t is string => Boolean(t)), + ); + if (types.size === 1) return [...types][0] as 'CONTAINER' | 'BULK'; + if (types.size > 1) return 'MIXED'; + return null; + } - if (!Number.isFinite(numericValue)) { - return 0; + private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) { + return { + id: schedule.id, + scheduleDate: schedule.scheduledDepartureDate, + trainNumber: schedule.trainNumber ?? null, + routeName: schedule.route?.name ?? null, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + locomotive: schedule.trainSet?.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name ?? null, + } + : null, + wagonCount: schedule.trainSet?.wagonCount ?? 0, + totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), + totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), + bookingsCount: schedule.scheduleBookings?.length ?? 0, + freightType: this.resolveScheduleFreightType(schedule), + status: schedule.status, + }; + } + + private async mapScheduleDetail( + schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, + ) { + const allocationIds = (schedule.trainSet?.wagons ?? []) + .flatMap((w) => w.allocations ?? []) + .map((a) => a.id); + + const [containerItems, bulkLoads] = await Promise.all([ + allocationIds.length + ? this.wagonAllocationContainerItemsRepository.findAll({ + where: { wagonBookingAllocationId: In(allocationIds) }, + relations: { containerType: true, bookingContainer: true }, + }) + : [], + allocationIds.length + ? this.wagonAllocationBulkLoadsRepository.findAll({ + where: { wagonBookingAllocationId: In(allocationIds) }, + relations: { cargoType: true }, + }) + : [], + ]); + + const containerItemsByAllocation = new Map(); + for (const item of containerItems) { + const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? []; + list.push(item); + containerItemsByAllocation.set(item.wagonBookingAllocationId, list); } + const bulkLoadsByAllocation = new Map( + bulkLoads.map((load) => [load.wagonBookingAllocationId, load]), + ); - return Number(numericValue.toFixed(3)); + return { + id: schedule.id, + status: schedule.status, + freightType: this.resolveScheduleFreightType(schedule), + trainNumber: schedule.trainNumber ?? null, + direction: schedule.direction ?? null, + route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : null, + scheduledDepartureDate: schedule.scheduledDepartureDate, + scheduledArrivalDate: schedule.scheduledArrivalDate, + actualDepartureAt: schedule.actualDepartureAt ?? null, + originStation: schedule.originStation, + destinationStation: schedule.destinationStation, + trainSet: schedule.trainSet + ? { + id: schedule.trainSet.id, + status: schedule.trainSet.status, + wagonCount: schedule.trainSet.wagonCount, + totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)), + totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)), + locomotive: schedule.trainSet.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name, + status: schedule.trainSet.locomotive.status, + maxPullWeightTons: roundTons( + Number(schedule.trainSet.locomotive.maxPullWeightTons), + ), + maxTrainLengthMeters: roundTons( + Number(schedule.trainSet.locomotive.maxTrainLengthMeters), + ), + } + : null, + wagons: [...(schedule.trainSet.wagons ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((wagon) => ({ + id: wagon.id, + sequenceNo: wagon.sequenceNo, + capacityTons: roundTons(Number(wagon.capacityTons)), + lengthMeters: roundTons(Number(wagon.lengthMeters)), + assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), + status: wagon.status, + physicalWagonId: wagon.physicalWagonId ?? null, + physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + wagonType: wagon.wagonType + ? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name } + : null, + allocations: + wagon.allocations?.map((allocation) => ({ + id: allocation.id, + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)), + loadType: allocation.loadType ?? null, + status: allocation.status, + containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map( + (item) => ({ + id: item.id, + containerNumber: item.containerNumber ?? null, + containerTypeId: item.containerTypeId, + grossWeightTons: item.grossWeightTons ?? null, + containerId: item.containerId ?? null, + positionOnWagon: item.positionOnWagon ?? null, + bookingContainerId: item.bookingContainerId ?? null, + }), + ), + bulkLoad: bulkLoadsByAllocation.get(allocation.id) + ? { + id: bulkLoadsByAllocation.get(allocation.id)!.id, + weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons, + cargoDescription: + bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null, + } + : null, + })) ?? [], + })), + } + : null, + bookings: + schedule.scheduleBookings?.map((sb) => ({ + id: sb.booking?.id ?? sb.bookingId, + reference: sb.booking?.reference ?? null, + customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null, + weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)), + status: sb.booking?.status ?? null, + schedulingStatus: sb.booking?.schedulingStatus ?? null, + })) ?? [], + }; + } + + private isHoldActive(booking: Booking): boolean { + if (!booking.holdExpiresAt) return false; + return booking.holdExpiresAt.getTime() > Date.now(); + } + + private resolvePostUnassignStatus(booking: Booking | null): string { + if (!booking) return SchedulingStatus.NotScheduled; + if (booking.holdExpiresAt && booking.holdExpiresAt.getTime() > Date.now()) { + return SchedulingStatus.Holding; + } + return SchedulingStatus.Eligible; } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts new file mode 100644 index 000000000..824c45e6e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -0,0 +1,202 @@ +import { AllocationLoadType } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + buildBulkWagonPlan, + buildContainerWagonPlan, + buildMixedWagonPlan, + expandBookingContainerUnits, + expandContainerItems, + roundTons, + sumWagonsRequired, + validate20ftContainerRules, + validateContainerPlacements, +} from './wagon-plan.util'; + +const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, +} as WagonType; + +const cw3: WagonType = { + id: 'wt-cw3', + code: 'CW3', + name: 'Covered Wagon', + capacityTons: 60, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, +} as WagonType; + +const makeContainerBooking = ( + id: string, + lines: Array<{ quantity: number; wagonsRequired: number; vgmPerUnitTons?: number }>, +): Booking => + ({ + id, + reference: id, + freightType: 'CONTAINER', + cargoTotalWeightVgm: lines.reduce( + (sum, line) => sum + line.quantity * (line.vgmPerUnitTons ?? 25), + 0, + ), + bookingContainers: lines.map((line, index) => ({ + id: `${id}-line-${index}`, + containerTypeId: `ct-${index}`, + quantity: line.quantity, + wagonsRequired: line.wagonsRequired, + vgmPerUnitTons: line.vgmPerUnitTons ?? 25, + })), + }) as Booking; + +describe('wagon-plan.util', () => { + it('uses slot-based planning: 2ร—20ft = 1 wagon slot', () => { + const booking = makeContainerBooking('b1', [{ quantity: 2, wagonsRequired: 1 }]); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(1); + expect(plan[0]?.allocations[0]?.loadType).toBe(AllocationLoadType.Container); + }); + + it('uses slot-based planning: 1ร—40ft = 1 wagon slot', () => { + const booking = makeContainerBooking('b2', [{ quantity: 1, wagonsRequired: 1 }]); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(1); + }); + + it('sums wagons across multiple container lines', () => { + const booking = makeContainerBooking('b3', [ + { quantity: 2, wagonsRequired: 1 }, + { quantity: 1, wagonsRequired: 1 }, + ]); + expect(sumWagonsRequired(booking)).toBe(2); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(2); + }); + + it('6ร—20ft containers = 3 wagon slots (2 per wagon)', () => { + // 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons + const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); + expect(sumWagonsRequired(booking)).toBe(3); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(3); + // Verify sequence numbers are 1, 2, 3 + expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); + }); + + it('expands container items per quantity', () => { + const booking = makeContainerBooking('b4', [{ quantity: 3, wagonsRequired: 3 }]); + const items = expandContainerItems(booking, 'alloc-1'); + expect(items).toHaveLength(3); + expect(items[0]?.wagonBookingAllocationId).toBe('alloc-1'); + }); + + it('rounds tons to three decimal places', () => { + expect(roundTons(1.23456)).toBe(1.235); + expect(roundTons('bad')).toBe(0); + }); + + it('builds mixed plan with container block before bulk', () => { + const containerBooking = makeContainerBooking('c1', [{ quantity: 2, wagonsRequired: 2 }]); + const bulkBooking = { + id: 'b1', + reference: 'BKG-BULK', + freightType: 'BULK', + cargoTotalWeightVgm: 120, + bookingContainers: [], + } as unknown as Booking; + + const plan = buildMixedWagonPlan([containerBooking], [bulkBooking], nw5, cw3); + expect(plan).toHaveLength(4); + expect(plan[0]?.slotLoadType).toBe('CONTAINER'); + expect(plan[2]?.slotLoadType).toBe('BULK'); + expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3, 4]); + }); + + it('expands booking container units for UI rows', () => { + const booking = makeContainerBooking('c2', [{ quantity: 3, wagonsRequired: 3 }]); + const units = expandBookingContainerUnits([booking]); + expect(units).toHaveLength(3); + expect(units[1]?.unitIndex).toBe(1); + expect(units[1]?.bookingContainerId).toBe('c2-line-0'); + }); + + it('validates required placements per container unit', () => { + const booking = makeContainerBooking('c3', [{ quantity: 2, wagonsRequired: 2 }]); + const plan = buildContainerWagonPlan([booking], nw5); + const violations = validateContainerPlacements([booking], plan, []); + expect(violations.some((v) => v.includes('required'))).toBe(true); + + const units = expandBookingContainerUnits([booking]); + const placements = units.map((unit, index) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: plan[index]?.sequenceNo ?? 1, + containerNumber: `CNTR-${index + 1}`, + })); + expect(validateContainerPlacements([booking], plan, placements)).toEqual([]); + }); + + it('rejects 20ft container over max individual weight', () => { + const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]); + const units = expandBookingContainerUnits([booking]); + const placements = units.map((unit, index) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: 1, + containerNumber: `CNTR-${index + 1}`, + })); + + const violations = validate20ftContainerRules(units, placements, { + max20ftContainerWeightTons: 30, + max20ftPairWeightDiffTons: 10, + }); + + expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true); + }); + + it('rejects 20ft pair when weight difference exceeds limit', () => { + const booking = makeContainerBooking('c21', [ + { quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }, + ]); + booking.bookingContainers![0]!.vgmPerUnitTons = 25; + const units = expandBookingContainerUnits([booking]); + units[1]!.grossWeightTons = 10; + const placements = units.map((unit) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: 1, + containerNumber: `CNTR-${unit.unitIndex}`, + })); + + const violations = validate20ftContainerRules(units, placements, { + max20ftContainerWeightTons: 30, + max20ftPairWeightDiffTons: 10, + }); + + expect(violations.some((v) => v.includes('weight difference'))).toBe(true); + }); + + it('builds bulk-only plan as degenerate mixed case', () => { + const bulkBooking = { + id: 'b2', + reference: 'BKG-BULK-2', + freightType: 'BULK', + cargoTotalWeightVgm: 60, + bookingContainers: [], + } as unknown as Booking; + const plan = buildMixedWagonPlan([], [bulkBooking], nw5, cw3); + expect(plan).toHaveLength(1); + expect(plan[0]?.slotLoadType).toBe('BULK'); + expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts new file mode 100644 index 000000000..a450fe8c9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -0,0 +1,612 @@ +import { AllocationLoadType } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; + +export const MAX_TRAIN_WEIGHT_TONS = 3500; +export const MAX_TRAIN_LENGTH_METERS = 760; +export const MAX_TEU_SLOTS_PER_WAGON = 2; + +export type TrainLimitConfig = { + maxWeightTons?: number; + maxLengthMeters?: number; + maxWagonsPerTrain?: number; + max20ftContainerWeightTons?: number; + max20ftPairWeightDiffTons?: number; +}; + +export type ContainerPlacementRules = { + max20ftContainerWeightTons?: number; + max20ftPairWeightDiffTons?: number; +}; + +export type WagonAllocationRecord = { + bookingId: string; + bookingReference: string; + allocatedWeightTons: number; + loadType: AllocationLoadType; +}; + +export type SlotLoadType = 'CONTAINER' | 'BULK'; + +export type WagonPlanSlot = { + sequenceNo: number; + wagonTypeId: string; + wagonTypeCode: string; + capacityTons: number; + lengthMeters: number; + assignedWeightTons: number; + allocations: WagonAllocationRecord[]; + slotLoadType?: SlotLoadType; +}; + +export type ContainerUnitRow = { + bookingId: string; + bookingReference: string; + bookingContainerId: string; + unitIndex: number; + containerTypeId: string; + containerTypeCode: string; + label: string; + grossWeightTons: number; + sizeFt?: number; + wagonsPerUnit?: number; + containersPerWagon?: number; + teuSlots?: number; + containerNumber?: string | null; +}; + +export type ContainerPlacementInput = { + bookingContainerId: string; + unitIndex: number; + sequenceNo: number; + containerId?: string; + containerNumber?: string; + sealNumber?: string; +}; + +export function roundTons(value: number | string | null | undefined): number { + const numericValue = typeof value === 'number' ? value : Number(value ?? 0); + if (!Number.isFinite(numericValue)) return 0; + return Number(numericValue.toFixed(3)); +} + +/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */ +export function teuSlotsForSizeFt(sizeFt: number): number { + return sizeFt >= 40 ? 2 : 1; +} + +export function containersPerWagonFromType(wagonsPerUnit: number): number { + const wpu = Number(wagonsPerUnit); + if (!wpu || wpu <= 0) return 1; + return Math.max(1, Math.round(1 / wpu)); +} + +function lineWagonsRequired(line: { + quantity?: number | null; + wagonsRequired?: number | null; + containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; +}): number { + const qty = Number(line.quantity ?? 0); + if (qty <= 0) return 0; + const wpu = Number(line.containerType?.wagonsPerUnit); + if (Number.isFinite(wpu) && wpu > 0) { + return Math.ceil(qty * wpu); + } + return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1))); +} + +/** + * Build slot-based wagon plan for CONTAINER bookings using booking_container.wagons_required. + */ +export function buildContainerWagonPlan( + bookings: Booking[], + wagonType: WagonType, +): WagonPlanSlot[] { + const totalSlots = bookings.reduce((sum, booking) => { + const lineSlots = (booking.bookingContainers ?? []).reduce( + (lineSum, line) => lineSum + lineWagonsRequired(line), + 0, + ); + return sum + Math.max(lineSlots, 1); + }, 0); + + const slots = Math.max(1, Math.ceil(totalSlots)); + const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ + sequenceNo: index + 1, + wagonTypeId: wagonType.id, + wagonTypeCode: wagonType.code, + capacityTons: Number(wagonType.capacityTons), + lengthMeters: Number(wagonType.lengthMeters), + assignedWeightTons: 0, + allocations: [], + })); + + return allocateContainersToSlots(bookings, basePlan).map((slot) => ({ + ...slot, + slotLoadType: 'CONTAINER' as SlotLoadType, + })); +} + +/** + * Build weight-based wagon plan for BULK bookings. + */ +export function buildBulkWagonPlan( + bookings: Booking[], + wagonType: WagonType, +): WagonPlanSlot[] { + const totalWeight = roundTons( + bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0), + ); + const capacity = Number(wagonType.capacityTons); + const slots = Math.max(1, Math.ceil(totalWeight / capacity)); + + const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ + sequenceNo: index + 1, + wagonTypeId: wagonType.id, + wagonTypeCode: wagonType.code, + capacityTons: capacity, + lengthMeters: Number(wagonType.lengthMeters), + assignedWeightTons: 0, + allocations: [], + })); + + return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Bulk).map((slot) => ({ + ...slot, + slotLoadType: 'BULK' as SlotLoadType, + })); +} + +/** + * Build a mixed consist: container slots first, then bulk slots, with unified sequence numbers. + */ +export function buildMixedWagonPlan( + containerBookings: Booking[], + bulkBookings: Booking[], + containerWagonType: WagonType, + bulkWagonType: WagonType, +): WagonPlanSlot[] { + const containerPlan = containerBookings.length + ? buildContainerWagonPlan(containerBookings, containerWagonType) + : []; + const bulkPlan = bulkBookings.length + ? buildBulkWagonPlan(bulkBookings, bulkWagonType) + : []; + + const tagged: WagonPlanSlot[] = [ + ...containerPlan.map((slot) => ({ ...slot, slotLoadType: 'CONTAINER' as SlotLoadType })), + ...bulkPlan.map((slot) => ({ ...slot, slotLoadType: 'BULK' as SlotLoadType })), + ]; + + if (!tagged.length) { + return [ + { + sequenceNo: 1, + wagonTypeId: containerWagonType.id, + wagonTypeCode: containerWagonType.code, + capacityTons: Number(containerWagonType.capacityTons), + lengthMeters: Number(containerWagonType.lengthMeters), + assignedWeightTons: 0, + allocations: [], + slotLoadType: 'CONTAINER', + }, + ]; + } + + return tagged.map((slot, index) => ({ + ...slot, + sequenceNo: index + 1, + })); +} + +export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitRow[] { + const rows: ContainerUnitRow[] = []; + + for (const booking of bookings.filter((b) => b.freightType === 'CONTAINER')) { + for (const line of booking.bookingContainers ?? []) { + const qty = Number(line.quantity ?? 0); + const code = line.containerType?.code ?? line.containerType?.label ?? 'Container'; + const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20)); + const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); + const perWagon = containersPerWagonFromType(wagonsPerUnit); + const teuSlots = teuSlotsForSizeFt(sizeFt); + for (let i = 0; i < qty; i += 1) { + rows.push({ + bookingId: booking.id, + bookingReference: booking.reference, + bookingContainerId: line.id, + unitIndex: i, + containerTypeId: line.containerTypeId ?? '', + containerTypeCode: code, + label: `${booking.reference} ยท ${i + 1}/${qty} ยท ${code}`, + grossWeightTons: Number(line.vgmPerUnitTons), + sizeFt, + wagonsPerUnit, + containersPerWagon: perWagon, + teuSlots, + containerNumber: line.containerNumber ?? null, + }); + } + } + } + + return rows; +} + +export function getContainerSlotSequenceNos(wagonPlan: WagonPlanSlot[]): number[] { + return wagonPlan + .filter((slot) => slot.slotLoadType === 'CONTAINER' || slot.allocations.some( + (a) => a.loadType === AllocationLoadType.Container, + )) + .map((slot) => slot.sequenceNo); +} + +function allocateBookingsToSlots( + bookings: Booking[], + basePlan: WagonPlanSlot[], + loadType: AllocationLoadType, +): WagonPlanSlot[] { + const remaining = bookings.map((booking) => ({ + bookingId: booking.id, + bookingReference: booking.reference, + remainingWeightTons: roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), + })); + + let bookingIndex = 0; + + return basePlan.map((slot) => { + let wagonRemaining = roundTons(slot.capacityTons); + const allocations: WagonAllocationRecord[] = []; + let assignedWeightTons = 0; + + while (wagonRemaining > 0 && bookingIndex < remaining.length) { + const booking = remaining[bookingIndex]; + const allocatedWeightTons = roundTons( + Math.min(wagonRemaining, booking.remainingWeightTons), + ); + + if (allocatedWeightTons <= 0) { + bookingIndex += 1; + continue; + } + + allocations.push({ + bookingId: booking.bookingId, + bookingReference: booking.bookingReference, + allocatedWeightTons, + loadType, + }); + + booking.remainingWeightTons = roundTons( + booking.remainingWeightTons - allocatedWeightTons, + ); + wagonRemaining = roundTons(wagonRemaining - allocatedWeightTons); + assignedWeightTons = roundTons(assignedWeightTons + allocatedWeightTons); + + if (booking.remainingWeightTons <= 0) { + bookingIndex += 1; + } + } + + return { ...slot, assignedWeightTons, allocations }; + }); +} + +/** + * Allocate container bookings across wagon slots by TEU capacity. A wagon holds at most + * 2 TEU, so it carries either one 40ft container (2 TEU) or two 20ft containers (1 TEU + * each) โ€” a 40ft is NEVER mixed onto the same wagon as a 20ft. Every physical container + * maps to a real wagon allocation, and this mirrors the frontend auto-fill packing + * exactly so a placement's sequenceNo always lands on a slot that holds an allocation + * for its booking. + * + * Weight-based packing (allocateBookingsToSlots) is wrong for containers: it collapses + * several light containers into the first wagons by tonnage and leaves later container + * units without an allocation slot, which silently drops their container items on assign. + */ +function allocateContainersToSlots( + bookings: Booking[], + basePlan: WagonPlanSlot[], +): WagonPlanSlot[] { + const slots = basePlan.map((slot) => ({ + ...slot, + assignedWeightTons: 0, + allocations: [] as WagonAllocationRecord[], + })); + if (!slots.length) return slots; + + const units = expandBookingContainerUnits(bookings); + let currentSlotIndex = 0; + let teuInCurrentSlot = 0; + + for (const unit of units) { + const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); + + // Move to the next wagon once this one can't fit the container's TEU. This keeps a + // 40ft (2 TEU) alone on its wagon and never pairs it with a 20ft. + if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_SLOTS_PER_WAGON) { + currentSlotIndex += 1; + teuInCurrentSlot = 0; + } + + const slot = slots[Math.min(currentSlotIndex, slots.length - 1)]!; + + let allocation = slot.allocations.find((a) => a.bookingId === unit.bookingId); + if (!allocation) { + allocation = { + bookingId: unit.bookingId, + bookingReference: unit.bookingReference, + allocatedWeightTons: 0, + loadType: AllocationLoadType.Container, + }; + slot.allocations.push(allocation); + } + allocation.allocatedWeightTons = roundTons( + allocation.allocatedWeightTons + unit.grossWeightTons, + ); + slot.assignedWeightTons = roundTons(slot.assignedWeightTons + unit.grossWeightTons); + teuInCurrentSlot += teu; + } + + return slots; +} + +export function expandContainerItems( + booking: Booking, + allocationId: string, +): Array<{ + wagonBookingAllocationId: string; + bookingContainerId: string; + containerTypeId: string; + grossWeightTons: number; + positionOnWagon: number | null; +}> { + const items: Array<{ + wagonBookingAllocationId: string; + bookingContainerId: string; + containerTypeId: string; + grossWeightTons: number; + positionOnWagon: number | null; + }> = []; + + for (const line of booking.bookingContainers ?? []) { + const qty = Number(line.quantity ?? 0); + for (let i = 0; i < qty; i += 1) { + items.push({ + wagonBookingAllocationId: allocationId, + bookingContainerId: line.id, + containerTypeId: line.containerTypeId ?? '', + grossWeightTons: Number(line.vgmPerUnitTons), + positionOnWagon: qty > 1 ? i + 1 : null, + }); + } + } + + return items; +} + +export function sumWagonsRequired(booking: Booking): number { + if (booking.freightType === 'BULK') { + return 1; + } + return (booking.bookingContainers ?? []).reduce( + (sum, line) => sum + Number(line.wagonsRequired ?? 0), + 0, + ); +} + +export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] { + const violations: string[] = []; + for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) { + if (slot.assignedWeightTons > slot.capacityTons) { + violations.push( + `Bulk wagon #${slot.sequenceNo} load ${slot.assignedWeightTons}T exceeds capacity ${slot.capacityTons}T`, + ); + } + } + return violations; +} + +export function validateTrainLimits( + wagonPlan: WagonPlanSlot[], + wagonType: WagonType, + limits?: TrainLimitConfig, +): string[] { + const violations: string[] = []; + const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; + const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; + const maxWagonsPerTrain = + limits?.maxWagonsPerTrain ?? Number(wagonType.maxWagonsPerTrain ?? 53); + + const totalWeightTons = roundTons( + wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0), + ); + const totalLengthMeters = roundTons( + wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), + ); + + if (totalWeightTons > maxWeightTons) { + violations.push( + `Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`, + ); + } + if (totalLengthMeters > maxLengthMeters) { + violations.push( + `Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`, + ); + } + if (wagonPlan.length > maxWagonsPerTrain) { + violations.push( + `Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`, + ); + } + + violations.push(...validateBulkWagonSlotWeights(wagonPlan)); + + return violations; +} + +export function validateMixedTrainLimits( + wagonPlan: WagonPlanSlot[], + wagonTypes: WagonType[], + limits?: TrainLimitConfig, +): string[] { + const maxWagonsPerTrain = + limits?.maxWagonsPerTrain ?? + Math.max(...wagonTypes.map((wt) => Number(wt.maxWagonsPerTrain ?? 53)), 53); + + return validateTrainLimits( + wagonPlan, + { maxWagonsPerTrain } as WagonType, + { ...limits, maxWagonsPerTrain }, + ); +} + +export function validate20ftContainerRules( + units: ContainerUnitRow[], + placements: ContainerPlacementInput[], + rules?: ContainerPlacementRules, +): string[] { + const violations: string[] = []; + const maxEach = rules?.max20ftContainerWeightTons; + const maxDiff = rules?.max20ftPairWeightDiffTons; + if (maxEach == null && maxDiff == null) return violations; + + const placementByUnit = new Map( + placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]), + ); + + const weightsBySlot = new Map(); + + for (const unit of units) { + const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20); + if (sizeFt >= 40) continue; + + if (maxEach != null && unit.grossWeightTons > maxEach) { + violations.push( + `${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`, + ); + } + + const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`); + if (!placement?.sequenceNo) continue; + + const list = weightsBySlot.get(placement.sequenceNo) ?? []; + list.push(unit.grossWeightTons); + weightsBySlot.set(placement.sequenceNo, list); + } + + if (maxDiff != null) { + for (const [sequenceNo, weights] of weightsBySlot.entries()) { + if (weights.length < 2) continue; + const diff = Math.abs(weights[0]! - weights[1]!); + if (diff > maxDiff) { + violations.push( + `Wagon #${sequenceNo} 20ft pair weight difference ${roundTons(diff)}T exceeds max ${maxDiff}T`, + ); + } + } + } + + return violations; +} + +export function validateContainerPlacements( + containerBookings: Booking[], + wagonPlan: WagonPlanSlot[], + placements: ContainerPlacementInput[], + rules?: ContainerPlacementRules, +): string[] { + const violations: string[] = []; + const units = expandBookingContainerUnits(containerBookings); + if (!units.length) return violations; + + const containerSlots = new Set(getContainerSlotSequenceNos(wagonPlan)); + const unitKeys = new Set(units.map((u) => `${u.bookingContainerId}:${u.unitIndex}`)); + const placementKeys = new Set(); + const containerNumbers = new Set(); + + if (!placements.length) { + violations.push('Container placements are required for container bookings'); + return violations; + } + + for (const placement of placements) { + const unitKey = `${placement.bookingContainerId}:${placement.unitIndex}`; + if (!unitKeys.has(unitKey)) { + violations.push( + `Unknown container unit ${placement.bookingContainerId}#${placement.unitIndex}`, + ); + continue; + } + if (placementKeys.has(unitKey)) { + violations.push(`Duplicate placement for container unit ${unitKey}`); + } + placementKeys.add(unitKey); + + if (!containerSlots.has(placement.sequenceNo)) { + violations.push(`Slot #${placement.sequenceNo} is not a container wagon slot`); + } + + const hasInventory = Boolean(placement.containerId); + const hasManual = Boolean(placement.containerNumber?.trim()); + if (!hasInventory && !hasManual) { + violations.push( + `Container unit ${unitKey} requires an existing container or a new container number`, + ); + } + + if (hasManual) { + const normalized = placement.containerNumber!.trim().toUpperCase(); + if (containerNumbers.has(normalized)) { + violations.push(`Duplicate container number ${normalized}`); + } + containerNumbers.add(normalized); + } + } + + for (const unit of units) { + const unitKey = `${unit.bookingContainerId}:${unit.unitIndex}`; + if (!placementKeys.has(unitKey)) { + violations.push(`Missing placement for ${unit.label}`); + } + } + + const slotTeuUsed = new Map(); + const slotWeightUsed = new Map(); + const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s])); + + for (const placement of placements) { + const unit = units.find( + (u) => + u.bookingContainerId === placement.bookingContainerId && + u.unitIndex === placement.unitIndex, + ); + if (!unit) continue; + + const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); + const usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0; + if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) { + violations.push( + `Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1ร—40ft or 2ร—20ft per wagon)`, + ); + } else { + slotTeuUsed.set(placement.sequenceNo, usedTeu + teu); + } + + const slot = slotBySeq.get(placement.sequenceNo); + if (slot) { + const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons; + slotWeightUsed.set(placement.sequenceNo, weight); + if (weight > slot.capacityTons) { + violations.push( + `Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`, + ); + } + } + } + + violations.push(...validate20ftContainerRules(units, placements, rules)); + + return violations; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts new file mode 100644 index 000000000..b39c12e89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts @@ -0,0 +1,35 @@ +import { WagonReadiness } from '@edr/types'; + +import { + requiredWagonReadiness, + wagonReadinessMatchesSchedule, +} from './wagon-readiness.util'; + +describe('wagonReadinessMatchesSchedule', () => { + it('requires IMPORT_READY for IMPORT schedules', () => { + expect(requiredWagonReadiness('IMPORT')).toBe(WagonReadiness.ImportReady); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'IMPORT'), + ).toBe(true); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'IMPORT'), + ).toBe(false); + }); + + it('requires EXPORT_READY for EXPORT schedules', () => { + expect(requiredWagonReadiness('EXPORT')).toBe(WagonReadiness.ExportReady); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'EXPORT'), + ).toBe(true); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'EXPORT'), + ).toBe(false); + }); + + it('allows any readiness for DOMESTIC schedules', () => { + expect(requiredWagonReadiness('DOMESTIC')).toBeNull(); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'DOMESTIC'), + ).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts new file mode 100644 index 000000000..3cdd717d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts @@ -0,0 +1,18 @@ +import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types'; + +export function requiredWagonReadiness( + direction: ScheduleTradeDirection | string | null | undefined, +): WagonReadiness | null { + if (direction === 'IMPORT') return WagonReadiness.ImportReady; + if (direction === 'EXPORT') return WagonReadiness.ExportReady; + return null; +} + +export function wagonReadinessMatchesSchedule( + wagonReadiness: WagonReadiness | string, + direction: ScheduleTradeDirection | string | null | undefined, +): boolean { + const required = requiredWagonReadiness(direction); + if (!required) return true; + return wagonReadiness === required; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts new file mode 100644 index 000000000..bac0330f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts @@ -0,0 +1,49 @@ +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; + +const CARGO_CODE_TO_WAGON_TYPE: Record = { + COFFEE: 'KW2', + GRAIN: 'KW2', + WHEAT: 'KW2', + SORGHUM: 'KW2', + CORN: 'KW2', + FERTILIZER: 'PW2', + SUGAR: 'PW2', + COAL: 'KW3', + STEEL: 'CW3', + ORE: 'CW3', +}; + +const DEFAULT_BULK_WAGON_TYPE = 'CW3'; +const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5'; + +/** + * Resolve wagon type code from cargo type code for bulk freight. + */ +export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string { + if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE; + const normalized = cargoTypeCode.trim().toUpperCase(); + return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE; +} + +/** + * Pick the best matching wagon type entity for bulk cargo. + */ +export function pickBulkWagonType( + wagonTypes: WagonType[], + cargoTypeCode?: string | null, +): WagonType | undefined { + const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode); + const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive); + if (direct) return direct; + + return wagonTypes.find( + (wt) => + wt.isActive && + !wt.supportsContainer && + wt.code !== DEFAULT_CONTAINER_WAGON_TYPE, + ); +} + +export function getDefaultContainerWagonTypeCode(): string { + return DEFAULT_CONTAINER_WAGON_TYPE; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts index 4a2ab16cb..b505a5643 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { TrainSetWagonStatus } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; @@ -6,6 +7,15 @@ import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { TrainSet } from './train-set.entity'; +export const TRAIN_SET_WAGON_STATUSES = [ + TrainSetWagonStatus.Planned, + TrainSetWagonStatus.Reserved, + TrainSetWagonStatus.Loaded, + TrainSetWagonStatus.Departed, +] as const; + +export type TrainSetWagonStatusType = (typeof TRAIN_SET_WAGON_STATUSES)[number]; + @Entity({ schema: 'freight', name: 'train_set_wagons' }) @Index(['trainSetId', 'sequenceNo'], { unique: true }) export class TrainSetWagon extends BaseEntity { @@ -42,6 +52,16 @@ export class TrainSetWagon extends BaseEntity { @Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 }) assignedWeightTons!: number; + @Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true }) + physicalWagonId?: string | null; + + @ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'physical_wagon_id' }) + physicalWagon?: Wagon | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' }) + status!: string; + @OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon) allocations?: WagonBookingAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index 184b9c88d..ab6b49b1d 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -4,6 +4,10 @@ import { Freight } from '@edr/types'; import { Column, Entity, OneToMany } from 'typeorm'; import { Wagon } from '../../wagons/entities/wagon.entity'; +/** + * Fleet master data โ€” named wagon consist in inventory (POST /trains). + * Operational departures use train_schedules + locomotives; scheduling never creates trains rows. + */ @Entity({ schema: 'freight', name: 'trains' }) export class Train extends BaseEntity { // --- existing fields (keep for backward compatibility) --- diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts index 0888f4fbf..64bfc973e 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -28,7 +28,12 @@ const toStringArray = ({ value }: { value: unknown }) => { if (Array.isArray(value)) { return value.map((entry) => String(entry).trim()).filter(Boolean); } +<<<<<<< HEAD +======= + +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db if (typeof value !== 'string') return []; + return value .split(',') .map((entry) => entry.trim()) @@ -36,29 +41,45 @@ const toStringArray = ({ value }: { value: unknown }) => { }; export class CreateWagonTypeDto { - @ApiProperty({ maxLength: 32, example: 'FLAT' }) + @ApiProperty({ maxLength: 32, example: 'NW5' }) @IsString() @MaxLength(32) code!: string; +<<<<<<< HEAD @ApiProperty({ description: 'Display name, e.g. "Flat Wagon"', maxLength: 100 }) +======= + @ApiProperty({ maxLength: 100, example: 'Flat wagon container' }) +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db @IsString() @MaxLength(100) name!: string; +<<<<<<< HEAD @ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 60 }) +======= + @ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 70 }) +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db @Transform(toNumber) @IsNumber() @Min(0.001) capacityTons!: number; +<<<<<<< HEAD @ApiProperty({ description: 'Wagon length in meters', example: 14.2 }) +======= + @ApiProperty({ description: 'Wagon length in meters', example: 14 }) +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db @Transform(toNumber) @IsNumber() @Min(0.001) lengthMeters!: number; +<<<<<<< HEAD @ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 45 }) +======= + @ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 }) +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db @IsOptional() @Transform(toOptionalNumber) @IsInt() diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts index f1bfeedea..2181a2bd1 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -28,6 +28,18 @@ export class WagonType extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + @Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true }) + equatedLengthM?: number | null; + + @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + tareWeightTons?: number | null; + + @Column({ name: 'supports_container', type: 'boolean', default: false }) + supportsContainer!: boolean; + + @Column({ name: 'max_container_gross_t', type: 'numeric', precision: 10, scale: 3, nullable: true }) + maxContainerGrossT?: number | null; + @OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType) trainSetWagons?: TrainSetWagon[]; } diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts index d7dceaeb0..92a55a438 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts @@ -28,7 +28,22 @@ export class WagonTypesController { @RuleEngineView('wagon-types') @ApiOperation({ summary: 'List wagon types' }) findAll(@Query() query: Record) { +<<<<<<< HEAD return this.wagonTypesService.findAll(query); +======= + return this.wagonTypesService.findAll({ + isActive: + query.isActive === 'all' + ? undefined + : query.isActive !== undefined + ? query.isActive === 'true' + : true, + page: query.page ? parseInt(query.page, 10) : undefined, + pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db } @Get(':id') diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index ec039cfd5..6e9784667 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -6,15 +6,25 @@ import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; import { WagonType } from './entities/wagon-type.entity'; import { WagonTypesRepository } from './wagon-types.repository'; +<<<<<<< HEAD type WagonTypeListResponse = { data: WagonType[]; meta: { total: number; page: number; pageSize: number; totalPages: number }; +======= +type WagonTypeListFilter = { + isActive?: boolean; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: string; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db }; @Injectable() export class WagonTypesService { constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} +<<<<<<< HEAD async findAll(query: Record = {}): Promise { const page = Math.max(1, Number(query.page) || 1); const pageSize = Math.max(1, Number(query.pageSize) || 20); @@ -33,6 +43,23 @@ export class WagonTypesService { const [data, total] = await this.wagonTypesRepository.findAndCount({ where: isActive === undefined ? {} : { isActive }, +======= + async findAll(filter: WagonTypeListFilter = {}): Promise<{ + data: WagonType[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 500; + const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes( + filter.sortBy ?? '', + ) + ? (filter.sortBy as keyof WagonType) + : 'code'; + const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + const [data, total] = await this.wagonTypesRepository.findAndCount({ + where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db order: { [sortBy]: sortOrder } as FindOptionsOrder, skip: (page - 1) * pageSize, take: pageSize, @@ -44,13 +71,21 @@ export class WagonTypesService { total, page, pageSize, +<<<<<<< HEAD totalPages: Math.ceil(total / pageSize), +======= + totalPages: Math.max(1, Math.ceil(total / pageSize)), +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db }, }; } async findById(id: string): Promise { const wagonType = await this.wagonTypesRepository.findById(id); +<<<<<<< HEAD +======= + +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db if (!wagonType) { throw new NotFoundException(`Wagon type ${id} not found`); } @@ -68,14 +103,25 @@ export class WagonTypesService { async create(dto: CreateWagonTypeDto): Promise { const code = dto.code.trim().toUpperCase(); const existing = await this.wagonTypesRepository.findByCode(code); +<<<<<<< HEAD +======= + +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db if (existing) { throw new ConflictException(`Wagon type code "${code}" already exists`); } return this.wagonTypesRepository.create({ +<<<<<<< HEAD ...dto, code, name: dto.name.trim(), +======= + code, + name: dto.name.trim(), + capacityTons: dto.capacityTons, + lengthMeters: dto.lengthMeters, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null, supportedLoadTypes: dto.supportedLoadTypes ?? [], isActive: dto.isActive ?? true, @@ -97,6 +143,12 @@ export class WagonTypesService { ...dto, ...(nextCode ? { code: nextCode } : {}), ...(dto.name ? { name: dto.name.trim() } : {}), +<<<<<<< HEAD +======= + maxWagonsPerTrain: + dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null, + supportedLoadTypes: dto.supportedLoadTypes ?? undefined, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db }); if (!updated) { diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index 5e5ba9035..d50c3a2ab 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -1,4 +1,5 @@ -import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator'; +import { WagonReadiness, WagonStatus } from '@edr/types'; +import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator'; export class CreateWagonDto { @IsString() @@ -29,8 +30,17 @@ export class CreateWagonDto { maxPayloadWeight!: number; @IsOptional() +<<<<<<< HEAD @IsIn(['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED', 'MAINTENANCE', 'RETIRED']) status?: string; +======= + @IsEnum(WagonStatus) + status?: WagonStatus; + + @IsOptional() + @IsEnum(WagonReadiness) + readiness?: WagonReadiness; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 5d6894e88..90b2d281d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -1,12 +1,31 @@ // apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts -import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; +import { WagonReadiness, WagonStatus } from '@edr/types'; +import { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; import { Train } from '../../trains/entities/train.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; import { Container } from '../../container-management/entities/container.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +export const WAGON_STATUSES = [ + WagonStatus.Available, + WagonStatus.Assigned, + WagonStatus.Maintenance, + WagonStatus.Retired, +] as const; + +export const WAGON_READINESS_VALUES = [ + WagonReadiness.ImportReady, + WagonReadiness.ExportReady, +] as const; + +export type WagonStatusType = (typeof WAGON_STATUSES)[number]; +export type WagonReadinessType = (typeof WAGON_READINESS_VALUES)[number]; + @Entity({ name: 'wagons', schema: 'freight' }) +@Index(['readiness']) export class Wagon extends BaseEntity { @Column({ unique: true, name: 'wagon_number' }) wagonNumber!: string; @@ -37,13 +56,35 @@ export class Wagon extends BaseEntity { @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 }) maxPayloadWeight!: number; +<<<<<<< HEAD @Column({ type: 'varchar', default: 'AVAILABLE' }) status!: string; // AVAILABLE, IMPORT_READY, EXPORT_READY, ASSIGNED, MAINTENANCE, RETIRED +======= + @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) + status!: WagonStatusType; + + @Column({ type: 'varchar', length: 20, default: WagonReadiness.ImportReady }) + readiness!: WagonReadinessType; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db @Column({ type: 'text', nullable: true }) notes!: string | null; - // Relationship to Train + @Column({ name: 'train_set_wagon_id', type: 'uuid', nullable: true }) + trainSetWagonId!: string | null; + + @ManyToOne(() => TrainSetWagon, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'train_set_wagon_id' }) + trainSetWagon?: TrainSetWagon | null; + + @Column({ name: 'current_train_schedule_id', type: 'uuid', nullable: true }) + currentTrainScheduleId!: string | null; + + @ManyToOne(() => TrainSchedule, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'current_train_schedule_id' }) + currentTrainSchedule?: TrainSchedule | null; + + /** Fleet master consist grouping โ€” separate from operational train_schedules. */ @ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'train_id' }) train!: Train | null; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 95b27f8b5..9d905aaf6 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,3 +1,4 @@ +import { WagonReadiness, WagonStatus } from '@edr/types'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; @@ -22,7 +23,11 @@ export class WagonsService { ) {} async create(dto: CreateWagonDto): Promise { - const wagon = this.wagonRepo.create(dto); + const wagon = this.wagonRepo.create({ + ...dto, + status: dto.status ?? WagonStatus.Available, + readiness: dto.readiness ?? WagonReadiness.ImportReady, + }); // Convert undefined to null for nullable fields if (dto.trainId === undefined) wagon.trainId = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; @@ -34,26 +39,43 @@ export class WagonsService { const where: FindOptionsWhere[] | FindOptionsWhere = []; const search = query.search?.trim(); const status = query.status?.trim(); + const readiness = query.readiness?.trim(); const trainId = query.trainId?.trim(); +<<<<<<< HEAD const currentLocationYardId = query.currentLocationYardId?.trim(); +======= + const filters = { + ...(status ? { status: status as Wagon['status'] } : {}), + ...(readiness ? { readiness: readiness as Wagon['readiness'] } : {}), + ...(trainId ? { trainId } : {}), + }; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db if (search) { where.push({ wagonNumber: ILike(`%${search}%`), +<<<<<<< HEAD ...(status ? { status } : {}), ...(trainId ? { trainId } : {}), ...(currentLocationYardId ? { currentLocationYardId } : {}), +======= + ...filters, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db }); } - const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'sequenceNumber'].includes(query.sortBy ?? '') + const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'readiness', 'sequenceNumber'].includes(query.sortBy ?? '') ? (query.sortBy as keyof Wagon) : 'wagonNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; return this.wagonRepo.find({ +<<<<<<< HEAD where: search ? where : { ...(status ? { status } : {}), ...(trainId ? { trainId } : {}), ...(currentLocationYardId ? { currentLocationYardId } : {}) }, relations: { currentLocationYard: true, wagonType: true }, +======= + where: search ? where : filters, +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db order: { [sortBy]: sortOrder } as FindOptionsOrder, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, take: query.limit ? Number(query.limit) : undefined, @@ -82,7 +104,7 @@ export class WagonsService { async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { const wagon = await this.findById(wagonId); - if (wagon.status === 'ASSIGNED') { + if (wagon.status === WagonStatus.Assigned) { throw new ConflictException('Wagon already assigned to a train'); } @@ -101,7 +123,7 @@ export class WagonsService { wagon.trainId = train.id; wagon.sequenceNumber = sequence; - wagon.status = 'ASSIGNED'; + wagon.status = WagonStatus.Assigned; return this.wagonRepo.save(wagon); } @@ -109,7 +131,11 @@ export class WagonsService { const wagon = await this.findById(wagonId); wagon.trainId = null; wagon.sequenceNumber = null; +<<<<<<< HEAD wagon.status = await this.statusForLocation(wagon.currentLocationYardId, 'AVAILABLE'); +======= + wagon.status = WagonStatus.Available; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db return this.wagonRepo.save(wagon); } diff --git a/apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts b/apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts new file mode 100644 index 000000000..d9ae15f2d --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); +process.env.SEED_DEMO_BOOKINGS = 'true'; + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { DemoBookingsSeeder } from '../seed/demo-bookings.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(DemoBookingsSeeder); + await seeder.run(); + console.log('Demo train scheduling data seeded successfully.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Demo scheduling seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index edf19adbb..23c5862ac 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -13,7 +13,13 @@ import { Locomotive } from "../modules/locomotives/entities/locomotive.entity"; import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; +import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; +import { Container } from "../modules/container-management/entities/container.entity"; +import { Route } from "../modules/routes/entities/route.entity"; +import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity"; +import { Wagon } from "../modules/wagons/entities/wagon.entity"; +import { WagonReadiness, WagonStatus } from "@edr/types"; const SEED_FLAG = "SEED_DEMO_BOOKINGS"; @@ -144,6 +150,39 @@ const DEMO_BOOKINGS = [ }, ]; +const DEMO_BULK_BOOKINGS = [ + { + reference: "BKG-BULK-001", + cargoCode: "COFFEE", + totalWeightTons: 1200, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-BULK-002", + cargoCode: "FERTILIZER", + totalWeightTons: 800, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-BULK-003", + cargoCode: "STEEL", + totalWeightTons: 450, + originCode: "ADDIS_ABABA", + destinationCode: "DIRE_DAWA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, +]; + @Injectable() export class DemoBookingsSeeder { private readonly logger = new Logger(DemoBookingsSeeder.name); @@ -161,15 +200,57 @@ export class DemoBookingsSeeder { await this.dataSource.transaction(async (manager) => { await manager.getRepository(WagonType).upsert( - { - code: "NW5", - name: "Flat Wagon", - capacityTons: 70, - lengthMeters: 14, - maxWagonsPerTrain: 53, - supportedLoadTypes: ["CONTAINER"], - isActive: true, - }, + [ + { + code: "NW5", + name: "Flat Wagon", + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ["CONTAINER"], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 20, + supportsContainer: true, + maxContainerGrossT: 70, + }, + { + code: "KW2", + name: "Covered Hopper", + capacityTons: 60, + lengthMeters: 12, + maxWagonsPerTrain: 55, + supportedLoadTypes: ["BULK"], + isActive: true, + equatedLengthM: 12, + tareWeightTons: 18, + supportsContainer: false, + }, + { + code: "PW2", + name: "Powder Wagon", + capacityTons: 55, + lengthMeters: 12, + maxWagonsPerTrain: 55, + supportedLoadTypes: ["BULK"], + isActive: true, + equatedLengthM: 12, + tareWeightTons: 17, + supportsContainer: false, + }, + { + code: "CW3", + name: "Open Wagon", + capacityTons: 65, + lengthMeters: 13, + maxWagonsPerTrain: 53, + supportedLoadTypes: ["BULK"], + isActive: true, + equatedLengthM: 13, + tareWeightTons: 19, + supportsContainer: false, + }, + ], { conflictPaths: { code: true } }, ); @@ -320,6 +401,9 @@ export class DemoBookingsSeeder { await manager .getRepository(BookingContainer) .delete({ bookingId: booking.id }); + const wagonsRequired = + Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + await manager.getRepository(BookingContainer).insert({ id: randomUUID(), bookingId: booking.id, @@ -327,15 +411,139 @@ export class DemoBookingsSeeder { quantity: demoBooking.quantity, vgmPerUnitTons, totalVgmTons: demoBooking.totalWeightTons, - wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70), + wagonsRequired, weightLimitRuleId: null, - isOverweight: demoBooking.totalWeightTons > 70, - overweightExcessTons: - demoBooking.totalWeightTons > 70 - ? demoBooking.totalWeightTons - 70 - : null, + isOverweight: vgmPerUnitTons > 35, + overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null, }); } + + await manager.getRepository(CargoType).upsert( + [ + { code: "COFFEE", cargoTypeName: "Coffee", isActive: true, displayOrder: 1 }, + { code: "FERTILIZER", cargoTypeName: "Fertilizer", isActive: true, displayOrder: 2 }, + { code: "STEEL", cargoTypeName: "Steel", isActive: true, displayOrder: 3 }, + ], + { conflictPaths: { code: true } }, + ); + + const cargoTypes = await manager.getRepository(CargoType).find(); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + + for (const demoBulk of DEMO_BULK_BOOKINGS) { + const origin = yardByCode.get(demoBulk.originCode); + const destination = yardByCode.get(demoBulk.destinationCode); + const cargoType = cargoByCode.get(demoBulk.cargoCode); + + if (!origin || !destination || !cargoType) { + throw new Error(`demo_bulk_seed_dependency_missing:${demoBulk.reference}`); + } + + await manager.getRepository(Booking).upsert( + { + reference: demoBulk.reference, + companyId: company.id, + status: demoBulk.status, + scheduledDate: new Date(demoBulk.scheduledDate), + totalAmount: 0, + paymentStatus: demoBulk.paymentStatus, + contractType: "NEW", + serviceTypeId: serviceType.id, + equipmentReturn: "WITHOUT_RETURN", + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: "IMPORT", + freightType: "BULK", + cargoTypeId: cargoType.id, + cargoFreeText: demoBulk.cargoCode, + shippingLineId: null, + cargoTotalWeightVgm: demoBulk.totalWeightTons, + isHazardous: false, + paymentCurrency: "USD", + allowConsolidation: false, + priorityScore: 10, + schedulingStatus: "HOLDING", + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + } + + const djibouti = yardByCode.get("DJIBOUTI"); + const addis = yardByCode.get("ADDIS_ABABA"); + if (djibouti && addis) { + const routeName = "Djibouti โ†’ Addis Ababa"; + let route = await manager.getRepository(Route).findOneBy({ name: routeName }); + if (!route) { + route = await manager.getRepository(Route).save( + manager.getRepository(Route).create({ + name: routeName, + originYardId: djibouti.id, + destinationYardId: addis.id, + isActive: true, + }), + ); + await manager.getRepository(RouteMilestone).save([ + manager.getRepository(RouteMilestone).create({ + routeId: route.id, + yardId: djibouti.id, + sequenceNo: 1, + }), + manager.getRepository(RouteMilestone).create({ + routeId: route.id, + yardId: addis.id, + sequenceNo: 2, + }), + ]); + } + } + + const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" }); + if (nw5) { + await manager.getRepository(Wagon).upsert( + Array.from({ length: 20 }, (_, index) => ({ + wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`, + wagonTypeId: nw5.id, + trainId: null, + sequenceNumber: null, + tareWeight: 20, + maxPayloadWeight: 70, + status: WagonStatus.Available, + readiness: + index % 2 === 0 + ? WagonReadiness.ImportReady + : WagonReadiness.ExportReady, + notes: "Demo wagon for train scheduling", + trainSetWagonId: null, + currentTrainScheduleId: null, + })), + { conflictPaths: { wagonNumber: true } }, + ); + } + + const ft20 = containerTypeByCode.get("20FT"); + const ft40 = containerTypeByCode.get("40FT"); + if (ft20 && ft40) { + await manager.getRepository(Container).upsert( + Array.from({ length: 30 }, (_, index) => { + const is40Ft = index % 2 === 0; + return { + containerNumber: `CONT-DEMO-${String(index + 1).padStart(3, "0")}`, + containerTypeId: is40Ft ? ft40.id : ft20.id, + wagonId: null, + position: null, + tareWeight: is40Ft ? 4.0 : 2.5, + maxGrossWeight: is40Ft ? 32.5 : 24.5, + sealNumber: null, + status: "AVAILABLE", + bookingId: null, + wagonBookingAllocationId: null, + bookingContainerId: null, + }; + }), + { conflictPaths: { containerNumber: true } }, + ); + } }); this.logger.log("Seeded demo train scheduling data"); 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 e82032cff..14d599ab3 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -52,6 +52,8 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'), perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), + perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'), + perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'), ]; const RULE_ENGINE_PERMISSION_IDS: Record = { @@ -103,6 +105,10 @@ export const FREIGHT_PERMS = { operations: 'edr_freight_app:bookings:operations', cancel: 'edr_freight_app:bookings:cancel', }, + trainScheduling: { + view: 'edr_freight_app:train_scheduling:view', + manage: 'edr_freight_app:train_scheduling:manage', + }, ruleEngine: { view: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, @@ -123,6 +129,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.trainScheduling.manage, ...allRuleEngineViewKeys(), ], director: [ 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 fb8fbf2e6..882674862 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -115,7 +115,7 @@ export class PricingDataSeeder { code: "20FT", label: "20FT Standard", sizeFt: 20, - wagonsPerUnit: 1, + wagonsPerUnit: 0.5, isReefer: false, isOpenTop: false, isActive: true, @@ -135,7 +135,7 @@ export class PricingDataSeeder { code: "20FT_REEFER", label: "20FT Reefer", sizeFt: 20, - wagonsPerUnit: 1, + wagonsPerUnit: 0.5, isReefer: true, isOpenTop: false, isActive: true, @@ -323,7 +323,11 @@ export class PricingDataSeeder { private async seedPriorityRules(prRepo: any): Promise { const existing = await prRepo.find({ - where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }], + where: [ + { code: "USD_PRIORITY" }, + { code: "STANDARD_PRIORITY" }, + { code: "GOVERNMENT_ACCOUNT" }, + ], }); for (const r of existing) { await prRepo.remove(r); @@ -343,6 +347,13 @@ export class PricingDataSeeder { conditionCurrency: null, isActive: true, }), + prRepo.create({ + code: "GOVERNMENT_ACCOUNT", + label: "Government Account Priority", + score: 50000, + conditionCurrency: null, + isActive: true, + }), ]); this.logger.log("Seeded priority rules"); } diff --git a/apps/edr-freight-web/Dockerfile.backoffice b/apps/edr-freight-web/Dockerfile.backoffice deleted file mode 100644 index 62bec7399..000000000 --- a/apps/edr-freight-web/Dockerfile.backoffice +++ /dev/null @@ -1,18 +0,0 @@ -FROM node:20-alpine AS base -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate -WORKDIR /app - -FROM base AS deps -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY apps/edr-freight-web/backoffice/package.json ./apps/edr-freight-web/backoffice/ -COPY packages ./packages -RUN pnpm install --frozen-lockfile --filter @edr/freight-backoffice... - -FROM deps AS build -COPY apps/edr-freight-web/backoffice ./apps/edr-freight-web/backoffice -RUN pnpm --filter @edr/freight-backoffice build - -FROM nginx:1.27-alpine AS runtime -COPY --from=build /app/apps/edr-freight-web/backoffice/dist /usr/share/nginx/html -EXPOSE 5183 -CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/edr-freight-web/Dockerfile.portal b/apps/edr-freight-web/Dockerfile.portal deleted file mode 100644 index 32b71f672..000000000 --- a/apps/edr-freight-web/Dockerfile.portal +++ /dev/null @@ -1,18 +0,0 @@ -FROM node:20-alpine AS base -RUN corepack enable && corepack prepare pnpm@9.12.0 --activate -WORKDIR /app - -FROM base AS deps -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY apps/edr-freight-web/portal/package.json ./apps/edr-freight-web/portal/ -COPY packages ./packages -RUN pnpm install --frozen-lockfile --filter @edr/freight-portal... - -FROM deps AS build -COPY apps/edr-freight-web/portal ./apps/edr-freight-web/portal -RUN pnpm --filter @edr/freight-portal build - -FROM nginx:1.27-alpine AS runtime -COPY --from=build /app/apps/edr-freight-web/portal/dist /usr/share/nginx/html -EXPOSE 5173 -CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css index 8d40838ea..5dd466b0c 100644 --- a/apps/edr-freight-web/backoffice/index.css +++ b/apps/edr-freight-web/backoffice/index.css @@ -2,12 +2,12 @@ @import "@edr/ui-common/theme.css" layer(theme); :root { - --freight-brand: #15803d; - --freight-brand-dark: #166534; - --freight-brand-light: #22c55e; - --freight-brand-muted: #f0fdf4; - --freight-brand-border: #bbf7d0; - --freight-brand-ring: rgb(21 128 61 / 0.2); + --freight-brand: #1B9E7A; + --freight-brand-dark: #15805F; + --freight-brand-light: #2DBF95; + --freight-brand-muted: #E7F8F2; + --freight-brand-border: #B7EBDC; + --freight-brand-ring: rgb(27 158 122 / 0.2); } html, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f5ceb858b..687ad1814 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -21,6 +21,7 @@ import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import NewBookingPage from "./pages/bookings/NewBookingPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import OverviewPage from "./pages/dashboard/OverviewPage"; @@ -35,6 +36,7 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainsPage from "./pages/trains/TrainsPage"; +<<<<<<< HEAD import { CargoesCrudPage, ContainersCrudPage, @@ -42,6 +44,12 @@ import { TrainMasterDataPage, WagonsCrudPage, } from "./pages/fleet/FleetCrudPages"; +======= +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; @@ -76,6 +84,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/operations/train-scheduling", icon: , }, + { + label: "Train Schedules v2", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + }, ], }, { @@ -188,7 +201,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Configuration", href: "/dashboard/configuration", icon: , - children: getCategorySidebarChildren("configuration"), + children: [ + ...getCategorySidebarChildren("configuration"), + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + }, + ], }, { label: "Rules", @@ -264,21 +283,39 @@ const App = () => { } /> } /> + } /> } /> } /> } /> +<<<<<<< HEAD } /> } /> +======= + } + /> + } + /> +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db } /> - } /> - } /> + } /> + } /> } /> +<<<<<<< HEAD } /> } /> } /> +======= + } /> + } /> + } /> +>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db } /> } /> @@ -299,6 +336,10 @@ const App = () => { path="configuration" element={} /> + } + /> } /> void; + onAllocateBooking?: () => void; } export function BookingActionsMenu({ row, variant = "table", onSuppressRowClick, + onAllocateBooking, }: BookingActionsMenuProps) { const navigate = useNavigate(); const { user } = useAuth(); @@ -34,6 +37,7 @@ export function BookingActionsMenu({ paymentCurrency: row.paymentCurrency, reference: row.reference, approvalSteps: row.approvalSteps, + schedulingStatus: row.schedulingStatus, }; const flow = useBookingActionDialog(row.id, context); @@ -46,6 +50,8 @@ export function BookingActionsMenu({ onSuppressRowClick?.(); if (isContractNavAction(action.id)) { goToContract(); + } else if (isAllocateAction(action.id)) { + onAllocateBooking?.(); } else { flow.openAction(action); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index e451f2d2c..0c20184cf 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -1,10 +1,13 @@ +import { useState } from "react"; import { Download, Zap, FileText, Clock } from "lucide-react"; import { Stack, Text, Button } from "@mantine/core"; +import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard"; import type { BookingDetail } from "@/types/booking"; import { BookingActionsMenu } from "./BookingActionsMenu"; import { SectionCard } from "./detail/SectionCard"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; +import { canAllocateBooking } from "@/features/bookings/booking-actions.config"; import type { useBookingMutations } from "@/hooks/bookings/useBookings"; type Mutations = ReturnType; @@ -18,6 +21,7 @@ interface BookingActionsToolbarProps { export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { const row = toBookingListRow(booking); const { status } = booking; + const [allocateOpen, setAllocateOpen] = useState(false); const downloadBlob = async (fn: () => Promise, filename: string) => { const blob = await fn(); @@ -98,7 +102,11 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool Confirm each step before it is applied. - + setAllocateOpen(true)} + /> @@ -118,6 +126,14 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool )} + + {canAllocateBooking(booking) ? ( + setAllocateOpen(false)} + /> + ) : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx new file mode 100644 index 000000000..0195efe11 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx @@ -0,0 +1,353 @@ +import type { ReactNode } from "react"; +import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import type { LucideIcon } from "lucide-react"; +import { + AlertTriangle, + CheckCircle2, + Clock, + Inbox, + LayoutList, + Plus, + RefreshCw, +} from "lucide-react"; + +import { freightBrand } from "@/theme/freight-brand"; +import type { + BookingListSummaryMetrics, + BookingListSummaryTabs, +} from "@/services/bookings.service"; + +const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`; + +/** Lifecycle stages for the pipeline distribution bar (in flow order). */ +const PIPELINE_STAGES: Array<{ + key: keyof BookingListSummaryTabs; + label: string; + color: string; +}> = [ + { key: "intake", label: "Intake", color: "#38bdf8" }, + { key: "in_approval", label: "Approval", color: "#fbbf24" }, + { key: "approved_contract", label: "Contract", color: "#a78bfa" }, + { key: "payment", label: "Payment", color: "#fb923c" }, + { key: "operations", label: "Operations", color: "#2dd4bf" }, + { key: "completed", label: "Completed", color: "#86efac" }, +]; + +export interface BookingRequestsHeaderProps { + metrics?: BookingListSummaryMetrics; + tabs?: BookingListSummaryTabs; + loading?: boolean; + isFetching?: boolean; + onCreate: () => void; + onRefresh: () => void; +} + +export function BookingRequestsHeader({ + metrics, + tabs, + loading, + isFetching, + onCreate, + onRefresh, +}: BookingRequestsHeaderProps) { + const val = (n?: number) => (loading ? "โ€”" : (n ?? 0)); + + return ( + + {/* decorative glows */} + + + + + + + + + + + + Operations + + + Booking Requests + + + Track every booking from submission through approval, payment, and + dispatch โ€” prioritize what needs action. + + + + + + + + + + + + + + + + + {tabs ? : null} + + + ); +} + +/** Compact ring gauge with the stat icon at its center. */ +function MiniDonut({ + pct, + color = "white", + children, + size = 52, + stroke = 5, +}: { + pct?: number | null; + color?: string; + children: ReactNode; + size?: number; + stroke?: number; +}) { + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const clamped = + pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null; + const dash = clamped != null ? (clamped / 100) * circumference : 0; + + return ( + + + + {clamped != null ? ( + + ) : null} + + + {children} + + + ); +} + +function HeroStat({ + icon: Icon, + label, + value, + hint, + ratio, + ratioColor = "white", +}: { + icon: LucideIcon; + label: string; + value: ReactNode; + hint?: string; + ratio?: number; + ratioColor?: string; +}) { + const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null; + return ( + + + + + + + + {label} + + + {value} + + + {pct != null ? `${pct}% of queue` : hint} + + + + + ); +} + +function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) { + const segments = PIPELINE_STAGES.map((s) => ({ ...s, count: tabs[s.key] ?? 0 })); + const total = segments.reduce((sum, s) => sum + s.count, 0); + + return ( + + + + Booking pipeline + + + {total} active + + + + + {total > 0 ? ( + segments.map((s) => + s.count > 0 ? ( + + ) : null, + ) + ) : ( + + )} + + + + {segments.map((s) => ( + + + + {s.label} + + + {s.count} + + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx index 2743d0c31..ec0880189 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx @@ -8,22 +8,23 @@ import { Wallet, XCircle, } from "lucide-react"; -import { Group, Badge, UnstyledButton, Text } from "@mantine/core"; +import { Badge, Tabs } from "@mantine/core"; import { BOOKING_LIST_TABS, type BookingStatusTabKey, } from "@/features/bookings/booking-status.config"; +import "@/components/overview/overview.css"; const TAB_ICONS: Record = { - all: , - intake: , - in_approval: , - approved_contract: , - payment: , - operations: , - completed: , - closed: , + all: , + intake: , + in_approval: , + approved_contract: , + payment: , + operations: , + completed: , + closed: , }; interface BookingStatusTabsProps { @@ -38,74 +39,47 @@ export function BookingStatusTabs({ counts, }: BookingStatusTabsProps) { return ( - onChange((value as BookingStatusTabKey) ?? "all")} + variant="pills" + color="green" + keepMounted={false} + classNames={{ list: "ov-tablist", tab: "ov-tab" }} > - {BOOKING_LIST_TABS.map((tab) => { - const isActive = active === tab.key; - const count = counts?.[tab.key]; - return ( - onChange(tab.key)} - style={{ - flexShrink: 0, - background: isActive ? "white" : "transparent", - border: isActive ? "1px solid var(--freight-brand-border)" : "1px solid var(--mantine-color-gray-2)", - borderRadius: "10px", - padding: "10px 16px", - transition: "all 0.2s ease", - cursor: "pointer", - boxShadow: isActive ? "0 2px 8px rgb(21 128 61 / 0.12)" : "none", - }} - > - - -
- {TAB_ICONS[tab.key]} -
- - {tab.label} - -
- {count !== undefined && count > 0 && ( - - {count} - - )} -
-
- ); - })} -
+ + {BOOKING_LIST_TABS.map((tab) => { + const isActive = active === tab.key; + const count = counts?.[tab.key]; + return ( + + {count} + + ) : undefined + } + > + {tab.label} + + ); + })} + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx new file mode 100644 index 000000000..abd787334 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx @@ -0,0 +1,225 @@ +import { useMemo, useState } from "react"; +import { ArrowRight, Building2, Package } from "lucide-react"; +import { + Accordion, + Badge, + Button, + Checkbox, + Group, + Paper, + Stack, + Text, + Title, +} from "@mantine/core"; + +import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { canAllocateBooking } from "@/features/bookings/booking-actions.config"; +import type { BookingListRow } from "@/types/booking"; +import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue"; + +function BookingQueueRow({ + booking, + selected, + disabled, + onToggle, +}: { + booking: BookingListRow; + selected: boolean; + disabled: boolean; + onToggle: () => void; +}) { + return ( + + + + + + {booking.reference} + {booking.isGovernment ? ( + }> + Government + + ) : null} + {booking.freightType} + {booking.schedulingStatus ? ( + {booking.schedulingStatus} + ) : null} + + {booking.customerLabel} + + {booking.originLabel} + + {booking.destinationLabel} + + + + {booking.serviceTypeLabel ? ( + + {booking.serviceTypeLabel} + {booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""} + + ) : null} + + + + ); +} + +export function OperationsBookingQueue({ + bookings, + isLoading, + onAllocate, +}: { + bookings: BookingListRow[]; + isLoading?: boolean; + onAllocate: (bookingIds: string[]) => void; +}) { + const { government, commercial } = useMemo( + () => groupBookingsForOperationsQueue(bookings), + [bookings], + ); + const [govSelected, setGovSelected] = useState([]); + const [selectedByBucket, setSelectedByBucket] = useState>({}); + + const allocatable = (row: BookingListRow) => + row.status === "PAID" && + canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus }); + + const govSelection = govSelected.length + ? govSelected + : government.filter(allocatable).map((b) => b.id); + + const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => { + const existing = selectedByBucket[bucketKey]; + if (existing) return existing; + return bucketBookings.filter(allocatable).map((b) => b.id); + }; + + const toggleGov = (bookingId: string) => { + setGovSelected((prev) => { + const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id); + return base.includes(bookingId) + ? base.filter((id) => id !== bookingId) + : [...base, bookingId]; + }); + }; + + const toggleBucket = (bucketKey: string, bookingId: string) => { + setSelectedByBucket((prev) => { + const current = prev[bucketKey] ?? []; + const next = current.includes(bookingId) + ? current.filter((id) => id !== bookingId) + : [...current, bookingId]; + return { ...prev, [bucketKey]: next }; + }); + }; + + if (isLoading) { + return Loading operations queueโ€ฆ; + } + + if (!government.length && !commercial.length) { + return ( + + No PAID bookings ready to allocate. + + ); + } + + return ( + + {government.length > 0 ? ( + + + + Government priority + + Served first โ€” not grouped by 3-hour window + + + + {govSelection.length} selected + + + + + {government.map((booking) => ( + toggleGov(booking.id)} + /> + ))} + + + ) : null} + + {commercial.length > 0 ? ( + + {commercial.map((bucket) => { + const selected = bucketSelection(bucket.key, bucket.bookings); + return ( + + + + + {bucket.label} + + {bucket.bookings.length} commercial booking + {bucket.bookings.length === 1 ? "" : "s"} + + + + {selected.length} selected + + + + + + + {bucket.bookings.map((booking) => ( + toggleBucket(bucket.key, booking.id)} + /> + ))} + + + + ); + })} + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsScheduledBookings.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsScheduledBookings.tsx new file mode 100644 index 000000000..44a1ef030 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsScheduledBookings.tsx @@ -0,0 +1,97 @@ +import { Link } from "react-router-dom"; +import { ArrowRight, ExternalLink } from "lucide-react"; +import { Badge, Button, Group, Stack, Text } from "@mantine/core"; + +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; +import type { BookingListRow } from "@/types/booking"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; + +export function OperationsScheduledBookings({ + bookings, + isLoading, +}: { + bookings: BookingListRow[]; + isLoading?: boolean; +}) { + const columns: ColumnDef[] = [ + { + id: "reference", + header: "Booking", + cell: ({ row }) => ( + + + {row.original.reference} + {row.original.isGovernment ? ( + Government + ) : null} + + {row.original.customerLabel} + + ), + }, + { + id: "route", + header: "Route", + cell: ({ row }) => ( + + {row.original.originLabel} + + {row.original.destinationLabel} + + ), + }, + { + id: "scheduled", + header: "Scheduled", + cell: ({ row }) => ( + {String(row.original.scheduledDate).slice(0, 16)} + ), + }, + { + id: "status", + header: "Scheduling", + cell: ({ row }) => + row.original.schedulingStatus ? ( + + ) : ( + โ€” + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => ( + + + {row.original.trainScheduleId ? ( + + ) : null} + + ), + }, + ]; + + return ( + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx index 854e121ce..1fbb859af 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingApprovalCard.tsx @@ -19,6 +19,7 @@ export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCar {approvedCount} / {steps.length} approved diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx index 90e26bed4..b0a01f625 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx @@ -15,7 +15,7 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { const containers = booking.bookingContainers ?? []; return ( - + {containers.length} line{containers.length === 1 ? "" : "s"} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx index aebb0f52f..1e86a7d2a 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx @@ -10,7 +10,7 @@ export interface BookingContractSummaryCardProps { /** Generated contract terms, shown verbatim. */ export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) { return ( - + {files.length} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingFactsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingFactsCard.tsx index 24042c18f..8d82f51e2 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingFactsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingFactsCard.tsx @@ -43,7 +43,7 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) { ]; return ( - + {facts.map((fact, index) => (
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx index 2b09618df..3236e5cae 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx @@ -17,7 +17,7 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp } return ( - + {booking.firstMilePickupAddress && ( diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index cb6503217..67a7fbc52 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -1,12 +1,28 @@ -import { Building2, Calendar, Clock, RefreshCw, ArrowLeft } from "lucide-react"; -import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core"; +import type { ReactNode } from "react"; +import { + ArrowLeft, + Building2, + Calendar, + Clock, + Container as ContainerIcon, + Flame, + RefreshCw, + Wallet, + Weight, +} from "lucide-react"; +import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; +import type { LucideIcon } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { NextStepBanner } from "@/components/bookings/NextStepBanner"; +import { freightBrand } from "@/theme/freight-brand"; -import { detailStyles, formatDate } from "./booking-detail.styles"; +import { formatDate } from "./booking-detail.styles"; + +const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`; export interface BookingRequestHeroProps { booking: BookingDetail; @@ -16,7 +32,7 @@ export interface BookingRequestHeroProps { isFetching?: boolean; } -/** Top hero for the request detail page: identity, status, next step, total value. */ +/** Top hero for the request detail page: identity, status, next step, key figures. */ export function BookingRequestHero({ booking, customerLabel, @@ -25,85 +41,198 @@ export function BookingRequestHero({ isFetching, }: BookingRequestHeroProps) { const amount = Number(booking.totalAmount); + const containers = booking.bookingContainers ?? []; + const containerCount = containers.reduce( + (sum, c) => sum + Number(c.quantity ?? 0), + 0, + ); + const weight = Number(booking.cargoTotalWeightVgm ?? 0); return ( - - + + - - - - Booking reference - - - - {booking.reference} - - - - - - {booking.nextStep && ( - - - - )} - - - - - - {customerLabel} - - - - - - Scheduled {booking.scheduledDate} - - - - - - Created {formatDate(booking.createdAt)} - - - - - - - - - Total value - - - {booking.paymentCurrency}{" "} - {amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} - - - {booking.paymentStatus} - - + + + + + + + + + Booking reference + + + + {booking.reference} + + + + {booking.schedulingStatus ? ( + + ) : null} + + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} + + ) : null} + + + + + + + + + + {booking.nextStep ? ( + + + + ) : null} + + + + + + + + + + ); +} + +function MetaItem({ + icon: Icon, + text, + strong, +}: { + icon: LucideIcon; + text: ReactNode; + strong?: boolean; +}) { + return ( + + + + {text} + + + ); +} + +function HeroTile({ + icon: Icon, + label, + value, + hint, +}: { + icon: LucideIcon; + label: string; + value: ReactNode; + hint?: ReactNode; +}) { + return ( + + + + + + + + {label} + + + {value} + + {hint ? ( + + {hint} + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx index 6f2f1f76f..218f0c7c5 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingReviewNotesCard.tsx @@ -12,7 +12,7 @@ export interface BookingReviewNotesCardProps { export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) { if (notes.length === 0) { return ( - + No review notes have been added yet. diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx index 964d83803..05fa58100 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx @@ -10,7 +10,7 @@ export interface BookingRouteCardProps { export function BookingRouteCard({ booking }: BookingRouteCardProps) { return ( - + {/* Origin */} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index ea2aa491e..a4976ec61 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -63,7 +63,7 @@ export function BookingRouteServiceCard({ ]; return ( - + diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/SectionCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/SectionCard.tsx index 1eadf24bc..1756600c9 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/SectionCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/SectionCard.tsx @@ -7,20 +7,68 @@ import { detailStyles } from "./booking-detail.styles"; export interface SectionCardProps { icon: LucideIcon; title: string; + /** Optional one-line context shown under the title. */ + subtitle?: string; + /** Mantine palette key used to tint the icon chip + top accent (default green). */ + accent?: string; extra?: ReactNode; children: ReactNode; } -/** Consistent flat card with a minimal icon + title header used by every detail section. */ -export function SectionCard({ icon: Icon, title, extra, children }: SectionCardProps) { +/** Consistent card with a colored icon chip + accent stripe header used by every detail section. */ +export function SectionCard({ + icon: Icon, + title, + subtitle, + accent = "green", + extra, + children, +}: SectionCardProps) { return ( - - - - - - {title} - + + + + + + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + {extra} diff --git a/apps/edr-freight-web/backoffice/src/components/container_management/AssignContainerDialog.tsx b/apps/edr-freight-web/backoffice/src/components/container_management/AssignContainerDialog.tsx deleted file mode 100644 index 55f44936c..000000000 --- a/apps/edr-freight-web/backoffice/src/components/container_management/AssignContainerDialog.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@edr/ui-common'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useContainers, useAssignContainerToWagon } from './use-containers'; -import { useToast } from '@/hooks/use-toast'; -import { Plus } from 'lucide-react'; - -export function AssignContainerDialog({ wagonId }: { wagonId: string }) { - const [open, setOpen] = useState(false); - const [containerId, setContainerId] = useState(''); - const [position, setPosition] = useState(); - const { data: containers } = useContainers(); - const assign = useAssignContainerToWagon(); - const { toast } = useToast(); - - const available = containers?.filter(c => c.status === 'AVAILABLE' && !c.wagonId); - - const handleAssign = async () => { - if (!containerId) return; - await assign.mutateAsync({ containerId, wagonId, position }); - toast({ title: 'Assigned', description: 'Container placed on wagon.' }); - setOpen(false); - }; - - return ( - - - - Assign Container to Wagon -
-
-
setPosition(parseInt(e.target.value) || undefined)} />
- -
-
-
- ); -} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/components/container_management/CargoFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/container_management/CargoFormDialog.tsx deleted file mode 100644 index b36aafe39..000000000 --- a/apps/edr-freight-web/backoffice/src/components/container_management/CargoFormDialog.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@edr/ui-common'; -import { Textarea } from '@/components/ui/textarea'; -import { useCargoTypes } from './use-cargo-types'; -import { useCargoMutations } from './use-cargoes'; -import { Loader2 } from 'lucide-react'; - -interface Cargo { - id: string; - cargoNumber: string; - cargoTypeId: string; - weight: number; - remarks?: string; -} - -interface CargoFormDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - cargo?: Cargo | null; - onSuccess?: () => void; -} - -export default function CargoFormDialog({ - open, - onOpenChange, - cargo, - onSuccess, -}: CargoFormDialogProps) { - const { data: cargoTypes } = useCargoTypes(); - const { createCargo, updateCargo } = useCargoMutations(); - const [formData, setFormData] = useState>({ - cargoNumber: '', - cargoTypeId: '', - weight: 0, - remarks: '', - }); - - useEffect(() => { - if (cargo) { - setFormData(cargo); - } else { - setFormData({ - cargoNumber: '', - cargoTypeId: '', - weight: 0, - remarks: '', - }); - } - }, [cargo, open]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (cargo?.id) { - updateCargo.mutate( - { id: cargo.id, data: formData }, - { onSuccess: () => { onOpenChange(false); onSuccess?.(); } } - ); - } else { - createCargo.mutate(formData, { - onSuccess: () => { onOpenChange(false); onSuccess?.(); } - }); - } - }; - - const isLoading = createCargo.isPending || updateCargo.isPending; - - return ( - - - {cargo ? 'Edit Cargo' : 'Create New Cargo'} -
-
-
- - setFormData({...formData, cargoNumber: e.target.value})} required /> -
-
- - -
-
-
- - setFormData({...formData, weight: parseFloat(e.target.value)})} required /> -
-
- - +
+ +
+
+ )} + + setShowModal(false)} title="Create Notification Template"> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx new file mode 100644 index 000000000..339cd591f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Download } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import { reportsApi } from '@/lib/api'; +import { formatDateTime, formatCurrency } from '@/lib/utils'; + +export default function OperationalreportsPage() { + const [filters, setFilters] = useState({ search: '', reportType: '' }); + + const { data, isLoading } = useQuery({ + queryKey: ['operational-reports', filters], + queryFn: () => reportsApi.getOperationalReports(filters), + }); + + const columns = [ + { key: 'reportType', label: 'Type', render: (report: any) => {report.reportType} }, + { key: 'period', label: 'Period', render: (report: any) => report.period || 'N/A' }, + { key: 'generatedBy', label: 'Generated By', render: (report: any) => report.generatedBy?.fullName || 'System' }, + { key: 'createdAt', label: 'Generated', render: (report: any) => formatDateTime(report.createdAt) }, + ]; + + return ( +
+
+
+

Operational Reports

+

View operational reports and analytics

+
+ Export +
+ +
+
+ +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ +
+
+ + +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/page.tsx b/apps/edr-passenger-web/backoffice/src/app/page.tsx new file mode 100644 index 000000000..1413e02ca --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Home() { + redirect('/login'); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/layout.tsx new file mode 100644 index 000000000..f72f4df0b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function PassengersLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx new file mode 100644 index 000000000..2f47cdb7a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -0,0 +1,342 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Download, Eye, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import Pagination from '@/components/ui/Pagination'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { passengersApi, apiClient } from '@/lib/api'; +import { formatDate, formatDateTime } from '@/lib/utils'; +import { PassengerFilters } from '@/types'; + +export default function PassengersPage() { + const [filters, setFilters] = useState({ + page: 1, + pageSize: 20, + search: '', + }); + const [selectedPassenger, setSelectedPassenger] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); + + const queryClient = useQueryClient(); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['passengers'] }); + }, + }); + + const handleDelete = (passenger: any) => { + setDeleteConfirm({ isOpen: true, passenger }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.passenger) { + await deleteMutation.mutateAsync(deleteConfirm.passenger.id); + setDeleteConfirm({ isOpen: false, passenger: null }); + } + }; + + const { data, isLoading, error } = useQuery({ + queryKey: ['passengers', filters], + queryFn: () => passengersApi.getAll(filters), + }); + + if (error) { + console.error('Passengers API Error:', error); + } + + const columns = [ + { + key: 'fullName', + label: 'Name', + sortable: true, + render: (passenger: any) => ( +
+
{passenger.fullName}
+
{passenger.email}
+
+ ), + }, + { + key: 'phone', + label: 'Phone', + render: (passenger: any) => passenger.phone, + }, + { + key: 'nationalId', + label: 'National ID', + render: (passenger: any) => passenger.nationalId || 'N/A', + }, + { + key: 'dateOfBirth', + label: 'Date of Birth', + render: (passenger: any) => passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : 'N/A', + }, + { + key: 'verified', + label: 'Status', + render: (passenger: any) => ( + + {passenger.nationalId ? 'Verified' : 'Unverified'} + + ), + }, + ]; + + const actions = [ + { + label: 'View Details', + onClick: (passenger: any) => setSelectedPassenger(passenger), + variant: 'secondary' as const, + icon: Eye, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
+
+
+

Passengers

+

Manage passenger profiles and verification

+
+
+ Export +
+
+ +
+ {error && ( +
+ Error loading passengers: {error instanceof Error ? error.message : 'Unknown error'} +
+ )} +
+
+ setFilters({ ...filters, search: e.target.value, page: 1 })} + /> +
+ +
+ + + + {data?.meta && ( + setFilters({ ...filters, page })} + /> + )} +
+ + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, passenger: null })} + onConfirm={confirmDelete} + title="Delete Passenger" + message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`} + confirmText="Delete" + isDanger={true} + warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records." + /> + + {/* Passenger Details Modal */} + setSelectedPassenger(null)} + title="Passenger Details" + size="xl" + > + {selectedPassenger && ( +
+ {/* Personal Information */} +
+

Personal Information

+
+
+ +

{selectedPassenger.fullName}

+
+
+ +

+ {selectedPassenger.dateOfBirth ? formatDate(selectedPassenger.dateOfBirth) : 'N/A'} +

+
+
+ +

{selectedPassenger.gender || 'N/A'}

+
+
+ +

{selectedPassenger.nationality || 'N/A'}

+
+
+
+ +
+ + {/* Contact Information */} +
+

Contact Information

+
+
+ +

{selectedPassenger.email || 'N/A'}

+
+
+ +

{selectedPassenger.phone || 'N/A'}

+
+
+
+ +
+ + {/* Identification */} +
+

Identification

+
+
+ +

{selectedPassenger.nationalId || 'N/A'}

+
+
+ +

{selectedPassenger.passportNumber || 'N/A'}

+
+
+ +

{selectedPassenger.passportCountry || 'N/A'}

+
+
+ +
+ + {selectedPassenger.nationalId ? 'Verified' : 'Unverified'} + +
+
+
+
+ +
+ + {/* Account Information */} +
+

Account Information

+
+
+ +

{selectedPassenger.id}

+
+
+ +

{selectedPassenger.userId || 'N/A'}

+
+
+
+ + {/* Loyalty & Wallet (if available) */} + {(selectedPassenger.loyalty || selectedPassenger.wallet) && ( + <> +
+
+ {selectedPassenger.loyalty && ( +
+

Loyalty Account

+
+
+ +

{selectedPassenger.loyalty.tier || 'N/A'}

+
+
+ +

{selectedPassenger.loyalty.pointsBalance || 0}

+
+
+
+ )} + {selectedPassenger.wallet && ( +
+

Wallet

+
+
+ +

+ {(selectedPassenger.wallet.balanceMinor / 100).toFixed(2)} {selectedPassenger.wallet.currency} +

+
+
+
+ )} +
+ + )} + +
+ + {/* Timestamps */} +
+

Timestamps

+
+
+ +

{selectedPassenger.createdAt ? formatDateTime(selectedPassenger.createdAt) : 'N/A'}

+
+
+ +

{selectedPassenger.updatedAt ? formatDateTime(selectedPassenger.updatedAt) : 'N/A'}

+
+
+
+ +
+ setSelectedPassenger(null)} + > + Close + +
+
+ )} +
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/payments/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx new file mode 100644 index 000000000..69ef22859 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Download } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import { paymentsApi } from '@/lib/api'; +import { formatDateTime, formatCurrency } from '@/lib/utils'; + +export default function PaymentsPage() { + const [filters, setFilters] = useState({ search: '', status: '', method: '' }); + + const { data, isLoading } = useQuery({ + queryKey: ['payments', filters], + queryFn: () => paymentsApi.getAll(filters), + }); + + const columns = [ + { key: 'reference', label: 'Reference', render: (payment: any) => {payment.reference || payment.id?.substring(0, 8)} }, + { key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' }, + { key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) }, + { key: 'method', label: 'Method', render: (payment: any) => {payment.method} }, + { key: 'status', label: 'Status', render: (payment: any) => {payment.status} }, + { key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, + ]; + + const actions: any[] = []; + + return ( +
+
+
+

Payments

+

Manage payment transactions and refunds

+
+ Export +
+ +
+
+ +
+ + setFilters({ ...filters, search: e.target.value })} /> +
+
+ + +
+ +
+
+ + +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/layout.tsx new file mode 100644 index 000000000..9a5bb12de --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function PricingLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx new file mode 100644 index 000000000..36937946f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Save } from 'lucide-react'; +import Table from '@/components/ui/Table'; +import { routesApi } from '@/lib/api/routes'; +import { formatCurrency } from '@/lib/utils'; + +export default function PricingPage() { + const [selectedRoute, setSelectedRoute] = useState('1'); + + const { data: fareRules } = useQuery({ + queryKey: ['fare-rules', selectedRoute], + queryFn: () => routesApi.getFareRules(selectedRoute), + initialData: [ + { id: '1', routeId: '1', passengerCategory: 'ADULT', serviceClass: 'ECONOMY_REGULAR', baseFare: 35000, currency: 'ETB' }, + { id: '2', routeId: '1', passengerCategory: 'CHILD', serviceClass: 'ECONOMY_REGULAR', baseFare: 0, currency: 'ETB' }, + { id: '3', routeId: '1', passengerCategory: 'ADULT', serviceClass: 'ECONOMY_BED', baseFare: 52500, currency: 'ETB' }, + { id: '4', routeId: '1', passengerCategory: 'ADULT', serviceClass: 'VIP_BED', baseFare: 70000, currency: 'ETB' }, + ], + }); + + return ( +
+
+
+

Pricing & Fare Rules

+

Manage fare rules and pricing for different routes and classes

+
+ +
+ +
+
+ + +
+ +
+

Fare Rules

+

Configure base fares for different passenger categories and service classes

+
+ + ( + + )}, + { key: 'currency', label: 'Currency' }, + ]} + /> + + +
+

Pricing Rules

+
+
+

Age-Based Pricing

+
    +
  • โ€ข ADULT (โ‰ฅ5 years): Pay 100% of base fare
  • +
  • โ€ข CHILD (<5 years): First child travels FREE, subsequent children pay 100%
  • +
+
+
+

Multi-Currency Support

+
    +
  • โ€ข Transaction Currency: ETB (Ethiopian Birr)
  • +
  • โ€ข Display Currencies: ETB, DJF, USD
  • +
  • โ€ข Exchange rates: ETBโ†’DJF=3.25, ETBโ†’USD=0.018
  • +
+
+
+
+ + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/providers.tsx b/apps/edr-passenger-web/backoffice/src/app/providers.tsx new file mode 100644 index 000000000..663a52e05 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/providers.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState, useEffect } from 'react'; +import { useTheme } from '@/lib/theme-store'; +import { useAuthStore } from '@/lib/auth-store'; + +function ThemeProvider({ children }: { children: React.ReactNode }) { + const { isDark, setTheme } = useTheme(); + + useEffect(() => { + document.documentElement.classList.toggle('dark', isDark); + }, [isDark]); + + return <>{children}; +} + +function AuthProvider({ children }: { children: React.ReactNode }) { + const initialize = useAuthStore((state) => state.initialize); + + useEffect(() => { + initialize(); + }, [initialize]); + + return <>{children}; +} + +export default function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + }, + }, + })); + + return ( + + + {children} + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/layout.tsx new file mode 100644 index 000000000..d51983daf --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function ReportsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx new file mode 100644 index 000000000..7f1f02abe --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState } from 'react'; +import { Download, Calendar } from 'lucide-react'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { formatCurrency } from '@/lib/utils'; + +const revenueByRoute = [ + { route: 'Addis - Djibouti', revenue: 125000000 }, + { route: 'Addis - Dire Dawa', revenue: 85000000 }, + { route: 'Dire Dawa - Djibouti', revenue: 45000000 }, +]; + +const bookingsByClass = [ + { name: 'Economy Regular', value: 65, color: '#3b82f6' }, + { name: 'Economy Bed', value: 25, color: '#10b981' }, + { name: 'VIP Bed', value: 10, color: '#f59e0b' }, +]; + +const occupancyData = [ + { month: 'Jan', rate: 72 }, + { month: 'Feb', rate: 78 }, + { month: 'Mar', rate: 85 }, + { month: 'Apr', rate: 82 }, + { month: 'May', rate: 88 }, + { month: 'Jun', rate: 91 }, +]; + +export default function ReportsPage() { + const [dateRange, setDateRange] = useState('last-30-days'); + + return ( +
+
+
+

Reports & Analytics

+

View detailed reports and analytics

+
+
+ + +
+
+ +
+
+

Revenue by Route

+ + + + + + formatCurrency(value, 'ETB')} /> + + + +
+ +
+

Bookings by Class

+ + + `${name}: ${value}%`} + outerRadius={100} + fill="#8884d8" + dataKey="value" + > + {bookingsByClass.map((entry, index) => ( + + ))} + + + + +
+ +
+

Occupancy Rate Trend

+ + + + + + `${value}%`} /> + + + +
+
+ +
+

Quick Stats

+
+
+

Total Revenue

+

{formatCurrency(255000000, 'ETB')}

+
+
+

Total Bookings

+

1,247

+
+
+

Avg. Ticket Price

+

{formatCurrency(42500, 'ETB')}

+
+
+

Cancellation Rate

+

3.2%

+
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/layout.tsx new file mode 100644 index 000000000..987b54e48 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/routes/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function RoutesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx new file mode 100644 index 000000000..cc1638a1b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -0,0 +1,534 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit, Trash2, X } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { routesApi } from '@/lib/api/routes'; +import { stationsApi } from '@/lib/api'; + +interface RouteStop { + stationId: string; + sequence: number; + distanceKm?: number; + distanceFromOrigin?: number; +} + +export default function RoutesPage() { + const [showModal, setShowModal] = useState(false); + const [editingRoute, setEditingRoute] = useState(null); + const [stops, setStops] = useState([]); + const [originStationId, setOriginStationId] = useState(''); + const [destinationStationId, setDestinationStationId] = useState(''); + const [destinationDistance, setDestinationDistance] = useState(undefined); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null }>({ isOpen: false, route: null }); + const queryClient = useQueryClient(); + + const { data: routes, isLoading: routesLoading } = useQuery({ + queryKey: ['routes'], + queryFn: async () => { + const result = await routesApi.getAll(); + console.log('Routes query result:', result); + return result; + }, + }); + + const { data: stations } = useQuery({ + queryKey: ['stations'], + queryFn: stationsApi.getAll, + }); + + const createMutation = useMutation({ + mutationFn: routesApi.create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['routes'] }); + setShowModal(false); + setEditingRoute(null); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => routesApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['routes'] }); + setShowModal(false); + setEditingRoute(null); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: routesApi.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['routes'] }); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + if (!originStationId || !destinationStationId) { + alert('Please select origin and destination stations'); + return; + } + + if (originStationId === destinationStationId) { + alert('Origin and destination must be different'); + return; + } + + // Sort middle stops by distance from origin + const sortedMiddleStops = [...stops].sort((a, b) => + (a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0) + ); + + // Calculate distanceKm (distance from previous stop) + const stopsArray = [ + { stationId: originStationId, sequence: 1, distanceKm: 0 }, + ...sortedMiddleStops.map((stop, idx) => { + const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0); + return { + stationId: stop.stationId, + sequence: idx + 2, + distanceKm: (stop.distanceFromOrigin || 0) - prevDistance, + }; + }), + { + stationId: destinationStationId, + sequence: sortedMiddleStops.length + 2, + distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0), + }, + ]; + + const routeData = { + code: formData.get('code') as string, + name: formData.get('name') as string, + description: formData.get('description') as string || undefined, + effectiveFrom: formData.get('effectiveFrom') as string, + effectiveUntil: formData.get('effectiveUntil') as string || undefined, + stops: stopsArray, + }; + + console.log('Submitting route data:', JSON.stringify(routeData, null, 2)); + + if (editingRoute) { + await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData }); + } else { + await createMutation.mutateAsync(routeData); + } + }; + + const addStop = () => { + setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]); + }; + + const removeStop = (index: number) => { + setStops(stops.filter((_, i) => i !== index)); + }; + + const updateStop = (index: number, field: keyof RouteStop, value: any) => { + const updated = [...stops]; + updated[index] = { ...updated[index], [field]: value }; + setStops(updated); + }; + + const generateRouteCode = (originId: string, destId: string) => { + if (!originId || !destId) return ''; + const origin = stations?.items?.find((s: any) => s.id === originId); + const dest = stations?.items?.find((s: any) => s.id === destId); + return origin && dest ? `${origin.code}-${dest.code}` : ''; + }; + + const generateRouteName = (originId: string, destId: string) => { + if (!originId || !destId) return ''; + const origin = stations?.items?.find((s: any) => s.id === originId); + const dest = stations?.items?.find((s: any) => s.id === destId); + return origin && dest ? `${origin.name} - ${dest.name}` : ''; + }; + + const handleDelete = (route: any) => { + setDeleteConfirm({ isOpen: true, route }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.route) { + await deleteMutation.mutateAsync(deleteConfirm.route.id); + setDeleteConfirm({ isOpen: false, route: null }); + } + }; + + const routeColumns = [ + { key: 'code', label: 'Route Code', sortable: true }, + { key: 'name', label: 'Route Name', sortable: true }, + { key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' }, + { + key: 'active', + label: 'Status', + render: (route: any) => ( + + {route.active ? 'Active' : 'Inactive'} + + ), + }, + ]; + + const routeActions = [ + { + label: 'Edit', + onClick: (route: any) => { + setEditingRoute(route); + const routeStops = route.stops || []; + if (routeStops.length >= 2) { + setOriginStationId(routeStops[0].stationId); + setDestinationStationId(routeStops[routeStops.length - 1].stationId); + + // Calculate cumulative distance for destination + let cumulativeDistance = 0; + routeStops.forEach((stop: any, idx: number) => { + if (idx > 0) { + cumulativeDistance += stop.distanceKm || 0; + } + }); + setDestinationDistance(cumulativeDistance); + + // Calculate distance from origin for middle stops + const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => { + let distFromOrigin = 0; + for (let i = 1; i <= idx + 1; i++) { + distFromOrigin += routeStops[i].distanceKm || 0; + } + return { + stationId: stop.stationId, + sequence: stop.sequence, + distanceKm: stop.distanceKm, + distanceFromOrigin: distFromOrigin, + }; + }); + setStops(middleStops); + } + setShowModal(true); + }, + variant: 'secondary' as const, + icon: Edit, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
+
+
+

Routes

+

Manage railway routes

+
+ { + setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); + setStops([]); + setShowModal(true); + }} + > + Add Route + +
+ + + + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, route: null })} + onConfirm={confirmDelete} + title="Delete Route" + message={`Are you sure you want to delete ${deleteConfirm.route?.name}?`} + confirmText="Delete" + isDanger={true} + warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems." + /> + + {/* Add/Edit Modal */} + { + setShowModal(false); + setEditingRoute(null); + setOriginStationId(''); + setDestinationStationId(''); + setDestinationDistance(undefined); + setStops([]); + }} + title={`${editingRoute ? 'Edit' : 'Add'} Route`} + size="lg" + > +
+ {editingRoute && ( +
+

โš  Warning

+

Editing this route may impact schedules, trips, and bookings that reference it. Proceed with caution.

+
+ )} +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +