mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
Merge freight/develop into Warehouses
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.turbo
|
||||||
|
**/.git
|
||||||
|
**/.github
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
**/coverage
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
**/*.log
|
||||||
|
.DS_Store
|
||||||
92
.github/workflows/deploy.yml
vendored
Normal file
92
.github/workflows/deploy.yml
vendored
Normal file
@@ -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
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,6 +23,10 @@ coverage/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.npmrc
|
||||||
|
branch_structure.json
|
||||||
|
temp_auto_push.bat
|
||||||
|
temp_interactive_push.bat
|
||||||
|
|
||||||
# emacs cache files
|
# emacs cache files
|
||||||
*~
|
*~
|
||||||
|
|||||||
187
DEPLOYMENT.md
Normal file
187
DEPLOYMENT.md
Normal file
@@ -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/<DEPLOY_USER>/environment/edr/<branch-slug>/<project>/`
|
||||||
|
|
||||||
|
Where:
|
||||||
|
|
||||||
|
- `<DEPLOY_USER>` defaults to `tria` (overridable by `DEPLOY_USER`)
|
||||||
|
- `<branch-slug>` is derived from Git branch (lowercase, non-alphanumeric replaced with `-`)
|
||||||
|
- `<project>` 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=<number>`
|
||||||
|
|
||||||
|
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=<project>-<branch-slug>`
|
||||||
|
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
|
||||||
|
- Runs:
|
||||||
|
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
|
||||||
|
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
|
||||||
|
- Cleans `.npmrc`/`.npmrc_temp`.
|
||||||
|
|
||||||
|
## Branch/Environment Isolation
|
||||||
|
|
||||||
|
Compose project name is generated as:
|
||||||
|
|
||||||
|
`<project>-<branch-slug>`
|
||||||
|
|
||||||
|
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 <service>
|
||||||
|
docker compose up -d <service>
|
||||||
|
```
|
||||||
|
|
||||||
|
If private packages are required locally, create `.npmrc`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat <<EOF > .npmrc
|
||||||
|
@tria-plc:registry=https://npm.pkg.github.com
|
||||||
|
//npm.pkg.github.com/:_authToken=<YOUR_TOKEN>
|
||||||
|
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=<number>` 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
|
||||||
|
|
||||||
766
README.md
766
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 <repository-url>
|
||||||
|
cd edr-platform
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install Dependencies
|
||||||
# EDR Platform
|
# 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.
|
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
|
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 <jwt-token>`
|
||||||
|
- **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 <iam-token>`
|
||||||
|
- **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 <jwt-token>
|
||||||
|
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 <jwt-token>
|
||||||
|
|
||||||
|
# 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 <iam-token>
|
||||||
|
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/<branch>/` |
|
||||||
|
| [`.github/workflows/deploy-passenger.yml`](.github/workflows/deploy-passenger.yml) | passenger-api, passenger-portal, passenger-backoffice | `/home/user/environmen/edr-passenger/<branch>/` |
|
||||||
|
|
||||||
|
**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
|
### Start local databases
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -257,3 +1022,4 @@ pnpm dev:passenger # passenger API + portal + backoffice
|
|||||||
- **One DB per domain** — no cross-database joins.
|
- **One DB per domain** — no cross-database joins.
|
||||||
|
|
||||||
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
|
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
|
||||||
|
>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
# App
|
# Copy to .env for local/docker compose (not committed).
|
||||||
NODE_ENV=development
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5433
|
DB_PORT=5433
|
||||||
DB_NAME=edr_freight
|
|
||||||
DB_USER=postgres
|
DB_USER=postgres
|
||||||
DB_PASSWORD=
|
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 (used by @tria-plc/api-common SharedAuthModule)
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
JWT_ACCESS_TOKEN_SECRET=
|
JWT_ACCESS_TOKEN_SECRET=
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
FROM node:20-alpine AS base
|
# syntax=docker/dockerfile:1
|
||||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS pruner
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY . .
|
||||||
COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/
|
RUN pnpm dlx turbo prune "@edr/freight-api" --docker
|
||||||
COPY packages ./packages
|
|
||||||
RUN pnpm install --frozen-lockfile --filter @edr/freight-api...
|
|
||||||
|
|
||||||
FROM deps AS build
|
FROM base AS installer
|
||||||
COPY apps/edr-freight-api ./apps/edr-freight-api
|
COPY --from=pruner /app/out/json/ .
|
||||||
RUN pnpm --filter @edr/freight-api build
|
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
|
FROM base AS builder
|
||||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
COPY --from=installer /app/ .
|
||||||
WORKDIR /app/apps/edr-freight-api
|
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
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./../../node_modules
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules
|
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||||
COPY --from=build /app/apps/edr-freight-api/dist ./dist
|
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||||
COPY --from=build /app/apps/edr-freight-api/package.json ./package.json
|
USER nestjs
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -13,16 +13,23 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
<<<<<<< HEAD
|
||||||
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
|
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
|
||||||
"type-check": "tsc --noEmit"
|
"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": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
"@edr/payment-providers": "workspace:*",
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/event-emitter": "^2.0.4",
|
||||||
"@nestjs/mapped-types": "^2.1.1",
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/microservices": "^11.0.0",
|
"@nestjs/microservices": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
@@ -64,7 +71,7 @@
|
|||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typeorm": "^1.0.0",
|
"typeorm": "^0.3.30",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
|||||||
@@ -8,17 +8,19 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.
|
|||||||
|
|
||||||
import appConfig from "./config/app.config";
|
import appConfig from "./config/app.config";
|
||||||
import databaseConfig from "./config/database.config";
|
import databaseConfig from "./config/database.config";
|
||||||
|
import telebirrConfig from "./config/telebirr.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
import { FilesModule } from "./modules/files/files.module";
|
import { FilesModule } from "./modules/files/files.module";
|
||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.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 { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.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 { CustomersModule } from "./modules/customers/customers.module";
|
||||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
import { TrackingModule } from "./modules/tracking/tracking.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 { ContainersModule } from './modules/container-management/containers.module';
|
||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||||
import { RoutesModule } from './modules/routes/routes.module';
|
import { RoutesModule } from './modules/routes/routes.module';
|
||||||
|
<<<<<<< HEAD
|
||||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||||
|
=======
|
||||||
|
import { OverviewModule } from './modules/overview/overview.module';
|
||||||
|
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, databaseConfig],
|
load: [appConfig, databaseConfig, telebirrConfig],
|
||||||
}),
|
}),
|
||||||
|
// EventEmitterModule.forRoot(),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||||
@@ -82,6 +89,7 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
|||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
TrainSchedulesModule,
|
TrainSchedulesModule,
|
||||||
TrainSchedulingModule,
|
TrainSchedulingModule,
|
||||||
|
SchedulingRescheduleModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
@@ -101,7 +109,11 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
|||||||
ContainersModule,
|
ContainersModule,
|
||||||
CargoesModule,
|
CargoesModule,
|
||||||
RoutesModule,
|
RoutesModule,
|
||||||
|
<<<<<<< HEAD
|
||||||
WarehousesModule,
|
WarehousesModule,
|
||||||
|
=======
|
||||||
|
OverviewModule,
|
||||||
|
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,3 +15,9 @@ export const BookingStaff = (permission: string | string[]) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||||
|
|
||||||
|
export const TrainSchedulingView = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||||
|
|
||||||
|
export const TrainSchedulingManage = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { registerAs } from "@nestjs/config";
|
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", () => ({
|
export default registerAs("app", () => ({
|
||||||
env: process.env.NODE_ENV ?? "development",
|
env: process.env.NODE_ENV ?? "development",
|
||||||
port: parseInt(process.env.PORT ?? "3001", 10),
|
port: parseInt(process.env.PORT ?? "3001", 10),
|
||||||
apiPrefix: "api",
|
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),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal file
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal file
@@ -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 ?? ""
|
||||||
|
}));
|
||||||
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
@@ -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",
|
||||||
|
}));
|
||||||
@@ -61,7 +61,10 @@ export class ContractPricingScheduleBuilder {
|
|||||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
||||||
containerLines: (booking.bookingContainers ?? []).map((c) => ({
|
containerLines: (booking.bookingContainers ?? []).map((c) => ({
|
||||||
label:
|
label:
|
||||||
c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId,
|
c.containerType?.label ??
|
||||||
|
c.containerType?.code ??
|
||||||
|
c.containerTypeId ??
|
||||||
|
'—',
|
||||||
quantity: c.quantity,
|
quantity: c.quantity,
|
||||||
vgmPerUnitTons: Number(c.vgmPerUnitTons),
|
vgmPerUnitTons: Number(c.vgmPerUnitTons),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddSchedulingAllocationEnhancements1750400000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddSchedulingAllocationEnhancements1750400000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddWagonReadiness1750500000000 implements MigrationInterface {
|
||||||
|
name = 'AddWagonReadiness1750500000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.wagons
|
||||||
|
DROP COLUMN IF EXISTS readiness
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddGovernmentBookingFields1750600000000 implements MigrationInterface {
|
||||||
|
name = 'AddGovernmentBookingFields1750600000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateSchedulingEvents1750700000000 implements MigrationInterface {
|
||||||
|
name = 'CreateSchedulingEvents1750700000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_scheduling_events_train_schedule_id`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.scheduling_events`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
const hasContainerTypes = await queryRunner.hasTable('freight.container_types');
|
||||||
|
if (!hasContainerTypes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.container_types SET wagons_per_unit = 1.00;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddContainerNumberToBookingContainer1750900000000 implements MigrationInterface {
|
||||||
|
name = "AddContainerNumberToBookingContainer1750900000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class CreateTrainSchedulingGlobalRules1751000000000 implements MigrationInterface {
|
||||||
|
name = "CreateTrainSchedulingGlobalRules1751000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_scheduling_global_rules;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddDeletedAtToTrainSchedulingGlobalRules1751000000001
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddDeletedAtToTrainSchedulingGlobalRules1751000000001";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_scheduling_global_rules
|
||||||
|
DROP COLUMN IF EXISTS deleted_at;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ export function computeNextStep(
|
|||||||
const { status } = booking;
|
const { status } = booking;
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
case 'PRICE_CHANGED_PENDING_CONFIRM':
|
||||||
|
return {
|
||||||
|
action: 'CONFIRM_SUBMIT',
|
||||||
|
description: 'Price has changed since preview; confirm to submit booking',
|
||||||
|
};
|
||||||
case 'SUBMITTED':
|
case 'SUBMITTED':
|
||||||
return {
|
return {
|
||||||
action: 'ACCEPT_INTAKE',
|
action: 'ACCEPT_INTAKE',
|
||||||
|
|||||||
@@ -4,56 +4,44 @@ import { Booking } from './entities/booking.entity';
|
|||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||||
import { PaymentService } from '../payment/payment.service';
|
import { PaymentService } from '../payment/payment.service';
|
||||||
|
import { PaymentStatus } from '../payment/entities/payment.entity';
|
||||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||||
|
|
||||||
|
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
||||||
|
"action-required",
|
||||||
|
"processing",
|
||||||
|
"success",
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingPaymentService {
|
export class BookingPaymentService {
|
||||||
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly paymentService: PaymentService,
|
||||||
|
) { }
|
||||||
|
|
||||||
async pay(
|
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||||
bookingId: string,
|
|
||||||
): Promise<{ redirectUrl: string }> {
|
|
||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
|
||||||
|
|
||||||
// const receipt = this.buildMockReceipt(booking);
|
const existing = await this.paymentService.findBookingById(bookingId);
|
||||||
|
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||||
// const updated = await this.bookingsRepository.update(bookingId, {
|
if (existing.clientAction) {
|
||||||
// status: 'PAID',
|
const action = existing.clientAction as { type?: string; url?: string };
|
||||||
// paymentStatus: 'PAID',
|
if (action.type === "REDIRECT" && action.url) {
|
||||||
// } as never);
|
return { redirectUrl: action.url };
|
||||||
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 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<Booking> {
|
private async requireBooking(id: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsRepository.findById(id);
|
const booking = await this.bookingsRepository.findById(id);
|
||||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price
|
|||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
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()
|
@Injectable()
|
||||||
export class BookingPricingService {
|
export class BookingPricingService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -26,22 +44,56 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
const booking = await this.requireBooking(bookingId);
|
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<ComputedPriceResult> {
|
||||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||||
console.log('evalInput----', evalInput);
|
|
||||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
|
||||||
|
|
||||||
const lineItems: PriceLineItemDto[] = [];
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
let total = 0;
|
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) {
|
for (const line of baseLines) {
|
||||||
lineItems.push(line);
|
lineItems.push(line);
|
||||||
total += line.amount;
|
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) {
|
for (const mod of ruleResult.appliedModifiers) {
|
||||||
const item: PriceLineItemDto = {
|
const item: PriceLineItemDto = {
|
||||||
code: mod.surchargeTypeCode,
|
code: mod.surchargeTypeCode,
|
||||||
@@ -51,44 +103,76 @@ export class BookingPricingService {
|
|||||||
};
|
};
|
||||||
lineItems.push(item);
|
lineItems.push(item);
|
||||||
total += mod.calculatedAmount;
|
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 {
|
return {
|
||||||
bookingId,
|
lineItems,
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
currency: booking.paymentCurrency,
|
currency: booking.paymentCurrency,
|
||||||
lineItems,
|
usedRates: [...usedRatesMap.values()],
|
||||||
|
appliedModifiers: ruleResult.appliedModifiers,
|
||||||
|
priorityScore: ruleResult.priorityScore,
|
||||||
warnings: ruleResult.warnings,
|
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<void> {
|
||||||
|
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<typeof r> => r !== null);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.bookingsRepository.createCargoModifiers(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||||||
const containers = await Promise.all(
|
const containers = await Promise.all(
|
||||||
(booking.bookingContainers ?? []).map(async (bc) => {
|
(booking.bookingContainers ?? [])
|
||||||
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||||
const vgm = Number(bc.vgmPerUnitTons);
|
.map(async (bc) => {
|
||||||
const qty = bc.quantity;
|
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
||||||
return {
|
const vgm = Number(bc.vgmPerUnitTons);
|
||||||
containerTypeId: bc.containerTypeId,
|
const qty = bc.quantity;
|
||||||
quantity: qty,
|
return {
|
||||||
vgmPerUnitTons: vgm,
|
containerTypeId: bc.containerTypeId,
|
||||||
totalVgmTons: qty * vgm,
|
quantity: qty,
|
||||||
isReefer: ct.isReefer,
|
vgmPerUnitTons: vgm,
|
||||||
};
|
totalVgmTons: qty * vgm,
|
||||||
}),
|
isReefer: ct.isReefer,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||||
@@ -97,6 +181,7 @@ export class BookingPricingService {
|
|||||||
paymentCurrency: booking.paymentCurrency,
|
paymentCurrency: booking.paymentCurrency,
|
||||||
tradeDirection: booking.tradeDirection,
|
tradeDirection: booking.tradeDirection,
|
||||||
isHazardous: booking.isHazardous,
|
isHazardous: booking.isHazardous,
|
||||||
|
isGovernment: booking.isGovernment,
|
||||||
allowConsolidation: booking.allowConsolidation,
|
allowConsolidation: booking.allowConsolidation,
|
||||||
shippingLineId: booking.shippingLineId,
|
shippingLineId: booking.shippingLineId,
|
||||||
containers,
|
containers,
|
||||||
@@ -115,11 +200,7 @@ export class BookingPricingService {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
}> {
|
}> {
|
||||||
const stored = booking.pricingBreakdown as {
|
const stored = booking.pricingBreakdown as StoredPricingBreakdown;
|
||||||
lineItems?: PriceLineItemDto[];
|
|
||||||
totalAmount?: number;
|
|
||||||
currency?: string;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
if (stored?.lineItems?.length) {
|
if (stored?.lineItems?.length) {
|
||||||
return {
|
return {
|
||||||
@@ -129,41 +210,28 @@ export class BookingPricingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
const computed = await this.computePriceForBooking(booking);
|
||||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
|
||||||
const lineItems: PriceLineItemDto[] = [];
|
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
if (computed.lineItems.length === 0) {
|
||||||
for (const line of baseLines) {
|
const total = Number(booking.totalAmount);
|
||||||
lineItems.push(line);
|
return {
|
||||||
total += line.amount;
|
lineItems: [
|
||||||
}
|
{
|
||||||
|
code: 'TOTAL',
|
||||||
for (const mod of ruleResult.appliedModifiers) {
|
description: 'Contract total',
|
||||||
lineItems.push({
|
amount: total,
|
||||||
code: mod.surchargeTypeCode,
|
currency: booking.paymentCurrency,
|
||||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
},
|
||||||
amount: mod.calculatedAmount,
|
],
|
||||||
currency: mod.currency,
|
totalAmount: total,
|
||||||
});
|
|
||||||
total += mod.calculatedAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lineItems.length === 0) {
|
|
||||||
total = Number(booking.totalAmount);
|
|
||||||
lineItems.push({
|
|
||||||
code: 'TOTAL',
|
|
||||||
description: 'Contract total',
|
|
||||||
amount: total,
|
|
||||||
currency: booking.paymentCurrency,
|
currency: booking.paymentCurrency,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lineItems,
|
lineItems: computed.lineItems,
|
||||||
totalAmount: total || Number(booking.totalAmount),
|
totalAmount: computed.totalAmount || Number(booking.totalAmount),
|
||||||
currency: booking.paymentCurrency,
|
currency: computed.currency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,14 +258,14 @@ export class BookingPricingService {
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async computeBaseRailLines(
|
private async computeBaseRailLinesWithRates(
|
||||||
booking: Booking,
|
booking: Booking,
|
||||||
evalInput: BookingEvaluationInput,
|
evalInput: BookingEvaluationInput,
|
||||||
): Promise<PriceLineItemDto[]> {
|
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
const liveRates = await this.ratesService.findLiveRates();
|
||||||
const currency = booking.paymentCurrency;
|
const currency = booking.paymentCurrency;
|
||||||
const isBulk = booking.freightType === 'BULK';
|
const isBulk = booking.freightType === 'BULK';
|
||||||
console.log('liveRates----', liveRates);
|
|
||||||
const rateType =
|
const rateType =
|
||||||
booking.tradeDirection === 'IMPORT'
|
booking.tradeDirection === 'IMPORT'
|
||||||
? isBulk
|
? isBulk
|
||||||
@@ -209,18 +277,15 @@ console.log('liveRates----', liveRates);
|
|||||||
: 'CONTAINER_EXPORT'
|
: 'CONTAINER_EXPORT'
|
||||||
: 'INTERCITY_CONTAINER';
|
: 'INTERCITY_CONTAINER';
|
||||||
|
|
||||||
|
|
||||||
console.log('rateType----', rateType);
|
|
||||||
|
|
||||||
const lines: PriceLineItemDto[] = [];
|
const lines: PriceLineItemDto[] = [];
|
||||||
|
const usedRatesMap = new Map<string, Rate>();
|
||||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||||
|
|
||||||
for (const container of evalInput.containers) {
|
for (const container of evalInput.containers) {
|
||||||
console.log('container----', container);
|
|
||||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
||||||
console.log('rate----', rate);
|
|
||||||
if (!rate) continue;
|
if (!rate) continue;
|
||||||
|
|
||||||
|
usedRatesMap.set(rate.id, rate);
|
||||||
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||||
lines.push({
|
lines.push({
|
||||||
code: rateType,
|
code: rateType,
|
||||||
@@ -235,6 +300,7 @@ console.log('liveRates----', liveRates);
|
|||||||
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
||||||
);
|
);
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
|
usedRatesMap.set(fallback.id, fallback);
|
||||||
const amount = this.amountForRate(fallback, 1, wagonCount);
|
const amount = this.amountForRate(fallback, 1, wagonCount);
|
||||||
lines.push({
|
lines.push({
|
||||||
code: rateType,
|
code: rateType,
|
||||||
@@ -245,7 +311,7 @@ console.log('liveRates----', liveRates);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||||
}
|
}
|
||||||
|
|
||||||
private pickRate(
|
private pickRate(
|
||||||
@@ -281,31 +347,15 @@ console.log('liveRates----', liveRates);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistPriceRun(
|
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||||
bookingId: string,
|
return JSON.stringify(
|
||||||
modifiers: AppliedCargoModifier[],
|
[...items]
|
||||||
_total: number,
|
.map((item) => ({
|
||||||
): Promise<void> {
|
code: item.code,
|
||||||
await this.bookingsRepository.clearPricingArtifacts(bookingId);
|
amount: item.amount,
|
||||||
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
|
currency: item.currency,
|
||||||
|
}))
|
||||||
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
|
.sort((a, b) => a.code.localeCompare(b.code)),
|
||||||
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<typeof r> => r !== null);
|
|
||||||
|
|
||||||
if (rows.length > 0) {
|
|
||||||
await this.bookingsRepository.createCargoModifiers(rows);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { BookingPricingService } from './booking-pricing.service';
|
|||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.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 { Booking } from './entities/booking.entity';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ export class BookingTransitionService {
|
|||||||
private readonly bookingsService: BookingsService,
|
private readonly bookingsService: BookingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async submit(bookingId: string): Promise<Booking> {
|
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||||
|
|
||||||
@@ -32,14 +34,119 @@ export class BookingTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
const computed = await this.pricingService.computePriceForBooking(booking);
|
||||||
await this.ruleEngineService.snapshotLiveRates(bookingId);
|
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<SubmitBookingResponseDto> {
|
||||||
|
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, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
priorityScore,
|
priorityScore,
|
||||||
|
totalAmount: computed.totalAmount,
|
||||||
|
pricingBreakdown: {
|
||||||
|
lineItems: computed.lineItems,
|
||||||
|
totalAmount: computed.totalAmount,
|
||||||
|
currency: computed.currency,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
} as never);
|
} 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(
|
async requestChanges(
|
||||||
@@ -279,6 +386,7 @@ export class BookingTransitionService {
|
|||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
|
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||||
'CHANGES_REQUESTED',
|
'CHANGES_REQUESTED',
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
'CONTRACT_READY',
|
'CONTRACT_READY',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { CreateBookingDto } from './dto/create-booking.dto';
|
|||||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||||
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||||
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import {
|
import {
|
||||||
ApproveStepDto,
|
ApproveStepDto,
|
||||||
CancelBookingDto,
|
CancelBookingDto,
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
type AuthUserPayload,
|
type AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from '../../common/resolve-auth-user-id';
|
} from '../../common/resolve-auth-user-id';
|
||||||
|
import { assertFreightPermission } from '../../common/freight-permission.util';
|
||||||
|
|
||||||
@ApiTags('bookings')
|
@ApiTags('bookings')
|
||||||
@Controller('bookings')
|
@Controller('bookings')
|
||||||
@@ -74,10 +76,12 @@ export class BookingsController {
|
|||||||
create(
|
create(
|
||||||
@Body() dto: CreateBookingDto,
|
@Body() dto: CreateBookingDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Request() req: { user?: { id?: string; sub?: string } },
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id ?? req.user?.sub;
|
if (dto.isGovernment) {
|
||||||
return this.bookingsService.create(dto, files ?? [], userId);
|
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||||
|
}
|
||||||
|
return this.bookingsService.create(dto, files ?? [], user?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@@ -165,17 +169,36 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/generate-price')
|
@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 })
|
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.pricingService.generatePrice(id);
|
return this.pricingService.generatePrice(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/submit')
|
@Post(':id/submit')
|
||||||
@ApiOperation({ summary: 'Customer submit booking' })
|
@ApiOperation({
|
||||||
async submit(@Param('id', ParseUUIDPipe) id: string) {
|
summary: 'Customer submit booking',
|
||||||
const booking = await this.transitionService.submit(id);
|
description:
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
'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')
|
@Post(':id/staff/request-changes')
|
||||||
@@ -224,6 +247,20 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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')
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
|
|||||||
@@ -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<Repository<Booking>>;
|
||||||
|
let dataSource: { getRepository: jest.Mock };
|
||||||
|
let bookingsRepository: BookingsRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repository = {
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
} as unknown as jest.Mocked<Repository<Booking>>;
|
||||||
|
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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { SchedulingStatus } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
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 { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.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 { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.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 { Booking } from './entities/booking.entity';
|
||||||
import {
|
import {
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
@@ -20,6 +22,8 @@ import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
|||||||
export interface BookingListFilterOptions {
|
export interface BookingListFilterOptions {
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
|
schedulingStatuses?: string[];
|
||||||
|
assignedToSchedule?: 'true' | 'false';
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
contractType?: string;
|
contractType?: string;
|
||||||
serviceTypeId?: string;
|
serviceTypeId?: string;
|
||||||
@@ -345,6 +349,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async hasPricingArtifacts(bookingId: string): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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. */
|
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||||
async findQueue(options: {
|
async findQueue(options: {
|
||||||
status: string | string[];
|
status: string | string[];
|
||||||
@@ -407,17 +431,37 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
|
|
||||||
this.applyListFilters(qb, options);
|
this.applyListFilters(qb, options);
|
||||||
|
|
||||||
const sortField =
|
if (options.sortBy === 'isGovernment') {
|
||||||
options.sortBy === 'priorityScore'
|
qb.orderBy('booking.isGovernment', 'DESC')
|
||||||
? 'booking.priorityScore'
|
.addOrderBy('booking.priorityScore', 'DESC')
|
||||||
: 'booking.createdAt';
|
.addOrderBy('booking.scheduledDate', 'ASC');
|
||||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
} 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
|
const [items, total] = await qb
|
||||||
.skip((page - 1) * pageSize)
|
.skip((page - 1) * pageSize)
|
||||||
.take(pageSize)
|
.take(pageSize)
|
||||||
.getManyAndCount();
|
.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 };
|
return { items, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +580,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
} else if (options.consolidationPaired === 'false') {
|
} else if (options.consolidationPaired === 'false') {
|
||||||
qb.andWhere('booking.consolidation_partner_id IS NULL');
|
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<Booking>, options: {
|
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
|
||||||
@@ -585,4 +649,99 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
}
|
}
|
||||||
return repo.save(repo.create(data));
|
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<Booking[]> {
|
||||||
|
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<Booking[]> {
|
||||||
|
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<void> {
|
||||||
|
await this.bookingRepo(manager).update(bookingId, fields as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise<void> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { SchedulingStatus } from '@edr/types';
|
||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -64,6 +65,7 @@ export class BookingsService {
|
|||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean;
|
||||||
|
isGovernment?: boolean;
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
containers: CreateBookingContainerDto[];
|
containers: CreateBookingContainerDto[];
|
||||||
@@ -92,6 +94,7 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
isHazardous: dto.isHazardous ?? false,
|
||||||
|
isGovernment: dto.isGovernment ?? false,
|
||||||
allowConsolidation:
|
allowConsolidation:
|
||||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
@@ -178,8 +181,15 @@ export class BookingsService {
|
|||||||
// customerId = customer.id;
|
// customerId = customer.id;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
let companyId = dto.companyId;
|
const isGovernment = dto.isGovernment === true;
|
||||||
if (!companyId) {
|
|
||||||
|
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) {
|
if (!userId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'companyId is required or must be resolvable from auth token',
|
'companyId is required or must be resolvable from auth token',
|
||||||
@@ -209,6 +219,7 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
isHazardous: dto.isHazardous,
|
isHazardous: dto.isHazardous,
|
||||||
|
isGovernment,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
containers,
|
containers,
|
||||||
@@ -220,7 +231,9 @@ export class BookingsService {
|
|||||||
|
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
companyId,
|
companyId: companyId ?? null,
|
||||||
|
isGovernment,
|
||||||
|
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||||
trainId: dto.trainId,
|
trainId: dto.trainId,
|
||||||
contractType: dto.contractType,
|
contractType: dto.contractType,
|
||||||
previousContractId: dto.previousContractId,
|
previousContractId: dto.previousContractId,
|
||||||
@@ -300,12 +313,13 @@ export class BookingsService {
|
|||||||
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
||||||
let containers =
|
let containers =
|
||||||
dto.containers ??
|
dto.containers ??
|
||||||
existing.bookingContainers?.map((bc) => ({
|
(existing.bookingContainers ?? [])
|
||||||
containerTypeId: bc.containerTypeId,
|
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||||
quantity: bc.quantity,
|
.map((bc) => ({
|
||||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
containerTypeId: bc.containerTypeId,
|
||||||
})) ??
|
quantity: bc.quantity,
|
||||||
[];
|
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||||
|
}));
|
||||||
|
|
||||||
let cargoTypeId =
|
let cargoTypeId =
|
||||||
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
|
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
|
||||||
@@ -348,6 +362,15 @@ export class BookingsService {
|
|||||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||||
warnings.push(...ruleResult.warnings);
|
warnings.push(...ruleResult.warnings);
|
||||||
|
|
||||||
|
const pricingFieldsChanged = this.pricingRelevantFieldsChanged(
|
||||||
|
existing,
|
||||||
|
dto,
|
||||||
|
freightType,
|
||||||
|
cargoTypeId,
|
||||||
|
allowConsolidation,
|
||||||
|
containers,
|
||||||
|
);
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {
|
const updates: Record<string, unknown> = {
|
||||||
...dto,
|
...dto,
|
||||||
freightType,
|
freightType,
|
||||||
@@ -375,6 +398,10 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pricingFieldsChanged) {
|
||||||
|
await this.bookingsRepository.invalidatePricingPreview(id);
|
||||||
|
}
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await this.filesService.uploadMany(id, 'bookings', files);
|
await this.filesService.uploadMany(id, 'bookings', files);
|
||||||
}
|
}
|
||||||
@@ -390,6 +417,19 @@ export class BookingsService {
|
|||||||
return { booking, warnings };
|
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. */
|
/** Parse comma-separated or repeated status query values. */
|
||||||
private parseStatusFilter(filter: FilterBookingDto): {
|
private parseStatusFilter(filter: FilterBookingDto): {
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
@@ -420,11 +460,14 @@ export class BookingsService {
|
|||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 20;
|
||||||
const statusFilter = this.parseStatusFilter(filter);
|
const statusFilter = this.parseStatusFilter(filter);
|
||||||
|
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
|
||||||
|
|
||||||
return this.bookingsRepository.findAllPaginated({
|
return this.bookingsRepository.findAllPaginated({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
...statusFilter,
|
...statusFilter,
|
||||||
|
...schedulingStatusFilter,
|
||||||
|
assignedToSchedule: filter.assignedToSchedule,
|
||||||
companyId: filter.companyId,
|
companyId: filter.companyId,
|
||||||
contractType: filter.contractType,
|
contractType: filter.contractType,
|
||||||
serviceTypeId: filter.serviceTypeId,
|
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<Booking> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,11 +76,12 @@ export class ConsolidationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
|
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
|
||||||
const lines =
|
const lines = (booking.bookingContainers ?? [])
|
||||||
booking.bookingContainers?.map((bc) => ({
|
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||||
|
.map((bc) => ({
|
||||||
containerTypeId: bc.containerTypeId,
|
containerTypeId: bc.containerTypeId,
|
||||||
quantity: bc.quantity,
|
quantity: bc.quantity,
|
||||||
})) ?? [];
|
}));
|
||||||
return this.slotsFromContainerLines(lines);
|
return this.slotsFromContainerLines(lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
Min,
|
Min,
|
||||||
|
MinLength,
|
||||||
Validate,
|
Validate,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -66,7 +67,21 @@ export class CreateBookingDto {
|
|||||||
// @IsUUID()
|
// @IsUUID()
|
||||||
// customerId?: string;
|
// 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' })
|
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||||
|
@ValidateIf((o) => o.isGovernment !== true)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|||||||
@@ -84,8 +84,25 @@ export class FilterBookingDto {
|
|||||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
|
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
|
||||||
pageSize?: number;
|
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' })
|
@ApiPropertyOptional({ default: 'createdAt' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsIn(['createdAt', 'priorityScore', 'scheduledDate', 'isGovernment'])
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -15,12 +15,15 @@ export class BookingContainer extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'booking_id' })
|
@JoinColumn({ name: 'booking_id' })
|
||||||
booking?: Booking;
|
booking?: Booking;
|
||||||
|
|
||||||
@Column({ name: 'container_type_id', type: 'uuid' })
|
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||||
containerTypeId!: string;
|
containerTypeId?: string | null;
|
||||||
|
|
||||||
@ManyToOne(() => ContainerType)
|
@ManyToOne(() => ContainerType, { nullable: true })
|
||||||
@JoinColumn({ name: 'container_type_id' })
|
@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' })
|
@Column({ name: 'quantity', type: 'smallint' })
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BaseEntity } from '@edr/api-common';
|
|||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
import { Booking } from './booking.entity';
|
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];
|
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { SchedulingStatus } from '@edr/types';
|
||||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
// import { Customer } from '../../customers/entities/customer.entity';
|
// import { Customer } from '../../customers/entities/customer.entity';
|
||||||
import { Company } from '../../companies/entities/company.entity';
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
@@ -17,6 +18,7 @@ import { BookingReviewNote } from './booking-review-note.entity';
|
|||||||
export const BOOKING_STATUSES = [
|
export const BOOKING_STATUSES = [
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
|
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||||
'CHANGES_REQUESTED',
|
'CHANGES_REQUESTED',
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
'APPROVED_PENDING_SIGNATURE',
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
@@ -53,6 +55,16 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
|||||||
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||||
export type FreightType = (typeof FREIGHT_TYPES)[number];
|
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. */
|
/** Statuses where the customer may edit booking fields. */
|
||||||
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
|
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
@@ -71,16 +83,24 @@ export class Booking extends BaseEntity {
|
|||||||
// @JoinColumn({ name: 'customer_id' })
|
// @JoinColumn({ name: 'customer_id' })
|
||||||
// customer?: Customer;
|
// customer?: Customer;
|
||||||
|
|
||||||
@Column({ name: 'company_id', type: 'uuid' })
|
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||||
companyId!: string;
|
companyId?: string | null;
|
||||||
|
|
||||||
@ManyToOne(() => Company)
|
@ManyToOne(() => Company, { nullable: true })
|
||||||
@JoinColumn({ name: 'company_id' })
|
@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 })
|
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||||
trainId?: string | null;
|
trainId?: string | null;
|
||||||
|
|
||||||
|
/** @deprecated Use train_schedule_bookings for operational scheduling. */
|
||||||
@ManyToOne(() => Train, { nullable: true })
|
@ManyToOne(() => Train, { nullable: true })
|
||||||
@JoinColumn({ name: 'train_id' })
|
@JoinColumn({ name: 'train_id' })
|
||||||
train?: Train | null;
|
train?: Train | null;
|
||||||
@@ -242,6 +262,21 @@ export class Booking extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'consolidation_partner_id' })
|
@JoinColumn({ name: 'consolidation_partner_id' })
|
||||||
consolidationPartner?: Booking | null;
|
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)
|
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||||
bookingContainers?: BookingContainer[];
|
bookingContainers?: BookingContainer[];
|
||||||
|
|
||||||
|
|||||||
@@ -159,9 +159,12 @@ export class CargoesService {
|
|||||||
cargo.status = 'DELIVERED';
|
cargo.status = 'DELIVERED';
|
||||||
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
|
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
|
||||||
|
|
||||||
const remaining = await this.cargoRepo.count({
|
const remaining =
|
||||||
where: { containerId: cargo.containerId, status: 'LOADED' },
|
cargo.containerId != null
|
||||||
});
|
? await this.cargoRepo.count({
|
||||||
|
where: { containerId: cargo.containerId, status: 'LOADED' },
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
if (remaining === 0 && cargo.container) {
|
if (remaining === 0 && cargo.container) {
|
||||||
cargo.container.status = 'AVAILABLE';
|
cargo.container.status = 'AVAILABLE';
|
||||||
await this.containerRepo.save(cargo.container);
|
await this.containerRepo.save(cargo.container);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts
|
// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts
|
||||||
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
|
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Container } from '../../container-management/entities/container.entity';
|
import { Container } from '../../container-management/entities/container.entity';
|
||||||
|
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
|
|
||||||
@Entity({ name: 'cargoes', schema: 'freight' })
|
@Entity({ name: 'cargoes', schema: 'freight' })
|
||||||
export class Cargo extends BaseEntity {
|
export class Cargo extends BaseEntity {
|
||||||
@@ -11,8 +13,8 @@ export class Cargo extends BaseEntity {
|
|||||||
@Column({ name: 'shipment_id', type: 'uuid' })
|
@Column({ name: 'shipment_id', type: 'uuid' })
|
||||||
shipmentId!: string;
|
shipmentId!: string;
|
||||||
|
|
||||||
@Column({ name: 'container_id', type: 'uuid' })
|
@Column({ name: 'container_id', type: 'uuid', nullable: true })
|
||||||
containerId!: string;
|
containerId!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||||
cargoTypeId!: string | null; // optional link to cargo_types table
|
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 })
|
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
|
||||||
unloadedAt!: Date | null;
|
unloadedAt!: Date | null;
|
||||||
|
|
||||||
// Relationship to Container
|
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
|
||||||
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' })
|
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' })
|
@JoinColumn({ name: 'container_id' })
|
||||||
container!: Container;
|
container!: Container | null;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
|
// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
|
||||||
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
|
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
|
||||||
import { BaseEntity } from '@edr/api-common';
|
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 { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
import { Cargo } from '../../cargoes/entities/cargoes.entity';
|
import { Cargo } from '../../cargoes/entities/cargoes.entity';
|
||||||
|
|
||||||
@@ -34,7 +37,27 @@ sealNumber!: string | null;
|
|||||||
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
||||||
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
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' })
|
@ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' })
|
||||||
@JoinColumn({ name: 'wagon_id' })
|
@JoinColumn({ name: 'wagon_id' })
|
||||||
wagon!: Wagon | null;
|
wagon!: Wagon | null;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<OverviewResponseDto> {
|
||||||
|
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<OverviewBookingsTabDto> {
|
||||||
|
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<OverviewBillingTabDto> {
|
||||||
|
return this.overviewService.getBillingTab(query.range ?? '30d');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('operations')
|
||||||
|
@BookingView()
|
||||||
|
@ApiOperation({ summary: 'Operations tab metrics and charts' })
|
||||||
|
@ApiOkResponse({ type: OverviewOperationsTabDto })
|
||||||
|
getOperationsTab(): Promise<OverviewOperationsTabDto> {
|
||||||
|
return this.overviewService.getOperationsTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('customers')
|
||||||
|
@BookingView()
|
||||||
|
@ApiOperation({ summary: 'Customers tab metrics and charts' })
|
||||||
|
@ApiOkResponse({ type: OverviewCustomersTabDto })
|
||||||
|
getCustomersTab(@Query() query: OverviewQueryDto): Promise<OverviewCustomersTabDto> {
|
||||||
|
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<OverviewStaffTabDto> {
|
||||||
|
return this.overviewService.getStaffTab(query.range ?? '30d');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/edr-freight-api/src/modules/overview/overview.module.ts
Normal file
34
apps/edr-freight-api/src/modules/overview/overview.module.ts
Normal file
@@ -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 {}
|
||||||
553
apps/edr-freight-api/src/modules/overview/overview.repository.ts
Normal file
553
apps/edr-freight-api/src/modules/overview/overview.repository.ts
Normal file
@@ -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<Booking>,
|
||||||
|
@InjectRepository(PaymentEntity)
|
||||||
|
private readonly paymentRepository: Repository<PaymentEntity>,
|
||||||
|
@InjectRepository(Customer)
|
||||||
|
private readonly customerRepository: Repository<Customer>,
|
||||||
|
@InjectRepository(Train)
|
||||||
|
private readonly trainRepository: Repository<Train>,
|
||||||
|
@InjectRepository(Wagon)
|
||||||
|
private readonly wagonRepository: Repository<Wagon>,
|
||||||
|
@InjectRepository(Container)
|
||||||
|
private readonly containerRepository: Repository<Container>,
|
||||||
|
@InjectRepository(Cargo)
|
||||||
|
private readonly cargoRepository: Repository<Cargo>,
|
||||||
|
@InjectRepository(Employee)
|
||||||
|
private readonly employeeRepository: Repository<Employee>,
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
||||||
|
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<Record<string, string>>();
|
||||||
|
|
||||||
|
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<Record<string, string>>();
|
||||||
|
|
||||||
|
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<Record<string, string>>();
|
||||||
|
|
||||||
|
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<Record<string, number>> {
|
||||||
|
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<OverviewRecentBookingRow[]> {
|
||||||
|
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<ObjectLiteral>,
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
210
apps/edr-freight-api/src/modules/overview/overview.service.ts
Normal file
210
apps/edr-freight-api/src/modules/overview/overview.service.ts
Normal file
@@ -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<string, number>) {
|
||||||
|
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<OverviewResponseDto> {
|
||||||
|
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<OverviewBookingsTabDto> {
|
||||||
|
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<OverviewBillingTabDto> {
|
||||||
|
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<OverviewOperationsTabDto> {
|
||||||
|
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<OverviewCustomersTabDto> {
|
||||||
|
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<OverviewStaffTabDto> {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { IsString } from "class-validator";
|
|
||||||
|
|
||||||
export class InitiateBookingPayment {
|
|
||||||
@IsString()
|
|
||||||
bookingId!: string;
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn }
|
|||||||
type PaymentType = "booking"
|
type PaymentType = "booking"
|
||||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
|
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
|
||||||
type Currency = "ETB" | "USD"
|
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' })
|
@Entity({ schema: 'freight', name: 'payments' })
|
||||||
export class PaymentEntity extends BaseEntity {
|
export class PaymentEntity extends BaseEntity {
|
||||||
|
|||||||
@@ -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 { PaymentService } from "./payment.service";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
// import { randomUUID } from "crypto";
|
|
||||||
import { Response } from "express"
|
import { Response } from "express"
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Controller("payments")
|
@Controller("payments")
|
||||||
export class PaymentController {
|
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")
|
@Post("/bookings/check-payment/:orderId")
|
||||||
// async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
|
checkPayment(@Param("orderId") orderId: string) {
|
||||||
// const filled = await this.paymentService.genReceiptHtml(orderId);
|
return this.paymentService.checkStatusAndUpdate(orderId)
|
||||||
// return res.send(filled)
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
// @Post("/initiate/booking")
|
@Get("/bookings/telebirr/redirect/:orderId")
|
||||||
// async initiatePayment() {
|
async pay(@Param("orderId") orderId: string, @Res() res: Response) {
|
||||||
|
const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr")
|
||||||
// //Only for testing..
|
if (!payment) {
|
||||||
// const description = "Booking for contact"
|
throw new NotFoundException('payment not found')
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return res.send(`
|
||||||
@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(`
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -62,6 +40,5 @@ export class PaymentController {
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
|
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { PaymentController } from "./payment.controller";
|
import { PaymentController } from "./payment.controller";
|
||||||
@@ -7,10 +6,11 @@ import { ConfigModule } from "@nestjs/config";
|
|||||||
import { PaymentRepository } from "./payment.repository";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
import { WebhookController } from "./webhooks/webhook.controller";
|
import { WebhookController } from "./webhooks/webhook.controller";
|
||||||
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
||||||
|
import { TelebirrProvider } from "@edr/payment-providers";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HttpModule, ConfigModule],
|
imports: [HttpModule, ConfigModule],
|
||||||
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
|
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
|
||||||
controllers: [PaymentController, WebhookController],
|
controllers: [PaymentController, WebhookController],
|
||||||
exports: [PaymentService]
|
exports: [PaymentService]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export class PaymentRepository {
|
|||||||
return qr.manager.save(payment)
|
return qr.manager.save(payment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt" | "reason">): Promise<PaymentEntity> {
|
||||||
|
const payment = this.paymentRepo.create(data)
|
||||||
|
return this.paymentRepo.save(payment)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
|
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
|
||||||
return this.paymentRepo.findOneBy(options);
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,83 +1,68 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { DataSource, QueryRunner } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
import { PaymentEntity } from "./entities/payment.entity";
|
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 { PaymentRepository } from "./payment.repository";
|
||||||
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
|
|
||||||
import * as crypto from "crypto";
|
|
||||||
|
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as Handlebars from "handlebars";
|
import * as Handlebars from "handlebars";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { SchedulingStatus } from "@edr/types";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
|
||||||
type PaymentMethod = PaymentEntity["method"];
|
import {
|
||||||
type CurrencyType = PaymentEntity["currency"];
|
ClientAction,
|
||||||
|
createMerchantOrderId,
|
||||||
|
ProviderPaymentStatus,
|
||||||
|
TelebirrProvider,
|
||||||
|
} from "@edr/payment-providers";
|
||||||
|
import { ProviderInitiationInput } from "@edr/types"
|
||||||
|
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
|
||||||
|
|
||||||
|
const DEFAULT_CURRENCY = "ETB";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentService {
|
export class PaymentService {
|
||||||
private strategies: Map<PaymentMethod, PaymentStrategy>;
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly datasource: DataSource,
|
||||||
|
private readonly paymentRepo: PaymentRepository,
|
||||||
|
private readonly telebirrProvider: TelebirrProvider,
|
||||||
|
) { }
|
||||||
|
|
||||||
constructor(
|
async initBookingTelebirr(
|
||||||
private readonly configService: ConfigService,
|
bookingId: string,
|
||||||
private readonly datasource: DataSource,
|
platform: PaymentPlatformDto,
|
||||||
private readonly paymentRepo: PaymentRepository,
|
): Promise<{ redirectUrl: string }> {
|
||||||
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy,
|
// const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId });
|
||||||
) {
|
// if (!booking) throw new NotFoundException("Booking not found");
|
||||||
this.strategies = new Map([
|
|
||||||
["telebirr", this.telebirrPaymentStategy as PaymentStrategy],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async pay(
|
// const booking = new Booking()
|
||||||
amount: number,
|
// booking.totalAmount = 20
|
||||||
currency: CurrencyType,
|
// booking.id = randomUUID
|
||||||
method: PaymentMethod,
|
const amount = 20
|
||||||
reason: string,
|
const merchantOrderId = createMerchantOrderId();
|
||||||
type: PaymentEntity["type"],
|
const redirectBase = this.configService.get<string>("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL");
|
||||||
cb: (
|
const redirectUrl = `${redirectBase}/${merchantOrderId}`;
|
||||||
qr: QueryRunner,
|
const amountMinor = Math.round(Number(amount) * 100);
|
||||||
) => 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 orderId = `${Date.now()}${crypto.randomBytes(4).toString("hex")}`; //todo: make it dynamic
|
const input: ProviderInitiationInput = {
|
||||||
let redirectUrl: string;
|
merchantOrderId,
|
||||||
switch (type) {
|
orderRef: bookingId,
|
||||||
case "booking":
|
amountMinor,
|
||||||
const url = this.configService.get<string>(
|
currency: DEFAULT_CURRENCY,
|
||||||
"TELEBIRR_SUCCESS_REDIRECT_BASE_URL",
|
platform: platform || "web",
|
||||||
);
|
redirectUrl,
|
||||||
redirectUrl = `${url}/${orderId}`;
|
};
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentResp = await strategy.pay({
|
const result = await this.telebirrProvider.initiate(input);
|
||||||
redirectUrl,
|
|
||||||
amountMinor: amount,
|
|
||||||
currency: currency,
|
|
||||||
merchantOrderId: orderId,
|
|
||||||
platform: payform,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
const queryRunner = this.datasource.createQueryRunner();
|
const queryRunner = this.datasource.createQueryRunner();
|
||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
await queryRunner.startTransaction();
|
await queryRunner.startTransaction();
|
||||||
@@ -168,22 +153,111 @@ export class PaymentService {
|
|||||||
const ordersStatus = bizContent.order_status;
|
const ordersStatus = bizContent.order_status;
|
||||||
if (ordersStatus == "PAY_SUCCESS") {
|
if (ordersStatus == "PAY_SUCCESS") {
|
||||||
await this.datasource.transaction(async (mg) => {
|
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" });
|
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<string, unknown>,
|
||||||
|
expiresAt: result.expiresAt,
|
||||||
|
reason: `Payment for booking`,
|
||||||
|
>>>>>>> eda21e22d872344b74c0c72308f87ce7435b299f
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
status: result.status,
|
redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
|
||||||
};
|
}
|
||||||
} catch {
|
}
|
||||||
// Telebirr API unavailable — fall back to current DB payment status
|
|
||||||
const dbStatus =
|
|
||||||
resp.status === "success"
|
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||||
? "success"
|
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method)
|
||||||
: resp.status === "failed"
|
}
|
||||||
? "failed"
|
|
||||||
: "processing";
|
|
||||||
return { status: dbStatus };
|
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<string, ProviderPaymentStatus> = {
|
||||||
|
"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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
62
apps/edr-freight-api/src/modules/payment/payments.dto.ts
Normal file
62
apps/edr-freight-api/src/modules/payment/payments.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<ProviderInitiationResult>
|
|
||||||
}
|
|
||||||
@@ -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<any> {
|
|
||||||
// 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<boolean>('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<ProviderInitiationResult> {
|
|
||||||
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<ProviderStatus> {
|
|
||||||
const fabricToken = await this.applyFabricToken();
|
|
||||||
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
|
|
||||||
const response = await this.postJson<QueryOrderResponse>(
|
|
||||||
`${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<string, unknown>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown>): 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<string> {
|
|
||||||
console.log(this.baseUrl, "base url")
|
|
||||||
const response = await this.postJson<FabricTokenResponse>(
|
|
||||||
`${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<CreateOrderResponse> {
|
|
||||||
return this.postJson<CreateOrderResponse>(
|
|
||||||
`${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<string, unknown>, this.privateKey);
|
|
||||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
|
|
||||||
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<string, unknown>, this.privateKey);
|
|
||||||
return { ...req, sign, sign_type: 'SHA256WithRSA' };
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildCheckoutUrl(prepayId: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
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<T>(
|
|
||||||
url: string,
|
|
||||||
body: unknown,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
): Promise<T> {
|
|
||||||
const config: AxiosRequestConfig = {
|
|
||||||
headers,
|
|
||||||
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
|
|
||||||
httpsAgent: this.httpsAgent,
|
|
||||||
};
|
|
||||||
const started = Date.now();
|
|
||||||
try {
|
|
||||||
const res = await firstValueFrom(this.http.post<T>(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<string, unknown> {
|
|
||||||
const { sign: _sign, ...rest } = body;
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
|
|
||||||
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
|
|
||||||
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
|
|
||||||
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
|
|
||||||
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
|
|
||||||
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
|
|
||||||
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
|
|
||||||
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
|
|
||||||
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
|
|
||||||
private get publicKey(): string {
|
|
||||||
return this.config.get<string>('telebirr.publicKey') ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderStatus {
|
|
||||||
status: PaymentIntentStatus;
|
|
||||||
providerTxnId?: string;
|
|
||||||
failureCode?: string;
|
|
||||||
failureMessage?: string;
|
|
||||||
rawResponse: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentProvider {
|
|
||||||
readonly method: PaymentMethodType;
|
|
||||||
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
|
|
||||||
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
|
|
||||||
}
|
|
||||||
@@ -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, unknown>): string {
|
|
||||||
const fieldMap: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
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<string, unknown>)) {
|
|
||||||
if (EXCLUDE_FIELDS.has(key)) continue;
|
|
||||||
fieldMap[key] = (biz as Record<string, unknown>)[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(fieldMap)
|
|
||||||
.sort()
|
|
||||||
.map((k) => `${k}=${fieldMap[k]}`)
|
|
||||||
.join('&');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function signRequestObject(
|
|
||||||
requestObject: Record<string, unknown>,
|
|
||||||
privateKey: string,
|
|
||||||
): string {
|
|
||||||
return signString(buildCanonicalString(requestObject), privateKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyRequestObject(
|
|
||||||
requestObject: Record<string, unknown>,
|
|
||||||
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')}`;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,82 +1,53 @@
|
|||||||
import { Injectable, } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import * as crypto from "crypto"
|
|
||||||
import { TelebirrDto } from '../dto/telebirr.dto';
|
import { TelebirrDto } from '../dto/telebirr.dto';
|
||||||
import { PaymentRepository } from '../../payment.repository';
|
import { PaymentRepository } from '../../payment.repository';
|
||||||
import { DataSource } from 'typeorm';
|
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()
|
@Injectable()
|
||||||
export class TelebirrWebhookService {
|
export class TelebirrWebhookService {
|
||||||
// private readonly logger = new Logger(TelebirrWebhookService.name);
|
private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly datasource: DataSource,
|
private readonly datasource: DataSource,
|
||||||
private readonly config: ConfigService,
|
|
||||||
private readonly paymentRepo: PaymentRepository,
|
private readonly paymentRepo: PaymentRepository,
|
||||||
|
private readonly telebirrProvider: TelebirrProvider,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
verifyTelebirrNotification(payload: TelebirrDto) {
|
verifyTelebirrNotification(payload: TelebirrDto) {
|
||||||
// 1. Extract the signature provided by Telebirr
|
return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record<string, unknown>);
|
||||||
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<string>("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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handle(payload: TelebirrDto): Promise<void> {
|
async handle(payload: TelebirrDto): Promise<void> {
|
||||||
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
|
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
|
||||||
if (!payment) {
|
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":
|
const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status);
|
||||||
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
|
|
||||||
switch (payment.type) {
|
switch (mapped) {
|
||||||
case "booking":
|
case ProviderPaymentStatus.SUCCEEDED:
|
||||||
await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", })
|
await this.paymentRepo.update(
|
||||||
// await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
|
{ id: payment.id },
|
||||||
break;
|
{ status: "success", paidAt: new Date() },
|
||||||
|
);
|
||||||
|
if (payment.type === "booking") {
|
||||||
|
await this.datasource.manager.update(
|
||||||
|
Booking,
|
||||||
|
{ id: payment.refId },
|
||||||
|
{ paymentStatus: "PAID" },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "FAILED":
|
case ProviderPaymentStatus.FAILED:
|
||||||
await this.paymentRepo.update({ id: payment.id }, { status: "failed" })
|
await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
|
||||||
break;
|
break;
|
||||||
case "CANCELLED":
|
case ProviderPaymentStatus.PROCESSING:
|
||||||
await this.paymentRepo.update({ id: payment.id }, { status: "canceled" })
|
await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
|
||||||
break;
|
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;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -22,14 +22,12 @@ export class WebhookController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// const verified = this.telebirr.verifyTelebirrNotification(payload)
|
const verified = this.telebirr.verifyTelebirrNotification(payload)
|
||||||
// if (!verified) {
|
if (!verified) {
|
||||||
// throw new Error("not valid")
|
throw new Error("Telebirr webhook signature verification failed")
|
||||||
// }
|
}
|
||||||
// const merchantOrderId = payload.merch_order_id;
|
|
||||||
await this.telebirr.handle(payload);
|
await this.telebirr.handle(payload);
|
||||||
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
this.logger.error(`Telebirr webhook handler threw: ${message}`);
|
this.logger.error(`Telebirr webhook handler threw: ${message}`);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
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 { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||||
import { ApprovalRulesService } from '../services/approval-rules.service';
|
import { ApprovalRulesService } from '../services/approval-rules.service';
|
||||||
|
|
||||||
@@ -35,6 +37,22 @@ export class ApprovalRulesController {
|
|||||||
return this.service.findChain(flag === 'true');
|
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')
|
@Get(':id')
|
||||||
@RuleEngineView('approval-rules')
|
@RuleEngineView('approval-rules')
|
||||||
@ApiOperation({ summary: 'Get an approval rule by ID' })
|
@ApiOperation({ summary: 'Get an approval rule by ID' })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
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 { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||||
import { CargoTypesService } from '../services/cargo-types.service';
|
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')
|
@Get(':id')
|
||||||
@RuleEngineView('cargo-types')
|
@RuleEngineView('cargo-types')
|
||||||
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
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 { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||||
import { ContainerTypesService } from '../services/container-types.service';
|
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')
|
@Get(':id')
|
||||||
@RuleEngineView('container-types')
|
@RuleEngineView('container-types')
|
||||||
@ApiOperation({ summary: 'Get a container type by ID' })
|
@ApiOperation({ summary: 'Get a container type by ID' })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
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 { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||||
import { ServiceTypesService } from '../services/service-types.service';
|
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')
|
@Get(':id')
|
||||||
@RuleEngineView('service-types')
|
@RuleEngineView('service-types')
|
||||||
@ApiOperation({ summary: 'Get a service type by ID' })
|
@ApiOperation({ summary: 'Get a service type by ID' })
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
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 { UpdateYardDto } from '../dto/update-yard.dto';
|
||||||
import { YardsService } from '../services/yards.service';
|
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')
|
@Get(':id')
|
||||||
@RuleEngineView('yards')
|
@RuleEngineView('yards')
|
||||||
@ApiOperation({ summary: 'Get a yard by ID' })
|
@ApiOperation({ summary: 'Get a yard by ID' })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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;
|
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
|
||||||
|
|
||||||
@@ -8,10 +8,16 @@ export class CreateApprovalRuleDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
requiresDirectorApproval!: boolean;
|
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()
|
@IsInt()
|
||||||
@Min(1)
|
@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' })
|
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -32,4 +32,9 @@ export class CreateCargoTypeDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Insert after this record ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
insertAfterId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
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 {
|
export class CreateContainerTypeDto {
|
||||||
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
||||||
@@ -40,4 +40,9 @@ export class CreateContainerTypeDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Insert after this record ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
insertAfterId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 {
|
export class CreateServiceTypeDto {
|
||||||
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
|
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
|
||||||
@@ -48,4 +48,9 @@ export class CreateServiceTypeDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Insert after this record ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
insertAfterId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 {
|
export class CreateYardDto {
|
||||||
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
||||||
@@ -22,4 +22,9 @@ export class CreateYardDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Insert after this record ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
insertAfterId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Ensures government bookings outrank commercial priority (max ~1,500 today). */
|
||||||
|
export const GOVERNMENT_PRIORITY_BONUS = 50_000;
|
||||||
@@ -46,6 +46,7 @@ import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.re
|
|||||||
import { YardsRepository } from './repositories/yards.repository';
|
import { YardsRepository } from './repositories/yards.repository';
|
||||||
|
|
||||||
import { ApprovalRulesService } from './services/approval-rules.service';
|
import { ApprovalRulesService } from './services/approval-rules.service';
|
||||||
|
import { DisplayOrderService } from './services/display-order.service';
|
||||||
import { CargoTypesService } from './services/cargo-types.service';
|
import { CargoTypesService } from './services/cargo-types.service';
|
||||||
import { ContainerTypesService } from './services/container-types.service';
|
import { ContainerTypesService } from './services/container-types.service';
|
||||||
import { PriorityRulesService } from './services/priority-rules.service';
|
import { PriorityRulesService } from './services/priority-rules.service';
|
||||||
@@ -126,6 +127,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
|
DisplayOrderService,
|
||||||
RuleEngineService,
|
RuleEngineService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
SHIPPING_LINES_REPOSITORY,
|
SHIPPING_LINES_REPOSITORY,
|
||||||
} from './interfaces/shipping-lines.repository.interface';
|
} from './interfaces/shipping-lines.repository.interface';
|
||||||
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
|
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
|
||||||
|
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||||
|
|
||||||
export interface BookingContainerEvalInput {
|
export interface BookingContainerEvalInput {
|
||||||
containerTypeId: string;
|
containerTypeId: string;
|
||||||
@@ -54,6 +55,7 @@ export interface BookingEvaluationInput {
|
|||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
|
isGovernment?: boolean;
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
containers: BookingContainerEvalInput[];
|
containers: BookingContainerEvalInput[];
|
||||||
@@ -180,6 +182,10 @@ export class RuleEngineService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.isGovernment) {
|
||||||
|
priorityScore += GOVERNMENT_PRIORITY_BONUS;
|
||||||
|
}
|
||||||
|
|
||||||
let shippingLineMapped = false;
|
let shippingLineMapped = false;
|
||||||
if (input.shippingLineId) {
|
if (input.shippingLineId) {
|
||||||
const line = await this.shippingLinesRepo.findById(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<BookingRateSnapshot[]> {
|
async snapshotRates(
|
||||||
const liveRates = await this.ratesRepo.findLiveRates();
|
bookingId: string,
|
||||||
|
rates: Array<{
|
||||||
|
id: string;
|
||||||
|
rateType: string;
|
||||||
|
rateValue: number;
|
||||||
|
rateUnit: string;
|
||||||
|
currency: string;
|
||||||
|
}>,
|
||||||
|
): Promise<BookingRateSnapshot[]> {
|
||||||
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
|
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const seen = new Set<string>();
|
||||||
const snapshots: BookingRateSnapshot[] = [];
|
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({
|
const snapshot = snapshotRepo.create({
|
||||||
bookingId,
|
bookingId,
|
||||||
rateId: rate.id,
|
rateId: rate.id,
|
||||||
|
|||||||
@@ -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 { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||||
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||||
import {
|
import {
|
||||||
APPROVAL_RULES_REPOSITORY,
|
APPROVAL_RULES_REPOSITORY,
|
||||||
IApprovalRulesRepository,
|
IApprovalRulesRepository,
|
||||||
} from '../interfaces/approval-rules.repository.interface';
|
} from '../interfaces/approval-rules.repository.interface';
|
||||||
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApprovalRulesService {
|
export class ApprovalRulesService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||||
private readonly repository: IApprovalRulesRepository,
|
private readonly repository: IApprovalRulesRepository,
|
||||||
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List approval rules. */
|
/** List approval rules. */
|
||||||
@@ -21,7 +24,7 @@ export class ApprovalRulesService {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 10;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.requiresDirectorApproval !== undefined) {
|
if (filter.requiresDirectorApproval !== undefined) {
|
||||||
where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
||||||
@@ -50,9 +53,20 @@ export class ApprovalRulesService {
|
|||||||
|
|
||||||
/** Create an approval rule step. */
|
/** Create an approval rule step. */
|
||||||
async create(dto: CreateApprovalRuleDto): Promise<ApprovalRule> {
|
async create(dto: CreateApprovalRuleDto): Promise<ApprovalRule> {
|
||||||
|
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({
|
return this.repository.create({
|
||||||
requiresDirectorApproval: dto.requiresDirectorApproval,
|
requiresDirectorApproval: dto.requiresDirectorApproval,
|
||||||
stepOrder: dto.stepOrder,
|
stepOrder,
|
||||||
requiredRole: dto.requiredRole,
|
requiredRole: dto.requiredRole,
|
||||||
actionLabel: dto.actionLabel,
|
actionLabel: dto.actionLabel,
|
||||||
blocksRole: dto.blocksRole,
|
blocksRole: dto.blocksRole,
|
||||||
@@ -72,4 +86,20 @@ export class ApprovalRulesService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorder(dto: ReorderItemsDto): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
const rule = await this.findById(id);
|
||||||
|
await this.displayOrder.moveOne(ApprovalRule, 'stepOrder', id, direction, {
|
||||||
|
requiresDirectorApproval: rule.requiresDirectorApproval,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj
|
|||||||
import { ILike } from 'typeorm';
|
import { ILike } from 'typeorm';
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||||
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||||
import { CargoType } from '../entities/cargo-type.entity';
|
import { CargoType } from '../entities/cargo-type.entity';
|
||||||
import {
|
import {
|
||||||
CARGO_TYPES_REPOSITORY,
|
CARGO_TYPES_REPOSITORY,
|
||||||
ICargoTypesRepository,
|
ICargoTypesRepository,
|
||||||
} from '../interfaces/cargo-types.repository.interface';
|
} from '../interfaces/cargo-types.repository.interface';
|
||||||
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CargoTypesService {
|
export class CargoTypesService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(CARGO_TYPES_REPOSITORY)
|
@Inject(CARGO_TYPES_REPOSITORY)
|
||||||
private readonly repository: ICargoTypesRepository,
|
private readonly repository: ICargoTypesRepository,
|
||||||
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List cargo types with pagination and optional filtering. */
|
/** List cargo types with pagination and optional filtering. */
|
||||||
@@ -28,7 +31,7 @@ export class CargoTypesService {
|
|||||||
sortOrder?: 'ASC' | 'DESC';
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 10;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||||
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
||||||
@@ -66,6 +69,12 @@ export class CargoTypesService {
|
|||||||
const parent = await this.repository.findById(dto.parentGroupId);
|
const parent = await this.repository.findById(dto.parentGroupId);
|
||||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
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({
|
return this.repository.create({
|
||||||
code,
|
code,
|
||||||
cargoTypeName: dto.cargoTypeName,
|
cargoTypeName: dto.cargoTypeName,
|
||||||
@@ -73,7 +82,7 @@ export class CargoTypesService {
|
|||||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
showFreeTextBox: dto.showFreeTextBox ?? false,
|
||||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
displayOrder: dto.displayOrder ?? 1,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,4 +104,13 @@ export class CargoTypesService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorder(dto: ReorderItemsDto): Promise<void> {
|
||||||
|
await this.displayOrder.reorderByIds(CargoType, 'displayOrder', dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.displayOrder.moveOne(CargoType, 'displayOrder', id, direction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||||
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||||
import { ContainerType } from '../entities/container-type.entity';
|
import { ContainerType } from '../entities/container-type.entity';
|
||||||
import {
|
import {
|
||||||
CONTAINER_TYPES_REPOSITORY,
|
CONTAINER_TYPES_REPOSITORY,
|
||||||
IContainerTypesRepository,
|
IContainerTypesRepository,
|
||||||
} from '../interfaces/container-types.repository.interface';
|
} from '../interfaces/container-types.repository.interface';
|
||||||
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContainerTypesService {
|
export class ContainerTypesService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(CONTAINER_TYPES_REPOSITORY)
|
@Inject(CONTAINER_TYPES_REPOSITORY)
|
||||||
private readonly repository: IContainerTypesRepository,
|
private readonly repository: IContainerTypesRepository,
|
||||||
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List container types with pagination. */
|
/** List container types with pagination. */
|
||||||
@@ -22,7 +25,7 @@ export class ContainerTypesService {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 10;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||||
|
|
||||||
@@ -47,6 +50,12 @@ export class ContainerTypesService {
|
|||||||
const code = generateCode(dto.label);
|
const code = generateCode(dto.label);
|
||||||
const existing = await this.repository.findByCode(code);
|
const existing = await this.repository.findByCode(code);
|
||||||
if (existing) throw new ConflictException(`Container type with label "${dto.label}" conflicts with existing code "${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({
|
return this.repository.create({
|
||||||
code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
@@ -55,7 +64,7 @@ export class ContainerTypesService {
|
|||||||
isReefer: dto.isReefer ?? false,
|
isReefer: dto.isReefer ?? false,
|
||||||
isOpenTop: dto.isOpenTop ?? false,
|
isOpenTop: dto.isOpenTop ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
displayOrder: dto.displayOrder ?? 1,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,4 +81,13 @@ export class ContainerTypesService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorder(dto: ReorderItemsDto): Promise<void> {
|
||||||
|
await this.displayOrder.reorderByIds(ContainerType, 'displayOrder', dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.displayOrder.moveOne(ContainerType, 'displayOrder', id, direction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T extends ObjectLiteral>(
|
||||||
|
entity: EntityTarget<T>,
|
||||||
|
field: OrderField,
|
||||||
|
where?: FindOptionsWhere<T>,
|
||||||
|
): Promise<number> {
|
||||||
|
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<T extends ObjectLiteral>(
|
||||||
|
entity: EntityTarget<T>,
|
||||||
|
field: OrderField,
|
||||||
|
options: {
|
||||||
|
explicitOrder?: number;
|
||||||
|
insertAfterId?: string;
|
||||||
|
scopeWhere?: FindOptionsWhere<T>;
|
||||||
|
},
|
||||||
|
): Promise<number> {
|
||||||
|
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<T>,
|
||||||
|
});
|
||||||
|
if (!after) {
|
||||||
|
throw new NotFoundException(`Record ${insertAfterId} not found in scope`);
|
||||||
|
}
|
||||||
|
const afterOrder = Number((after as Record<string, unknown>)[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<T extends ObjectLiteral>(
|
||||||
|
entity: EntityTarget<T>,
|
||||||
|
field: OrderField,
|
||||||
|
ids: string[],
|
||||||
|
scopeWhere?: FindOptionsWhere<T>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<T extends ObjectLiteral>(
|
||||||
|
entity: EntityTarget<T>,
|
||||||
|
field: OrderField,
|
||||||
|
id: string,
|
||||||
|
direction: 'up' | 'down',
|
||||||
|
scopeWhere?: FindOptionsWhere<T>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
const neighbor = items[targetIndex] as Record<string, unknown>;
|
||||||
|
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<T extends ObjectLiteral>(
|
||||||
|
entity: EntityTarget<T>,
|
||||||
|
field: OrderField,
|
||||||
|
fromOrder: number,
|
||||||
|
delta: number,
|
||||||
|
scopeWhere?: FindOptionsWhere<T>,
|
||||||
|
): Promise<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export class RatesService {
|
|||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: 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. */
|
/** Return all currently LIVE rates. */
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj
|
|||||||
import { ILike } from 'typeorm';
|
import { ILike } from 'typeorm';
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||||
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||||
import { ServiceType } from '../entities/service-type.entity';
|
import { ServiceType } from '../entities/service-type.entity';
|
||||||
import {
|
import {
|
||||||
IServiceTypesRepository,
|
IServiceTypesRepository,
|
||||||
SERVICE_TYPES_REPOSITORY,
|
SERVICE_TYPES_REPOSITORY,
|
||||||
} from '../interfaces/service-types.repository.interface';
|
} from '../interfaces/service-types.repository.interface';
|
||||||
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ServiceTypesService {
|
export class ServiceTypesService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SERVICE_TYPES_REPOSITORY)
|
@Inject(SERVICE_TYPES_REPOSITORY)
|
||||||
private readonly repository: IServiceTypesRepository,
|
private readonly repository: IServiceTypesRepository,
|
||||||
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List service types with pagination and optional filtering. */
|
/** List service types with pagination and optional filtering. */
|
||||||
@@ -27,7 +30,7 @@ export class ServiceTypesService {
|
|||||||
sortOrder?: 'ASC' | 'DESC';
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 10;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||||
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
|
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
|
||||||
@@ -59,6 +62,12 @@ export class ServiceTypesService {
|
|||||||
const code = generateCode(dto.serviceName);
|
const code = generateCode(dto.serviceName);
|
||||||
const existing = await this.repository.findByCode(code);
|
const existing = await this.repository.findByCode(code);
|
||||||
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${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({
|
return this.repository.create({
|
||||||
code,
|
code,
|
||||||
serviceName: dto.serviceName,
|
serviceName: dto.serviceName,
|
||||||
@@ -69,7 +78,7 @@ export class ServiceTypesService {
|
|||||||
includesCustoms: dto.includesCustoms ?? false,
|
includesCustoms: dto.includesCustoms ?? false,
|
||||||
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
|
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
displayOrder: dto.displayOrder ?? 1,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +96,13 @@ export class ServiceTypesService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorder(dto: ReorderItemsDto): Promise<void> {
|
||||||
|
await this.displayOrder.reorderByIds(ServiceType, 'displayOrder', dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.displayOrder.moveOne(ServiceType, 'displayOrder', id, direction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||||
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||||
import { Yard } from '../entities/yard.entity';
|
import { Yard } from '../entities/yard.entity';
|
||||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||||
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class YardsService {
|
export class YardsService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(YARDS_REPOSITORY)
|
@Inject(YARDS_REPOSITORY)
|
||||||
private readonly repository: IYardsRepository,
|
private readonly repository: IYardsRepository,
|
||||||
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List yards with pagination. */
|
/** List yards with pagination. */
|
||||||
@@ -20,7 +23,7 @@ export class YardsService {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
}): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 10;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||||
if (filter.country) where.country = filter.country;
|
if (filter.country) where.country = filter.country;
|
||||||
@@ -46,12 +49,18 @@ export class YardsService {
|
|||||||
const code = generateCode(dto.label);
|
const code = generateCode(dto.label);
|
||||||
const existing = await this.repository.findByCode(code);
|
const existing = await this.repository.findByCode(code);
|
||||||
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${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({
|
return this.repository.create({
|
||||||
code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
country: dto.country,
|
country: dto.country,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
displayOrder: dto.displayOrder ?? 1,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,4 +77,13 @@ export class YardsService {
|
|||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.repository.softDelete(id);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorder(dto: ReorderItemsDto): Promise<void> {
|
||||||
|
await this.displayOrder.reorderByIds(Yard, 'displayOrder', dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.displayOrder.moveOne(Yard, 'displayOrder', id, direction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ name: 'displaced_booking_ids', type: 'jsonb', default: '[]' })
|
||||||
|
displacedBookingIds!: string[];
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<SchedulingEvent>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Persist an audit record for a completed reschedule. */
|
||||||
|
async createEvent(data: {
|
||||||
|
trainScheduleId: string;
|
||||||
|
trigger: RescheduleTrigger;
|
||||||
|
actorUserId?: string;
|
||||||
|
reason?: string;
|
||||||
|
planSnapshot: Record<string, unknown>;
|
||||||
|
displacedBookingIds: string[];
|
||||||
|
}): Promise<SchedulingEvent> {
|
||||||
|
return this.repository.save(this.repository.create(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {},
|
||||||
|
) => ({
|
||||||
|
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<string, jest.Mock>;
|
||||||
|
let bookingsRepository: Record<string, jest.Mock>;
|
||||||
|
let trainSchedulingService: Record<string, jest.Mock>;
|
||||||
|
let schedulingRescheduleRepository: Record<string, jest.Mock>;
|
||||||
|
|
||||||
|
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<string, number>();
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ReschedulePlan> {
|
||||||
|
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<string, Booking>();
|
||||||
|
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<string, unknown>,
|
||||||
|
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<boolean> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||||
|
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
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';
|
import { TrainScheduleBooking } from './train-schedule-booking.entity';
|
||||||
|
|
||||||
export const TRAIN_SCHEDULE_STATUSES = [
|
export const TRAIN_SCHEDULE_STATUSES = [
|
||||||
|
<<<<<<< HEAD
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
'READY',
|
'READY',
|
||||||
'PUBLISHED',
|
'PUBLISHED',
|
||||||
@@ -15,6 +17,13 @@ export const TRAIN_SCHEDULE_STATUSES = [
|
|||||||
'ARRIVED',
|
'ARRIVED',
|
||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
|
=======
|
||||||
|
TrainScheduleStatusEnum.Draft,
|
||||||
|
TrainScheduleStatusEnum.Scheduled,
|
||||||
|
TrainScheduleStatusEnum.Dispatched,
|
||||||
|
TrainScheduleStatusEnum.Arrived,
|
||||||
|
TrainScheduleStatusEnum.Cancelled,
|
||||||
|
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number];
|
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' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||||
status!: TrainScheduleStatus;
|
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)
|
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||||
scheduleBookings?: TrainScheduleBooking[];
|
scheduleBookings?: TrainScheduleBooking[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,22 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
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 { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.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' })
|
@Entity({ schema: 'freight', name: 'wagon_booking_allocations' })
|
||||||
@Index(['trainSetWagonId', 'bookingId'])
|
@Index(['trainSetWagonId', 'bookingId'])
|
||||||
@@ -23,4 +37,19 @@ export class WagonBookingAllocation extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
@Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||||
allocatedWeightTons!: number;
|
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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { DeepPartial, EntityManager, In, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
||||||
|
|
||||||
@@ -13,4 +13,38 @@ export class TrainScheduleBookingsRepository extends BaseRepository<TrainSchedul
|
|||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private repo(manager?: EntityManager) {
|
||||||
|
return manager ? manager.getRepository(TrainScheduleBooking) : this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(
|
||||||
|
records: DeepPartial<TrainScheduleBooking>[],
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<TrainScheduleBooking[]> {
|
||||||
|
if (!records.length) return [];
|
||||||
|
const repo = this.repo(manager);
|
||||||
|
return repo.save(repo.create(records));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByScheduleAndBooking(
|
||||||
|
trainScheduleId: string,
|
||||||
|
bookingId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repo(manager).delete({ trainScheduleId, bookingId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsForBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
|
||||||
|
const count = await this.repo(manager).count({ where: { bookingId } });
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
findByBookingIds(bookingIds: string[], manager?: EntityManager): Promise<TrainScheduleBooking[]> {
|
||||||
|
if (!bookingIds.length) return Promise.resolve([]);
|
||||||
|
return this.repo(manager).find({
|
||||||
|
where: { bookingId: In(bookingIds) },
|
||||||
|
select: { id: true, bookingId: true, trainScheduleId: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,38 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
|
|
||||||
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedule } from './entities/train-schedule.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 { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
|
||||||
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
|
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
|
||||||
import { TrainSchedulesRepository } from './train-schedules.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';
|
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
TrainSchedule,
|
||||||
|
TrainScheduleBooking,
|
||||||
|
WagonBookingAllocation,
|
||||||
|
WagonAllocationContainerItem,
|
||||||
|
WagonAllocationBulkLoad,
|
||||||
|
]),
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
TrainSchedulesRepository,
|
TrainSchedulesRepository,
|
||||||
TrainScheduleBookingsRepository,
|
TrainScheduleBookingsRepository,
|
||||||
WagonBookingAllocationsRepository,
|
WagonBookingAllocationsRepository,
|
||||||
|
WagonAllocationContainerItemsRepository,
|
||||||
|
WagonAllocationBulkLoadsRepository,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TrainSchedulesRepository,
|
TrainSchedulesRepository,
|
||||||
TrainScheduleBookingsRepository,
|
TrainScheduleBookingsRepository,
|
||||||
WagonBookingAllocationsRepository,
|
WagonBookingAllocationsRepository,
|
||||||
|
WagonAllocationContainerItemsRepository,
|
||||||
|
WagonAllocationBulkLoadsRepository,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TrainSchedulesModule {}
|
export class TrainSchedulesModule {}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
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()
|
@Injectable()
|
||||||
export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||||
@@ -13,4 +13,48 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
|||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private repo(manager?: EntityManager) {
|
||||||
|
return manager ? manager.getRepository(TrainSchedule) : this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
|
||||||
|
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<TrainSchedule>,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repo(manager).update(id, { status, ...extra } as never);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<WagonAllocationBulkLoad> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(WagonAllocationBulkLoad)
|
||||||
|
repository: Repository<WagonAllocationBulkLoad>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
private repo(manager?: EntityManager) {
|
||||||
|
return manager
|
||||||
|
? manager.getRepository(WagonAllocationBulkLoad)
|
||||||
|
: this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(
|
||||||
|
items: DeepPartial<WagonAllocationBulkLoad>[],
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<WagonAllocationBulkLoad[]> {
|
||||||
|
if (!items.length) return [];
|
||||||
|
const repo = this.repo(manager);
|
||||||
|
return repo.save(repo.create(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise<void> {
|
||||||
|
if (!allocationIds.length) return;
|
||||||
|
await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) });
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user