mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 05:58:18 +00:00
5
.gitignore
vendored
5
.gitignore
vendored
@@ -26,3 +26,8 @@ coverage/
|
|||||||
branch_structure.json
|
branch_structure.json
|
||||||
temp_auto_push.bat
|
temp_auto_push.bat
|
||||||
temp_interactive_push.bat
|
temp_interactive_push.bat
|
||||||
|
|
||||||
|
# emacs cache files
|
||||||
|
*~
|
||||||
|
\#*\#
|
||||||
|
.\#*
|
||||||
|
|||||||
11
.npmrc
11
.npmrc
@@ -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
1002
ITMLS_DB_Design.md
Normal file
File diff suppressed because it is too large
Load Diff
258
README.md
258
README.md
@@ -93,10 +93,194 @@ cd edr-platform
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. Install Dependencies
|
### 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
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
### 3. Environment Configuration
|
### 3. Environment Configuration
|
||||||
```bash
|
```bash
|
||||||
# Copy environment template
|
# Copy environment template
|
||||||
@@ -765,3 +949,77 @@ For technical support or questions:
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Built with ❤️ for Ethio-Djibouti Railway**
|
**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
0
WagonForm.tsx
Normal file
@@ -5,3 +5,45 @@ DB_PORT=5433
|
|||||||
DB_USER=postgres
|
DB_USER=postgres
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
DB_NAME=edr_freight
|
DB_NAME=edr_freight
|
||||||
|
|
||||||
|
# Telebirr payment gateway (freight merchant credentials)
|
||||||
|
TELEBIRR_BASE_URL=
|
||||||
|
TELEBIRR_WEB_BASE_URL=
|
||||||
|
TELEBIRR_FABRIC_APP_ID=
|
||||||
|
TELEBIRR_APP_SECRET=
|
||||||
|
TELEBIRR_MERCHANT_APP_ID=
|
||||||
|
TELEBIRR_MERCHANT_CODE=
|
||||||
|
TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr
|
||||||
|
TELEBIRR_RETURN_URL=
|
||||||
|
TELEBIRR_TIMEOUT_EXPRESS=15m
|
||||||
|
TELEBIRR_PRIVATE_KEY=
|
||||||
|
TELEBIRR_PUBLIC_KEY=
|
||||||
|
TELEBIRR_INSECURE_TLS=false
|
||||||
|
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
||||||
|
JWT_SECRET=
|
||||||
|
JWT_ACCESS_TOKEN_SECRET=
|
||||||
|
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
|
||||||
|
|||||||
@@ -3,6 +3,21 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": false,
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"include": "migrations/**/*",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": "contracts/templates/**/*",
|
||||||
|
"watchAssets": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": "modules/payment/templates/**/*",
|
||||||
|
"watchAssets": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"description": "EDR Freight Management API",
|
"description": "EDR Freight Management API",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
||||||
|
"predev": "pnpm run clean",
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
|
"prebuild": "pnpm run clean",
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
@@ -13,35 +16,48 @@
|
|||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tria-plc/api-common": "^0.1.0",
|
|
||||||
"@tria-plc/iamapi-common": "^0.1.0",
|
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
"@edr/payment-providers": "workspace:*",
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/event-emitter": "^2.0.4",
|
||||||
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/microservices": "^11.0.0",
|
"@nestjs/microservices": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/swagger": "^11.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"@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-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"handlebars": "^4.7.9",
|
||||||
|
"minio": "7.1.3",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
|
"puppeteer": "^24.2.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1"
|
||||||
"typeorm": "^0.3.20"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
"@edr/types": "workspace:*",
|
|
||||||
"@edr/eslint-config": "workspace:*",
|
"@edr/eslint-config": "workspace:*",
|
||||||
"@edr/tsconfig": "workspace:*",
|
"@edr/tsconfig": "workspace:*",
|
||||||
|
"@edr/types": "workspace:*",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.0",
|
"@nestjs/testing": "^11.0.0",
|
||||||
|
"@types/amqplib": "^0.10.8",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.13",
|
"@types/jest": "^29.5.13",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/pg": "^8.6.7",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
@@ -49,6 +65,7 @@
|
|||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typeorm": "^0.3.30",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
|||||||
206
apps/edr-freight-api/pnpm-lock.yaml
generated
Normal file
206
apps/edr-freight-api/pnpm-lock.yaml
generated
Normal 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: {}
|
||||||
@@ -1,36 +1,128 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
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 appConfig from "./config/app.config";
|
||||||
import databaseConfig from "./config/database.config";
|
import databaseConfig from "./config/database.config";
|
||||||
|
import telebirrConfig from "./config/telebirr.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
|
import { FilesModule } from "./modules/files/files.module";
|
||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
|
||||||
|
// import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
|
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||||
|
import { 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 { CustomersModule } from "./modules/customers/customers.module";
|
||||||
|
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, databaseConfig],
|
load: [appConfig, databaseConfig, telebirrConfig],
|
||||||
}),
|
}),
|
||||||
|
// EventEmitterModule.forRoot(),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||||
config.get<TypeOrmModuleOptions>("database")!,
|
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,
|
BookingsModule,
|
||||||
|
FilesModule,
|
||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
TrainsModule,
|
LocomotivesModule,
|
||||||
|
WagonTypesModule,
|
||||||
|
TrainSetsModule,
|
||||||
|
TrainSchedulesModule,
|
||||||
|
TrainSchedulingModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
|
CompaniesModule,
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
17
apps/edr-freight-api/src/common/booking-guards.ts
Normal file
17
apps/edr-freight-api/src/common/booking-guards.ts
Normal 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);
|
||||||
38
apps/edr-freight-api/src/common/freight-permission.guard.ts
Normal file
38
apps/edr-freight-api/src/common/freight-permission.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
109
apps/edr-freight-api/src/common/freight-permission.util.ts
Normal file
109
apps/edr-freight-api/src/common/freight-permission.util.ts
Normal 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);
|
||||||
|
}
|
||||||
12
apps/edr-freight-api/src/common/resolve-auth-user-id.ts
Normal file
12
apps/edr-freight-api/src/common/resolve-auth-user-id.ts
Normal 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;
|
||||||
|
}
|
||||||
18
apps/edr-freight-api/src/common/rule-engine-guards.ts
Normal file
18
apps/edr-freight-api/src/common/rule-engine-guards.ts
Normal 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)])),
|
||||||
|
);
|
||||||
15
apps/edr-freight-api/src/common/utils/generate-code.util.ts
Normal file
15
apps/edr-freight-api/src/common/utils/generate-code.util.ts
Normal 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, '');
|
||||||
|
}
|
||||||
@@ -1,19 +1,123 @@
|
|||||||
import { registerAs } from "@nestjs/config";
|
import { registerAs } from "@nestjs/config";
|
||||||
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
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,
|
||||||
|
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";
|
||||||
|
|
||||||
export default registerAs(
|
const iamEntities = [
|
||||||
"database",
|
DefaultPosition,
|
||||||
(): TypeOrmModuleOptions => ({
|
DefaultUnit,
|
||||||
|
EmployeePosition,
|
||||||
|
Employee,
|
||||||
|
OrganizationConfiguration,
|
||||||
|
GlobalOrganizationConfiguration,
|
||||||
|
OrganizationSetting,
|
||||||
|
OrganizationType,
|
||||||
|
Organization,
|
||||||
|
PositionConfiguration,
|
||||||
|
PositionPermission,
|
||||||
|
PositionTypeConfiguration,
|
||||||
|
PositionTypePermission,
|
||||||
|
PositionType,
|
||||||
|
Position,
|
||||||
|
Project,
|
||||||
|
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 => {
|
||||||
|
return {
|
||||||
type: "postgres",
|
type: "postgres",
|
||||||
host: process.env.DB_HOST ?? "localhost",
|
host: process.env.DB_HOST ?? "localhost",
|
||||||
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
||||||
username: process.env.DB_USER ?? "postgres",
|
username: process.env.DB_USER ?? "postgres",
|
||||||
password: process.env.DB_PASSWORD ?? "",
|
password: process.env.DB_PASSWORD ?? "",
|
||||||
database: process.env.DB_NAME ?? "edr_freight",
|
database: process.env.DB_NAME ?? "edr_freight",
|
||||||
entities: [__dirname + "/../**/*.entity.{ts,js}"],
|
schema: "public",
|
||||||
migrations: [__dirname + "/../../migrations/*.{ts,js}"],
|
extra: {
|
||||||
// Never enable synchronize in production. Use migrations.
|
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
|
||||||
synchronize: process.env.NODE_ENV === "development",
|
},
|
||||||
|
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",
|
logging: process.env.NODE_ENV === "development",
|
||||||
}),
|
};
|
||||||
);
|
});
|
||||||
|
|||||||
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal file
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export default registerAs("dmoney", () => ({
|
||||||
|
baseUrl: process.env.DMONEY_BASE_URL ?? "",
|
||||||
|
appId: process.env.DMONEY_APP_ID ?? "",
|
||||||
|
appSecret: process.env.DMONEY_APP_SECRET ?? "",
|
||||||
|
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
|
||||||
|
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
|
||||||
|
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? ""
|
||||||
|
}));
|
||||||
50
apps/edr-freight-api/src/config/ensure-postgres-schemas.ts
Normal file
50
apps/edr-freight-api/src/config/ensure-postgres-schemas.ts
Normal 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();
|
||||||
|
}
|
||||||
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export default registerAs("telebirr", () => ({
|
||||||
|
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
|
||||||
|
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
|
||||||
|
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
|
||||||
|
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
|
||||||
|
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
|
||||||
|
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
|
||||||
|
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
|
||||||
|
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
|
||||||
|
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
|
||||||
|
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
|
||||||
|
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
|
||||||
|
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
|
||||||
|
}));
|
||||||
224
apps/edr-freight-api/src/contracts/contract-clause-packs.ts
Normal file
224
apps/edr-freight-api/src/contracts/contract-clause-packs.ts
Normal 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;
|
||||||
|
}
|
||||||
116
apps/edr-freight-api/src/contracts/contract-pdf.service.ts
Normal file
116
apps/edr-freight-api/src/contracts/contract-pdf.service.ts
Normal 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-'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
apps/edr-freight-api/src/contracts/contract-template.registry.ts
Normal file
119
apps/edr-freight-api/src/contracts/contract-template.registry.ts
Normal 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 Ababa–Djibouti 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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
91
apps/edr-freight-api/src/contracts/templates/generic.hbs
Normal file
91
apps/edr-freight-api/src/contracts/templates/generic.hbs
Normal 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>
|
||||||
21
apps/edr-freight-api/src/data-source.ts
Normal file
21
apps/edr-freight-api/src/data-source.ts
Normal 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.
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +12,31 @@ import {
|
|||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
|
||||||
async function bootstrap() {
|
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.setGlobalPrefix("api");
|
||||||
app.useGlobalPipes(createValidationPipe());
|
app.useGlobalPipes(createValidationPipe());
|
||||||
@@ -27,9 +53,12 @@ async function bootstrap() {
|
|||||||
SwaggerModule.setup("api/docs", app, document);
|
SwaggerModule.setup("api/docs", app, document);
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
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
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[freight-api] listening on http://localhost:${port}`);
|
console.log(`[freight-api] listening on port ${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
Normal file
10
apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
Normal 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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/edr-freight-api/src/modules/auth/freight-me.service.ts
Normal file
57
apps/edr-freight-api/src/modules/auth/freight-me.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
|||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
|
|
||||||
@ApiTags("billing")
|
@ApiTags("billing")
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
|
||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billingService: BillingService) {}
|
constructor(private readonly billingService: BillingService) {}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common";
|
|||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
import { Column, Entity } from "typeorm";
|
import { Column, Entity } from "typeorm";
|
||||||
|
|
||||||
@Entity({ name: "invoices" })
|
@Entity({schema:"freight", name: "invoices" })
|
||||||
export class Invoice extends BaseEntity {
|
export class Invoice extends BaseEntity {
|
||||||
@Column({ name: "booking_id", type: "uuid" })
|
@Column({ name: "booking_id", type: "uuid" })
|
||||||
bookingId!: string;
|
bookingId!: string;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,43 +6,412 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
} from "@nestjs/common";
|
Request,
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
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 { BookingContractService } from './booking-contract.service';
|
||||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
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")
|
@ApiTags('bookings')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
@Controller('bookings')
|
||||||
@Controller("bookings")
|
@ApiBearerAuth()
|
||||||
export class BookingsController {
|
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()
|
@Post()
|
||||||
@ApiOperation({ summary: "Create a new freight booking" })
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
create(@Body() dto: CreateBookingDto) {
|
@ApiConsumes('multipart/form-data')
|
||||||
return this.bookingsService.create(dto);
|
@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()
|
@Get()
|
||||||
@ApiOperation({ summary: "List freight bookings (paginated)" })
|
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||||
findAll(@Query() filter: FilterBookingDto) {
|
findAll(@Query() filter: FilterBookingDto) {
|
||||||
return this.bookingsService.findAll(filter);
|
return this.bookingsService.findAll(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get('list-summary')
|
||||||
@ApiOperation({ summary: "Get a freight booking by ID" })
|
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||||
return this.bookingsService.findById(id);
|
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)
|
@HttpCode(204)
|
||||||
@ApiOperation({ summary: "Soft-delete a freight booking" })
|
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.bookingsService.remove(id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,69 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { BookingsController } from "./bookings.controller";
|
// import { CustomersModule } from '../customers/customers.module';
|
||||||
import { BookingsRepository } from "./bookings.repository";
|
import { CompaniesModule } from '../companies/companies.module';
|
||||||
import { BookingsService } from "./bookings.service";
|
import { FilesModule } from '../files/files.module';
|
||||||
import { Booking } from "./entities/booking.entity";
|
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({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Booking])],
|
imports: [
|
||||||
controllers: [BookingsController],
|
TypeOrmModule.forFeature([
|
||||||
providers: [BookingsService, BookingsRepository],
|
Booking,
|
||||||
exports: [BookingsService],
|
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 {}
|
export class BookingsModule {}
|
||||||
|
|||||||
@@ -1,15 +1,42 @@
|
|||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from "typeorm";
|
import { 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()
|
@Injectable()
|
||||||
export class BookingsRepository extends BaseRepository<Booking> {
|
export class BookingsRepository extends BaseRepository<Booking> {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Booking)
|
@InjectRepository(Booking)
|
||||||
repository: Repository<Booking>,
|
repository: Repository<Booking>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
@@ -18,4 +45,544 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
findByReference(reference: string): Promise<Booking | null> {
|
findByReference(reference: string): Promise<Booking | null> {
|
||||||
return this.repository.findOne({ where: { reference } });
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
const NEEDS_ACTION_STATUSES = [
|
||||||
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
'SUBMITTED',
|
||||||
import { Booking } from "./entities/booking.entity";
|
'PENDING_APPROVAL',
|
||||||
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingsService {
|
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. */
|
/** Create a new freight booking. */
|
||||||
async create(dto: CreateBookingDto): Promise<Booking> {
|
async create(
|
||||||
return this.bookingsRepository.create({
|
dto: CreateBookingDto,
|
||||||
...dto,
|
files: Express.Multer.File[],
|
||||||
scheduledDate: new Date(dto.scheduledDate),
|
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. */
|
/** Return a paginated list of bookings matching the filter. */
|
||||||
@@ -23,30 +419,233 @@ export class BookingsService {
|
|||||||
): Promise<{ items: Booking[]; total: number }> {
|
): Promise<{ items: Booking[]; total: number }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 20;
|
||||||
const [items, total] = await this.bookingsRepository.findAndCount({
|
const statusFilter = this.parseStatusFilter(filter);
|
||||||
where: {
|
|
||||||
...(filter.status ? { status: filter.status } : {}),
|
return this.bookingsRepository.findAllPaginated({
|
||||||
...(filter.customerId ? { customerId: filter.customerId } : {}),
|
page,
|
||||||
},
|
pageSize,
|
||||||
skip: (page - 1) * pageSize,
|
...statusFilter,
|
||||||
take: pageSize,
|
companyId: filter.companyId,
|
||||||
order: { createdAt: "DESC" },
|
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> {
|
async findById(id: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsRepository.findById(id);
|
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking ${id} not found`);
|
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;
|
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> {
|
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);
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('; ')}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -1,33 +1,197 @@
|
|||||||
import { Freight } from "@edr/types";
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsEnum,
|
IsIn,
|
||||||
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
Min,
|
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 {
|
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()
|
@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()
|
@IsUUID()
|
||||||
customerId!: string;
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
trainId?: string;
|
trainId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate!: string;
|
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()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
totalAmount!: number;
|
@Transform(({ value }) => Number(value))
|
||||||
|
cargoTotalWeightVgm!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(Freight.BookingStatus)
|
@IsBoolean()
|
||||||
status?: Freight.BookingStatus;
|
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,95 @@
|
|||||||
import { Freight } from "@edr/types";
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from "class-transformer";
|
import { Transform } from 'class-transformer';
|
||||||
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator";
|
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
import {
|
||||||
|
BOOKING_STATUSES,
|
||||||
|
FREIGHT_TYPES,
|
||||||
|
PAYMENT_CURRENCIES,
|
||||||
|
TRADE_DIRECTIONS,
|
||||||
|
} from './create-booking.dto';
|
||||||
|
|
||||||
export class FilterBookingDto {
|
export class FilterBookingDto {
|
||||||
|
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(Freight.BookingStatus)
|
@IsIn([...BOOKING_STATUSES])
|
||||||
status?: Freight.BookingStatus;
|
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()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
customerId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
contractType?: string;
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
page?: number = 1;
|
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@IsUUID()
|
||||||
@IsInt()
|
serviceTypeId?: string;
|
||||||
@Min(1)
|
|
||||||
pageSize?: number = 20;
|
@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';
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user