telebirr out in the payment

This commit is contained in:
Eyosiyas
2026-06-08 11:49:50 +03:00
769 changed files with 87305 additions and 2232 deletions

5
.gitignore vendored
View File

@@ -26,3 +26,8 @@ coverage/
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
# emacs cache files
*~
\#*\#
.\#*

11
.npmrc
View File

@@ -1,11 +0,0 @@
# Increase fetch timeouts for network resilience
fetch-timeout=60000
fetch-retry-mintimeout=20000
fetch-retry-maxtimeout=120000
# GitHub Packages configuration for @tria-plc scope
@tria-plc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGE_TOKEN}
# Default registry for other packages
registry=https://registry.npmjs.org/

1002
ITMLS_DB_Design.md Normal file

File diff suppressed because it is too large Load Diff

258
README.md
View File

@@ -93,10 +93,194 @@ cd edr-platform
```
### 2. Install Dependencies
# EDR Platform
Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library.
---
## Tech Stack
### Backend
| Layer | Tech |
| ---------------- | ----------------------------------------------- |
| Runtime | Node.js ≥ 20 |
| Framework | NestJS 11 (modular architecture) |
| Language | TypeScript 5 (strict mode, project-wide) |
| ORM | TypeORM 0.3 (UUID PKs, soft deletes, `snake_case` columns) |
| Database | PostgreSQL 16 (one DB per domain) |
| Validation | class-validator + class-transformer |
| API docs | Swagger via `@nestjs/swagger` |
| Messaging | `@nestjs/microservices` (inter-service ready) |
| Testing | Jest + Supertest |
### Frontend
| Layer | Tech |
| ---------------- | ----------------------------------------------- |
| Framework | React 18 + Vite 5 |
| Language | TypeScript 5 (strict) |
| Routing | React Router v6 |
| Styling | Tailwind CSS v4 (`@tailwindcss/vite`) + `tailwind-merge` + `class-variance-authority` |
| UI primitives | Radix UI (meta `radix-ui` package, shadcn-style components) |
| Icons | lucide-react |
| State / Data | Zustand (client state) · TanStack Query (server state) |
| HTTP | Axios |
| Auth UI | `@tria-plc/iamui-common` (external IAM) |
### Shared Packages
| Package | Purpose |
| ---------------------- | -------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | NestJS decorators, filters, interceptors, pipes, `BaseEntity`, `BaseRepository` |
| `@edr/ui-common` | Shared React components (`DashboardLayout`, `Sidebar`, `Button`, `Modal`, etc.) and theme tokens |
| `@edr/eslint-config` | Shared ESLint configs (base / nestjs / react) |
| `@edr/tsconfig` | Shared TypeScript configs |
| `@edr/prettier-config` | Shared Prettier configuration |
### Tooling
- **pnpm 9** — workspace package manager (sole supported PM)
- **Turborepo 2** — task orchestrator with caching
- **Husky + lint-staged + commitlint** — pre-commit ESLint/Prettier and conventional-commit enforcement
- **Docker Compose** — local Postgres instances + production stack (see `infrastructure/`)
- **Nginx** — reverse proxy / static asset server in production
### Planned Integrations
- **MinIO** — S3-compatible object storage for documents (object keys already shaped under `edr-freight/{linkedType}/{ref}/{filename}`)
---
## Architecture
### High-level layout
```
┌──────────────────────────────────────────────────────────────────┐
│ EDR Platform (monorepo) │
├──────────────────────┬───────────────────────────────────────────┤
│ Freight domain │ Passenger domain │
│ ┌────────────────┐ │ ┌────────────────┐ │
│ │ freight-portal │ │ │ passenger- │ │
│ │ (React) │ │ │ portal (React)│ │
│ ├────────────────┤ │ ├────────────────┤ │
│ │ freight- │ │ │ passenger- │ │
│ │ backoffice │ │ │ backoffice │ │
│ └───────┬────────┘ │ └───────┬────────┘ │
│ │ │ │ │
│ ▼ │ ▼ │
│ ┌────────────────┐ │ ┌────────────────┐ │
│ │ freight-api │ │ │ passenger-api │ │
│ │ (NestJS) │ │ │ (NestJS) │ │
│ └───────┬────────┘ │ └───────┬────────┘ │
│ │ │ │ │
│ ▼ │ ▼ │
│ ┌────────────────┐ │ ┌────────────────┐ │
│ │ postgres- │ │ │ postgres- │ │
│ │ freight │ │ │ passenger │ │
│ └────────────────┘ │ └────────────────┘ │
└──────────────────────┴───────────────────────────────────────────┘
Shared (workspace) packages
@edr/types · @edr/api-common · @edr/ui-common · @edr/{eslint,tsconfig,prettier}-config
```
### Domain isolation
- **One database per domain.** `postgres-freight` (port 5433, db `edr_freight`) and `postgres-passenger` (port 5434, db `edr_passenger`). No cross-database joins. Cross-domain data flows only through API calls or message queues.
- **Each domain owns its data model.** Freight bookings/consignments/shipments/trains/invoices/documents live only in the freight DB; passenger journeys/tickets live only in the passenger DB.
### NestJS module pattern (per feature)
```
modules/<feature>/
entities/<feature>.entity.ts // extends BaseEntity (UUID, timestamps, soft delete)
dto/<verb>-<feature>.dto.ts // class-validator DTOs
<feature>.module.ts // wires controller + service + repository
<feature>.controller.ts // HTTP layer only — no business logic
<feature>.service.ts // business logic
<feature>.repository.ts // extends BaseRepository<Entity>; services inject this, NEVER `Repository<T>` directly
```
Conventions enforced across the codebase:
- All entities have UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
- All entities inherit `createdAt` / `updatedAt` / `deletedAt` from `BaseEntity` (soft delete).
- DB columns use `snake_case` via `@Column({ name: '...' })`; TS properties stay `camelCase`.
- **No `synchronize: true`** in production — schema changes go through TypeORM migrations.
- ESLint + Prettier run on pre-commit via Husky + lint-staged.
- Conventional-commits enforced via commitlint.
### Frontend application structure
```
apps/edr-freight-web/portal/src/
App.tsx // routes + sidebar definition
main.tsx // React Router + QueryClient providers
components/
Breadcrumbs.tsx // shared local UI
ui/ // shadcn-style primitives (Button, Input, Dialog, Label, Textarea)
pages/
customers/ // CustomersPage + CustomerDetailPage + NewCustomerPage (dialog)
bookings/ // ... + multimodal Transport Legs editor
consignments/
tracking/ // Shipment grid + table view toggle
trains/ // Fleet roster
billing/ // Invoices
documents/ // MinIO-shaped document library
hooks/ // TanStack Query hooks (per feature)
services/ // Axios clients (per feature)
store/ // Zustand stores
lib/ // utilities (`cn` helper, formatters)
```
Each feature folder typically contains: `*Page.tsx` (list), `*DetailPage.tsx`, `New*Page.tsx` (create/edit dialog with `mode: "create" | "edit"`), `Delete*Dialog.tsx`, and a `*.mock.ts` seed file used by the current mock UI.
### Shared layout (`@edr/ui-common`)
`DashboardLayout` provides the sidebar + header shell shared across all freight and passenger web apps:
- **Sidebar** — brand-tinted, icon-led navigation. Main brand color: `#10B981` (`rgb(16, 185, 129)` — emerald-500). Icon containers use the filled style: brand-color background with a white icon.
- **Header** — language picker, notifications, user dropdown (click-driven, click-outside / Escape close); host apps opt into a light/dark theme toggle via `enableThemeToggle` (Tailwind class-based, persisted in `localStorage`).
### Auth integration (planned)
Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamapi-common` package. **Do not** implement login/JWT/password logic in this repo. Use placeholder TODO comments next to controllers and `@CurrentUser` decorators (in `@edr/api-common`) until the integration ships.
---
## Apps & Ports
| App | Package name | Purpose | Port |
| ------------------------------ | --------------------------- | ---------------------------------------- | ---- |
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight | 3001 |
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customers | 5173 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight employees | 5183 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passengers | 3002 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customers | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger employees | 5184 |
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds independent `portal/` and `backoffice/` workspace packages declared in `pnpm-workspace.yaml`.
---
## Getting Started
### Prerequisites
- Node.js ≥ 20
- pnpm 9 (`corepack enable && corepack prepare pnpm@9.12.0 --activate`)
- Docker (for local Postgres)
### Install
```bash
pnpm install
```
<<<<<<< HEAD
### 3. Environment Configuration
```bash
# Copy environment template
@@ -765,3 +949,77 @@ For technical support or questions:
---
**Built with ❤️ for Ethio-Djibouti Railway**
=======
### Start local databases
```bash
docker compose -f infrastructure/docker/docker-compose.db.dev.yml up -d
```
### Run apps
```bash
pnpm dev # every app
pnpm dev:freight # freight API + portal + backoffice
pnpm dev:passenger # passenger API + portal + backoffice
```
### Common scripts
| Script | Description |
| ------------------- | ------------------------------------ |
| `pnpm install` | Install workspace dependencies |
| `pnpm dev` | Run every app in watch mode |
| `pnpm build` | Build every package and app |
| `pnpm test` | Run all tests |
| `pnpm lint` | Lint everything |
| `pnpm type-check` | Type-check every package |
| `pnpm format` | Format with Prettier |
---
## Repository Layout
```
.
├── apps/
│ ├── edr-freight-api/ NestJS — freight backend
│ ├── edr-freight-web/
│ │ ├── portal/ React — freight customer portal
│ │ └── backoffice/ React — freight back-office
│ ├── edr-passenger-api/ NestJS — passenger backend
│ └── edr-passenger-web/
│ ├── portal/ React — passenger customer portal
│ └── backoffice/ React — passenger back-office
├── packages/
│ ├── api-common/ Shared NestJS utilities + BaseEntity/Repository
│ ├── types/ Shared TS types/enums
│ ├── ui-common/ Shared React components + theme
│ └── config/
│ ├── eslint/ @edr/eslint-config
│ ├── tsconfig/ @edr/tsconfig
│ └── prettier/ @edr/prettier-config
├── infrastructure/
│ ├── docker/ docker-compose files (db.dev / dev / prod)
│ └── nginx/ Production nginx config
├── CLAUDE.md Developer guide for AI-assisted work
├── turbo.json Turborepo task pipeline
├── pnpm-workspace.yaml Workspace manifest
└── README.md
```
---
## Standards (recap)
- **TypeScript strict mode** is enabled in every package.
- **pnpm only** never run `npm install` or `yarn`.
- **Conventional commits** enforced via commitlint on every commit.
- **NestJS 4-layer pattern** `module → controller → service → repository`.
- **Repository injection** services inject the custom `*Repository` class, not `Repository<T>`.
- **Controllers are thin** no business logic; delegate to services.
- **Migrations only** never enable TypeORM `synchronize` in production.
- **One DB per domain** no cross-database joins.
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467

0
WagonForm.tsx Normal file
View File

View File

@@ -19,3 +19,31 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET=
JWT_REFRESH_TOKEN_SECRET=
JWT_EXPIRES_IN=3600
# JWT expiry for @tria-plc/api-common token utils (jsonwebtoken timespan format)
JWT_ACCESS_TOKEN_EXPIRES=1h
JWT_REFRESH_TOKEN_EXPIRES=7d
# IAM seed defaults (used by @tria-plc/iamapi-common on first boot)
SUPER_ADMIN_EMAIL=superadmin@tria.com
SUPER_ADMIN_PHONE=
DEFAULT_PASSWORD=password@tria
# Freight org + staff (bookings / rule-engine IAM)
SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true
# MinIO (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379

View File

@@ -3,6 +3,21 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
{
"include": "migrations/**/*",
"outDir": "dist"
},
{
"include": "contracts/templates/**/*",
"watchAssets": true
},
{
"include": "modules/payment/templates/**/*",
"watchAssets": true
}
],
"watchAssets": true
}
}

View File

@@ -13,39 +13,48 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@tria-plc/api-common": "^0.1.0",
"@tria-plc/iamapi-common": "^0.1.0",
"@edr/api-common": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/axios": "^4.0.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/microservices": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"axios": "^1.7.7",
"@tria-plc/api-common": "^1.4.0",
"@tria-plc/iamapi-common": "^0.5.1",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
"handlebars": "^4.7.9",
"minio": "7.1.3",
"pg": "^8.13.0",
"puppeteer": "^24.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
"rxjs": "^7.8.1"
},
"devDependencies": {
"@edr/api-common": "workspace:*",
"@edr/types": "workspace:*",
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/amqplib": "^0.10.8",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
"@types/pg": "^8.6.7",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"supertest": "^7.0.0",
@@ -53,6 +62,7 @@
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typeorm": "^0.3.30",
"typescript": "^5.5.4"
},
"jest": {

206
apps/edr-freight-api/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,206 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@edr/api-common':
specifier: workspace:*
version: link:../../packages/api-common
'@edr/types':
specifier: workspace:*
version: link:../../packages/types
'@nestjs/common':
specifier: ^11.0.0
version: 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types':
specifier: ^2.1.1
version: 2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
rxjs:
specifier: ^7.8.1
version: 7.8.2
devDependencies:
'@edr/eslint-config':
specifier: workspace:*
version: link:../../packages/config/eslint-config
'@edr/tsconfig':
specifier: workspace:*
version: link:../../packages/config/tsconfig
packages:
'@borewit/text-codec@0.2.2':
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@nestjs/common@11.1.24':
resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==}
peerDependencies:
class-transformer: '>=0.4.1'
class-validator: '>=0.13.2'
reflect-metadata: ^0.1.12 || ^0.2.0
rxjs: ^7.1.0
peerDependenciesMeta:
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/mapped-types@2.1.1':
resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
class-transformer: ^0.4.0 || ^0.5.0
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
class-transformer:
optional: true
class-validator:
optional: true
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
file-type@21.3.4:
resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==}
engines: {node: '>=20'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
iterare@1.2.1:
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
engines: {node: '>=6'}
load-esm@1.0.3:
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
engines: {node: '>=13.2.0'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
strtok3@10.3.5:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
token-types@6.1.2:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
uid@2.0.2:
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
engines: {node: '>=8'}
uint8array-extras@1.5.0:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
snapshots:
'@borewit/text-codec@0.2.2': {}
'@lukeed/csprng@1.1.0': {}
'@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
file-type: 21.3.4
iterare: 1.2.1
load-esm: 1.0.3
reflect-metadata: 0.2.2
rxjs: 7.8.2
tslib: 2.8.1
uid: 2.0.2
transitivePeerDependencies:
- supports-color
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
'@tokenizer/token@0.3.0': {}
debug@4.4.3:
dependencies:
ms: 2.1.3
file-type@21.3.4:
dependencies:
'@tokenizer/inflate': 0.4.1
strtok3: 10.3.5
token-types: 6.1.2
uint8array-extras: 1.5.0
transitivePeerDependencies:
- supports-color
ieee754@1.2.1: {}
iterare@1.2.1: {}
load-esm@1.0.3: {}
ms@2.1.3: {}
reflect-metadata@0.2.2: {}
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
strtok3@10.3.5:
dependencies:
'@tokenizer/token': 0.3.0
token-types@6.1.2:
dependencies:
'@borewit/text-codec': 0.2.2
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
tslib@2.8.1: {}
uid@2.0.2:
dependencies:
'@lukeed/csprng': 1.1.0
uint8array-extras@1.5.0: {}

View File

@@ -1,20 +1,54 @@
import { Module } from "@nestjs/common";
import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
import { TrainsModule } from "./modules/trains/trains.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { CustomersModule } from "./modules/customers/customers.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { PaymentsModule } from "./modules/payments/payments.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { OtpModule } from './modules/otp/otp.module';
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
import { FreightAuthModule } from "./modules/auth/freight-auth.module";
import {
EDR_FREIGHT_APPLICATION,
EDR_FREIGHT_PERMISSIONS,
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module';
import { ContainersModule } from './modules/container-management/containers.module';
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
@Module({
imports: [
@@ -22,20 +56,73 @@ import { PaymentsModule } from "./modules/payments/payments.module";
isGlobal: true,
load: [appConfig, databaseConfig, telebirrConfig],
}),
EventEmitterModule.forRoot(),
// EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>("database")!,
dataSourceFactory: async (options) => {
if (!options) {
throw new Error("Missing TypeORM DataSource options");
}
await ensurePostgresSchemas(options as DataSourceOptions);
const dataSource = new DataSource(options as DataSourceOptions);
return dataSource.initialize();
},
}),
SharedAuthModule,
IamModule.forRoot({
applications: [EDR_FREIGHT_APPLICATION],
permissions: EDR_FREIGHT_PERMISSIONS,
}),
BookingsModule,
FilesModule,
ConsignmentsModule,
TrainsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
TrainSchedulingModule,
CustomersModule,
CompaniesModule,
TrackingModule,
BillingModule,
NotificationsModule,
PaymentsModule,
FileUploadSettingsModule,
DropdownSettingsModule,
OtpModule,
RuleEngineModule,
BackofficeModule,
DemoPermissionsModule,
FreightAuthModule,
PaymentModule,
//New Modules
TrainsModule,
WagonsModule,
ContainersModule,
CargoesModule,
RoutesModule,
],
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
})
export class AppModule {}
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
) { }
async onApplicationBootstrap() {
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run();
}
}

View File

@@ -0,0 +1,17 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
export const BookingStaff = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
FreightPermissionGuard(
Array.isArray(permission) ? permission : [permission],
),
),
);
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);

View File

@@ -0,0 +1,38 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Type,
UnauthorizedException,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { hasFreightPermission } from './freight-permission.util';
export function FreightPermissionGuard(
permissions: string[],
): Type<CanActivate> {
@Injectable()
class FreightPermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!permissions?.length) return true;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (permissions.some((p) => hasFreightPermission(user, p))) {
return true;
}
throw new ForbiddenException(
`Missing permission. Required one of: ${permissions.join(', ')}`,
);
}
}
return FreightPermissionsGuard;
}

View File

@@ -0,0 +1,109 @@
import { ForbiddenException } from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
const SUPER_ADMIN_ROLE = 'super_admin';
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
type PermissionLike = { key?: string };
type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?:
| {
position?: { permissions?: PermissionLike[] };
delegatedPositions?: { permissions?: PermissionLike[] }[];
}
| {
positions?: { permissions?: PermissionLike[] }[];
}[]
| null;
};
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
if (!user?.roles?.length) return false;
return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE);
}
export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean {
if (!user?.roles?.length) return false;
return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE);
}
export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean {
return isSuperAdmin(user) || isOrganizationAdmin(user);
}
/** Flat permission keys from JWT / session user (roles + position permissions). */
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
if (!user) return [];
const keys = new Set<string>();
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
const employee = user.employee;
if (!employee) {
return [...keys];
}
if (Array.isArray(employee)) {
for (const emp of employee) {
for (const pos of emp.positions ?? []) {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
}
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
return [...keys];
}
export function hasFreightPermission(
user: MeLikeUser | null | undefined,
permissionKey: string,
): boolean {
if (!user) return false;
if (isSuperAdmin(user)) return true;
return collectPermissionKeys(user).includes(permissionKey);
}
export function assertFreightPermission(
user: TCurrentUser | MeLikeUser | null | undefined,
permissionKey: string,
): void {
if (hasFreightPermission(user, permissionKey)) return;
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
}
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
export function assertCanApproveBookingStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,
): void {
if (isFreightApprovalAdmin(user)) return;
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
if (!perm) {
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
}
assertFreightPermission(user, perm);
}

View File

@@ -0,0 +1,12 @@
import { UnauthorizedException } from '@nestjs/common';
export type AuthUserPayload = { id?: string; sub?: string } | null | undefined;
/** Resolve IAM user id from JWT payload attached by JwtGuard. */
export function resolveAuthUserId(user: AuthUserPayload): string {
const id = user?.id ?? user?.sub;
if (!id) {
throw new UnauthorizedException('Authentication required');
}
return id;
}

View File

@@ -0,0 +1,18 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard';
import {
FREIGHT_PERMS,
type RuleEngineResourceSlug,
} from '../seed/freight-permissions.registry';
export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
);
export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
);

View File

@@ -0,0 +1,15 @@
/**
* Derives a stable, uppercase, underscore-separated code from a human-readable name.
*
* Examples:
* "Hazard Surcharge" → "HAZARD_SURCHARGE"
* "20ft Dry Container" → "20FT_DRY_CONTAINER"
* "Kality Yard (ET)" → "KALITY_YARD_ET"
*/
export function generateCode(name: string): string {
return name
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}

View File

@@ -1,19 +1,128 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { join, dirname } from "path";
import {
DefaultPosition,
DefaultUnit,
EmployeePosition,
Employee,
OrganizationConfiguration,
GlobalOrganizationConfiguration,
OrganizationType,
Organization,
PositionConfiguration,
PositionPermission,
PositionTypeConfiguration,
PositionTypePermission,
PositionType,
Position,
Project,
UnitSetting,
GlobalUnitConfiguration,
Unit,
EmployeeSignature,
EmployeeStamp,
RecordFooter,
RecordHeader,
Seal,
AccountConfiguration,
Application,
DocumentaryRequirement,
Permission,
RolePermission,
Role,
Session,
UserCredential,
UserDocument,
UserRole,
UserVerification,
User,
Notification,
NotificationEvent,
NotificationPlaceholder,
NotificationReceiverField,
NotificationReceiver,
NotificationTemplate,
} from "@tria-plc/iamapi-common";
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
const iamEntities = [
DefaultPosition,
DefaultUnit,
EmployeePosition,
Employee,
OrganizationConfiguration,
GlobalOrganizationConfiguration,
OrganizationSetting,
OrganizationType,
Organization,
PositionConfiguration,
PositionPermission,
PositionTypeConfiguration,
PositionTypePermission,
PositionType,
Position,
Project,
UnitSetting,
GlobalUnitConfiguration,
Unit,
EmployeeSignature,
EmployeeStamp,
RecordFooter,
RecordHeader,
Seal,
AccountConfiguration,
Application,
DocumentaryRequirement,
Permission,
RolePermission,
Role,
Session,
UserCredential,
UserDocument,
UserRole,
UserVerification,
User,
Notification,
NotificationEvent,
NotificationPlaceholder,
NotificationReceiverField,
NotificationReceiver,
NotificationTemplate,
];
const iamMigrationsGlob = join(
dirname(require.resolve("@tria-plc/iamapi-common/package.json")),
"dist/db/migrations/*.js",
);
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
export default registerAs(
"database",
(): TypeOrmModuleOptions => ({
(): TypeOrmModuleOptions => {
return {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5433", 10),
username: process.env.DB_USER ?? "postgres",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight",
entities: [__dirname + "/../**/*.entity.{ts,js}"],
migrations: [__dirname + "/../../migrations/*.{ts,js}"],
// Never enable synchronize in production. Use migrations.
synchronize: process.env.NODE_ENV === "development",
schema: "public",
extra: {
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
},
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true,
migrations: [
// IAM schema + tables must be created before freight migrations
iamMigrationsGlob,
freightMigrationsGlob,
],
migrationsRun: true,
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
logging: process.env.NODE_ENV === "development",
}),
};
},
);

View File

@@ -0,0 +1,50 @@
import { DataSource, DataSourceOptions } from "typeorm";
/** Schemas required before TypeORM migrations and entity access. */
export const APPLICATION_SCHEMAS = [
"public",
"iam",
"freight",
"audit",
] as const;
export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(",");
/**
* TypeORM creates the migrations table before any migration runs. If `public` was
* dropped, current_schema() is null and CREATE TABLE migrations fails.
* IAM/freight migrations assume their schemas already exist.
*/
export async function ensurePostgresSchemas(
options: DataSourceOptions,
): Promise<void> {
const bootstrap = new DataSource({
...options,
entities: [],
migrations: [],
migrationsRun: false,
synchronize: false,
});
await bootstrap.initialize();
for (const schema of APPLICATION_SCHEMAS) {
if (schema === "public") {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS public`);
await bootstrap.query(`GRANT ALL ON SCHEMA public TO public`);
await bootstrap.query(`GRANT CREATE ON SCHEMA public TO public`);
} else {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
await bootstrap.query(`GRANT USAGE ON SCHEMA "${schema}" TO public`);
await bootstrap.query(
`GRANT CREATE ON SCHEMA "${schema}" TO public`,
);
}
}
await bootstrap.query(
`SET search_path TO ${APPLICATION_SEARCH_PATH}`,
);
await bootstrap.destroy();
}

View File

@@ -0,0 +1,224 @@
import type {
Article1Clause,
ContractClausePack,
ContractDirection,
ContractFreight,
ContractServiceScope,
} from './contract-template.types';
const STANDARD_CONTRACT_DOCUMENTS = [
'Amendments (if any)',
'This Contract Agreement',
'Final Minutes of Negotiation (if any)',
];
const PAYMENT_OBLIGATION =
'Pay 100% transportation fees in advance per train set in accordance with Article 5.';
const HAZARDOUS_OBLIGATION =
'Notify EDR 48 hours in advance for hazardous or valuable cargo.';
function clonePack(pack: ContractClausePack): ContractClausePack {
return {
article1: {
objective: pack.article1.objective,
scope: [...pack.article1.scope],
},
clientObligations: [...pack.clientObligations],
providerObligations: [...pack.providerObligations],
contractDocuments: [...pack.contractDocuments],
};
}
function applyForwardingOverlay(
pack: ContractClausePack,
service: ContractServiceScope,
): ContractClausePack {
if (service !== 'FORWARDING') return pack;
const next = clonePack(pack);
next.article1.scope.push(
'First-mile and/or last-mile coordination, documentation, and handover with road or port partners where included in the agreed service scope.',
);
next.providerObligations.push(
'Coordinate first-mile and last-mile logistics with designated partners and keep the Client informed of handover milestones.',
);
return next;
}
function buildImportContainerPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.',
scope: [
'Railway transport service on the agreed import corridor.',
'Cargo handling at Galaan Multipurpose port (GMP) where applicable.',
],
},
clientObligations: [
'Provide shipment instructions to EDR for container movements on the agreed corridor.',
'Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.',
'Submit required documents to Djibouti Nagad station at least 24 hours before loading.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
'Provide safe transportation and deliver within agreed timelines when documents are complete.',
'Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.',
'Maintain cargo liability insurance per wagon.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildImportBulkPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for bulk cargo from SGTD railway freight station at Djibouti to designated Ethiopian rail terminals on the import corridor.',
scope: [
'Railway bulk transport service on the agreed import corridor.',
'Loading and unloading coordination at designated terminals per EDR operational rules.',
],
},
clientObligations: [
'Provide accurate commodity description, weight, and shipment instructions for each train movement.',
'Ensure cargo is prepared and available at origin per the agreed loading window.',
'Submit required customs and operational documents at least 24 hours before loading where applicable.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
'Provide safe bulk transportation and deliver within agreed timelines when documents are complete.',
'Maintain cargo liability insurance per wagon or train consist as applicable.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildExportContainerPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for 40ft and/or 20ft full containers from designated Ethiopian dry ports and terminals to SGTD and related export corridors.',
scope: [
'Railway export transport service on the agreed corridor.',
'Terminal coordination at origin yards for export dispatch where applicable.',
],
},
clientObligations: [
'Provide export shipment instructions and container release details for each movement.',
'Ensure containers are available at origin terminals per EDR operational windows.',
'Submit required export, customs, and operational documents at origin at least 24 hours before loading.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
'Provide safe transportation to SGTD and hand over for export processing when documents are complete.',
'Maintain cargo liability insurance per wagon.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildExportBulkPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for bulk export cargo from designated Ethiopian rail terminals to SGTD and related export corridors.',
scope: [
'Railway bulk export transport on the agreed corridor.',
'Loading coordination at origin terminals per EDR operational rules.',
],
},
clientObligations: [
'Provide accurate commodity description, weight, and export shipment instructions.',
'Ensure bulk cargo is prepared and available at origin per the agreed loading window.',
'Submit required export and customs documents at least 24 hours before loading where applicable.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
'Provide safe bulk transportation to SGTD within agreed timelines when documents are complete.',
'Maintain cargo liability insurance per wagon or train consist as applicable.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildDomesticContainerPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for 40ft and/or 20ft containers between designated Ethiopian rail terminals on the domestic corridor.',
scope: ['Domestic railway container transport between agreed origin and destination yards.'],
},
clientObligations: [
'Provide shipment instructions for each domestic container movement.',
'Ensure containers are available at origin per EDR operational rules.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign voyage per operational schedule and notify train schedule 48 hours in advance where practicable.',
'Provide safe transportation and deliver within agreed timelines when instructions are complete.',
'Maintain cargo liability insurance per wagon.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
function buildDomesticBulkPack(): ContractClausePack {
return {
article1: {
objective:
'To provide railway transportation for bulk cargo between designated Ethiopian rail terminals on the domestic corridor.',
scope: ['Domestic railway bulk transport between agreed origin and destination terminals.'],
},
clientObligations: [
'Provide commodity description, weight, and shipment instructions for each movement.',
'Ensure cargo is prepared at origin per the agreed loading window.',
PAYMENT_OBLIGATION,
HAZARDOUS_OBLIGATION,
],
providerObligations: [
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
'Provide safe bulk transportation within agreed timelines.',
'Maintain cargo liability insurance per wagon or train consist as applicable.',
],
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
};
}
const BASE_PACKS: Record<ContractDirection, Record<ContractFreight, () => ContractClausePack>> = {
IMP: {
CON: buildImportContainerPack,
BULK: buildImportBulkPack,
},
EXP: {
CON: buildExportContainerPack,
BULK: buildExportBulkPack,
},
DOM: {
CON: buildDomesticContainerPack,
BULK: buildDomesticBulkPack,
},
};
export function buildClausePack(
direction: ContractDirection,
freight: ContractFreight,
service: ContractServiceScope,
): ContractClausePack {
const base = BASE_PACKS[direction][freight]();
return applyForwardingOverlay(base, service);
}
export function article1ObjectiveFromClause(article1: Article1Clause): string {
return article1.objective;
}

View File

@@ -0,0 +1,116 @@
import { existsSync } from 'fs';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
const MIN_VALID_PDF_BYTES = 2_000;
const PDF_PRINT_STYLES = `
<style id="contract-pdf-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.cover {
min-height: auto !important;
page-break-after: always;
}
.cover-title {
margin: 24mm 0 20mm !important;
}
}
</style>`;
@Injectable()
export class ContractPdfService {
private readonly logger = new Logger(ContractPdfService.name);
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, {
waitUntil: 'load',
timeout: 60_000,
});
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 400));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
headerTemplate: '<span></span>',
footerTemplate:
'<div style="width:100%;font-size:8px;color:#64748b;text-align:center;font-family:Arial,sans-serif;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(
`Puppeteer produced invalid PDF (${buffer.length} bytes)`,
);
}
this.logger.log(
`Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (err) {
this.logger.error(
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
);
throw new InternalServerErrorException(
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes('contract-pdf-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${PDF_PRINT_STYLES}</head>`);
}
return `${PDF_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((p) => existsSync(p));
}
private isValidPdf(buffer: Buffer): boolean {
return (
buffer.length >= MIN_VALID_PDF_BYTES &&
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
);
}
}

View File

@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { BookingPricingService } from '../modules/bookings/booking-pricing.service';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { PriceLineItemDto } from '../modules/bookings/dto/generate-price-response.dto';
export interface PricingScheduleRow {
label: string;
description: string;
amount: number;
currency: string;
}
export interface PricingSchedule {
lineItems: PricingScheduleRow[];
surcharges: PricingScheduleRow[];
totalAmount: number;
currency: string;
equipmentReturn?: string;
originLabel: string;
destinationLabel: string;
containerLines: Array<{
label: string;
quantity: number;
vgmPerUnitTons: number;
}>;
}
@Injectable()
export class ContractPricingScheduleBuilder {
constructor(private readonly pricingService: BookingPricingService) {}
async build(booking: Booking): Promise<PricingSchedule> {
const { lineItems, totalAmount, currency } =
await this.pricingService.computeContractLineItems(booking);
const isSurcharge = (l: PriceLineItemDto) =>
l.code.includes('SURCHARGE') || l.description.toLowerCase().includes('surcharge');
const baseLines = lineItems.filter((l) => !isSurcharge(l));
const surchargeLines = lineItems.filter(isSurcharge);
return {
lineItems: baseLines.map((l) => ({
label: l.code,
description: l.description,
amount: l.amount,
currency: l.currency,
})),
surcharges: surchargeLines.map((l) => ({
label: l.code,
description: l.description,
amount: l.amount,
currency: l.currency,
})),
totalAmount,
currency,
equipmentReturn: booking.equipmentReturn ?? '—',
originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
destinationLabel:
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
containerLines: (booking.bookingContainers ?? []).map((c) => ({
label:
c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: Number(c.vgmPerUnitTons),
})),
};
}
}

View File

@@ -0,0 +1,82 @@
import { ContractRendererService } from './contract-renderer.service';
import { getTemplateMeta } from './contract-template.registry';
import type { ContractViewModel } from './contract-view-model.builder';
describe('ContractRendererService', () => {
const renderer = new ContractRendererService();
renderer.onModuleInit();
function minimalView(templateKey: string): ContractViewModel {
const template = getTemplateMeta(templateKey);
return {
bookingId: 'test-id',
reference: 'BK-TEST-001',
status: 'CONTRACT_READY',
templateKey,
template,
contractDate: '1 January 2026',
contractYear: 2026,
client: {
companyName: 'Test Co',
companyAddress: 'Addis Ababa',
companyLocation: 'Ethiopia',
phone: '+251900000000',
email: 'test@example.com',
tinNumber: '1234567890',
vatNumber: 'VAT-001',
fanNumber: 'FAN-001',
businessLicense: 'BL-001',
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
address: 'Addis Ababa, Ethiopia',
phone: '+251 11 872 0000',
email: 'info@edr.gov.et',
tinNumber: '—',
},
schedule: {
originLabel: 'SGTD',
destinationLabel: 'Modjo',
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: 'Rail transport',
scheduledDate: '1 January 2026',
contractType: 'NEW',
cargoDescription: 'Container cargo',
totalWeightVgm: '24 tons',
equipmentReturn: 'RETURN',
hazardousLabel: 'No',
firstMilePickupAddress: '—',
lastMileDeliveryAddress: '—',
},
pricing: {
lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],
surcharges: [],
totalAmount: 1000,
currency: 'ETB',
originLabel: 'SGTD',
destinationLabel: 'Modjo',
containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }],
},
signatures: [],
canSignCustomer: true,
canSignStaff: false,
hasContractDocument: false,
hasCustomerSignature: false,
hasStaffSignature: false,
};
}
it('renders import flagship with Nagad and Article 5', () => {
const html = renderer.render(minimalView('IMP_CON_ETB_TRANSPORT_ONLY'));
expect(html).toContain('Djibouti Nagad');
expect(html).toContain('Article 5: Contract Price');
expect(html).toContain('Article 2: Obligations of the Client');
});
it('renders export variant without import empty-return clause', () => {
const html = renderer.render(minimalView('EXP_CON_USD_TRANSPORT_ONLY'));
expect(html).toContain('export corridors');
expect(html).not.toContain('Return empty containers from Dire Dawa');
});
});

View File

@@ -0,0 +1,51 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import Handlebars from 'handlebars';
import { ContractViewModel } from './contract-view-model.builder';
@Injectable()
export class ContractRendererService implements OnModuleInit {
private readonly templatesDir = path.join(__dirname, 'templates');
private readonly compiled = new Map<string, Handlebars.TemplateDelegate>();
onModuleInit(): void {
Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
const partialsDir = path.join(this.templatesDir, '_partials');
if (fs.existsSync(partialsDir)) {
for (const file of fs.readdirSync(partialsDir)) {
if (!file.endsWith('.hbs')) continue;
const name = file.replace(/\.hbs$/, '');
const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8');
Handlebars.registerPartial(name, content);
}
}
}
render(view: ContractViewModel): string {
const fileName =
view.template.templateFile ?? 'generic.hbs';
const template = this.getCompiled(fileName);
return template({
...view,
paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD',
});
}
private getCompiled(fileName: string): Handlebars.TemplateDelegate {
const cached = this.compiled.get(fileName);
if (cached) return cached;
const filePath = path.join(this.templatesDir, fileName);
const fallbackPath = path.join(this.templatesDir, 'generic.hbs');
const source = fs.existsSync(filePath)
? fs.readFileSync(filePath, 'utf-8')
: fs.readFileSync(fallbackPath, 'utf-8');
const compiled = Handlebars.compile(source);
this.compiled.set(fileName, compiled);
return compiled;
}
}

View File

@@ -0,0 +1,68 @@
import {
CONTRACT_TEMPLATE_KEYS,
CONTRACT_TEMPLATE_REGISTRY,
getTemplateMeta,
isValidTemplateKey,
} from './contract-template.registry';
describe('ContractTemplateRegistry', () => {
it('defines exactly 24 template keys', () => {
expect(CONTRACT_TEMPLATE_KEYS).toHaveLength(24);
expect(Object.keys(CONTRACT_TEMPLATE_REGISTRY)).toHaveLength(24);
});
it('keys match the direction_freight_currency_service pattern', () => {
for (const key of CONTRACT_TEMPLATE_KEYS) {
expect(isValidTemplateKey(key)).toBe(true);
}
});
it('each meta has non-empty obligations and article1 scope', () => {
for (const key of CONTRACT_TEMPLATE_KEYS) {
const meta = CONTRACT_TEMPLATE_REGISTRY[key]!;
expect(meta.clientObligations.length).toBeGreaterThan(0);
expect(meta.providerObligations.length).toBeGreaterThan(0);
expect(meta.article1.scope.length).toBeGreaterThan(0);
expect(meta.article1.objective.length).toBeGreaterThan(0);
expect(meta.contractDocuments.length).toBeGreaterThan(0);
}
});
it('IMP_CON_ETB_TRANSPORT_ONLY retains import container flagship clauses', () => {
const meta = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
expect(meta.direction).toBe('IMP');
expect(meta.freight).toBe('CON');
expect(meta.article1.objective).toContain('SGTD');
expect(meta.article1.objective).toContain('empty container return');
const clientText = meta.clientObligations.join(' ');
expect(clientText).toContain('Djibouti Nagad');
const providerText = meta.providerObligations.join(' ');
expect(providerText).toContain('seven (7) calendar days');
});
it('EXP_CON_USD_TRANSPORT_ONLY uses export-oriented article1', () => {
const meta = getTemplateMeta('EXP_CON_USD_TRANSPORT_ONLY');
expect(meta.direction).toBe('EXP');
expect(meta.article1.objective).toContain('SGTD');
expect(meta.providerObligations.join(' ')).not.toContain(
'Return empty containers from Dire Dawa',
);
});
it('FORWARDING adds scope and provider obligations', () => {
const transport = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
const forwarding = getTemplateMeta('IMP_CON_ETB_FORWARDING');
expect(forwarding.article1.scope.length).toBeGreaterThan(
transport.article1.scope.length,
);
expect(forwarding.providerObligations.length).toBeGreaterThan(
transport.providerObligations.length,
);
});
it('getTemplateMeta fallback includes clause arrays for unknown keys', () => {
const meta = getTemplateMeta('UNKNOWN_KEY');
expect(meta.clientObligations.length).toBeGreaterThan(0);
expect(meta.article1.scope.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,119 @@
import {
article1ObjectiveFromClause,
buildClausePack,
} from './contract-clause-packs';
import type {
ContractDirection,
ContractFreight,
ContractServiceScope,
ContractTemplateMeta,
} from './contract-template.types';
export type { ContractTemplateMeta } from './contract-template.types';
const DIRECTION_LABELS: Record<ContractDirection, string> = {
IMP: 'Import',
EXP: 'Export',
DOM: 'Domestic',
};
const FREIGHT_LABELS: Record<ContractFreight, string> = {
CON: 'Container',
BULK: 'Bulk',
};
const DIRECTIONS: ContractDirection[] = ['IMP', 'EXP', 'DOM'];
const FREIGHTS: ContractFreight[] = ['CON', 'BULK'];
const CURRENCIES = ['ETB', 'USD'] as const;
const SERVICES: ContractServiceScope[] = ['TRANSPORT_ONLY', 'FORWARDING'];
const KEY_PATTERN =
/^(IMP|EXP|DOM)_(CON|BULK)_(ETB|USD)_(TRANSPORT_ONLY|FORWARDING)$/;
function buildMeta(
dir: ContractDirection,
freight: ContractFreight,
currency: string,
service: ContractServiceScope,
): ContractTemplateMeta {
const key = `${dir}_${freight}_${currency}_${service}`;
const dirLabel = DIRECTION_LABELS[dir];
const freightLabel = FREIGHT_LABELS[freight];
const serviceLabel =
service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only';
const corridor =
dir === 'IMP'
? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable'
: dir === 'EXP'
? 'from Ethiopian dry ports to SGTD and related export corridors'
: 'between designated Ethiopian rail terminals';
const clauses = buildClausePack(dir, freight, service);
return {
key,
direction: dir,
freight,
currency,
serviceScope: service,
title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
directionLabel: dirLabel,
freightLabel,
whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis AbabaDjibouti Railway line. The Service Provider has agreed to provide services per this contract.`,
article1Objective: article1ObjectiveFromClause(clauses.article1),
article1: clauses.article1,
clientObligations: clauses.clientObligations,
providerObligations: clauses.providerObligations,
contractDocuments: clauses.contractDocuments,
};
}
/** Full template matrix (24 keys). */
export const CONTRACT_TEMPLATE_REGISTRY: Record<string, ContractTemplateMeta> =
{};
for (const dir of DIRECTIONS) {
for (const freight of FREIGHTS) {
for (const currency of CURRENCIES) {
for (const service of SERVICES) {
const meta = buildMeta(dir, freight, currency, service);
CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
}
}
}
}
export const CONTRACT_TEMPLATE_KEYS = Object.keys(CONTRACT_TEMPLATE_REGISTRY);
export function listTemplateKeys(): string[] {
return CONTRACT_TEMPLATE_KEYS;
}
export function getTemplateMeta(key: string): ContractTemplateMeta {
const found = CONTRACT_TEMPLATE_REGISTRY[key];
if (found) return found;
const fallbackClauses = buildClausePack('IMP', 'CON', 'TRANSPORT_ONLY');
return {
key,
direction: 'IMP',
freight: 'CON',
currency: 'USD',
serviceScope: 'TRANSPORT_ONLY',
title: 'Freight Contract Agreement',
directionLabel: 'Freight',
freightLabel: 'Cargo',
whereas:
'The parties agree to railway freight services as described in the schedule below.',
article1Objective: article1ObjectiveFromClause(fallbackClauses.article1),
article1: fallbackClauses.article1,
clientObligations: fallbackClauses.clientObligations,
providerObligations: fallbackClauses.providerObligations,
contractDocuments: fallbackClauses.contractDocuments,
};
}
export function isValidTemplateKey(key: string): boolean {
return KEY_PATTERN.test(key);
}

View File

@@ -0,0 +1,65 @@
import { ContractTemplateResolver } from './contract-template.resolver';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
describe('ContractTemplateResolver', () => {
const resolver = new ContractTemplateResolver();
function booking(partial: Partial<Booking>): Booking {
return partial as Booking;
}
it('resolves import container ETB transport-only', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
paymentCurrency: 'ETB',
serviceType: { code: 'RAIL_ONLY', includesFirstMile: false, includesLastMile: false } as ServiceType,
}),
);
expect(key).toBe('IMP_CON_ETB_TRANSPORT_ONLY');
});
it('resolves export bulk USD forwarding', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'EXPORT',
freightType: 'BULK',
paymentCurrency: 'USD',
serviceType: {
code: 'RAIL_FORWARDING',
includesFirstMile: true,
includesLastMile: false,
} as ServiceType,
}),
);
expect(key).toBe('EXP_BULK_USD_FORWARDING');
});
it('maps BREAK_BULK cargo to BULK freight', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
paymentCurrency: 'ETB',
cargoType: { code: 'BREAK_BULK_GENERAL' } as CargoType,
serviceType: undefined,
}),
);
expect(key).toBe('IMP_BULK_ETB_TRANSPORT_ONLY');
});
it('resolves domestic container', () => {
const key = resolver.resolve(
booking({
tradeDirection: 'DOMESTIC',
freightType: 'CONTAINER',
paymentCurrency: 'USD',
serviceType: undefined,
}),
);
expect(key).toBe('DOM_CON_USD_TRANSPORT_ONLY');
});
});

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
@Injectable()
export class ContractTemplateResolver {
resolve(booking: Booking): string {
const dir =
booking.tradeDirection === 'IMPORT'
? 'IMP'
: booking.tradeDirection === 'EXPORT'
? 'EXP'
: 'DOM';
let freight = booking.freightType === 'BULK' ? 'BULK' : 'CON';
const cargoCode = (booking.cargoType as CargoType | undefined)?.code ?? '';
if (cargoCode.startsWith('BREAK_BULK')) {
freight = 'BULK';
}
const currency = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
const service = this.resolveServiceScope(booking.serviceType);
return `${dir}_${freight}_${currency}_${service}`;
}
private resolveServiceScope(
serviceType?: ServiceType | null,
): 'TRANSPORT_ONLY' | 'FORWARDING' {
if (!serviceType) return 'TRANSPORT_ONLY';
const code = (serviceType.code ?? '').toUpperCase();
if (
serviceType.includesFirstMile ||
serviceType.includesLastMile ||
code.includes('FORWARD')
) {
return 'FORWARDING';
}
return 'TRANSPORT_ONLY';
}
}

View File

@@ -0,0 +1,35 @@
export type ContractDirection = 'IMP' | 'EXP' | 'DOM';
export type ContractFreight = 'CON' | 'BULK';
export type ContractServiceScope = 'TRANSPORT_ONLY' | 'FORWARDING';
export interface Article1Clause {
objective: string;
scope: string[];
}
export interface ContractClausePack {
article1: Article1Clause;
clientObligations: string[];
providerObligations: string[];
contractDocuments: string[];
}
export interface ContractTemplateMeta {
key: string;
direction: ContractDirection;
freight: ContractFreight;
currency: string;
serviceScope: ContractServiceScope;
title: string;
directionLabel: string;
freightLabel: string;
whereas: string;
/** Summary line for APIs; mirrors article1.objective */
article1Objective: string;
article1: Article1Clause;
clientObligations: string[];
providerObligations: string[];
contractDocuments: string[];
/** Optional dedicated .hbs file; otherwise uses generic.hbs */
templateFile?: string;
}

View File

@@ -0,0 +1,207 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BookingsRepository } from '../modules/bookings/bookings.repository';
import { Booking } from '../modules/bookings/entities/booking.entity';
import {
BookingContractSignature,
ContractSignerRole,
} from '../modules/bookings/entities/booking-contract-signature.entity';
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
import { ContractTemplateResolver } from './contract-template.resolver';
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
export interface ContractSignatureView {
role: ContractSignerRole;
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
}
export interface ContractViewModel {
bookingId: string;
reference: string;
status: string;
templateKey: string;
template: ContractTemplateMeta;
contractDate: string;
contractYear: number;
client: {
companyName: string;
companyAddress: string;
companyLocation: string;
phone: string;
email: string;
tinNumber: string;
vatNumber: string;
fanNumber: string;
businessLicense: string;
};
provider: {
name: string;
address: string;
phone: string;
email: string;
tinNumber: string;
};
schedule: {
originLabel: string;
destinationLabel: string;
tradeDirection: string;
freightType: string;
serviceType: string;
scheduledDate: string;
contractType: string;
cargoDescription: string;
totalWeightVgm: string;
equipmentReturn: string;
hazardousLabel: string;
firstMilePickupAddress: string;
lastMileDeliveryAddress: string;
};
pricing: PricingSchedule;
signatures: ContractSignatureView[];
canSignCustomer: boolean;
canSignStaff: boolean;
hasContractDocument: boolean;
hasCustomerSignature: boolean;
hasStaffSignature: boolean;
}
@Injectable()
export class ContractViewModelBuilder {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly templateResolver: ContractTemplateResolver,
private readonly pricingBuilder: ContractPricingScheduleBuilder,
) {}
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
const templateKey =
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
const template = getTemplateMeta(templateKey);
const pricing = await this.pricingBuilder.build(booking);
const signatures = await this.loadSignatures(bookingId);
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
const hasContractFile = Boolean(
booking.files?.some((f) => f.code === 'contract'),
);
const view: ContractViewModel = {
bookingId: booking.id,
reference: booking.reference,
status: booking.status,
templateKey,
template,
contractDate: new Date().toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
}),
contractYear: new Date().getFullYear(),
client: {
companyName: booking.company?.name ?? 'Client',
companyAddress: this.valueOrDash(booking.company?.address),
companyLocation: this.valueOrDash(booking.company?.country),
phone: this.valueOrDash(booking.company?.phone),
email: this.valueOrDash(booking.company?.email),
tinNumber: this.valueOrDash(booking.company?.tin),
vatNumber: this.valueOrDash(booking.company?.vatNumber),
fanNumber: this.valueOrDash(booking.company?.fanNumber),
businessLicense: this.valueOrDash(booking.company?.businessLicense),
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
address: 'Addis Ababa, Ethiopia',
phone: '+251 11 872 0000',
email: 'info@edr.gov.et',
tinNumber: '—',
},
schedule: this.buildSchedule(booking),
pricing,
signatures,
canSignCustomer:
booking.status === 'CONTRACT_READY' && !hasCustomer,
canSignStaff:
booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,
};
return { booking, view };
}
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
return rows.map((s) => this.toSignatureView(s));
}
toSignatureView(row: BookingContractSignature): ContractSignatureView {
return {
role: row.signerRole,
signerDisplayName: row.signerDisplayName,
signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null,
};
}
private buildSchedule(booking: Booking): ContractViewModel['schedule'] {
const cargoName =
booking.freightType === 'BULK'
? booking.cargoFreeText ||
booking.cargoType?.cargoTypeName ||
'Bulk commodity'
: booking.cargoType?.cargoTypeName || 'Container cargo';
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
return {
originLabel: this.yardLabel(booking.originYard),
destinationLabel: this.yardLabel(booking.destinationYard),
tradeDirection: this.valueOrDash(booking.tradeDirection),
freightType: this.valueOrDash(booking.freightType),
serviceType: this.valueOrDash(
booking.serviceType?.serviceName ?? booking.serviceType?.code,
),
scheduledDate: this.formatDate(booking.scheduledDate),
contractType: this.valueOrDash(booking.contractType),
cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm:
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
hazardousLabel: booking.isHazardous ? 'Yes' : 'No',
firstMilePickupAddress: this.valueOrDash(
booking.firstMilePickupAddress,
),
lastMileDeliveryAddress: this.valueOrDash(
booking.lastMileDeliveryAddress,
),
};
}
private yardLabel(yard?: { label?: string; code?: string } | null): string {
return this.valueOrDash(yard?.label ?? yard?.code);
}
private formatDate(value?: Date | string | null): string {
if (!value) return '—';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
private valueOrDash(value?: string | number | null): string {
if (value === undefined || value === null || value === '') return '—';
return String(value);
}
}

View File

@@ -0,0 +1,15 @@
<div class="article">
<h2>Article 1: Objective and Scope of Services</h2>
<p>
<strong>1.1 Objective.</strong>
{{template.article1.objective}}
</p>
{{#if template.article1.scope.length}}
<p><strong>1.2 Scope of Services.</strong></p>
<ol>
{{#each template.article1.scope}}
<li>{{this}}</li>
{{/each}}
</ol>
{{/if}}
</div>

View File

@@ -0,0 +1,66 @@
<h2>Article 5: Contract Price and Terms of Payment</h2>
<div class="article">
<h3>Contract Price</h3>
<p>
The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate
schedule, and any approved operational surcharges.
</p>
<table class="details-table">
<tbody>
<tr>
<th>Corridor</th>
<td>{{pricing.originLabel}}{{pricing.destinationLabel}}</td>
<th>Currency</th>
<td>{{pricing.currency}}</td>
</tr>
<tr>
<th>Payment currency</th>
<td>{{paymentArticle}}</td>
<th>Equipment return</th>
<td>{{pricing.equipmentReturn}}</td>
</tr>
</tbody>
</table>
{{#if pricing.equipmentReturn}}
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
{{/if}}
<h3>Charges</h3>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
{{#each pricing.lineItems}}
<tr>
<td>{{label}}</td>
<td>{{description}}</td>
<td>{{currency}} {{amount}}</td>
</tr>
{{/each}}
{{#if pricing.surcharges.length}}
<tr>
<th colspan="3">Surcharges and Adjustments</th>
</tr>
{{#each pricing.surcharges}}
<tr>
<td>{{label}}</td>
<td>{{description}}</td>
<td>{{currency}} {{amount}}</td>
</tr>
{{/each}}
{{/if}}
<tr class="total-row">
<td colspan="2"><strong>Total contract value</strong></td>
<td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td>
</tr>
</tbody>
</table>
<h3>Terms of payment</h3>
<p>
Unless otherwise agreed in writing, the Client shall settle the contract value in
<strong>{{paymentArticle}}</strong> before the service is performed and in accordance with EDR payment
instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the
responsibility of the Client where applicable.
</p>
</div>

View File

@@ -0,0 +1,19 @@
<div class="article">
<h2>Article 2: Obligations of the Client</h2>
<p>The Client shall perform the following obligations in good faith and within the operational timelines communicated by EDR:</p>
<ol>
{{#each template.clientObligations}}
<li>{{this}}</li>
{{/each}}
</ol>
</div>
<div class="article">
<h2>Article 3: Obligations of the Service Provider</h2>
<p>EDR shall provide the agreed railway freight services in accordance with this Agreement and applicable operational rules:</p>
<ol>
{{#each template.providerObligations}}
<li>{{this}}</li>
{{/each}}
</ol>
</div>

View File

@@ -0,0 +1,9 @@
<div class="article">
<h2>Article 6: Contract Documents</h2>
<p>The following documents form part of this Agreement and shall be read together with the signed contract:</p>
<ol>
{{#each template.contractDocuments}}
<li>{{this}}</li>
{{/each}}
</ol>
</div>

View File

@@ -0,0 +1,65 @@
<section class="page-section">
<h2>Booking Schedule and Commercial Summary</h2>
<table class="details-table">
<tbody>
<tr>
<th>Route</th>
<td>{{schedule.originLabel}}{{schedule.destinationLabel}}</td>
<th>Trade direction</th>
<td>{{schedule.tradeDirection}}</td>
</tr>
<tr>
<th>Freight type</th>
<td>{{schedule.freightType}}</td>
<th>Service type</th>
<td>{{schedule.serviceType}}</td>
</tr>
<tr>
<th>Scheduled date</th>
<td>{{schedule.scheduledDate}}</td>
<th>Contract type</th>
<td>{{schedule.contractType}}</td>
</tr>
<tr>
<th>Cargo</th>
<td>{{schedule.cargoDescription}}</td>
<th>Total VGM</th>
<td>{{schedule.totalWeightVgm}}</td>
</tr>
<tr>
<th>Equipment return</th>
<td>{{schedule.equipmentReturn}}</td>
<th>Hazardous cargo</th>
<td>{{schedule.hazardousLabel}}</td>
</tr>
<tr>
<th>First mile</th>
<td>{{schedule.firstMilePickupAddress}}</td>
<th>Last mile</th>
<td>{{schedule.lastMileDeliveryAddress}}</td>
</tr>
</tbody>
</table>
{{#if pricing.containerLines.length}}
<h3>Container Details</h3>
<table class="schedule">
<thead>
<tr>
<th>Container type</th>
<th>Quantity</th>
<th>VGM / unit (tons)</th>
</tr>
</thead>
<tbody>
{{#each pricing.containerLines}}
<tr>
<td>{{label}}</td>
<td>{{quantity}}</td>
<td>{{vgmPerUnitTons}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
</section>

View File

@@ -0,0 +1,12 @@
<div class="article">
<h2>Article 4: Force Majeure</h2>
<p>
Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control,
including natural disaster, war, civil unrest, government restriction, railway interruption, port closure,
or other force majeure events interpreted under the Ethiopian Civil Code.
</p>
<p>
The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the
effect of the force majeure event on the performance of this Agreement.
</p>
</div>

View File

@@ -0,0 +1,44 @@
<div class="signatures">
<div class="sig-block">
<p class="sig-title">For the Service Provider</p>
<p><strong>{{provider.name}}</strong></p>
{{#if hasStaffSignature}}
{{#each signatures}}
{{#if (eq role "STAFF")}}
<div class="sig-image-box">
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
</div>
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
<p class="sig-meta"><strong>Role:</strong> Authorized EDR representative</p>
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
{{/if}}
{{/each}}
{{else}}
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
<p class="sig-line"><strong>Name:</strong> Authorized representative</p>
<p class="sig-meta"><strong>Role:</strong> EDR representative</p>
<p class="sig-meta"><strong>Date:</strong></p>
{{/if}}
</div>
<div class="sig-block">
<p class="sig-title">For the Client</p>
<p><strong>{{client.companyName}}</strong></p>
{{#if hasCustomerSignature}}
{{#each signatures}}
{{#if (eq role "CUSTOMER")}}
<div class="sig-image-box">
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
</div>
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
{{/if}}
{{/each}}
{{else}}
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
<p class="sig-line"><strong>Name:</strong> Client representative</p>
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
<p class="sig-meta"><strong>Date:</strong></p>
{{/if}}
</div>
</div>

View File

@@ -0,0 +1,258 @@
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 18mm 14mm; }
body {
margin: 0;
background: #f5f7fb;
color: #111827;
font-family: "Times New Roman", Times, serif;
font-size: 10.5pt;
line-height: 1.48;
}
.contract {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
background: #fff;
padding: 18mm 15mm;
}
h1, h2, h3, p { margin-top: 0; }
h1 {
color: #0f2742;
font-size: 18pt;
line-height: 1.25;
margin-bottom: 10px;
text-align: center;
text-transform: uppercase;
}
h2 {
border-bottom: 1.5px solid #1e3a5f;
color: #1e3a5f;
font-size: 12pt;
letter-spacing: 0.03em;
margin: 18px 0 10px;
padding-bottom: 5px;
text-transform: uppercase;
}
h3 {
color: #0f2742;
font-size: 10.8pt;
margin: 12px 0 6px;
}
p { margin-bottom: 8px; }
ol { margin: 6px 0 0; padding-left: 20px; }
li { margin-bottom: 5px; }
.page-section,
.article {
margin-bottom: 18px;
page-break-inside: avoid;
}
.brand-row {
align-items: center;
border-bottom: 3px solid #1e3a5f;
display: flex;
gap: 14px;
padding-bottom: 14px;
}
.logo-mark {
align-items: center;
background: #1e3a5f;
border-radius: 8px;
color: #fff;
display: flex;
font-family: Arial, sans-serif;
font-size: 16pt;
font-weight: 700;
height: 52px;
justify-content: center;
letter-spacing: 0.08em;
width: 72px;
}
.kicker {
color: #1e3a5f;
font-family: Arial, sans-serif;
font-size: 10pt;
font-weight: 700;
letter-spacing: 0.04em;
margin-bottom: 2px;
text-transform: uppercase;
}
.muted {
color: #6b7280;
font-family: Arial, sans-serif;
font-size: 9pt;
margin: 0;
}
.cover {
min-height: 255mm;
position: relative;
}
.cover-title {
margin: 54mm 0 34mm;
text-align: center;
}
.document-label {
color: #6b7280;
font-family: Arial, sans-serif;
font-size: 10pt;
font-weight: 700;
letter-spacing: 0.12em;
margin-bottom: 10px;
text-transform: uppercase;
}
.summary-line {
color: #374151;
font-family: Arial, sans-serif;
font-size: 9.5pt;
margin-top: 12px;
}
table {
border-collapse: collapse;
width: 100%;
}
.meta-grid,
.details-table,
.schedule {
font-size: 9.5pt;
margin: 10px 0 16px;
}
.meta-grid th,
.meta-grid td,
.details-table th,
.details-table td,
.schedule th,
.schedule td {
border: 1px solid #cbd5e1;
padding: 7px 8px;
text-align: left;
vertical-align: top;
}
.meta-grid th,
.details-table th,
.schedule th {
background: #eef4fb;
color: #1e3a5f;
font-family: Arial, sans-serif;
font-size: 8.5pt;
text-transform: uppercase;
}
.schedule tbody tr:nth-child(even) td { background: #f8fafc; }
.total-row td {
background: #e8f0f8 !important;
color: #0f2742;
font-weight: 700;
}
.lead {
color: #374151;
font-size: 10.5pt;
}
.party-grid {
display: grid;
gap: 12px;
grid-template-columns: 1fr 1fr;
}
.party-card {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 12px;
}
.party-card h3 {
background: #1e3a5f;
border-radius: 5px;
color: #fff;
font-family: Arial, sans-serif;
font-size: 9pt;
margin: 0 0 10px;
padding: 7px 9px;
text-transform: uppercase;
}
.party-name {
color: #0f2742;
font-weight: 700;
margin-bottom: 8px;
}
dl {
display: grid;
grid-template-columns: 32% 68%;
margin: 0;
}
dt {
color: #475569;
font-family: Arial, sans-serif;
font-size: 8.5pt;
font-weight: 700;
padding: 2px 6px 2px 0;
}
dd {
margin: 0;
padding: 2px 0;
}
.signatures {
display: grid;
gap: 18px;
grid-template-columns: 1fr 1fr;
margin-top: 24px;
page-break-inside: avoid;
}
.sig-block {
border: 1.5px solid #1e3a5f;
border-radius: 8px;
min-height: 96mm;
padding: 12px;
}
.sig-title {
color: #1e3a5f;
font-family: Arial, sans-serif;
font-size: 9pt;
font-weight: 700;
margin-bottom: 10px;
text-transform: uppercase;
}
.sig-image-box {
align-items: center;
border: 1px dashed #94a3b8;
display: flex;
height: 28mm;
justify-content: center;
margin: 14px 0;
}
.sig-image-box img {
display: block;
max-height: 24mm;
max-width: 70mm;
}
.sig-placeholder {
color: #94a3b8;
font-family: Arial, sans-serif;
font-size: 8.5pt;
}
.sig-line {
border-top: 1px solid #111827;
margin-top: 16px;
padding-top: 5px;
}
.sig-meta {
color: #475569;
font-size: 9pt;
margin: 4px 0;
}
@media print {
body { background: #fff; }
.contract {
margin: 0;
padding: 0;
width: auto;
}
.cover { page-break-after: always; }
}
</style>

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{template.title}}{{reference}}</title>
{{> styles}}
</head>
<body>
<main class="contract">
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Contract</p>
</div>
</div>
<div class="cover-title">
<p class="document-label">Contract Agreement</p>
<h1>{{template.title}}</h1>
<p class="summary-line">{{template.directionLabel}}{{template.freightLabel}}{{template.currency}}{{template.serviceScope}}</p>
</div>
<table class="meta-grid">
<tr>
<th>Contract Ref No.</th>
<td>{{reference}}</td>
<th>Contract Year</th>
<td>{{contractYear}}</td>
</tr>
<tr>
<th>Contract Date</th>
<td>{{contractDate}}</td>
<th>Status</th>
<td>{{status}}</td>
</tr>
</table>
</section>
<section class="page-section">
<h2>Parties to the Agreement</h2>
<p class="lead">
This Contract Agreement is made on <strong>{{contractDate}}</strong> between the Service Provider and the Client named below.
</p>
<div class="party-grid">
<div class="party-card">
<h3>Service Provider</h3>
<p class="party-name">{{provider.name}}</p>
<dl>
<dt>Address</dt><dd>{{provider.address}}</dd>
<dt>Phone</dt><dd>{{provider.phone}}</dd>
<dt>Email</dt><dd>{{provider.email}}</dd>
<dt>TIN</dt><dd>{{provider.tinNumber}}</dd>
</dl>
</div>
<div class="party-card">
<h3>Client</h3>
<p class="party-name">{{client.companyName}}</p>
<dl>
<dt>Address</dt><dd>{{client.companyAddress}}</dd>
<dt>Location</dt><dd>{{client.companyLocation}}</dd>
<dt>Phone</dt><dd>{{client.phone}}</dd>
<dt>Email</dt><dd>{{client.email}}</dd>
<dt>TIN</dt><dd>{{client.tinNumber}}</dd>
<dt>VAT</dt><dd>{{client.vatNumber}}</dd>
<dt>FAN</dt><dd>{{client.fanNumber}}</dd>
<dt>Business license</dt><dd>{{client.businessLicense}}</dd>
</dl>
</div>
</div>
</section>
{{> contract_schedule}}
<section class="page-section">
<h2>Whereas</h2>
<p>{{template.whereas}}</p>
<p>Now therefore, the parties agree as follows:</p>
</section>
{{> article1}}
{{> articles_obligations}}
{{> force_majeure}}
{{> article5_pricing}}
{{> contract_documents}}
{{> signatures_block}}
</main>
</body>
</html>

View File

@@ -0,0 +1,21 @@
// apps/edr-freight-api/src/data-source.ts
import 'dotenv/config';
import { DataSource } from 'typeorm';
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5432),
username: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'edr_freight',
schema: 'freight', // default schema for entities without an explicit schema
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false,
logging: true,
});
// Optional: call ensurePostgresSchemas before initializing
// But you can also run it separately.

View File

@@ -1,4 +1,6 @@
import "reflect-metadata";
import * as dotenv from "dotenv";
dotenv.config();
import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import {
@@ -10,7 +12,31 @@ import {
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
const app = await NestFactory.create(AppModule);
// Dev CORS: reflect any localhost origin and allow credentials so the
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
// and any other dev port can call the API with cookies + Authorization.
// For production, restrict `origin` to known FQDNs.
app.enableCors({
origin: true, // reflect request origin
credentials: true,
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Accept",
"Authorization",
"X-Requested-With",
// IAM context headers required by @tria-plc/api-common's JwtGuard
"organization-unit-id",
"delegator-position-id",
"current-project-id",
"current-position-id",
],
exposedHeaders: ["Content-Disposition"],
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
});
app.setGlobalPrefix("api");
app.useGlobalPipes(createValidationPipe());
@@ -27,9 +53,12 @@ async function bootstrap() {
SwaggerModule.setup("api/docs", app, document);
const port = parseInt(process.env.PORT ?? "3001", 10);
await app.listen(port);
// await app.listen(port, "0.0.0.0");
await app.listen(
port)
// eslint-disable-next-line no-console
console.log(`[freight-api] listening on http://localhost:${port}`);
console.log(`[freight-api] listening on port ${port}`);
}
bootstrap();

View File

@@ -0,0 +1,228 @@
import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from "typeorm";
export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInterface {
name = "AddServiceTypesAndCargoTypes1748427600000";
public async up(queryRunner: QueryRunner): Promise<void> {
// Create service_types table
if (!(await queryRunner.hasTable("freight.service_types"))) await queryRunner.createTable(
new Table({
name: "service_types",
schema: "freight",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
generationStrategy: "uuid",
default: "uuid_generate_v4()",
},
{
name: "service_name",
type: "varchar",
length: "255",
isNullable: false,
},
{
name: "description",
type: "text",
isNullable: true,
},
{
name: "can_be_booked_alone",
type: "boolean",
default: true,
isNullable: false,
},
{
name: "includes_first_mile",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "includes_last_mile",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "includes_customs",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "priority_bonus_points",
type: "int",
default: 0,
isNullable: false,
},
{
name: "is_active",
type: "boolean",
default: true,
isNullable: false,
},
{
name: "display_order",
type: "int",
default: 1,
isNullable: false,
},
{
name: "created_at",
type: "timestamptz",
default: "now()",
isNullable: false,
},
{
name: "updated_at",
type: "timestamptz",
default: "now()",
isNullable: false,
},
{
name: "deleted_at",
type: "timestamptz",
isNullable: true,
},
],
}),
true,
);
// Create indexes for service_types
await queryRunner.createIndex(
"freight.service_types",
new TableIndex({
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
columnNames: ["is_active"],
}),
);
await queryRunner.createIndex(
"freight.service_types",
new TableIndex({
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
columnNames: ["display_order"],
}),
);
// Create cargo_types table
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(
new Table({
name: "cargo_types",
schema: "freight",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
generationStrategy: "uuid",
default: "uuid_generate_v4()",
},
{
name: "cargo_type_name",
type: "varchar",
length: "255",
isNullable: false,
},
{
name: "parent_group_id",
type: "uuid",
isNullable: true,
},
{
name: "show_free_text_box",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "requires_director_approval",
type: "boolean",
default: false,
isNullable: false,
},
{
name: "is_active",
type: "boolean",
default: true,
isNullable: false,
},
{
name: "display_order",
type: "int",
default: 1,
isNullable: false,
},
{
name: "created_at",
type: "timestamptz",
default: "now()",
isNullable: false,
},
{
name: "updated_at",
type: "timestamptz",
default: "now()",
isNullable: false,
},
{
name: "deleted_at",
type: "timestamptz",
isNullable: true,
},
],
}),
true,
);
// Create indexes for cargo_types
await queryRunner.createIndex(
"freight.cargo_types",
new TableIndex({
name: "IDX_CARGO_TYPES_IS_ACTIVE",
columnNames: ["is_active"],
}),
);
await queryRunner.createIndex(
"freight.cargo_types",
new TableIndex({
name: "IDX_CARGO_TYPES_DISPLAY_ORDER",
columnNames: ["display_order"],
}),
);
await queryRunner.createIndex(
"freight.cargo_types",
new TableIndex({
name: "IDX_CARGO_TYPES_PARENT_GROUP_ID",
columnNames: ["parent_group_id"],
}),
);
// Create self-referencing foreign key for cargo_types
await queryRunner.createForeignKey(
"freight.cargo_types",
new TableForeignKey({
name: "FK_CARGO_TYPES_PARENT_GROUP",
columnNames: ["parent_group_id"],
referencedSchema: "freight",
referencedTableName: "cargo_types",
referencedColumnNames: ["id"],
onDelete: "SET NULL",
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop foreign key first
await queryRunner.dropForeignKey("freight.cargo_types", "FK_CARGO_TYPES_PARENT_GROUP");
// Drop cargo_types table
await queryRunner.dropTable("freight.cargo_types", true);
// Drop service_types table
await queryRunner.dropTable("freight.service_types", true);
}
}

View File

@@ -0,0 +1,293 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
TableForeignKey,
TableColumn,
} from 'typeorm';
export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterface {
name = 'AddRuleEngineTablesAndCodes1748514000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. Add `code` column to existing tables ───────────────────────────
if (!(await queryRunner.hasColumn('freight.service_types', 'code'))) {
await queryRunner.addColumn(
'freight.service_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
);
}
await queryRunner.query(
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
);
await queryRunner.query(
`UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`,
);
await queryRunner.query(
`ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`,
);
const serviceTypesCodeIdx = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_service_types_code' LIMIT 1`,
);
if (serviceTypesCodeIdx.length === 0) {
await queryRunner.createIndex(
'freight.service_types',
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
);
}
if (!(await queryRunner.hasColumn('freight.cargo_types', 'code'))) {
await queryRunner.addColumn(
'freight.cargo_types',
new TableColumn({
name: 'code',
type: 'varchar',
length: '50',
isNullable: true,
}),
);
}
await queryRunner.query(
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
);
await queryRunner.query(
`UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`,
);
await queryRunner.query(
`ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`,
);
const cargoTypesCodeIdx = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_cargo_types_code' LIMIT 1`,
);
if (cargoTypesCodeIdx.length === 0) {
await queryRunner.createIndex(
'freight.cargo_types',
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
);
}
// ── 2. surcharge_types ────────────────────────────────────────────────
if (!(await queryRunner.hasTable('freight.surcharge_types'))) await queryRunner.createTable(
new Table({
name: 'surcharge_types',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '50', isNullable: false },
{ name: 'name', type: 'varchar', length: '100', isNullable: false },
{ name: 'description', type: 'text', isNullable: true },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.surcharge_types',
new TableIndex({ name: 'IDX_surcharge_types_code', columnNames: ['code'], isUnique: true }),
);
await queryRunner.createIndex(
'freight.surcharge_types',
new TableIndex({ name: 'IDX_surcharge_types_is_active', columnNames: ['is_active'] }),
);
// ── 3. surcharges ─────────────────────────────────────────────────────
if (!(await queryRunner.hasTable('freight.surcharges'))) await queryRunner.createTable(
new Table({
name: 'surcharges',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'surcharge_type_id', type: 'uuid', isNullable: false },
{ name: 'fee_name', type: 'varchar', length: '255', isNullable: false },
{ name: 'trigger_description', type: 'text', isNullable: true },
{
name: 'calculation_method',
type: 'enum',
enum: ['PER_TON', 'FLAT_FEE', 'PERCENTAGE'],
default: `'PER_TON'`,
},
{ name: 'rate', type: 'numeric', precision: 10, scale: 2, isNullable: false },
{ name: 'currency', type: 'char', length: '3', default: `'USD'` },
{ name: 'apply_to_rail', type: 'boolean', default: false },
{ name: 'apply_to_first_mile', type: 'boolean', default: false },
{ name: 'apply_to_last_mile', type: 'boolean', default: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.surcharges',
new TableForeignKey({
name: 'FK_surcharges_surcharge_type',
columnNames: ['surcharge_type_id'],
referencedTableName: 'freight.surcharge_types',
referencedColumnNames: ['id'],
onDelete: 'RESTRICT',
}),
);
await queryRunner.createIndex(
'freight.surcharges',
new TableIndex({ name: 'IDX_surcharges_surcharge_type_id', columnNames: ['surcharge_type_id'] }),
);
await queryRunner.createIndex(
'freight.surcharges',
new TableIndex({ name: 'IDX_surcharges_is_active', columnNames: ['is_active'] }),
);
// ── 4. container_types ────────────────────────────────────────────────
if (!(await queryRunner.hasTable('freight.container_types'))) await queryRunner.createTable(
new Table({
name: 'container_types',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'size_code', type: 'varchar', length: '20', isNullable: false },
{ name: 'description', type: 'varchar', length: '100', isNullable: true },
{ name: 'containers_per_wagon', type: 'int', isNullable: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.container_types',
new TableIndex({ name: 'IDX_container_types_size_code', columnNames: ['size_code'], isUnique: true }),
);
await queryRunner.createIndex(
'freight.container_types',
new TableIndex({ name: 'IDX_container_types_is_active', columnNames: ['is_active'] }),
);
// ── 5. weight_limit_rules ─────────────────────────────────────────────
if (!(await queryRunner.hasTable('freight.weight_limit_rules'))) await queryRunner.createTable(
new Table({
name: 'weight_limit_rules',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'container_type_id', type: 'uuid', isNullable: false },
{
name: 'trade_direction',
type: 'enum',
enum: ['IMPORT', 'EXPORT', 'BOTH'],
isNullable: false,
},
{ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false },
{ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false },
{
name: 'exceeded_action',
type: 'enum',
enum: ['WARNING_ONLY', 'HARD_BLOCK'],
default: `'WARNING_ONLY'`,
},
{ name: 'surcharge_id', type: 'uuid', isNullable: true },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.weight_limit_rules',
new TableForeignKey({
name: 'FK_weight_limit_rules_container_type',
columnNames: ['container_type_id'],
referencedTableName: 'freight.container_types',
referencedColumnNames: ['id'],
onDelete: 'RESTRICT',
}),
);
await queryRunner.createForeignKey(
'freight.weight_limit_rules',
new TableForeignKey({
name: 'FK_weight_limit_rules_surcharge',
columnNames: ['surcharge_id'],
referencedTableName: 'freight.surcharges',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.createIndex(
'freight.weight_limit_rules',
new TableIndex({ name: 'IDX_weight_limit_rules_container_type_id', columnNames: ['container_type_id'] }),
);
await queryRunner.createIndex(
'freight.weight_limit_rules',
new TableIndex({ name: 'IDX_weight_limit_rules_surcharge_id', columnNames: ['surcharge_id'] }),
);
await queryRunner.createIndex(
'freight.weight_limit_rules',
new TableIndex({ name: 'IDX_weight_limit_rules_is_active', columnNames: ['is_active'] }),
);
// ── 6. priority_rules ─────────────────────────────────────────────────
if (!(await queryRunner.hasTable('freight.priority_rules'))) await queryRunner.createTable(
new Table({
name: 'priority_rules',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{
name: 'priority_type',
type: 'enum',
enum: ['USD_PAYER', 'RAIL_AND_FORWARDING', 'GOVERNMENT_ACCOUNT', 'HIGH_VOLUME_SHIPMENT'],
isNullable: false,
},
{ name: 'rule_name', type: 'varchar', length: '255', isNullable: false },
{ name: 'description', type: 'text', isNullable: true },
{ name: 'activation_condition', type: 'text', isNullable: true },
{ name: 'bonus_points', type: 'int', default: 0 },
{ name: 'is_active', type: 'boolean', default: false },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.priority_rules',
new TableIndex({ name: 'IDX_priority_rules_priority_type', columnNames: ['priority_type'], isUnique: true }),
);
await queryRunner.createIndex(
'freight.priority_rules',
new TableIndex({ name: 'IDX_priority_rules_is_active', columnNames: ['is_active'] }),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.priority_rules', true);
await queryRunner.dropTable('freight.weight_limit_rules', true);
await queryRunner.dropTable('freight.container_types', true);
await queryRunner.dropTable('freight.surcharges', true);
await queryRunner.dropTable('freight.surcharge_types', true);
await queryRunner.dropIndex('freight.cargo_types', 'IDX_cargo_types_code');
await queryRunner.dropColumn('freight.cargo_types', 'code');
await queryRunner.dropIndex('freight.service_types', 'IDX_service_types_code');
await queryRunner.dropColumn('freight.service_types', 'code');
}
}

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Pre-ITMLS baseline for fresh databases. Older environments created `freight.bookings`
* via synchronize or manual SQL; ItmlsFullSchemaRewrite only ALTERs that table.
*/
export class CreateFreightLegacyBaseline1748550000000 implements MigrationInterface {
name = 'CreateFreightLegacyBaseline1748550000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.train_status AS ENUM (
'AVAILABLE', 'SCHEDULED', 'IN_SERVICE', 'UNDER_MAINTENANCE', 'OUT_OF_SERVICE'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.trains (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(32) NOT NULL UNIQUE,
capacity_tons NUMERIC(10, 2) NOT NULL DEFAULT 0,
status freight.train_status NOT NULL DEFAULT 'AVAILABLE',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.bookings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
reference VARCHAR(64) NOT NULL UNIQUE,
customer_id UUID NOT NULL,
train_id UUID,
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
scheduled_date TIMESTAMPTZ NOT NULL DEFAULT now(),
total_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
payment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
contract_type VARCHAR(20) NOT NULL DEFAULT 'SPOT',
previous_contract_id UUID,
trade_direction VARCHAR(10) NOT NULL DEFAULT 'IMPORT',
equipment_return VARCHAR(20) NOT NULL DEFAULT 'RETURN',
first_mile_pickup_address TEXT,
last_mile_delivery_address TEXT,
cargo_total_weight_vgm NUMERIC(12, 3) NOT NULL DEFAULT 0,
is_hazardous BOOLEAN NOT NULL DEFAULT false,
payment_currency VARCHAR(5) NOT NULL DEFAULT 'USD',
start_date DATE,
end_date DATE,
financial_terms TEXT,
version_number INT NOT NULL DEFAULT 1,
approved_by_staff_id UUID,
approved_by_staff_at TIMESTAMPTZ,
signed_by_director_id UUID,
signed_by_director_at TIMESTAMPTZ,
signed_by_ceo_id UUID,
signed_by_ceo_at TIMESTAMPTZ,
priority_score INT NOT NULL DEFAULT 0,
allow_consolidation BOOLEAN NOT NULL DEFAULT false,
consolidation_partner_id UUID,
origin_station VARCHAR(255),
destination_station VARCHAR(255),
service_type VARCHAR(100),
freight_type VARCHAR(100),
freight_subtype VARCHAR(255),
containers JSONB,
first_mile_enabled BOOLEAN DEFAULT false,
last_mile_enabled BOOLEAN DEFAULT false,
is_refrigerated BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.bookings CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.trains CASCADE`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.train_status`);
}
}

View File

@@ -0,0 +1,544 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
TableIndex,
TableUnique,
} from 'typeorm';
export class ItmlsFullSchemaRewrite1748600000000 implements MigrationInterface {
name = 'ItmlsFullSchemaRewrite1748600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── container_types ───────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.container_types RENAME COLUMN size_code TO code;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.container_types RENAME COLUMN description TO label;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS size_ft SMALLINT,
ADD COLUMN IF NOT EXISTS is_reefer BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS is_open_top BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS display_order INT NOT NULL DEFAULT 1;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS wagons_per_unit NUMERIC(4,2);
`);
await queryRunner.query(`
UPDATE freight.container_types
SET wagons_per_unit = CASE
WHEN containers_per_wagon > 0 THEN ROUND(1.0 / containers_per_wagon, 2)
ELSE 1.00
END
WHERE wagons_per_unit IS NULL;
`);
await queryRunner.query(`
UPDATE freight.container_types
SET size_ft = CASE WHEN code LIKE '40%' OR code LIKE '%40%' THEN 40 ELSE 20 END
WHERE size_ft IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ALTER COLUMN wagons_per_unit SET NOT NULL,
DROP COLUMN IF EXISTS containers_per_wagon;
`);
// ── weight_limit_rules ──────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.weight_limit_rules RENAME COLUMN max_weight_tons TO max_vgm_tons;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ALTER COLUMN max_vgm_tons TYPE NUMERIC(8,3);
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ADD COLUMN IF NOT EXISTS effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
ADD COLUMN IF NOT EXISTS effective_to DATE;
`);
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
DROP COLUMN IF EXISTS warning_threshold_tons,
DROP COLUMN IF EXISTS exceeded_action,
DROP COLUMN IF EXISTS surcharge_id,
DROP COLUMN IF EXISTS is_active;
`);
// ── priority_rules ────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.priority_rules
ADD COLUMN IF NOT EXISTS code VARCHAR(40),
ADD COLUMN IF NOT EXISTS label VARCHAR(100),
ADD COLUMN IF NOT EXISTS score INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS condition_currency VARCHAR(5);
`);
await queryRunner.query(`
UPDATE freight.priority_rules
SET code = COALESCE(code, upper(priority_type::text)),
label = COALESCE(label, rule_name),
score = COALESCE(score, bonus_points)
WHERE code IS NULL OR label IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_rules
DROP COLUMN IF EXISTS priority_type,
DROP COLUMN IF EXISTS rule_name,
DROP COLUMN IF EXISTS bonus_points,
DROP COLUMN IF EXISTS activation_condition,
DROP COLUMN IF EXISTS description;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_rules
ALTER COLUMN code SET NOT NULL,
ALTER COLUMN label SET NOT NULL;
`);
await queryRunner.createIndex(
'freight.priority_rules',
new TableIndex({ name: 'UQ_priority_rules_code', columnNames: ['code'], isUnique: true }),
);
// ── rates (before surcharge_types.rate_id) ────────────────────────────
await queryRunner.createTable(
new Table({
name: 'rates',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'rate_type', type: 'varchar', length: '50' },
{ name: 'container_type_id', type: 'uuid', isNullable: true },
{ name: 'trade_direction', type: 'varchar', length: '10', isNullable: true },
{ name: 'currency', type: 'varchar', length: '5' },
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
{ name: 'rate_unit', type: 'varchar', length: '30' },
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
{ name: 'proposed_by_staff_id', type: 'uuid' },
{ name: 'approved_by_ceo_id', type: 'uuid', isNullable: true },
{ name: 'approved_at', type: 'timestamptz', isNullable: true },
{ name: 'effective_from', type: 'date' },
{ name: 'effective_to', type: 'date', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// ── surcharge_types ───────────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.surcharge_types RENAME COLUMN name TO label;
EXCEPTION WHEN undefined_column THEN NULL;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.surcharge_types
ADD COLUMN IF NOT EXISTS trigger_condition VARCHAR(50),
ADD COLUMN IF NOT EXISTS rate_id UUID;
`);
await queryRunner.query(`ALTER TABLE freight.surcharge_types DROP COLUMN IF EXISTS description`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharges CASCADE`);
// ── yards ─────────────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'yards',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '20' },
{ name: 'label', type: 'varchar', length: '100' },
{ name: 'country', type: 'varchar', length: '50' },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'display_order', type: 'int', default: 1 },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.yards',
new TableIndex({ name: 'UQ_yards_code', columnNames: ['code'], isUnique: true }),
);
// ── shipping_lines ────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'shipping_lines',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'code', type: 'varchar', length: '20' },
{ name: 'label', type: 'varchar', length: '100' },
{ name: 'mapped_to_code', type: 'varchar', length: '20', isNullable: true },
{ name: 'show_extra_fee_notice', type: 'boolean', default: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// ── approval_rules ────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'approval_rules',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'requires_director_approval', type: 'boolean' },
{ name: 'step_order', type: 'smallint' },
{ name: 'required_role', type: 'varchar', length: '30' },
{ name: 'action_label', type: 'varchar', length: '50' },
{ name: 'blocks_role', type: 'varchar', length: '30', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createUniqueConstraint(
'freight.approval_rules',
new TableUnique({
name: 'UQ_approval_rules_chain_step',
columnNames: ['requires_director_approval', 'step_order'],
}),
);
// ── bookings ────────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS origin_yard_id UUID,
ADD COLUMN IF NOT EXISTS destination_yard_id UUID,
ADD COLUMN IF NOT EXISTS service_type_id UUID,
ADD COLUMN IF NOT EXISTS cargo_type_id UUID,
ADD COLUMN IF NOT EXISTS cargo_free_text VARCHAR(200),
ADD COLUMN IF NOT EXISTS shipping_line_id UUID,
ADD COLUMN IF NOT EXISTS pnr_code VARCHAR(50),
ADD COLUMN IF NOT EXISTS customer_signed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS fully_executed_at TIMESTAMPTZ;
`);
await queryRunner.query(`
INSERT INTO freight.yards (id, code, label, country, is_active, display_order, created_at, updated_at)
VALUES
(uuid_generate_v4(), 'KALITY', 'Kality Rail Terminal', 'Ethiopia', true, 1, now(), now()),
(uuid_generate_v4(), 'MOJO', 'Mojo Dry Port', 'Ethiopia', true, 2, now(), now()),
(uuid_generate_v4(), 'DIRE_DAWA', 'Dire Dawa Yard', 'Ethiopia', true, 3, now(), now()),
(uuid_generate_v4(), 'DJIB_PORT', 'Djibouti Port Terminal', 'Djibouti', true, 4, now(), now()),
(uuid_generate_v4(), 'NAGAD', 'Nagad Terminal, Djibouti', 'Djibouti', true, 5, now(), now()),
(uuid_generate_v4(), 'LEGACY_ORIGIN', 'Legacy Origin', 'Ethiopia', true, 99, now(), now()),
(uuid_generate_v4(), 'LEGACY_DEST', 'Legacy Destination', 'Ethiopia', true, 100, now(), now())
ON CONFLICT (code) DO NOTHING;
`);
await queryRunner.query(`
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
`);
await queryRunner.query(`
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
`);
const hasServiceTypeCol = await queryRunner.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'freight' AND table_name = 'bookings' AND column_name = 'service_type'
LIMIT 1;
`);
if (hasServiceTypeCol.length > 0) {
await queryRunner.query(`
UPDATE freight.bookings b
SET service_type_id = st.id
FROM freight.service_types st
WHERE b.service_type_id IS NULL
AND (
st.code = b.service_type
OR upper(replace(st.service_name, ' ', '_')) = upper(b.service_type)
OR st.code = upper(replace(b.service_type, ' ', '_'))
);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET cargo_type_id = ct.id
FROM freight.cargo_types ct
WHERE b.cargo_type_id IS NULL
AND (
ct.code = upper(b.freight_type)
OR ct.code = upper(concat(b.freight_type, '_', coalesce(b.freight_subtype, '')))
);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET cargo_free_text = b.freight_subtype
WHERE b.cargo_free_text IS NULL AND b.freight_subtype IS NOT NULL;
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET origin_yard_id = y.id
FROM freight.yards y
WHERE b.origin_yard_id IS NULL
AND (y.label ILIKE b.origin_station OR y.code = upper(replace(b.origin_station, ' ', '_')));
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET destination_yard_id = y.id
FROM freight.yards y
WHERE b.destination_yard_id IS NULL
AND (y.label ILIKE b.destination_station OR y.code = upper(replace(b.destination_station, ' ', '_')));
`);
}
const defaultServiceTypeId = await queryRunner.query(
`SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1`,
);
const defaultCargoTypeId = await queryRunner.query(
`SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1`,
);
const legacyOriginId = await queryRunner.query(
`SELECT id FROM freight.yards WHERE code = 'LEGACY_ORIGIN' LIMIT 1`,
);
const legacyDestId = await queryRunner.query(
`SELECT id FROM freight.yards WHERE code = 'LEGACY_DEST' LIMIT 1`,
);
if (defaultServiceTypeId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET service_type_id = $1 WHERE service_type_id IS NULL`,
[defaultServiceTypeId[0].id],
);
}
if (defaultCargoTypeId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET cargo_type_id = $1 WHERE cargo_type_id IS NULL`,
[defaultCargoTypeId[0].id],
);
}
if (legacyOriginId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET origin_yard_id = $1 WHERE origin_yard_id IS NULL`,
[legacyOriginId[0].id],
);
}
if (legacyDestId[0]?.id) {
await queryRunner.query(
`UPDATE freight.bookings SET destination_yard_id = $1 WHERE destination_yard_id IS NULL`,
[legacyDestId[0].id],
);
}
const nullBookings = await queryRunner.query(
`SELECT COUNT(*)::int AS cnt FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`,
);
if (nullBookings[0]?.cnt > 0) {
await queryRunner.query(`DELETE FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`);
}
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN service_type_id SET NOT NULL,
ALTER COLUMN cargo_type_id SET NOT NULL,
ALTER COLUMN origin_yard_id SET NOT NULL,
ALTER COLUMN destination_yard_id SET NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS origin_station,
DROP COLUMN IF EXISTS destination_station,
DROP COLUMN IF EXISTS service_type,
DROP COLUMN IF EXISTS freight_type,
DROP COLUMN IF EXISTS freight_subtype,
DROP COLUMN IF EXISTS containers,
DROP COLUMN IF EXISTS first_mile_enabled,
DROP COLUMN IF EXISTS last_mile_enabled,
DROP COLUMN IF EXISTS is_refrigerated;
`);
// ── booking_container ─────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'booking_container',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'container_type_id', type: 'uuid' },
{ name: 'quantity', type: 'smallint' },
{ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 },
{ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 },
{ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 },
{ name: 'weight_limit_rule_id', type: 'uuid', isNullable: true },
{ name: 'is_overweight', type: 'boolean', default: false },
{ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_rate_snapshot',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'rate_id', type: 'uuid' },
{ name: 'rate_type', type: 'varchar', length: '50' },
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
{ name: 'rate_unit', type: 'varchar', length: '30' },
{ name: 'currency', type: 'varchar', length: '5' },
{ name: 'snapshotted_at', type: 'timestamptz' },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_cargo_modifier',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'surcharge_type_id', type: 'uuid' },
{ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, isNullable: true },
{ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 },
{ name: 'rate_snapshot_id', type: 'uuid' },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'booking_approval_step',
schema: 'freight',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'approval_rule_id', type: 'uuid' },
{ name: 'step_order', type: 'smallint' },
{ name: 'required_role', type: 'varchar', length: '30' },
{ name: 'status', type: 'varchar', length: '20', default: "'PENDING'" },
{ name: 'actioned_by_staff_id', type: 'uuid', isNullable: true },
{ name: 'actioned_at', type: 'timestamptz', isNullable: true },
{ name: 'remarks', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
// Foreign keys
await queryRunner.createForeignKey(
'freight.surcharge_types',
new TableForeignKey({
name: 'FK_surcharge_types_rate_id',
columnNames: ['rate_id'],
referencedTableName: 'rates',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
await queryRunner.createForeignKey(
'freight.booking_container',
new TableForeignKey({
columnNames: ['booking_id'],
referencedTableName: 'bookings',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.bookings',
new TableForeignKey({
columnNames: ['origin_yard_id'],
referencedTableName: 'yards',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
await queryRunner.createForeignKey(
'freight.bookings',
new TableForeignKey({
columnNames: ['destination_yard_id'],
referencedTableName: 'yards',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.booking_approval_step', true);
await queryRunner.dropTable('freight.booking_cargo_modifier', true);
await queryRunner.dropTable('freight.booking_rate_snapshot', true);
await queryRunner.dropTable('freight.booking_container', true);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS origin_station VARCHAR(255),
ADD COLUMN IF NOT EXISTS destination_station VARCHAR(255),
ADD COLUMN IF NOT EXISTS service_type VARCHAR(30),
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20),
ADD COLUMN IF NOT EXISTS freight_subtype VARCHAR(100),
ADD COLUMN IF NOT EXISTS containers JSONB,
ADD COLUMN IF NOT EXISTS first_mile_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS last_mile_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS is_refrigerated BOOLEAN DEFAULT false;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS origin_yard_id,
DROP COLUMN IF EXISTS destination_yard_id,
DROP COLUMN IF EXISTS service_type_id,
DROP COLUMN IF EXISTS cargo_type_id,
DROP COLUMN IF EXISTS cargo_free_text,
DROP COLUMN IF EXISTS shipping_line_id,
DROP COLUMN IF EXISTS pnr_code,
DROP COLUMN IF EXISTS customer_signed_at,
DROP COLUMN IF EXISTS fully_executed_at;
`);
await queryRunner.dropTable('freight.approval_rules', true);
await queryRunner.dropTable('freight.shipping_lines', true);
await queryRunner.dropTable('freight.yards', true);
await queryRunner.dropTable('freight.rates', true);
}
}

View File

@@ -0,0 +1,94 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingsConfigForeignKeys1748700000000 implements MigrationInterface {
name = 'AddBookingsConfigForeignKeys1748700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Ensure parent config rows exist for backfill
await queryRunner.query(`
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
`);
await queryRunner.query(`
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
`);
// Clear orphan shipping_line references (nullable FK)
await queryRunner.query(`
UPDATE freight.bookings b
SET shipping_line_id = NULL
WHERE b.shipping_line_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.shipping_lines sl WHERE sl.id = b.shipping_line_id
);
`);
// Backfill required FK columns
await queryRunner.query(`
UPDATE freight.bookings
SET service_type_id = (SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1)
WHERE service_type_id IS NULL
OR NOT EXISTS (SELECT 1 FROM freight.service_types st WHERE st.id = service_type_id);
`);
await queryRunner.query(`
UPDATE freight.bookings
SET cargo_type_id = (SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1)
WHERE cargo_type_id IS NULL
OR NOT EXISTS (SELECT 1 FROM freight.cargo_types ct WHERE ct.id = cargo_type_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_service_type_id"
FOREIGN KEY (service_type_id)
REFERENCES freight.service_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_cargo_type_id"
FOREIGN KEY (cargo_type_id)
REFERENCES freight.cargo_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_shipping_line_id"
FOREIGN KEY (shipping_line_id)
REFERENCES freight.shipping_lines(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_shipping_line_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_cargo_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_service_type_id";
`);
}
}

View File

@@ -0,0 +1,331 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingsRemainingForeignKeys1748800000000 implements MigrationInterface {
name = 'AddBookingsRemainingForeignKeys1748800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const publicCustomersExists = await queryRunner.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'customers'
) AS exists
`);
const hasPublicCustomers = Boolean(publicCustomersExists[0]?.exists);
// ── freight.bookings: nullable FK cleanup ─────────────────────────────
await queryRunner.query(`
UPDATE freight.bookings b
SET train_id = NULL
WHERE b.train_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.id = b.train_id);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET previous_contract_id = NULL
WHERE b.previous_contract_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.previous_contract_id);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET consolidation_partner_id = NULL
WHERE b.consolidation_partner_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.consolidation_partner_id);
`);
// Legacy DBs only: public.customers is created/moved in MoveCustomersToFreightSchema (174890).
if (hasPublicCustomers) {
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.bookings b
WHERE bcm.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
USING freight.bookings b
WHERE bas.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
USING freight.bookings b
WHERE brs.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
USING freight.bookings b
WHERE bc.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.bookings b
WHERE NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES public.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
// ── freight.bookings FKs ────────────────────────────────────────────
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_train_id"
FOREIGN KEY (train_id)
REFERENCES freight.trains(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_previous_contract_id"
FOREIGN KEY (previous_contract_id)
REFERENCES freight.bookings(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_consolidation_partner_id"
FOREIGN KEY (consolidation_partner_id)
REFERENCES freight.bookings(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_container ─────────────────────────────────────────
await queryRunner.query(`
UPDATE freight.booking_container bc
SET weight_limit_rule_id = NULL
WHERE bc.weight_limit_rule_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.weight_limit_rules wlr WHERE wlr.id = bc.weight_limit_rule_id
);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
WHERE NOT EXISTS (SELECT 1 FROM freight.container_types ct WHERE ct.id = bc.container_type_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_container
ADD CONSTRAINT "FK_booking_container_container_type_id"
FOREIGN KEY (container_type_id)
REFERENCES freight.container_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_container
ADD CONSTRAINT "FK_booking_container_weight_limit_rule_id"
FOREIGN KEY (weight_limit_rule_id)
REFERENCES freight.weight_limit_rules(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_rate_snapshot ─────────────────────────────────────
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.booking_rate_snapshot brs
WHERE bcm.rate_snapshot_id = brs.id
AND (
NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id)
);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_rate_snapshot
ADD CONSTRAINT "FK_booking_rate_snapshot_booking_id"
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_rate_snapshot
ADD CONSTRAINT "FK_booking_rate_snapshot_rate_id"
FOREIGN KEY (rate_id)
REFERENCES freight.rates(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_approval_step ─────────────────────────────────────
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bas.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.approval_rules ar WHERE ar.id = bas.approval_rule_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_approval_step
ADD CONSTRAINT "FK_booking_approval_step_booking_id"
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_approval_step
ADD CONSTRAINT "FK_booking_approval_step_approval_rule_id"
FOREIGN KEY (approval_rule_id)
REFERENCES freight.approval_rules(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
// ── freight.booking_cargo_modifier ────────────────────────────────────
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bcm.booking_id)
OR NOT EXISTS (SELECT 1 FROM freight.surcharge_types st WHERE st.id = bcm.surcharge_type_id)
OR NOT EXISTS (
SELECT 1 FROM freight.booking_rate_snapshot brs WHERE brs.id = bcm.rate_snapshot_id
);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_cargo_modifier
ADD CONSTRAINT "FK_booking_cargo_modifier_booking_id"
FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_cargo_modifier
ADD CONSTRAINT "FK_booking_cargo_modifier_surcharge_type_id"
FOREIGN KEY (surcharge_type_id)
REFERENCES freight.surcharge_types(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.booking_cargo_modifier
ADD CONSTRAINT "FK_booking_cargo_modifier_rate_snapshot_id"
FOREIGN KEY (rate_snapshot_id)
REFERENCES freight.booking_rate_snapshot(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_snapshot_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_booking_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_approval_rule_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_booking_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_rate_snapshot
DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_rate_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_rate_snapshot
DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_booking_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_container
DROP CONSTRAINT IF EXISTS "FK_booking_container_weight_limit_rule_id";
`);
await queryRunner.query(`
ALTER TABLE freight.booking_container
DROP CONSTRAINT IF EXISTS "FK_booking_container_container_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_consolidation_partner_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_previous_contract_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_train_id";
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
`);
}
}

View File

@@ -0,0 +1,185 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MoveCustomersToFreightSchema1748900000000 implements MigrationInterface {
name = 'MoveCustomersToFreightSchema1748900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
phone VARCHAR(20) NOT NULL,
company_name VARCHAR(200) NOT NULL,
company_email VARCHAR(150) NOT NULL,
company_phone VARCHAR(20) NOT NULL,
company_location VARCHAR(100) NOT NULL,
company_address TEXT NOT NULL,
customer_type VARCHAR(32),
status VARCHAR(32),
contact_person_name VARCHAR(100) NOT NULL,
contact_person_phone VARCHAR(20) NOT NULL,
tin_number VARCHAR(10) NOT NULL UNIQUE,
vat_number VARCHAR(50),
fan_number VARCHAR(16) NOT NULL UNIQUE,
general_manager_name VARCHAR(100) NOT NULL,
general_manager_email VARCHAR(150) NOT NULL,
general_manager_phone VARCHAR(20) NOT NULL,
poa_name VARCHAR(100),
poa_phone VARCHAR(20),
poa_address TEXT,
poa_email VARCHAR(150),
poa_location VARCHAR(100),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_freight_customers_email"
ON freight.customers (email);
`);
// await queryRunner.query(`
// CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id"
// ON freight.customers (user_id);
//`);
// Copy rows from public.customers when that legacy table exists
await queryRunner.query(`
DO $$
DECLARE
has_public boolean;
has_user_id boolean;
has_userid boolean;
BEGIN
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'customers'
) INTO has_public;
IF NOT has_public THEN
RETURN;
END IF;
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'user_id'
) INTO has_user_id;
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'userid'
) INTO has_userid;
IF has_user_id THEN
INSERT INTO freight.customers (
id, user_id, first_name, last_name, email, phone,
company_name, company_email, company_phone, company_location, company_address,
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
general_manager_name, general_manager_email, general_manager_phone,
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
created_at, updated_at
)
SELECT
id, user_id, first_name, last_name, email, phone,
company_name, company_email, company_phone, company_location, company_address,
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
general_manager_name, general_manager_email, general_manager_phone,
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
COALESCE(created_at, now()), COALESCE(updated_at, now())
FROM public.customers
ON CONFLICT (id) DO NOTHING;
ELSIF has_userid THEN
INSERT INTO freight.customers (
id, user_id, first_name, last_name, email, phone,
company_name, company_email, company_phone, company_location, company_address,
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
general_manager_name, general_manager_email, general_manager_phone,
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
created_at, updated_at
)
SELECT
id, userid, firstname, lastname, email, phone,
companyname, companyemail, companyphone, companylocation, companyaddress,
contactpersonname, contactpersonphone, tinnumber, vatnumber, fannumber,
generalmanagername, generalmanageremail, generalmanagerphone,
poaname, poaphone, poaaddress, poaemail, poalocation, notes,
COALESCE("createdAt", now()), COALESCE("updatedAt", now())
FROM public.customers
ON CONFLICT (id) DO NOTHING;
END IF;
END $$;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
`);
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier bcm
USING freight.bookings b
WHERE bcm.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_approval_step bas
USING freight.bookings b
WHERE bas.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_rate_snapshot brs
USING freight.bookings b
WHERE brs.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.booking_container bc
USING freight.bookings b
WHERE bc.booking_id = b.id
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DELETE FROM freight.bookings b
WHERE NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES freight.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_customer_id"
FOREIGN KEY (customer_id)
REFERENCES public.customers(id)
ON DELETE RESTRICT;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.customers CASCADE;`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Align weight_limit_rules.trade_direction with app code: IMPORT, EXPORT, BOTH (not ANY).
*/
export class NormalizeWeightLimitTradeDirectionBoth1749000000000
implements MigrationInterface
{
name = 'NormalizeWeightLimitTradeDirectionBoth1749000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
UPDATE freight.weight_limit_rules
SET trade_direction = 'BOTH'
WHERE trade_direction::text = 'ANY';
EXCEPTION WHEN undefined_table OR undefined_column THEN NULL;
END $$;
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// No-op: ANY is not a valid enum value in PostgreSQL.
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateFreightFilesTable1749100000000 implements MigrationInterface {
name = 'CreateFreightFilesTable1749100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
resource_id UUID NOT NULL,
resource VARCHAR(100) NOT NULL,
code VARCHAR(100) NOT NULL,
name VARCHAR(500) NOT NULL,
url TEXT NOT NULL,
size INTEGER NOT NULL,
mime_type VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource"
ON freight.files (resource_id, resource);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource_code"
ON freight.files (resource_id, resource, code);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.files CASCADE`);
}
}

View File

@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class BookingFlowRefactor1749200000000 implements MigrationInterface {
name = 'BookingFlowRefactor1749200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_review_note (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
author_id UUID,
note TEXT NOT NULL,
type VARCHAR(30) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id
ON freight.booking_review_note(booking_id);
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID,
ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS contract_summary TEXT,
ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
`);
await queryRunner.query(`
UPDATE freight.bookings SET status = 'SUBMITTED'
WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED');
UPDATE freight.bookings SET status = 'REJECTED'
WHERE status = 'QUOTATION_REJECTED';
UPDATE freight.bookings SET status = 'CANCELLED'
WHERE status = 'CANCELLED';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS locked_at,
DROP COLUMN IF EXISTS contract_summary,
DROP COLUMN IF EXISTS marketing_approved_at,
DROP COLUMN IF EXISTS marketing_approved_by_id;
`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`);
}
}

View File

@@ -0,0 +1,115 @@
import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm';
export class CreateCompaniesModule1749200000000 implements MigrationInterface {
name = 'CreateCompaniesModule1749200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'companies',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'name', type: 'varchar', length: '200' },
{ name: 'type', type: 'varchar', length: '32' },
{ name: 'status', type: 'varchar', length: '32', default: "'pending'" },
{ name: 'tin', type: 'varchar', length: '10', isUnique: true },
{ name: 'vat_number', type: 'varchar', length: '50', isNullable: true },
{ name: 'business_license', type: 'varchar', length: '100', isNullable: true },
{ name: 'fan_number', type: 'varchar', length: '16', isNullable: true },
{ name: 'country', type: 'varchar', length: '32', default: "'Ethiopia'" },
{ name: 'address', type: 'text', isNullable: true },
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
{ name: 'email', type: 'varchar', length: '150', isNullable: true },
{ name: 'website', type: 'varchar', length: '200', isNullable: true },
{ name: 'attributes', type: 'jsonb', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'external_profiles',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'user_id', type: 'uuid' },
{ name: 'company_id', type: 'uuid' },
{ name: 'first_name', type: 'varchar', length: '100' },
{ name: 'last_name', type: 'varchar', length: '100' },
{ name: 'email', type: 'varchar', length: '150', isUnique: true },
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
{ name: 'national_id', type: 'varchar', length: '50', isNullable: true },
{ name: 'job_title', type: 'varchar', length: '100', isNullable: true },
{ name: 'is_primary_contact', type: 'boolean', default: false },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['company_id'],
referencedTableName: 'companies',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
},
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'ff_clients',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'forwarder_company_id', type: 'uuid' },
{ name: 'client_company_id', type: 'uuid' },
{ name: 'relationship_type', type: 'varchar', length: '32', default: "'managed_account'" },
{ name: 'can_book_on_behalf', type: 'boolean', default: true },
{ name: 'can_view_documents', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['forwarder_company_id'],
referencedTableName: 'companies',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
},
{
columnNames: ['client_company_id'],
referencedTableName: 'companies',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
},
],
}),
true,
);
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['tin'] }));
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['type'] }));
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['user_id'] }));
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['company_id'] }));
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['forwarder_company_id'] }));
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['client_company_id'] }));
await queryRunner.createUniqueConstraint('freight.ff_clients', new TableUnique({
columnNames: ['forwarder_company_id', 'client_company_id'],
}));
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.ff_clients');
await queryRunner.dropTable('freight.external_profiles');
await queryRunner.dropTable('freight.companies');
}
}

View File

@@ -0,0 +1,71 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingFreightType1749300000000 implements MigrationInterface {
name = 'AddBookingFreightType1749300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET freight_type = 'CONTAINER'
WHERE EXISTS (
SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id
);
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET freight_type = 'BULK'
WHERE freight_type IS NULL
AND b.cargo_type_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM freight.cargo_types ct
WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true
);
`);
await queryRunner.query(`
UPDATE freight.bookings
SET freight_type = 'CONTAINER'
WHERE freight_type IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN cargo_type_id DROP NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN freight_type SET NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD CONSTRAINT chk_bookings_freight_type
CHECK (freight_type IN ('CONTAINER', 'BULK'));
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type;
`);
await queryRunner.query(`
UPDATE freight.bookings SET cargo_type_id = (
SELECT id FROM freight.cargo_types LIMIT 1
) WHERE cargo_type_id IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN cargo_type_id SET NOT NULL;
`);
}
}

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFanNumberToCompanies1749300000000 implements MigrationInterface {
name = 'AddFanNumberToCompanies1749300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// fan_number may already exist when CreateCompaniesModule ran with the full schema
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS fan_number;
`);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddContractSignatures1749400000000 implements MigrationInterface {
name = 'AddContractSignatures1749400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80),
ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
signer_role VARCHAR(20) NOT NULL,
signer_user_id UUID,
signer_display_name VARCHAR(200) NOT NULL,
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL,
consent_text TEXT,
ip_address VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uq_booking_contract_signatures_role
UNIQUE (booking_id, signer_role)
);
CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id
ON freight.booking_contract_signatures(booking_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS pricing_breakdown,
DROP COLUMN IF EXISTS contract_generated_at,
DROP COLUMN IF EXISTS contract_template_key;
`);
}
}

View File

@@ -0,0 +1,153 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTrainScheduling1749400000000 implements MigrationInterface {
name = 'AddTrainScheduling1749400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
capacity_tons NUMERIC(10,3) NOT NULL,
length_meters NUMERIC(10,3) NOT NULL,
max_wagons_per_train INT NULL,
supported_load_types TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.locomotives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(100) NULL,
max_pull_weight_tons NUMERIC(10,3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
available_from TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
locomotive_id UUID NOT NULL,
total_weight_tons NUMERIC(10,3) NOT NULL,
total_length_meters NUMERIC(10,3) NOT NULL,
wagon_count INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id)
REFERENCES freight.locomotives(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_set_wagons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_id UUID NOT NULL,
wagon_type_id UUID NOT NULL,
sequence_no INT NOT NULL,
capacity_tons NUMERIC(10,3) NOT NULL,
length_meters NUMERIC(10,3) NOT NULL,
assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no),
CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets(id) ON DELETE CASCADE,
CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_id UUID NOT NULL UNIQUE,
origin_station_id UUID NOT NULL,
destination_station_id UUID NOT NULL,
scheduled_departure_date TIMESTAMPTZ NOT NULL,
scheduled_arrival_date TIMESTAMPTZ NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets(id),
CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id)
REFERENCES freight.yards(id),
CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id)
REFERENCES freight.yards(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_schedule_id UUID NOT NULL,
booking_id UUID NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id),
CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id)
REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_set_wagon_id UUID NOT NULL,
booking_id UUID NOT NULL,
allocated_weight_tons NUMERIC(10,3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id)
REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE,
CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id)
REFERENCES freight.bookings(id)
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_locomotives_status
ON freight.locomotives(status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_sets_status
ON freight.train_sets(status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status
ON freight.train_schedules(scheduled_departure_date, status);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking
ON freight.wagon_booking_allocations(booking_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`);
}
}

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCompanyIdToBookings1749500000000 implements MigrationInterface {
name = 'AddCompanyIdToBookings1749500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN customer_id DROP NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS company_id UUID;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_company_id
ON freight.bookings(company_id);
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_id'
) THEN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_company_id"
FOREIGN KEY (company_id)
REFERENCES freight.companies(id);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_company_id";
`);
await queryRunner.query(`
UPDATE freight.bookings SET customer_id = company_id WHERE customer_id IS NULL AND company_id IS NOT NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ALTER COLUMN customer_id SET NOT NULL;
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_bookings_company_id;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS company_id;
`);
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlocksRoleToApprovalStep1749600000000 implements MigrationInterface {
name = 'AddBlocksRoleToApprovalStep1749600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
ADD COLUMN IF NOT EXISTS blocks_role VARCHAR(30) NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
DROP COLUMN IF EXISTS blocks_role;
`);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seed ITMLS US-06 approval chains if missing (standard + bulk).
*/
export class SeedDefaultApprovalRules1749700000000 implements MigrationInterface {
name = 'SeedDefaultApprovalRules1749700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), false, 1, 'LINE_STAFF', 'Review & Approve', NULL, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = false AND step_order = 1 AND deleted_at IS NULL
);
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), false, 2, 'DIRECTOR', 'Final Signature', 'LINE_STAFF', now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = false AND step_order = 2 AND deleted_at IS NULL
);
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), true, 1, 'DIRECTOR', 'Review & Approve', 'LINE_STAFF', now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = true AND step_order = 1 AND deleted_at IS NULL
);
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), true, 2, 'CEO', 'Final Signature', NULL, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = true AND step_order = 2 AND deleted_at IS NULL
);
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Keep seeded rules on rollback to avoid breaking in-flight bookings.
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm';
/**
* shipping_lines was created without a unique index on code; seeder upserts require it.
*/
export class AddShippingLinesCodeUniqueIndex1749800000000 implements MigrationInterface {
name = 'AddShippingLinesCodeUniqueIndex1749800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND tablename = 'shipping_lines' AND indexdef ILIKE '%UNIQUE%code%' LIMIT 1`,
);
if (existing.length === 0) {
await queryRunner.createIndex(
'freight.shipping_lines',
new TableIndex({
name: 'UQ_shipping_lines_code',
columnNames: ['code'],
isUnique: true,
}),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndex('freight.shipping_lines', 'UQ_shipping_lines_code');
}
}

View File

@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* file_upload_settings / file_upload_fields entities had no migration; seeder requires both tables.
*/
export class CreateFileUploadSettingsTables1749900000000 implements MigrationInterface {
name = 'CreateFileUploadSettingsTables1749900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.file_upload_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(128) NOT NULL,
label VARCHAR(256) NOT NULL,
description TEXT,
entity VARCHAR(32) NOT NULL DEFAULT 'other',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_settings_code"
ON freight.file_upload_settings (code);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.file_upload_fields (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
setting_id UUID NOT NULL,
file_key VARCHAR(128) NOT NULL,
file_label VARCHAR(256) NOT NULL,
help_text TEXT,
is_required BOOLEAN NOT NULL DEFAULT false,
is_multiple BOOLEAN NOT NULL DEFAULT false,
max_files INTEGER NOT NULL DEFAULT 1,
allowed_extensions TEXT[] NOT NULL DEFAULT '{}'::text[],
max_size_mb INTEGER NOT NULL DEFAULT 10,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT "CHK_file_upload_fields_max_files" CHECK (max_files > 0),
CONSTRAINT "CHK_file_upload_fields_max_size_mb" CHECK (max_size_mb > 0),
CONSTRAINT "FK_file_upload_fields_setting"
FOREIGN KEY (setting_id)
REFERENCES freight.file_upload_settings(id)
ON DELETE CASCADE
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_fields_setting_file_key"
ON freight.file_upload_fields (setting_id, file_key);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_fields CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_settings CASCADE`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCompanyContactColumns1750000000000 implements MigrationInterface {
name = 'AddCompanyContactColumns1750000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_name VARCHAR(100);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_phone VARCHAR(20);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_name VARCHAR(100);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_email VARCHAR(150);`);
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_phone VARCHAR(20);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_phone;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_email;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_name;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_phone;`);
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_name;`);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Train entity gained extended fields; baseline trains table only had code/capacity/status/notes.
*/
export class AddTrainExtendedColumns1750000000000 implements MigrationInterface {
name = 'AddTrainExtendedColumns1750000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.trains
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20),
ADD COLUMN IF NOT EXISTS train_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS route_id UUID,
ADD COLUMN IF NOT EXISTS origin_station_id UUID,
ADD COLUMN IF NOT EXISTS destination_station_id UUID,
ADD COLUMN IF NOT EXISTS departure_time TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS arrival_time TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS locomotive_number VARCHAR(50),
ADD COLUMN IF NOT EXISTS remarks TEXT;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_train_number"
ON freight.trains (train_number)
WHERE train_number IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_train_number"`);
await queryRunner.query(`
ALTER TABLE freight.trains
DROP COLUMN IF EXISTS remarks,
DROP COLUMN IF EXISTS locomotive_number,
DROP COLUMN IF EXISTS arrival_time,
DROP COLUMN IF EXISTS departure_time,
DROP COLUMN IF EXISTS destination_station_id,
DROP COLUMN IF EXISTS origin_station_id,
DROP COLUMN IF EXISTS route_id,
DROP COLUMN IF EXISTS train_name,
DROP COLUMN IF EXISTS train_number;
`);
}
}

View File

@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface {
name = 'AddRoutesAndExtendLocomotives1750100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL',
ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760,
ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL,
ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL,
ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL;
`);
await queryRunner.query(`
UPDATE freight.locomotives
SET status = 'OUT_OF_SERVICE'
WHERE status = 'INACTIVE';
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(120) NOT NULL UNIQUE,
origin_yard_id UUID NOT NULL REFERENCES freight.yards(id),
destination_yard_id UUID NOT NULL REFERENCES freight.yards(id),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.route_milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE,
yard_id UUID NOT NULL REFERENCES freight.yards(id),
sequence_no INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no)
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id
ON freight.routes(origin_yard_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id
ON freight.routes(destination_yard_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_routes_is_active
ON freight.routes(is_active);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id
ON freight.route_milestones(route_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id
ON freight.route_milestones(yard_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`);
await queryRunner.query(`
ALTER TABLE freight.locomotives
DROP COLUMN IF EXISTS max_speed_kmh,
DROP COLUMN IF EXISTS traction_force_kn,
DROP COLUMN IF EXISTS power_kw,
DROP COLUMN IF EXISTS max_train_length_meters,
DROP COLUMN IF EXISTS locomotive_type;
`);
await queryRunner.query(`
UPDATE freight.locomotives
SET status = 'INACTIVE'
WHERE status = 'OUT_OF_SERVICE';
`);
}
}

View File

@@ -0,0 +1,127 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateFleetCrudTables1750100000000 implements MigrationInterface {
name = 'CreateFleetCrudTables1750100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
wagon_number VARCHAR NOT NULL UNIQUE,
wagon_type_id UUID NOT NULL,
train_id UUID,
sequence_number INT,
tare_weight NUMERIC(10, 2) NOT NULL,
max_payload_weight NUMERIC(10, 2) NOT NULL,
status VARCHAR NOT NULL DEFAULT 'AVAILABLE',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.containers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
container_number VARCHAR NOT NULL UNIQUE,
container_type_id UUID NOT NULL,
wagon_id UUID,
position INT,
tare_weight NUMERIC(10, 2) NOT NULL,
max_gross_weight NUMERIC(10, 2) NOT NULL,
seal_number VARCHAR,
status VARCHAR NOT NULL DEFAULT 'AVAILABLE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.cargoes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
cargo_reference VARCHAR NOT NULL UNIQUE,
shipment_id UUID NOT NULL,
container_id UUID NOT NULL,
cargo_type_id UUID,
description TEXT,
quantity NUMERIC(12, 3) NOT NULL,
weight NUMERIC(10, 2) NOT NULL,
volume NUMERIC(10, 2),
status VARCHAR NOT NULL DEFAULT 'PENDING',
loaded_at TIMESTAMP,
unloaded_at TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_wagons_train_id" ON freight.wagons (train_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_containers_wagon_id" ON freight.containers (wagon_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_cargoes_container_id" ON freight.cargoes (container_id)`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.wagons
ADD CONSTRAINT "FK_wagons_train_id"
FOREIGN KEY (train_id) REFERENCES freight.trains(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_wagon_type_id"
FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types(id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.containers
ADD CONSTRAINT "FK_containers_wagon_id"
FOREIGN KEY (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.containers
ADD CONSTRAINT "FK_containers_container_type_id"
FOREIGN KEY (container_type_id) REFERENCES freight.container_types(id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.cargoes
ADD CONSTRAINT "FK_cargoes_container_id"
FOREIGN KEY (container_id) REFERENCES freight.containers(id) ON DELETE RESTRICT;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.cargoes
ADD CONSTRAINT "FK_cargoes_cargo_type_id"
FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.cargoes CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.containers CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagons CASCADE`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SeedDefaultWagonTypes1750200000000 implements MigrationInterface {
name = 'SeedDefaultWagonTypes1750200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO freight.wagon_types (
code,
name,
capacity_tons,
length_meters,
max_wagons_per_train,
supported_load_types,
is_active
)
VALUES
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true),
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true),
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true),
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true),
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true),
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true),
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true),
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true),
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true),
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
capacity_tons = EXCLUDED.capacity_tons,
length_meters = EXCLUDED.length_meters,
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
supported_load_types = EXCLUDED.supported_load_types,
is_active = true,
deleted_at = NULL,
updated_at = now();
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.wagon_types
WHERE code IN ('NW7', 'NW5', 'PW2', 'GW2', 'CW4', 'CW3', 'KW2', 'KW3', 'NW6', 'BW1');
`);
}
}

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface {
name = 'AddRouteToTrainSchedules1750300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS route_id UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_train_schedules_route'
) THEN
ALTER TABLE freight.train_schedules
ADD CONSTRAINT fk_train_schedules_route
FOREIGN KEY (route_id) REFERENCES freight.routes(id);
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id
ON freight.train_schedules(route_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP CONSTRAINT IF EXISTS fk_train_schedules_route;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS route_id;
`);
}
}

View File

@@ -0,0 +1,98 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreatePaymentTable1780639311366 implements MigrationInterface {
name = "CreatePaymentTable1780639311366";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE freight.payments_type_enum AS ENUM ('booking');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_currency_enum AS ENUM ('ETB', 'USD');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_status_enum AS ENUM (
'action-required',
'processing',
'success',
'failed',
'canceled',
'refunded'
);
`);
await queryRunner.query(`
CREATE TABLE freight.payments (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
ref_id varchar(255) NOT NULL,
type freight.payments_type_enum NOT NULL,
method freight.payments_method_enum NOT NULL,
currency freight.payments_currency_enum NOT NULL,
amount numeric NOT NULL,
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
client_action json,
merchant_order_id varchar(255) NOT NULL,
transaction_id varchar(255),
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
paid_at date,
refunded_at date,
expires_at date,
failer_code varchar(30),
failer_message varchar(255),
reason varchar(255),
created_at TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT PK_payments PRIMARY KEY (id),
CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id),
CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_id)
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TABLE IF EXISTS freight.payments;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_status_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_currency_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_method_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_type_enum;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AlterClientActionToJsonb1780639978834 implements MigrationInterface {
name = "AlterClientActionToJsonb1780639978834";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action TYPE jsonb
USING client_action::jsonb;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action DROP DEFAULT;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action TYPE json
USING client_action::json;
`);
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdatePaymentTimestamp1780644945086 implements MigrationInterface {
name = "UpdatePaymentTimestamp1780644945086";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN refunded_at TYPE timestamp
USING refunded_at::timestamp;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN expires_at TYPE timestamp
USING expires_at::timestamp;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN refunded_at TYPE timestamptz
USING refunded_at::timestamptz;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN expires_at TYPE timestamptz
USING expires_at::timestamptz;
`);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
controllers: [FreightMeController],
providers: [FreightMeService],
})
export class FreightAuthModule {}

View File

@@ -0,0 +1,23 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FreightMeService } from './freight-me.service';
@ApiTags('auth')
@Controller('me')
@ApiBearerAuth()
export class FreightMeController {
constructor(private readonly freightMeService: FreightMeService) {}
@Get()
@UseGuards(JwtGuard)
@ApiOperation({
summary: 'Current user with flat permissionKeys for backoffice gating',
})
getMe(@CurrentUser() user: TCurrentUser) {
return this.freightMeService.getEnrichedProfile(user);
}
}

View File

@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import {
collectPermissionKeys,
isSuperAdmin,
} from '../../common/freight-permission.util';
import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
@Injectable()
export class FreightMeService {
getEnrichedProfile(user: TCurrentUser) {
const employee = user.employee
? [
{
id: user.employee.id,
organizationId: user.employee.organizationId,
unitId: user.employee.unitId,
name: user.employee.name,
positions: user.employee.position
? [
{
id: user.employee.position.id,
key: user.employee.position.key,
employeePositionId: user.employee.position.employeePositionId,
name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: user.employee.position.permissions ?? [],
},
]
: [],
},
]
: [];
const permissionKeys = collectPermissionKeys(user);
return {
id: user.id,
email: user.email,
name: user.name,
username: user.username,
phoneNumber: user.phoneNumber,
userType: user.userType,
status: user.status,
hasFinishedRegistration: user.hasFinishedRegistration,
hasFinishedDMSOnboarding: user.hasFinishedDMSOnboarding,
roles: user.roles,
permissions: user.permissions,
employee,
permissionKeys,
isSuperAdmin: isSuperAdmin(user),
permissionsCatalog: PERMISSIONS_CATALOG,
};
}
}

View File

@@ -0,0 +1,66 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
Put,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { BackofficeService } from "./backoffice.service";
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
@ApiTags("backoffice")
@Controller("backoffice")
export class BackofficeController {
constructor(private readonly backofficeService: BackofficeService) {}
@Post("organizations/:orgId/users")
@ApiOperation({ summary: "Create an organization user without assigning positions" })
createOrganizationUser(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@Body() dto: CreateOrganizationUserDto,
) {
return this.backofficeService.createOrganizationUser(organizationId, dto);
}
@Get("organizations/:orgId/employees")
@ApiOperation({ summary: "Get deduplicated organization employees for backoffice" })
getOrganizationEmployees(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@Query("skip") skip?: string,
@Query("take") take?: string,
) {
return this.backofficeService.getOrganizationEmployees(organizationId, {
skip,
take,
});
}
@Get("organizations/:orgId/employee-users/:userId/roles")
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
getEmployeeUserRoles(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@Param("userId", ParseUUIDPipe) userId: string,
) {
return this.backofficeService.getEmployeeUserRoles(organizationId, userId);
}
@Put("organizations/:orgId/employee-users/:userId/roles")
@ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" })
replaceEmployeeUserRoles(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@Param("userId", ParseUUIDPipe) userId: string,
@Body() dto: UpdateEmployeeUserRolesDto,
) {
return this.backofficeService.replaceEmployeeUserRoles(
organizationId,
userId,
dto.roleIds,
);
}
}

View File

@@ -0,0 +1,31 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import {
Employee,
Organization,
UserCredential,
} from "@tria-plc/iamapi-common";
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { BackofficeController } from "./backoffice.controller";
import { BackofficeService } from "./backoffice.service";
@Module({
imports: [
TypeOrmModule.forFeature([
Employee,
Organization,
Role,
User,
UserCredential,
UserRole,
]),
],
controllers: [BackofficeController],
providers: [BackofficeService],
exports: [BackofficeService],
})
export class BackofficeModule {}

View File

@@ -0,0 +1,425 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common";
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
const RESERVED_ROLE_KEYS = new Set([
"super_admin",
"organization_admin",
"unit_admin",
]);
const DEFAULT_USER_PASSWORD = "12345678";
const ORGANIZATION_ADMIN_ROLE_KEY = "organization_admin";
const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager";
@Injectable()
export class BackofficeService {
constructor(
@InjectRepository(Employee)
private readonly employeeRepository: Repository<Employee>,
@InjectRepository(Organization)
private readonly organizationRepository: Repository<Organization>,
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
@InjectRepository(UserRole)
private readonly userRoleRepository: Repository<UserRole>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly dataSource: DataSource,
) {}
async createOrganizationUser(
organizationId: string,
dto: CreateOrganizationUserDto,
) {
const organizationExists = await this.organizationRepository.exists({
where: { id: organizationId },
});
if (!organizationExists) {
throw new NotFoundException("organization_not_found");
}
const email = dto.email.trim().toLowerCase();
const username = dto.username.trim().toLowerCase();
const phoneNumber = dto.phoneNumber?.trim() || undefined;
const assignOrganizationAdmin = dto.assignOrganizationAdmin === true;
const name = {
en: dto.name.en.trim(),
...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}),
};
const existingUsers = await this.userRepository.find({
where: [{ email }, { username }],
select: { id: true, email: true, username: true },
});
const emailUser = existingUsers.find((user) => user.email === email);
const usernameUser = existingUsers.find((user) => user.username === username);
if (emailUser && usernameUser && emailUser.id !== usernameUser.id) {
throw new BadRequestException("email_or_username_already_in_use");
}
const existingUser = emailUser ?? usernameUser;
const hashedPassword = await hashPassword(DEFAULT_USER_PASSWORD);
return this.dataSource.transaction(async (manager) => {
let user = existingUser;
if (!user) {
user = await manager.getRepository(User).save(
manager.getRepository(User).create({
email,
username,
phoneNumber,
name,
isActive: true,
hasSetPassword: true,
status: EUserStatus.ACCEPTED,
}),
);
} else {
await manager.getRepository(User).update(
{ id: user.id },
{
email,
username,
phoneNumber,
name,
isActive: true,
hasSetPassword: true,
status: EUserStatus.ACCEPTED,
},
);
}
const activeCredentialExists = await manager.getRepository(UserCredential).exists({
where: {
userId: user.id,
isActive: true,
},
});
if (!activeCredentialExists) {
await manager.getRepository(UserCredential).insert({
userId: user.id,
password: hashedPassword,
isActive: true,
});
}
let employee = await manager.getRepository(Employee).findOne({
where: {
userId: user.id,
organizationId,
isCurrent: true,
},
relations: {
user: true,
employeePositions: {
position: true,
},
},
});
if (!employee) {
const insertResult = await manager.getRepository(Employee).insert({
userId: user.id,
organizationId,
isCurrent: true,
name,
});
employee = await manager.getRepository(Employee).findOne({
where: { id: insertResult.identifiers[0]?.id as string },
relations: {
user: true,
employeePositions: {
position: true,
},
},
});
} else {
await manager.getRepository(Employee).update(
{ id: employee.id },
{ name },
);
employee = await manager.getRepository(Employee).findOne({
where: { id: employee.id },
relations: {
user: true,
employeePositions: {
position: true,
},
},
});
}
if (!employee) {
throw new NotFoundException("employee_create_failed");
}
const userId = user.id;
if (!userId) {
throw new NotFoundException("user_create_failed");
}
if (assignOrganizationAdmin) {
await this.ensureOrganizationAdminAccess(manager, organizationId, userId);
}
return employee;
});
}
async getEmployeeUserRoles(organizationId: string, userId: string) {
await this.assertUserBelongsToOrganization(organizationId, userId);
const userRoles = await this.userRoleRepository.find({
where: {
userId,
organizationId,
unitId: IsNull(),
},
relations: {
role: true,
},
order: {
role: {
key: "ASC",
},
},
});
return userRoles
.map((userRole) => userRole.role)
.filter((role): role is Role => Boolean(role))
.map((role) => ({
id: role.id,
key: role.key,
name: role.name,
}));
}
async getOrganizationEmployees(
organizationId: string,
query: { skip?: string; take?: string },
) {
const organizationExists = await this.organizationRepository.exists({
where: { id: organizationId },
});
if (!organizationExists) {
throw new NotFoundException("organization_not_found");
}
const take = Number.parseInt(query.take ?? "1000", 10);
const skip = Number.parseInt(query.skip ?? "0", 10);
const employees = await this.employeeRepository.find({
where: {
organizationId,
isCurrent: true,
},
relations: {
user: true,
employeePositions: {
position: true,
},
},
order: {
createdAt: "DESC",
},
});
const deduplicated = this.mergeEmployeesByUser(employees);
return {
count: deduplicated.length,
items: deduplicated.slice(skip, skip + take),
};
}
async replaceEmployeeUserRoles(
organizationId: string,
userId: string,
roleIds: string[],
) {
await this.assertUserBelongsToOrganization(organizationId, userId);
const uniqueRoleIds = [...new Set(roleIds)];
const roles = uniqueRoleIds.length
? await this.roleRepository.find({
where: {
id: In(uniqueRoleIds),
},
})
: [];
if (roles.length !== uniqueRoleIds.length) {
throw new NotFoundException("one_or_more_roles_not_found");
}
const reservedRoles = roles.filter((role) => RESERVED_ROLE_KEYS.has(role.key));
if (reservedRoles.length) {
throw new BadRequestException("reserved_roles_must_use_admin_actions");
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(UserRole).delete({
userId,
organizationId,
unitId: IsNull(),
});
if (!roles.length) {
return;
}
await manager.getRepository(UserRole).insert(
roles.map((role) => ({
userId,
roleId: role.id,
organizationId,
})),
);
});
return this.getEmployeeUserRoles(organizationId, userId);
}
private async assertUserBelongsToOrganization(
organizationId: string,
userId: string,
) {
const exists = await this.userRepository
.createQueryBuilder("user")
.innerJoin(
"user.employee",
"employee",
"employee.organizationId = :organizationId AND employee.isCurrent = true",
{ organizationId },
)
.where("user.id = :userId", { userId })
.getExists();
if (!exists) {
throw new NotFoundException("user_not_found_in_organization");
}
}
private mergeEmployeesByUser(employees: Employee[]) {
const employeesByUserId = new Map<string, Employee>();
for (const employee of employees) {
const userId = employee.userId;
const employeeId = employee.id;
if (!userId) {
if (employeeId) {
employeesByUserId.set(employeeId, employee);
}
continue;
}
const existing = employeesByUserId.get(userId);
if (!existing) {
employeesByUserId.set(userId, employee);
continue;
}
const existingPositions = existing.employeePositions ?? [];
const nextPositions = employee.employeePositions ?? [];
const mergedEmployeePositions = Array.from(
new Map(
[...existingPositions, ...nextPositions].map((employeePosition) => [
employeePosition.id,
employeePosition,
]),
).values(),
);
employeesByUserId.set(userId, {
...existing,
...employee,
id: existing.id,
user: existing.user ?? employee.user,
userId,
name: existing.name ?? employee.name,
status: existing.status ?? employee.status,
employeePositions: mergedEmployeePositions,
});
}
return [...employeesByUserId.values()];
}
private async ensureOrganizationAdminAccess(
manager: EntityManager,
organizationId: string,
userId: string,
) {
const roles = await manager.getRepository(Role).find({
where: [
{ key: ORGANIZATION_ADMIN_ROLE_KEY },
{ key: EDR_ORG_MANAGER_ROLE_KEY },
],
select: { id: true, key: true },
});
const requiredRoles = [ORGANIZATION_ADMIN_ROLE_KEY, EDR_ORG_MANAGER_ROLE_KEY].map((key) => {
const role = roles.find((item) => item.key === key);
if (!role?.id) {
throw new NotFoundException(`required_role_not_seeded:${key}`);
}
return {
id: role.id,
key: role.key,
};
});
const existingRoleIds = new Set(
(
await manager.getRepository(UserRole).find({
where: {
userId,
organizationId,
},
select: { roleId: true },
})
).map((userRole) => userRole.roleId),
);
const rolesToInsert = requiredRoles
.filter((role) => !existingRoleIds.has(role.id))
.map((role) => ({
userId,
roleId: role.id,
organizationId,
}));
if (!rolesToInsert.length) {
return;
}
await manager.getRepository(UserRole).insert(rolesToInsert);
}
}

View File

@@ -0,0 +1,39 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
class CreateOrganizationUserNameDto {
@ApiProperty()
@IsString()
@MinLength(1)
en!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
am?: string;
}
export class CreateOrganizationUserDto {
@ApiProperty()
@IsEmail()
email!: string;
@ApiProperty()
@IsString()
@MinLength(1)
username!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
phoneNumber?: string;
@ApiProperty({ type: CreateOrganizationUserNameDto })
@IsObject()
name!: CreateOrganizationUserNameDto;
@ApiProperty({ required: false, default: false })
@IsOptional()
@IsBoolean()
assignOrganizationAdmin?: boolean;
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsArray, IsUUID } from "class-validator";
export class UpdateEmployeeUserRolesDto {
@ApiProperty({ type: [String] })
@IsArray()
@IsUUID("4", { each: true })
roleIds!: string[];
}

View File

@@ -4,7 +4,6 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { BillingService } from "./billing.service";
@ApiTags("billing")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("billing")
export class BillingController {
constructor(private readonly billingService: BillingService) {}

View File

@@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types";
import { Column, Entity } from "typeorm";
@Entity({ name: "invoices" })
@Entity({schema:"freight", name: "invoices" })
export class Invoice extends BaseEntity {
@Column({ name: "booking_id", type: "uuid" })
bookingId!: string;

View File

@@ -0,0 +1,284 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Readable } from 'stream';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { getTemplateMeta } from '../../contracts/contract-template.registry';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service';
import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
@Injectable()
export class BookingContractService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly templateResolver: ContractTemplateResolver,
private readonly viewModelBuilder: ContractViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
) {}
buildContractSummary(booking: Booking): string {
const direction =
booking.tradeDirection === 'IMPORT'
? 'Import'
: booking.tradeDirection === 'EXPORT'
? 'Export'
: booking.tradeDirection;
const cargo = booking.cargoType;
const isBulk = booking.freightType === 'BULK';
let cargoLabel: string;
if (isBulk) {
cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`;
} else {
const lines =
booking.bookingContainers?.map((bc) => {
const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container';
return `${bc.quantity}× ${label}`;
}) ?? [];
cargoLabel =
lines.length > 0
? `Container (${lines.join(', ')})`
: 'Container (Standard)';
}
return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
}
async getSummary(bookingId: string): Promise<{ summary: string }> {
const booking = await this.requireBooking(bookingId);
const summary = booking.contractSummary ?? this.buildContractSummary(booking);
return { summary };
}
async getContractView(bookingId: string): Promise<ContractViewDto> {
const { view } = await this.viewModelBuilder.build(bookingId);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
return {
bookingId: view.bookingId,
reference: view.reference,
status: view.status,
templateKey: view.templateKey,
title: view.template.title,
html,
canSignCustomer: view.canSignCustomer,
canSignStaff: view.canSignStaff,
hasContractDocument: view.hasContractDocument,
signatures: view.signatures,
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
};
}
async generateContract(bookingId: string): Promise<Booking> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['APPROVED']);
const templateKey = this.templateResolver.resolve(booking);
const summary = this.buildContractSummary(booking);
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CONTRACT_READY',
contractSummary: summary,
contractTemplateKey: templateKey,
contractGeneratedAt: now,
} as never);
return updated!;
}
async streamContract(bookingId: string) {
const booking = await this.requireBooking(bookingId);
const templateKey =
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
const record = await this.upsertContractPdf(
bookingId,
booking.reference,
templateKey,
);
return this.filesService.streamById(record.id);
}
async signContract(
bookingId: string,
dto: SignContractDto,
options: { signerUserId?: string; ipAddress?: string },
): Promise<Booking> {
const booking = await this.requireBooking(bookingId);
const role = dto.role as ContractSignerRole;
if (role === 'CUSTOMER') {
assertBookingStatus(booking, ['CONTRACT_READY']);
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'CUSTOMER',
);
if (existing) {
throw new BadRequestException('Customer has already signed this contract');
}
} else {
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'STAFF',
);
if (existing) {
throw new BadRequestException('Staff has already signed this contract');
}
}
const buffer = this.decodeSignatureImage(dto.signatureImageBase64);
const sigFile: Express.Multer.File = {
fieldname: `signature_${role.toLowerCase()}`,
originalname: `signature-${role.toLowerCase()}-${booking.reference}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
const fileRecord = await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff',
file: sigFile,
});
const now = new Date();
await this.bookingsRepository.saveContractSignature({
bookingId,
signerRole: role,
signerUserId: options.signerUserId ?? null,
signerDisplayName: dto.signerDisplayName,
signedAt: now,
signatureFileId: fileRecord.id,
consentText: dto.consentText ?? null,
ipAddress: options.ipAddress ?? null,
});
const updates: Record<string, unknown> = {};
if (role === 'CUSTOMER') {
updates.status = 'SIGNED_CUSTOMER';
updates.customerSignedAt = now;
} else {
updates.status = 'FULLY_EXECUTED';
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
updates.lockedAt = now;
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
await this.upsertContractPdf(
bookingId,
booking.reference,
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
);
return updated!;
}
async getSignatures(bookingId: string) {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
await this.inlineSignatureImages(views);
return { signatures: views };
}
private async upsertContractPdf(
bookingId: string,
reference: string,
templateKey: string,
): Promise<FileRecord> {
const { view } = await this.viewModelBuilder.build(bookingId);
view.templateKey = templateKey;
view.template = getTemplateMeta(templateKey);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
const file: Express.Multer.File = {
fieldname: 'contract',
originalname: `contract-${reference}.pdf`,
encoding: '7bit',
mimetype: 'application/pdf',
size: pdfBuffer.length,
buffer: pdfBuffer,
stream: Readable.from(pdfBuffer),
destination: '',
filename: '',
path: '',
};
return this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'contract',
file,
});
}
private async inlineSignatureImages(
signatures: Array<{ signatureImageUrl?: string | null }>,
): Promise<void> {
for (const sig of signatures) {
if (!sig.signatureImageUrl) continue;
try {
if (sig.signatureImageUrl.startsWith('data:')) continue;
const objectName = this.minioService.getObjectNameFromUrl(
sig.signatureImageUrl,
);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString(
'base64',
)}`;
} catch {
/* keep original url */
}
}
}
private streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}
private decodeSignatureImage(base64: string): Buffer {
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
return Buffer.from(raw, 'base64');
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
return booking;
}
}

View File

@@ -0,0 +1,44 @@
import { BadRequestException } from '@nestjs/common';
import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
/** Normalize and validate booking freight shape (used on create and after update merge). */
export function assertFreightShape(input: BookingFreightShapeInput): void {
if (!input.freightType || !FREIGHT_TYPES.includes(input.freightType as FreightType)) {
throw new BadRequestException(
`freightType is required and must be one of: ${FREIGHT_TYPES.join(', ')}`,
);
}
//
const containers = input.containers ?? [];
const hasContainers = containers.length > 0;
const hasCargoType = Boolean(input.cargoTypeId);
if (input.freightType === 'BULK') {
if (hasContainers) {
throw new BadRequestException(
'BULK freight cannot include container lines; use cargoTypeId only',
);
}
if (!hasCargoType) {
throw new BadRequestException('cargoTypeId is required for BULK freight');
}
return;
}
if (hasCargoType) {
throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
}
if (!hasContainers) {
throw new BadRequestException(
'CONTAINER freight requires at least one container line with containerTypeId',
);
}
for (const line of containers) {
if (!line.containerTypeId) {
throw new BadRequestException('Each container line must include containerTypeId');
}
}
}

View File

@@ -0,0 +1,54 @@
export const BOOKING_LIST_TAB_KEYS = [
'all',
'intake',
'in_approval',
'approved_contract',
'payment',
'operations',
'completed',
'closed',
] as const;
export type BookingListTabKey = (typeof BOOKING_LIST_TAB_KEYS)[number];
export const BOOKING_LIST_TABS: ReadonlyArray<{
key: BookingListTabKey;
statuses: readonly string[] | null;
}> = [
{ key: 'all', statuses: null },
{ key: 'intake', statuses: ['SUBMITTED'] },
{
key: 'in_approval',
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
},
{
key: 'approved_contract',
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
},
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
{
key: 'operations',
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
},
{ key: 'completed', statuses: ['COMPLETED'] },
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
];
export function mapStatusCountsToTabs(
statusCounts: Record<string, number>,
): Record<BookingListTabKey, number> {
const result = {} as Record<BookingListTabKey, number>;
for (const tab of BOOKING_LIST_TABS) {
if (!tab.statuses?.length) {
result[tab.key] = Object.values(statusCounts).reduce((sum, n) => sum + n, 0);
continue;
}
result[tab.key] = tab.statuses.reduce(
(sum, status) => sum + (statusCounts[status] ?? 0),
0,
);
}
return result;
}

View File

@@ -0,0 +1,68 @@
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { Booking } from './entities/booking.entity';
export interface BookingNextStep {
action: string;
description: string;
requiredRole?: string;
}
export function computeNextStep(
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
nextPendingStep?: Pick<BookingApprovalStep, 'requiredRole' | 'stepOrder'> | null,
): BookingNextStep | null {
const { status } = booking;
switch (status) {
case 'SUBMITTED':
return {
action: 'ACCEPT_INTAKE',
description: 'Line Staff must accept the submission to begin approval',
};
case 'PENDING_APPROVAL':
case 'APPROVED_PENDING_SIGNATURE':
if (nextPendingStep) {
return {
action: 'APPROVE_STEP',
requiredRole: nextPendingStep.requiredRole,
description: `${nextPendingStep.requiredRole} must approve step ${nextPendingStep.stepOrder}`,
};
}
return {
action: 'APPROVE_STEP',
description: 'Complete the pending approval step in sequence',
};
case 'APPROVED':
return {
action: 'CUSTOMER_SIGN',
description: 'Contract generated; customer must sign',
};
case 'CONTRACT_READY':
return {
action: 'CUSTOMER_SIGN',
description: 'Customer must sign the contract',
};
case 'SIGNED_CUSTOMER':
return {
action: 'STAFF_SIGN',
description: 'Internal staff must counter-sign the contract',
};
case 'FULLY_EXECUTED':
return {
action: 'AWAIT_PAYMENT',
description: 'Awaiting customer payment',
};
case 'PAID':
return {
action: 'START_TRANSIT',
description: 'Mark shipment as in transit',
};
case 'IN_TRANSIT':
return {
action: 'COMPLETE',
description: 'Mark shipment complete',
};
default:
return null;
}
}

View File

@@ -0,0 +1,50 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { PaymentService } from '../payment/payment.service';
import { PaymentStatus } from '../payment/entities/payment.entity';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
"action-required",
"processing",
"success",
];
@Injectable()
export class BookingPaymentService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly paymentService: PaymentService,
) { }
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
if (existing.clientAction) {
const action = existing.clientAction as { type?: string; url?: string };
if (action.type === "REDIRECT" && action.url) {
return { redirectUrl: action.url };
}
}
}
const resp = await this.paymentService.initBookingTelebirr(bookingId, "web");
return {
redirectUrl:
resp.redirectUrl ?? "",
};
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
return booking;
}
}

View File

@@ -0,0 +1,311 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import {
AppliedCargoModifier,
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { BookingsRepository } from './bookings.repository';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
@Injectable()
export class BookingPricingService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService,
private readonly serviceTypesService: ServiceTypesService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['DRAFT']);
const evalInput = await this.buildEvalInputForBooking(booking);
console.log('evalInput----', evalInput);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const baseLines = await this.computeBaseRailLines(booking, evalInput);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
}
for (const mod of ruleResult.appliedModifiers) {
const item: PriceLineItemDto = {
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: mod.calculatedAmount,
currency: mod.currency,
};
lineItems.push(item);
total += mod.calculatedAmount;
}
await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
await this.bookingsRepository.update(bookingId, {
totalAmount: total,
priorityScore: ruleResult.priorityScore,
pricingBreakdown: {
lineItems,
totalAmount: total,
currency: booking.paymentCurrency,
generatedAt: new Date().toISOString(),
},
} as never);
return {
bookingId,
totalAmount: total,
currency: booking.paymentCurrency,
lineItems,
warnings: ruleResult.warnings,
};
}
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
const containers = await Promise.all(
(booking.bookingContainers ?? []).map(async (bc) => {
const ct = await this.containerTypesService.findById(bc.containerTypeId);
const vgm = Number(bc.vgmPerUnitTons);
const qty = bc.quantity;
return {
containerTypeId: bc.containerTypeId,
quantity: qty,
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
};
}),
);
return {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId ?? null,
serviceTypeId: booking.serviceTypeId,
paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection,
isHazardous: booking.isHazardous,
allowConsolidation: booking.allowConsolidation,
shippingLineId: booking.shippingLineId,
containers,
};
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
return booking;
}
/** Line items for contract schedule (uses stored breakdown or recomputes). */
async computeContractLineItems(booking: Booking): Promise<{
lineItems: PriceLineItemDto[];
totalAmount: number;
currency: string;
}> {
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
} | null;
if (stored?.lineItems?.length) {
return {
lineItems: stored.lineItems,
totalAmount: Number(stored.totalAmount ?? booking.totalAmount),
currency: stored.currency ?? booking.paymentCurrency,
};
}
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const baseLines = await this.computeBaseRailLines(booking, evalInput);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
}
for (const mod of ruleResult.appliedModifiers) {
lineItems.push({
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: mod.calculatedAmount,
currency: mod.currency,
});
total += mod.calculatedAmount;
}
if (lineItems.length === 0) {
total = Number(booking.totalAmount);
lineItems.push({
code: 'TOTAL',
description: 'Contract total',
amount: total,
currency: booking.paymentCurrency,
});
}
return {
lineItems,
totalAmount: total || Number(booking.totalAmount),
currency: booking.paymentCurrency,
};
}
/** Recompute priority on submit (USD + service tier). */
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
let score = ruleResult.priorityScore;
const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
if (booking.paymentCurrency === 'USD' && serviceType) {
const code = (serviceType.code ?? '').toUpperCase();
const hasForwarding =
serviceType.includesFirstMile ||
serviceType.includesLastMile ||
code.includes('FORWARD') ||
code.includes('Y');
const railOnly = code.includes('RAIL') && !hasForwarding;
if (hasForwarding) score += 1000;
else if (railOnly || code.includes('X')) score += 500;
}
return score;
}
private async computeBaseRailLines(
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise<PriceLineItemDto[]> {
const liveRates = await this.ratesService.findLiveRates();
const currency = booking.paymentCurrency;
const isBulk = booking.freightType === 'BULK';
console.log('liveRates----', liveRates);
const rateType =
booking.tradeDirection === 'IMPORT'
? isBulk
? 'BULK_IMPORT'
: 'CONTAINER_IMPORT'
: booking.tradeDirection === 'EXPORT'
? isBulk
? 'BULK_EXPORT'
: 'CONTAINER_EXPORT'
: 'INTERCITY_CONTAINER';
console.log('rateType----', rateType);
const lines: PriceLineItemDto[] = [];
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
for (const container of evalInput.containers) {
console.log('container----', container);
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
console.log('rate----', rate);
if (!rate) continue;
const amount = this.amountForRate(rate, container.quantity, wagonCount);
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
amount,
currency: rate.currency,
});
}
if (lines.length === 0) {
const fallback = liveRates.find(
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
);
if (fallback) {
const amount = this.amountForRate(fallback, 1, wagonCount);
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
amount,
currency: fallback.currency,
});
}
}
return lines;
}
private pickRate(
rates: Rate[],
rateType: string,
containerTypeId: string,
currency: string,
): Rate | undefined {
return (
rates.find(
(r) =>
r.rateType === rateType &&
r.currency === currency &&
r.containerTypeId === containerTypeId,
) ??
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
);
}
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
const value = Number(rate.rateValue);
switch (rate.rateUnit) {
case 'PER_CONTAINER':
return value * quantity;
case 'PER_WAGON':
return value * wagonCount;
case 'PER_TON':
return value * quantity;
case 'FLAT':
return value;
default:
return value * quantity;
}
}
private async persistPriceRun(
bookingId: string,
modifiers: AppliedCargoModifier[],
_total: number,
): Promise<void> {
await this.bookingsRepository.clearPricingArtifacts(bookingId);
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
const rows = modifiers
.map((m) => {
const snapshotId = snapshotByRateId.get(m.rateId);
if (!snapshotId) return null;
return {
bookingId,
surchargeTypeId: m.surchargeTypeId,
triggerValue: m.triggerValue,
calculatedAmount: m.calculatedAmount,
rateSnapshotId: snapshotId,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (rows.length > 0) {
await this.bookingsRepository.createCargoModifiers(rows);
}
}
}

View File

@@ -0,0 +1,186 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../rule-engine/interfaces/cargo-types.repository.interface';
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
} from '../rule-engine/interfaces/container-types.repository.interface';
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from '../rule-engine/interfaces/service-types.repository.interface';
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from '../rule-engine/interfaces/shipping-lines.repository.interface';
import {
IYardsRepository,
YARDS_REPOSITORY,
} from '../rule-engine/interfaces/yards.repository.interface';
import {
BookingReferenceCargoTypeChildDto,
BookingReferenceCargoTypeGroupDto,
BookingReferenceContainerSizeGroupDto,
BookingReferenceContainerTypeDto,
BookingReferenceDataDto,
BookingReferenceServiceDto,
BookingReferenceShippingLineDto,
BookingReferenceYardDto,
} from './dto/booking-reference-data.dto';
const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const;
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
const active = rows.filter((r) => r.isActive);
const parents = active
.filter((r) => !r.parentGroupId)
.sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code));
return parents.map((parent) => {
const children = active
.filter((r) => r.parentGroupId === parent.id)
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
)
.map(
(child): BookingReferenceCargoTypeChildDto => ({
id: child.id,
name: child.cargoTypeName,
code: child.code,
show_free_text_box: child.showFreeTextBox,
}),
);
const group: BookingReferenceCargoTypeGroupDto = {
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
};
if (children.length > 0) {
group.children = children;
}
return group;
});
}
export function groupContainersBySize(
rows: ContainerType[],
): BookingReferenceContainerSizeGroupDto[] {
const active = rows.filter((r) => r.isActive);
const bySize = new Map<string, ContainerType[]>();
for (const ct of active) {
const sizeKey =
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other';
const list = bySize.get(sizeKey) ?? [];
list.push(ct);
bySize.set(sizeKey, list);
}
const sortSizeKey = (key: string): number => {
if (key === 'other') return Number.MAX_SAFE_INTEGER;
const n = parseInt(key, 10);
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
};
return [...bySize.entries()]
.sort(([a], [b]) => sortSizeKey(a) - sortSizeKey(b))
.map(([size, types]) => ({
size,
types: types
.sort(
(a, b) =>
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
a.code.localeCompare(b.code),
)
.map(
(ct): BookingReferenceContainerTypeDto => ({
id: ct.id,
name: ct.label?.trim() ? ct.label : ct.code,
code: ct.code,
is_reefer: ct.isReefer ?? false,
wagons_per_unit: Number(ct.wagonsPerUnit ?? 1),
}),
),
}));
}
@Injectable()
export class BookingReferenceDataService {
constructor(
@Inject(YARDS_REPOSITORY)
private readonly yardsRepository: IYardsRepository,
@Inject(CONTAINER_TYPES_REPOSITORY)
private readonly containerTypesRepository: IContainerTypesRepository,
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly serviceTypesRepository: IServiceTypesRepository,
@Inject(SHIPPING_LINES_REPOSITORY)
private readonly shippingLinesRepository: IShippingLinesRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
) {}
async getReferenceData(): Promise<BookingReferenceDataDto> {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
await Promise.all([
this.yardsRepository.findAll({
where: {
isActive: true,
code: Not(In([...LEGACY_YARD_CODES])),
},
order: { displayOrder: 'ASC', code: 'ASC' },
}),
this.containerTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
}),
this.serviceTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
}),
this.shippingLinesRepository.findAll({
where: { isActive: true },
order: { label: 'ASC', code: 'ASC' },
}),
this.cargoTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
}),
]);
return {
yard: yards.map(
(y): BookingReferenceYardDto => ({
id: y.id,
name: y.label,
code: y.code,
country: y.country,
}),
),
containers: groupContainersBySize(containerTypes),
service: serviceTypes.map(
(s): BookingReferenceServiceDto => ({
id: s.id,
name: s.serviceName,
code: s.code,
}),
),
shipping_line: shippingLines.map(
(sl): BookingReferenceShippingLineDto => ({
id: sl.id,
name: sl.label,
code: sl.code,
}),
),
cargo_type: buildCargoTypeTree(cargoTypes),
};
}
}

View File

@@ -0,0 +1,10 @@
import { ConflictException } from '@nestjs/common';
import { Booking } from './entities/booking.entity';
export function assertBookingStatus(booking: Booking, allowed: string[]): void {
if (!allowed.includes(booking.status)) {
throw new ConflictException(
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
);
}
}

View File

@@ -0,0 +1,324 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
@Injectable()
export class BookingTransitionService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
@Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService,
) {}
async submit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException(
'Generate a price before submitting (POST /bookings/:id/generate-price)',
);
}
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
await this.ruleEngineService.snapshotLiveRates(bookingId);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
priorityScore,
} as never);
return this.bookingsService.findById(updated!.id);
}
async requestChanges(
bookingId: string,
note: string,
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
await this.bookingsRepository.createReviewNote(
bookingId,
note,
'CHANGES_REQUESTED',
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CHANGES_REQUESTED',
} as never);
return this.bookingsService.findById(updated!.id);
}
/** Auto-create booking approval steps from system rules when none exist yet. */
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
});
}
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
approvedByStaffAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async staffReject(
bookingId: string,
reason: string,
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
} as never);
return this.bookingsService.findById(updated!.id);
}
async approveStep(
bookingId: string,
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Booking> {
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
await this.ensureBookingApprovalSteps(booking);
booking = await this.bookingsService.findById(bookingId);
}
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
}
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
'Approval steps must be completed in order',
);
}
if (step.requiredRole !== requiredRole) {
throw new BadRequestException(
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
);
}
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
}
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
updates.status = 'APPROVED_PENDING_SIGNATURE';
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
if (allDone) {
updates.status = 'APPROVED';
}
if (Object.keys(updates).length > 0) {
await this.bookingsRepository.update(bookingId, updates as never);
}
if (allDone) {
const generated = await this.contractService.generateContract(bookingId);
return this.bookingsService.findById(generated.id);
}
return this.bookingsService.findById(bookingId);
}
async rejectStep(
bookingId: string,
stepId: string,
actorId: string,
reason: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step) throw new BadRequestException('Approval step not found');
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
'REJECTED',
reason,
);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
} as never);
return this.bookingsService.findById(updated!.id);
}
async customerSign(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CONTRACT_READY']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SIGNED_CUSTOMER',
customerSignedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
marketingApprovedById: actorId,
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async startTransit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PAID']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'IN_TRANSIT',
} as never);
return this.bookingsService.findById(updated!.id);
}
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['IN_TRANSIT']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'COMPLETED',
endDate: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'CONTRACT_READY',
]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CANCELLED',
} as never);
return this.bookingsService.findById(updated!.id);
}
async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}> {
const note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
'CHANGES_REQUESTED',
);
const summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
const nextPending =
booking.status === 'PENDING_APPROVAL' ||
booking.status === 'APPROVED_PENDING_SIGNATURE'
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
return {
...booking,
latestChangeRequestNote: note?.note ?? null,
contractSummary: summary,
nextStep,
};
}
}

View File

@@ -6,43 +6,412 @@ import {
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
Request,
Res,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
import { BookingsService } from "./bookings.service";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingsService } from './bookings.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import {
ApproveStepDto,
CancelBookingDto,
RejectStepDto,
RequestChangesDto,
StaffRejectDto,
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
@ApiTags("bookings")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("bookings")
@ApiTags('bookings')
@Controller('bookings')
@ApiBearerAuth()
export class BookingsController {
constructor(private readonly bookingsService: BookingsService) {}
constructor(
private readonly bookingsService: BookingsService,
private readonly bookingReferenceDataService: BookingReferenceDataService,
private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
) {}
@Post()
@ApiOperation({ summary: "Create a new freight booking" })
create(@Body() dto: CreateBookingDto) {
return this.bookingsService.create(dto);
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
@ApiBody({ type: CreateBookingDto })
create(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
@Request() req: { user?: { id?: string; sub?: string } },
) {
const userId = req.user?.id ?? req.user?.sub;
return this.bookingsService.create(dto, files ?? [], userId);
}
@Patch(':id')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Update booking',
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
})
@ApiBody({ type: UpdateBookingDto })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.bookingsService.update(id, dto, files ?? []);
}
@Get()
@ApiOperation({ summary: "List freight bookings (paginated)" })
@ApiOperation({ summary: 'List freight bookings (paginated)' })
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
}
@Get(":id")
@ApiOperation({ summary: "Get a freight booking by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.findById(id);
@Get('list-summary')
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
return this.bookingsService.getListSummary(filter);
}
@Delete(":id")
@Get('queues/:queue')
@ApiOperation({
summary: 'List bookings for a dashboard queue',
description: 'Queues: intake, approval, signatures, marketing, finance',
})
findQueue(
@Param('queue') queue: string,
@Query() filter: FilterBookingDto,
@Query('excludeBulk') excludeBulk?: string,
) {
return this.bookingsService.findQueue(queue, filter, {
excludeBulk: excludeBulk === 'true',
});
}
@Get('reference-data')
@ApiOperation({ summary: 'Booking form catalog' })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
return this.bookingReferenceDataService.getReferenceData();
}
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
async findByReference(@Param('reference') reference: string) {
const booking = await this.bookingsService.findByReference(reference);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingsService.findById(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: "Soft-delete a freight booking" })
remove(@Param("id", ParseUUIDPipe) id: string) {
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
@Post(':id/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
async uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/generate-price')
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
@ApiOkResponse({ type: GeneratePriceResponseDto })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@ApiOperation({ summary: 'Customer submit booking' })
async submit(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.submit(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' })
async requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.requestChanges(
id,
dto.note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/accept')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.acceptIntake(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/reject')
@BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: 'Staff final reject' })
async staffReject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: StaffRejectDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.staffReject(
id,
dto.reason,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@ApiOperation({ summary: 'Approve one approval step in sequence' })
async approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/reject')
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: 'Reject at approval step' })
async rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.rejectStep(
id,
stepId,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/contract/generate')
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: 'Generate contract PDF from template' })
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/view')
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
getContractView(@Param('id', ParseUUIDPipe) id: string) {
return this.contractService.getContractView(id);
}
@Get(':id/contract/document')
@ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${record.name}"`,
);
stream.pipe(res);
}
@Get(':id/contract')
@ApiOperation({ summary: 'Download contract file (alias)' })
async downloadContract(
@Param('id', ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
return this.downloadContractDocument(id, res);
}
@Post(':id/contract/sign')
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
async signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
const userId = req.user?.id ?? req.user?.sub;
const booking = await this.contractService.signContract(id, dto, {
signerUserId: userId,
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/signatures')
@ApiOperation({ summary: 'List contract signatures' })
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id);
}
@Get(':id/summary')
@ApiOperation({ summary: 'Contract summary string for dashboard' })
getSummary(@Param('id', ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id);
}
@Post(':id/customer/sign')
@ApiOperation({
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
})
async customerSign(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
const booking = await this.contractService.signContract(id, payload, {
signerUserId: req.user?.id ?? req.user?.sub,
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/marketing/approve')
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
})
async marketingApprove(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload,
@Request() req: { ip?: string },
) {
const payload: SignContractDto = {
...dto,
role: 'STAFF',
};
const booking = await this.contractService.signContract(id, payload, {
signerUserId: resolveAuthUserId(user),
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/start-transit')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark in transit' })
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/complete')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark completed' })
async complete(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/cancel')
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: 'Cancel booking' })
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/consolidation')
@ApiOperation({ summary: 'Request freight consolidation' })
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
@Delete(':id/consolidation')
@ApiOperation({ summary: 'Remove consolidation pairing' })
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
@Get(':id/consolidation')
@ApiOperation({ summary: 'Get consolidation details' })
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
}

View File

@@ -1,15 +1,69 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsController } from "./bookings.controller";
import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service";
import { Booking } from "./entities/booking.entity";
// import { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { BookingContractService } from './booking-contract.service';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller';
import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { PaymentModule } from '../payment/payment.module';
@Module({
imports: [TypeOrmModule.forFeature([Booking])],
controllers: [BookingsController],
providers: [BookingsService, BookingsRepository],
exports: [BookingsService],
imports: [
TypeOrmModule.forFeature([
Booking,
BookingContainer,
BookingCargoModifier,
BookingApprovalStep,
BookingRateSnapshot,
BookingReviewNote,
BookingContractSignature,
]),
PaymentModule,
FilesModule,
MinioModule,
CompaniesModule,
// CustomersModule,
RuleEngineModule,
],
controllers: [BookingsController, PayController],
providers: [
BookingsService,
BookingsRepository,
ConsolidationService,
BookingReferenceDataService,
BookingPricingService,
BookingTransitionService,
BookingContractService,
BookingPaymentService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService, BookingsRepository],
})
export class BookingsModule {}

View File

@@ -1,15 +1,42 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
import { Booking } from "./entities/booking.entity";
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import {
BookingContractSignature,
ContractSignerRole,
} from './entities/booking-contract-signature.entity';
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
export interface BookingListFilterOptions {
statuses?: string[];
status?: string;
companyId?: string;
contractType?: string;
serviceTypeId?: string;
cargoTypeId?: string;
freightType?: string;
tradeDirection?: string;
paymentCurrency?: string;
allowConsolidation?: boolean;
consolidationPaired?: string;
}
@Injectable()
export class BookingsRepository extends BaseRepository<Booking> {
constructor(
@InjectRepository(Booking)
repository: Repository<Booking>,
private readonly dataSource: DataSource,
) {
super(repository);
}
@@ -18,4 +45,544 @@ export class BookingsRepository extends BaseRepository<Booking> {
findByReference(reference: string): Promise<Booking | null> {
return this.repository.findOne({ where: { reference } });
}
/** Count bookings created in a specific year. */
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
.createQueryBuilder('booking')
.where('booking.created_at >= :startDate', { startDate })
.andWhere('booking.created_at < :endDate', { endDate })
.getCount();
}
/** Find a booking by reference with files and relations. */
async findByReferenceWithFiles(reference: string): Promise<Booking | null> {
return this.findByIdWithFiles(
(
await this.repository.findOne({ where: { reference }, select: ['id'] })
)?.id ?? '',
);
}
/** Find a booking by ID with files, containers, and config relations. */
async findByIdWithFiles(id: string): Promise<Booking | null> {
if (!id) return null;
const booking = await this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('booking.company', 'company')
// .leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.train', 'train')
.leftJoinAndSelect('booking.serviceType', 'st')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.originYard', 'oy')
.leftJoinAndSelect('booking.destinationYard', 'dy')
.leftJoinAndSelect('booking.shippingLine', 'sl')
.leftJoinAndSelect('booking.approvalSteps', 'steps')
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
.where('booking.id = :id', { id })
.leftJoinAndMapMany(
'booking.files',
FileRecord,
'file',
"file.resource_id = booking.id AND file.resource = 'bookings'",
)
.getOne();
return booking ?? null;
}
/** Persist booking container rows with weight rule results. */
async createContainers(
bookingId: string,
containers: Array<{
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
weightResult: ContainerWeightResult;
}>,
): Promise<BookingContainer[]> {
const containerRepo = this.dataSource.getRepository(BookingContainer);
const typeRepo = this.dataSource.getRepository(ContainerType);
const saved: BookingContainer[] = [];
for (const item of containers) {
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const totalVgm = item.quantity * item.vgmPerUnitTons;
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
const row = containerRepo.create({
bookingId,
containerTypeId: item.containerTypeId,
quantity: item.quantity,
vgmPerUnitTons: item.vgmPerUnitTons,
totalVgmTons: totalVgm,
wagonsRequired,
weightLimitRuleId: item.weightResult.weightLimitRuleId,
isOverweight: item.weightResult.isOverweight,
overweightExcessTons: item.weightResult.overweightExcessTons,
});
saved.push(await containerRepo.save(row));
}
return saved;
}
/** SQL aggregate wagon count for a booking. */
async calculateWagonCount(bookingId: string): Promise<number> {
const result = await this.dataSource
.createQueryBuilder()
.select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total')
.from(BookingContainer, 'bc')
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
.where('bc.booking_id = :bookingId', { bookingId })
.getRawOne<{ total: string }>();
return Number(result?.total ?? 0);
}
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides).
*/
async findComplementaryConsolidationPartner(
booking: Booking,
slot: {
containerTypeId: string;
quantity: number;
containersPerWagon: number;
},
): Promise<Booking | null> {
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
return this.repository
.createQueryBuilder('b')
.innerJoinAndSelect('b.bookingContainers', 'bc')
.innerJoin('bc.containerType', 'ct')
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL')
.andWhere('b.status IN (:...statuses)', {
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'],
})
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
})
.andWhere('b.destinationYardId = :destinationYardId', {
destinationYardId: booking.destinationYardId,
})
.andWhere('b.tradeDirection = :tradeDirection', {
tradeDirection: booking.tradeDirection,
})
.andWhere('bc.containerTypeId = :containerTypeId', { containerTypeId })
.andWhere('(bc.quantity % :perWagon) > 0', { perWagon })
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
quantity,
perWagon,
})
.orderBy('b.createdAt', 'ASC')
.getOne();
}
/** Try each partial-wagon line until a complementary partner booking is found. */
async findConsolidationPartner(
booking: Booking,
slots: Array<{
containerTypeId: string;
quantity: number;
containersPerWagon: number;
}>,
): Promise<Booking | null> {
for (const slot of slots) {
const partner = await this.findComplementaryConsolidationPartner(booking, slot);
if (partner) return partner;
}
return null;
}
/** Pair two bookings for consolidation. */
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
status: 'CONSOLIDATED',
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
status: 'CONSOLIDATED',
} as never);
}
/** Un-pair a consolidation. */
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
status: 'PENDING_CONSOLIDATION',
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: null,
status: 'PENDING_CONSOLIDATION',
} as never);
}
/** Delete all containers for a booking (used on draft update). */
async deleteContainers(bookingId: string): Promise<void> {
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
}
/** Lowest-order pending approval step (sequential enforcement). */
async findNextPendingApprovalStep(
bookingId: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, status: 'PENDING' },
order: { stepOrder: 'ASC' },
});
}
async findApprovalStepById(
bookingId: string,
stepId: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, id: stepId },
});
}
/** Get pending approval step for a role (must match next in sequence). */
async findPendingApprovalStep(
bookingId: string,
requiredRole: string,
): Promise<BookingApprovalStep | null> {
const next = await this.findNextPendingApprovalStep(bookingId);
if (!next || next.requiredRole !== requiredRole) return null;
return next;
}
/** Mark an approval step complete. */
async completeApprovalStep(
stepId: string,
actorId: string,
status: 'APPROVED' | 'REJECTED',
remarks?: string,
): Promise<void> {
await this.dataSource.getRepository(BookingApprovalStep).update(stepId, {
status,
actionedByStaffId: actorId,
actionedAt: new Date(),
remarks,
});
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(bookingId: string): Promise<boolean> {
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
where: { bookingId, status: 'PENDING' },
});
return pending === 0;
}
/** Persist cargo modifiers linked to rate snapshots. */
async createCargoModifiers(
rows: Array<{
bookingId: string;
surchargeTypeId: string;
triggerValue: number | null;
calculatedAmount: number;
rateSnapshotId: string;
}>,
): Promise<BookingCargoModifier[]> {
const repo = this.dataSource.getRepository(BookingCargoModifier);
const saved: BookingCargoModifier[] = [];
for (const row of rows) {
saved.push(await repo.save(repo.create(row)));
}
return saved;
}
/** Find rate snapshot by rate id for a booking. */
async findRateSnapshotByRateId(
bookingId: string,
rateId: string,
): Promise<BookingRateSnapshot | null> {
return this.dataSource.getRepository(BookingRateSnapshot).findOne({
where: { bookingId, rateId },
});
}
async createReviewNote(
bookingId: string,
note: string,
type: ReviewNoteType,
authorId?: string,
): Promise<BookingReviewNote> {
const repo = this.dataSource.getRepository(BookingReviewNote);
return repo.save(
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
);
}
async findLatestReviewNote(
bookingId: string,
type?: ReviewNoteType,
): Promise<BookingReviewNote | null> {
const repo = this.dataSource.getRepository(BookingReviewNote);
return repo.findOne({
where: type ? { bookingId, type } : { bookingId },
order: { createdAt: 'DESC' },
});
}
async clearPricingArtifacts(bookingId: string): Promise<void> {
await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId });
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
}
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
async findQueue(options: {
status: string | string[];
page?: number;
pageSize?: number;
excludeBulk?: boolean;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ items: Booking[]; total: number }> {
const page = options.page ?? 1;
const pageSize = options.pageSize ?? 20;
const statuses = Array.isArray(options.status) ? options.status : [options.status];
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.status IN (:...statuses)', { statuses });
if (options.excludeBulk) {
qb.andWhere("booking.freight_type = 'CONTAINER'");
}
const sortField =
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
const [items, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
/** Paginated list with optional multi-status filter (API tab queues). */
async findAllPaginated(options: BookingListFilterOptions & {
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ items: Booking[]; total: number }> {
const page = options.page;
const pageSize = options.pageSize;
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
const sortField =
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
const [items, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.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 getListSummaryMetrics(
options: BookingListFilterOptions & {
page: number;
pageSize: number;
needsActionStatuses: readonly string[];
urgentPriorityThreshold: number;
},
): Promise<{
inQueue: number;
onThisPage: number;
needsAction: number;
urgent: number;
}> {
const baseQb = () => {
const qb = this.repository
.createQueryBuilder('booking')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
return qb;
};
const inQueue = await baseQb().getCount();
const needsAction = await baseQb()
.andWhere('booking.status IN (:...needsActionStatuses)', {
needsActionStatuses: [...options.needsActionStatuses],
})
.getCount();
const urgent = await baseQb()
.andWhere('booking.priority_score >= :urgentPriorityThreshold', {
urgentPriorityThreshold: options.urgentPriorityThreshold,
})
.getCount();
const offset = (options.page - 1) * options.pageSize;
const onThisPage = Math.min(
options.pageSize,
Math.max(0, inQueue - offset),
);
return { inQueue, onThisPage, needsAction, urgent };
}
private applyListFilters(
qb: SelectQueryBuilder<Booking>,
options: BookingListFilterOptions,
): void {
if (options.statuses?.length) {
qb.andWhere('booking.status IN (:...statuses)', {
statuses: options.statuses,
});
} else if (options.status) {
qb.andWhere('booking.status = :status', { status: options.status });
}
if (options.companyId) {
qb.andWhere('booking.company_id = :companyId', {
companyId: options.companyId,
});
}
if (options.contractType) {
qb.andWhere('booking.contract_type = :contractType', {
contractType: options.contractType,
});
}
if (options.serviceTypeId) {
qb.andWhere('booking.service_type_id = :serviceTypeId', {
serviceTypeId: options.serviceTypeId,
});
}
if (options.cargoTypeId) {
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
cargoTypeId: options.cargoTypeId,
});
}
if (options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,
});
}
if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,
});
}
if (options.paymentCurrency) {
qb.andWhere('booking.payment_currency = :paymentCurrency', {
paymentCurrency: options.paymentCurrency,
});
}
if (options.allowConsolidation !== undefined) {
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
allowConsolidation: options.allowConsolidation,
});
}
if (options.consolidationPaired === 'true') {
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
} else if (options.consolidationPaired === 'false') {
qb.andWhere('booking.consolidation_partner_id IS NULL');
}
}
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
skip: number;
take: number;
order: Record<string, 'ASC' | 'DESC'>;
}): Promise<[Booking[], number]> {
return this.repository.findAndCount({
where,
skip: options.skip,
take: options.take,
order: options.order,
});
}
findContractSignatures(bookingId: string): Promise<BookingContractSignature[]> {
return this.dataSource.getRepository(BookingContractSignature).find({
where: { bookingId },
relations: ['signatureFile'],
order: { signedAt: 'ASC' },
});
}
findContractSignature(
bookingId: string,
role: ContractSignerRole,
): Promise<BookingContractSignature | null> {
return this.dataSource.getRepository(BookingContractSignature).findOne({
where: { bookingId, signerRole: role },
relations: ['signatureFile'],
});
}
async saveContractSignature(
data: Partial<BookingContractSignature>,
): Promise<BookingContractSignature> {
const repo = this.dataSource.getRepository(BookingContractSignature);
const existing = await repo.findOne({
where: {
bookingId: data.bookingId!,
signerRole: data.signerRole!,
},
});
if (existing) {
Object.assign(existing, data);
return repo.save(existing);
}
return repo.save(repo.create(data));
}
}

View File

@@ -1,20 +1,416 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import {
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
BOOKING_STATUSES,
CUSTOMER_EDITABLE_STATUSES,
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
import { BookingsRepository } from "./bookings.repository";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { Booking } from "./entities/booking.entity";
const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
] as const;
@Injectable()
export class BookingsService {
constructor(private readonly bookingsRepository: BookingsRepository) {}
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
// private readonly customersService: CustomersService,
private readonly companiesService: CompaniesService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
) {}
/** Generate a unique booking reference number. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
/** Build evaluation input from booking freight shape. */
private async buildEvalInput(dto: {
freightType: FreightType;
cargoTypeId?: string | null;
serviceTypeId: string;
paymentCurrency: string;
tradeDirection: string;
isHazardous?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
const containerLines =
dto.freightType === 'CONTAINER' ? dto.containers : [];
const containers = await Promise.all(
containerLines.map(async (c) => {
const ct = await this.containerTypesService.findById(c.containerTypeId);
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
return {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
};
}),
);
return {
freightType: dto.freightType,
cargoTypeId: dto.cargoTypeId ?? null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
allowConsolidation:
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
shippingLineId: dto.shippingLineId,
containers,
};
}
/**
* Enable consolidation when any container line leaves a wagon partially filled
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
*/
private async resolveConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise<boolean> {
if (explicit === false) return false;
const needs = await this.consolidationService.needsConsolidation(
containers.map((c) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
})),
);
if (needs) return true;
return explicit ?? false;
}
/** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */
private async tryAutoConsolidate(booking: Booking): Promise<{
booking: Booking;
messages: string[];
}> {
const messages: string[] = [];
if (!booking.allowConsolidation || booking.consolidationPartnerId) {
return { booking, messages };
}
const slots = await this.consolidationService.slotsFromBooking(booking);
if (slots.length === 0) {
return { booking, messages };
}
const partner = await this.bookingsRepository.findConsolidationPartner(
booking,
slots,
);
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
const paired = await this.findById(booking.id);
messages.push(
this.consolidationService.describePaired(partner.reference, slots),
);
return { booking: paired, messages };
}
if (booking.status === 'DRAFT') {
await this.bookingsRepository.update(booking.id, {
status: 'PENDING_CONSOLIDATION',
} as never);
}
const pending = await this.findById(booking.id);
messages.push(this.consolidationService.describePending(pending, slots));
return { booking: pending, messages };
}
/** Create a new freight booking. */
async create(dto: CreateBookingDto): Promise<Booking> {
return this.bookingsRepository.create({
...dto,
scheduledDate: new Date(dto.scheduledDate),
async create(
dto: CreateBookingDto,
files: Express.Multer.File[],
userId?: string,
): Promise<{ booking: Booking; warnings: string[] }> {
const warnings: string[] = [];
// let customerId = dto.customerId;
// if (!customerId) {
// if (!userId) {
// throw new BadRequestException(
// 'customerId is required or must be resolvable from auth token',
// );
// }
// const customer = await this.customersService.findByUserId(userId);
// customerId = customer.id;
// }
let companyId = dto.companyId;
if (!companyId) {
if (!userId) {
throw new BadRequestException(
'companyId is required or must be resolvable from auth token',
);
}
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
companyId = company.id;
}
const reference = dto.reference || (await this.generateReference());
const containers = dto.containers ?? [];
assertFreightShape({
freightType: dto.freightType,
cargoTypeId: dto.cargoTypeId,
containers,
});
const allowConsolidation =
dto.freightType === 'CONTAINER'
? await this.resolveConsolidation(containers, dto.allowConsolidation)
: false;
const evalInput = await this.buildEvalInput({
freightType: dto.freightType as FreightType,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId,
containers,
});
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const booking = await this.bookingsRepository.create({
reference,
companyId,
trainId: dto.trainId,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
firstMilePickupAddress: dto.firstMilePickupAddress,
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
tradeDirection: dto.tradeDirection,
freightType: dto.freightType,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
isHazardous: dto.isHazardous ?? false,
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
scheduledDate: new Date(dto.scheduledDate),
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: 'DRAFT',
allowConsolidation,
priorityScore: ruleResult.priorityScore,
totalAmount: 0,
paymentStatus: 'PENDING',
});
if (dto.freightType === 'CONTAINER') {
await this.bookingsRepository.createContainers(
booking.id,
containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
weightResult: ruleResult.containerWeightResults[i],
})),
);
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
warnings.push(`Estimated wagons required: ${wagonCount}`);
}
if (files.length > 0) {
try {
await this.filesService.uploadMany(booking.id, 'bookings', files);
} catch {
warnings.push('File upload failed — booking was created without attached files.');
}
}
let full = await this.findById(booking.id);
if (allowConsolidation) {
const consolidation = await this.tryAutoConsolidate(full);
full = consolidation.booking;
warnings.push(...consolidation.messages);
}
return { booking: full, warnings };
}
/** Update a draft booking. */
async update(
id: string,
dto: UpdateBookingDto,
files: Express.Multer.File[],
): Promise<{ booking: Booking; warnings: string[] }> {
const existing = await this.findById(id);
if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
throw new BadRequestException(
'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
);
}
const warnings: string[] = [];
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
let containers =
dto.containers ??
existing.bookingContainers?.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
})) ??
[];
let cargoTypeId =
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
if (freightType === 'BULK') {
containers = [];
if (dto.containers !== undefined) {
await this.bookingsRepository.deleteContainers(id);
}
} else {
cargoTypeId = null;
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) {
throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
}
}
assertFreightShape({ freightType, cargoTypeId, containers });
const allowConsolidation =
freightType === 'CONTAINER'
? await this.resolveConsolidation(
containers,
dto.allowConsolidation ?? existing.allowConsolidation,
)
: false;
const evalInput = await this.buildEvalInput({
freightType,
cargoTypeId,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
containers,
});
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const updates: Record<string, unknown> = {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
};
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate);
delete updates.containers;
await this.bookingsRepository.update(id, updates);
if (freightType === 'CONTAINER' && dto.containers) {
await this.bookingsRepository.deleteContainers(id);
await this.bookingsRepository.createContainers(
id,
dto.containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
weightResult: ruleResult.containerWeightResults[i],
})),
);
}
if (files.length > 0) {
await this.filesService.uploadMany(id, 'bookings', files);
}
let booking = await this.findById(id);
if (allowConsolidation && !booking.consolidationPartnerId) {
const consolidation = await this.tryAutoConsolidate(booking);
booking = consolidation.booking;
warnings.push(...consolidation.messages);
}
return { booking, warnings };
}
/** Parse comma-separated or repeated status query values. */
private parseStatusFilter(filter: FilterBookingDto): {
statuses?: string[];
status?: string;
} {
const allowed = new Set<string>(BOOKING_STATUSES);
const raw = filter.statuses;
const statusList = raw
? raw
.split(',')
.map((s) => s.trim())
.filter((s) => allowed.has(s))
: [];
if (statusList.length > 0) {
return { statuses: statusList };
}
if (filter.status && allowed.has(filter.status)) {
return { status: filter.status };
}
return {};
}
/** Return a paginated list of bookings matching the filter. */
@@ -23,30 +419,233 @@ export class BookingsService {
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const [items, total] = await this.bookingsRepository.findAndCount({
where: {
...(filter.status ? { status: filter.status } : {}),
...(filter.customerId ? { customerId: filter.customerId } : {}),
},
skip: (page - 1) * pageSize,
take: pageSize,
order: { createdAt: "DESC" },
const statusFilter = this.parseStatusFilter(filter);
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
return { items, total };
}
/** Get a single booking by ID, throwing if not found. */
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const listFilter = {
...statusFilter,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};
const [statusCounts, metrics] = await Promise.all([
this.bookingsRepository.getStatusCounts(),
this.bookingsRepository.getListSummaryMetrics({
...listFilter,
page,
pageSize,
needsActionStatuses: NEEDS_ACTION_STATUSES,
urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD,
}),
]);
return {
metrics,
tabs: mapStatusCountsToTabs(statusCounts),
};
}
/** Get a single booking by ID with files. */
async findById(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) {
throw new NotFoundException(`Booking ${id} not found`);
}
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {
const objectName = this.minioService.getObjectNameFromUrl(file.url);
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl };
}),
);
}
return booking;
}
/** Soft-delete a booking. */
async findByReference(reference: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
if (!booking) {
throw new NotFoundException(`Booking with reference "${reference}" not found`);
}
return this.findById(booking.id);
}
/** Upload documents for a DRAFT booking. */
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
}
await this.filesService.uploadMany(id, 'bookings', files);
return this.findById(id);
}
async remove(id: string): Promise<void> {
await this.findById(id);
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT bookings can be deleted');
}
await this.bookingsRepository.softDelete(id);
}
async findQueue(
queue: string,
filter: FilterBookingDto,
options?: { excludeBulk?: boolean },
): Promise<{ items: Booking[]; total: number }> {
const statusMap: Record<string, string | string[]> = {
intake: 'SUBMITTED',
approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
marketing: 'SIGNED_CUSTOMER',
finance: 'FULLY_EXECUTED',
};
const status = statusMap[queue];
if (!status) {
throw new BadRequestException(`Unknown queue: ${queue}`);
}
return this.bookingsRepository.findQueue({
status,
page: filter.page,
pageSize: filter.pageSize,
excludeBulk: options?.excludeBulk ?? queue === 'approval',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
async requestConsolidation(id: string): Promise<{
booking: Booking;
partner: Booking | null;
paired: boolean;
message: string;
}> {
const booking = await this.findById(id);
if (!booking.allowConsolidation) {
throw new BadRequestException('Booking is not eligible for consolidation');
}
const needs = await this.consolidationService.needsConsolidationFromBooking(
booking,
);
if (!needs) {
throw new BadRequestException(
'Booking already fills whole wagon(s) for all container lines; consolidation is not required',
);
}
if (booking.consolidationPartnerId) {
throw new ConflictException('Booking is already paired for consolidation');
}
const result = await this.tryAutoConsolidate(booking);
const partner = result.booking.consolidationPartnerId
? await this.findById(result.booking.consolidationPartnerId)
: null;
return {
booking: result.booking,
partner,
paired: partner !== null,
message: result.messages[0] ?? '',
};
}
async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> {
const booking = await this.findById(id);
if (!booking.consolidationPartnerId) {
throw new BadRequestException('Booking has no consolidation partner');
}
const partnerId = booking.consolidationPartnerId;
await this.bookingsRepository.unpairConsolidation(id, partnerId);
return {
booking: await this.findById(id),
partner: await this.findById(partnerId),
};
}
async getConsolidationDetails(id: string): Promise<{
booking: Booking;
partner: Booking | null;
splitBilling: { bookingShare: number; partnerShare: number } | null;
wagonSlots: Awaited<ReturnType<ConsolidationService['slotsFromBooking']>>;
statusMessage: string;
}> {
const booking = await this.findById(id);
const wagonSlots = await this.consolidationService.slotsFromBooking(booking);
if (!booking.consolidationPartnerId) {
const statusMessage =
booking.status === 'PENDING_CONSOLIDATION'
? this.consolidationService.describePending(booking, wagonSlots)
: wagonSlots.length > 0
? 'Consolidation may be required; no partner paired yet.'
: 'No wagon consolidation needed.';
return {
booking,
partner: null,
splitBilling: null,
wagonSlots,
statusMessage,
};
}
const partner = await this.findById(booking.consolidationPartnerId);
return {
booking,
partner,
splitBilling: {
bookingShare: Number(booking.totalAmount),
partnerShare: Number(partner.totalAmount),
},
wagonSlots,
statusMessage: this.consolidationService.describePaired(
partner.reference,
wagonSlots,
),
};
}
}

View File

@@ -0,0 +1,123 @@
import { Injectable } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { Booking } from './entities/booking.entity';
export interface ConsolidationSlot {
containerTypeId: string;
containerTypeCode: string;
quantity: number;
containersPerWagon: number;
remainder: number;
slotsNeeded: number;
}
export interface ConsolidationAttemptResult {
booking: Booking;
partner: Booking | null;
paired: boolean;
messages: string[];
}
/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */
export function containersPerWagon(wagonsPerUnit: number): number {
const wpu = Number(wagonsPerUnit);
if (!wpu || wpu <= 0) return 1;
return Math.max(1, Math.round(1 / wpu));
}
export function wagonRemainder(quantity: number, perWagon: number): number {
const r = quantity % perWagon;
return r;
}
export function slotsNeededToFillWagon(quantity: number, perWagon: number): number {
const remainder = wagonRemainder(quantity, perWagon);
if (remainder === 0) return 0;
return perWagon - remainder;
}
/** Two bookings' quantities for the same type complete whole wagon(s). */
export function quantitiesComplementWagon(
q1: number,
q2: number,
perWagon: number,
): boolean {
return (
wagonRemainder(q1, perWagon) > 0 &&
wagonRemainder(q2, perWagon) > 0 &&
(q1 + q2) % perWagon === 0
);
}
@Injectable()
export class ConsolidationService {
constructor(private readonly containerTypesService: ContainerTypesService) {}
async slotsFromContainerLines(
lines: Array<{ containerTypeId: string; quantity: number }>,
): Promise<ConsolidationSlot[]> {
const slots: ConsolidationSlot[] = [];
for (const line of lines) {
const ct = await this.containerTypesService.findById(line.containerTypeId);
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
const remainder = wagonRemainder(line.quantity, perWagon);
if (remainder === 0) continue;
slots.push({
containerTypeId: line.containerTypeId,
containerTypeCode: ct.code,
quantity: line.quantity,
containersPerWagon: perWagon,
remainder,
slotsNeeded: perWagon - remainder,
});
}
return slots;
}
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
const lines =
booking.bookingContainers?.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
})) ?? [];
return this.slotsFromContainerLines(lines);
}
async needsConsolidation(
lines: Array<{ containerTypeId: string; quantity: number }>,
): Promise<boolean> {
const slots = await this.slotsFromContainerLines(lines);
return slots.length > 0;
}
async needsConsolidationFromBooking(booking: Booking): Promise<boolean> {
const slots = await this.slotsFromBooking(booking);
return slots.length > 0;
}
describePending(_booking: Booking, slots: ConsolidationSlot[]): string {
if (slots.length === 0) {
return 'Booking does not require wagon consolidation.';
}
const parts = slots.map(
(s) =>
`${s.slotsNeeded} more × ${s.containerTypeCode} (${s.containersPerWagon} per wagon; you have ${s.quantity})`,
);
return (
`No compatible partner found yet. Your booking is queued (PENDING_CONSOLIDATION). ` +
`Waiting for: ${parts.join('; ')}. You will be notified when another customer fills the wagon.`
);
}
describePaired(partnerReference: string, slots: ConsolidationSlot[]): string {
const parts = slots.map(
(s) =>
`${s.containerTypeCode}: ${s.quantity} + partner fills wagon (${s.containersPerWagon} per wagon)`,
);
return (
`Consolidation partner found (${partnerReference}). ` +
`Shared wagon confirmed: ${parts.join('; ')}.`
);
}
}

View File

@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
export class BookingListSummaryMetricsDto {
@ApiProperty({ example: 42 })
inQueue!: number;
@ApiProperty({ example: 10 })
onThisPage!: number;
@ApiProperty({ example: 8 })
needsAction!: number;
@ApiProperty({ example: 3 })
urgent!: number;
}
export class BookingListSummaryTabsDto {
@ApiProperty() all!: number;
@ApiProperty() intake!: number;
@ApiProperty() in_approval!: number;
@ApiProperty() approved_contract!: number;
@ApiProperty() payment!: number;
@ApiProperty() operations!: number;
@ApiProperty() completed!: number;
@ApiProperty() closed!: number;
}
export class BookingListSummaryDto {
@ApiProperty({ type: BookingListSummaryMetricsDto })
metrics!: BookingListSummaryMetricsDto;
@ApiProperty({ type: BookingListSummaryTabsDto })
tabs!: BookingListSummaryTabsDto;
}

View File

@@ -0,0 +1,107 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BookingReferenceYardDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Mojo Dry Port' })
name!: string;
@ApiProperty({ example: 'MOJO' })
code!: string;
@ApiProperty({ example: 'Ethiopia' })
country!: string;
}
export class BookingReferenceContainerTypeDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Dry' })
name!: string;
@ApiProperty({ example: '20GP' })
code!: string;
@ApiProperty()
is_reefer!: boolean;
@ApiProperty({ example: 0.5, description: 'Wagon fraction per container' })
wagons_per_unit!: number;
}
export class BookingReferenceContainerSizeGroupDto {
@ApiProperty({ example: '20ft' })
size!: string;
@ApiProperty({ type: [BookingReferenceContainerTypeDto] })
types!: BookingReferenceContainerTypeDto[];
}
export class BookingReferenceServiceDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Rail Transport Only' })
name!: string;
@ApiProperty({ example: 'RAIL' })
code!: string;
}
export class BookingReferenceShippingLineDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'MSC' })
name!: string;
@ApiProperty({ example: 'MSC' })
code!: string;
}
export class BookingReferenceCargoTypeChildDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Coffee' })
name!: string;
@ApiProperty({ example: 'BULK_COFFEE' })
code!: string;
@ApiProperty()
show_free_text_box!: boolean;
}
export class BookingReferenceCargoTypeGroupDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'Bulk Cargo' })
name!: string;
@ApiProperty({ example: 'BULK' })
code!: string;
@ApiPropertyOptional({ type: [BookingReferenceCargoTypeChildDto] })
children?: BookingReferenceCargoTypeChildDto[];
}
export class BookingReferenceDataDto {
@ApiProperty({ type: [BookingReferenceYardDto] })
yard!: BookingReferenceYardDto[];
@ApiProperty({ type: [BookingReferenceContainerSizeGroupDto] })
containers!: BookingReferenceContainerSizeGroupDto[];
@ApiProperty({ type: [BookingReferenceServiceDto] })
service!: BookingReferenceServiceDto[];
@ApiProperty({ type: [BookingReferenceShippingLineDto] })
shipping_line!: BookingReferenceShippingLineDto[];
@ApiProperty({ type: [BookingReferenceCargoTypeGroupDto] })
cargo_type!: BookingReferenceCargoTypeGroupDto[];
}

View File

@@ -0,0 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ContractSignatureDto {
@ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
role!: string;
@ApiProperty()
signerDisplayName!: string;
@ApiProperty()
signedAt!: string;
@ApiPropertyOptional()
signatureImageUrl?: string | null;
}
export class ContractViewDto {
@ApiProperty()
bookingId!: string;
@ApiProperty()
reference!: string;
@ApiProperty()
status!: string;
@ApiProperty()
templateKey!: string;
@ApiProperty()
title!: string;
@ApiProperty({ description: 'Full HTML document for in-browser display' })
html!: string;
@ApiProperty()
canSignCustomer!: boolean;
@ApiProperty()
canSignStaff!: boolean;
@ApiProperty()
hasContractDocument!: boolean;
@ApiProperty({ type: [ContractSignatureDto] })
signatures!: ContractSignatureDto[];
@ApiPropertyOptional()
pricingSchedule?: Record<string, unknown>;
}

View File

@@ -1,33 +1,197 @@
import { Freight } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
IsEnum,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
} from "class-validator";
Validate,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
export {
BOOKING_STATUSES,
CONTRACT_TYPES,
EQUIPMENT_RETURNS,
FREIGHT_TYPES,
TRADE_DIRECTIONS,
PAYMENT_CURRENCIES,
};
export class CreateBookingContainerDto {
@ApiProperty({ format: 'uuid', description: 'FK to container_types.id' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ description: 'Quantity of containers', minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmPerUnitTons!: number;
}
export class CreateBookingDto {
/** Class-level freight shape check (not a request field). */
@Validate(BookingFreightShapeConstraint)
freightShapeValidation?: boolean;
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
@IsOptional()
@IsString()
reference!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
reference?: string;
// @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer (legacy)' })
// @IsOptional()
// @IsUUID()
// customerId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@IsOptional()
@IsUUID()
customerId!: string;
companyId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
trainId?: string;
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString()
scheduledDate!: string;
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])
contractType!: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
@Transform(({ value }) => (value === '' || value == null ? undefined : value))
previousContractId?: string;
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
@IsUUID()
serviceTypeId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@ApiProperty({ enum: EQUIPMENT_RETURNS })
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn!: string;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
originYardId!: string;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
@IsUUID()
destinationYardId!: string;
@ApiProperty({ enum: TRADE_DIRECTIONS })
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
@ApiProperty({ enum: FREIGHT_TYPES, description: 'CONTAINER or BULK (mutually exclusive cargo shape)' })
@IsIn([...FREIGHT_TYPES])
freightType!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Required for BULK; must be omitted for CONTAINER',
})
@ValidateIf((o) => o.freightType === 'BULK')
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
cargoFreeText?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'FK to shipping_lines.id' })
@IsOptional()
@IsUUID()
shippingLineId?: string;
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
@IsNumber()
@Min(0)
totalAmount!: number;
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsEnum(Freight.BookingStatus)
status?: Freight.BookingStatus;
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
pnrCode?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
financialTerms?: string;
@ApiPropertyOptional({
type: [CreateBookingContainerDto],
description: 'Required for CONTAINER (min 1 line); must be empty for BULK',
})
@ValidateIf((o) => o.freightType === 'CONTAINER')
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingContainerDto)
containers?: CreateBookingContainerDto[];
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
allowConsolidation?: boolean;
}

View File

@@ -1,25 +1,95 @@
import { Freight } from "@edr/types";
import { Type } from "class-transformer";
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator";
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import {
BOOKING_STATUSES,
FREIGHT_TYPES,
PAYMENT_CURRENCIES,
TRADE_DIRECTIONS,
} from './create-booking.dto';
export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
@IsOptional()
@IsEnum(Freight.BookingStatus)
status?: Freight.BookingStatus;
@IsIn([...BOOKING_STATUSES])
status?: string;
@ApiPropertyOptional({
description:
'Filter by statuses: comma-separated (PENDING_APPROVAL,APPROVED) or repeated query params. Overrides status when set.',
})
@IsOptional()
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
if (Array.isArray(value)) return value.map(String).join(',');
return String(value);
})
statuses?: string;
// @ApiPropertyOptional({ format: 'uuid' })
// @IsOptional()
// @IsUUID()
// customerId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
customerId?: string;
companyId?: string;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
contractType?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
@IsUUID()
serviceTypeId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@IsOptional()
@IsIn([...FREIGHT_TYPES])
freightType?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])
tradeDirection?: string;
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
@IsOptional()
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
allowConsolidation?: boolean;
@ApiPropertyOptional({ description: 'true | false — filter paired consolidation' })
@IsOptional()
consolidationPaired?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
page?: number;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
pageSize?: number;
@ApiPropertyOptional({ default: 'createdAt' })
@IsOptional()
sortBy?: string;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
export class PriceLineItemDto {
@ApiProperty()
code!: string;
@ApiProperty()
description!: string;
@ApiProperty()
amount!: number;
@ApiProperty()
currency!: string;
}
export class GeneratePriceResponseDto {
@ApiProperty()
bookingId!: string;
@ApiProperty()
totalAmount!: number;
@ApiProperty()
currency!: string;
@ApiProperty({ type: [PriceLineItemDto] })
lineItems!: PriceLineItemDto[];
@ApiProperty({ type: [String] })
warnings!: string[];
}

View File

@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
export class InAppPaymentReceiptDto {
@ApiProperty({ example: true })
success!: boolean;
@ApiProperty({ example: 'TELEBIRR' })
provider!: string;
@ApiProperty({ example: 'TB-BK-2026-000123-1717584000000' })
providerRef!: string;
@ApiProperty({ example: 15000 })
amount!: number;
@ApiProperty({ example: 'ETB' })
currency!: string;
@ApiProperty({ example: '2026-06-05T12:00:00.000Z' })
paidAt!: string;
}
export class PayBookingResponseDto {
@ApiProperty({ type: InAppPaymentReceiptDto })
paymentReceipt!: InAppPaymentReceiptDto;
}

Some files were not shown because too many files have changed in this diff Show More