From 430dc449375bf54d9b397ff16b7b4944f3e529bd Mon Sep 17 00:00:00 2001 From: marshal Date: Sat, 30 May 2026 10:27:59 +0300 Subject: [PATCH] complete rule engine and booking flow --- ITMLS_DB_Design.md | 1002 +++++++++++++++++ apps/edr-freight-api/nest-cli.json | 4 +- .../src/config/database.config.ts | 8 +- ...8427600000-AddServiceTypesAndCargoTypes.ts | 4 +- ...48514000000-AddRuleEngineTablesAndCodes.ts | 90 +- .../1748600000000-ItmlsFullSchemaRewrite.ts | 544 +++++++++ ...8700000000-AddBookingsConfigForeignKeys.ts | 94 ++ .../modules/bookings/bookings.controller.ts | 4 +- .../src/modules/bookings/bookings.module.ts | 38 +- .../modules/bookings/bookings.repository.ts | 209 +++- .../src/modules/bookings/bookings.service.ts | 639 ++++++----- .../bookings/dto/create-booking.dto.ts | 180 ++- .../bookings/dto/filter-booking.dto.ts | 78 +- .../modules/bookings/dto/update-status.dto.ts | 47 +- .../entities/booking-approval-step.entity.ts | 45 + .../entities/booking-cargo-modifier.entity.ts | 37 + .../entities/booking-container.entity.ts | 49 + .../entities/booking-rate-snapshot.entity.ts | 39 + .../bookings/entities/booking.entity.ts | 197 ++-- .../controllers/approval-rules.controller.ts | 60 + .../controllers/rates.controller.ts | 70 ++ .../controllers/shipping-lines.controller.ts | 51 + .../weight-limit-rules.controller.ts | 2 +- ...rges.controller.ts => yards.controller.ts} | 30 +- .../dto/create-approval-rule.dto.ts | 31 + .../dto/create-container-type.dto.ts | 41 +- .../dto/create-priority-rule.dto.ts | 39 +- .../rule-engine/dto/create-rate.dto.ts | 64 ++ .../dto/create-shipping-line.dto.ts | 36 + .../dto/create-surcharge-type.dto.ts | 29 +- .../rule-engine/dto/create-surcharge.dto.ts | 63 -- .../dto/create-weight-limit-rule.dto.ts | 40 +- .../rule-engine/dto/create-yard.dto.ts | 30 + .../dto/update-approval-rule.dto.ts | 4 + .../rule-engine/dto/update-rate.dto.ts | 4 + .../dto/update-shipping-line.dto.ts | 4 + .../rule-engine/dto/update-surcharge.dto.ts | 4 - .../rule-engine/dto/update-yard.dto.ts | 4 + .../entities/approval-rule.entity.ts | 23 + .../entities/container-type.entity.ts | 26 +- .../entities/priority-rule.entity.ts | 22 +- .../rule-engine/entities/rate.entity.ts | 78 ++ .../entities/shipping-line.entity.ts | 22 + .../entities/surcharge-type.entity.ts | 35 +- .../rule-engine/entities/surcharge.entity.ts | 52 - .../entities/weight-limit-rule.entity.ts | 37 +- .../rule-engine/entities/yard.entity.ts | 23 + .../approval-rules.repository.interface.ts | 14 + .../container-types.repository.interface.ts | 2 +- .../interfaces/rates.repository.interface.ts | 14 + .../shipping-lines.repository.interface.ts | 14 + .../surcharge-types.repository.interface.ts | 1 + .../surcharges.repository.interface.ts | 14 - ...weight-limit-rules.repository.interface.ts | 4 +- .../interfaces/yards.repository.interface.ts | 14 + .../repositories/approval-rules.repository.ts | 46 + .../container-types.repository.ts | 4 +- .../repositories/rates.repository.ts | 49 + .../repositories/shipping-lines.repository.ts | 43 + .../surcharge-types.repository.ts | 7 + .../repositories/surcharges.repository.ts | 46 - .../weight-limit-rules.repository.ts | 18 +- .../repositories/yards.repository.ts | 43 + .../modules/rule-engine/rule-engine.module.ts | 125 +- .../rule-engine/rule-engine.service.ts | 361 ++++-- .../services/approval-rules.service.ts | 75 ++ .../services/container-types.service.ts | 22 +- .../services/priority-rules.service.ts | 15 +- .../rule-engine/services/rates.service.ts | 114 ++ .../services/shipping-lines.service.ts | 76 ++ .../services/surcharge-types.service.ts | 15 +- .../services/surcharges.service.ts | 76 -- .../services/weight-limit-rules.service.ts | 33 +- .../rule-engine/services/yards.service.ts | 75 ++ 74 files changed, 4304 insertions(+), 1248 deletions(-) create mode 100644 ITMLS_DB_Design.md create mode 100644 apps/edr-freight-api/src/migrations/1748600000000-ItmlsFullSchemaRewrite.ts create mode 100644 apps/edr-freight-api/src/migrations/1748700000000-AddBookingsConfigForeignKeys.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-rate-snapshot.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts rename apps/edr-freight-api/src/modules/rule-engine/controllers/{surcharges.controller.ts => yards.controller.ts} (59%) create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/create-shipping-line.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/update-approval-rule.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/update-rate.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/update-shipping-line.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/update-yard.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/shipping-line.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/surcharge.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharges.repository.interface.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/repositories/surcharges.repository.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/surcharges.service.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts diff --git a/ITMLS_DB_Design.md b/ITMLS_DB_Design.md new file mode 100644 index 000000000..d4757b7de --- /dev/null +++ b/ITMLS_DB_Design.md @@ -0,0 +1,1002 @@ +# ITMLS — Full Database Design +## Booking & Configuration Domain + +> **Schema:** `freight` +> **Base columns on every table (via `BaseEntity`):** `id UUID PK`, `created_at TIMESTAMPTZ`, `updated_at TIMESTAMPTZ` +> **Principle:** Every business rule lives as a config-table row — not hard-coded in application logic. + +--- + +## Table of Contents + +1. [Design Philosophy](#1-design-philosophy) +2. [Entity Relationship Diagram](#2-entity-relationship-diagram) +3. [Configuration Tables](#3-configuration-tables) + - 3.1 [`service_types`](#31-service_types) + - 3.2 [`container_types`](#32-container_types) + - 3.3 [`cargo_types`](#33-cargo_types) + - 3.4 [`yards`](#34-yards) + - 3.5 [`shipping_lines`](#35-shipping_lines) + - 3.6 [`weight_limit_rules`](#36-weight_limit_rules) + - 3.7 [`rates`](#37-rates) + - 3.8 [`surcharge_types`](#38-surcharge_types) + - 3.9 [`priority_rules`](#39-priority_rules) + - 3.10 [`approval_rules`](#310-approval_rules) +4. [Core Booking Tables](#4-core-booking-tables) + - 4.1 [`booking`](#41-booking) + - 4.2 [`booking_container`](#42-booking_container) + - 4.3 [`booking_cargo_modifier`](#43-booking_cargo_modifier) +5. [Supporting Tables](#5-supporting-tables) + - 5.1 [`booking_approval_step`](#51-booking_approval_step) + - 5.2 [`booking_rate_snapshot`](#52-booking_rate_snapshot) +6. [Business Rule Traceability](#6-business-rule-traceability) +7. [Key Formulas](#7-key-formulas) +8. [Index Strategy](#8-index-strategy) + +--- + +## 1. Design Philosophy + +| Principle | Application | +|---|---| +| **Config-Driven** | Every rule is a row in a config table. Changing a business rule = updating a row, not deploying code. | +| **No Redundancy** | If a flag already exists on a config table it is not duplicated on `booking`. `first_mile_enabled` is removed — `service_types.includes_first_mile` already encodes it. | +| **No Magic Strings** | All cross-table references use UUID FKs. No string-matched lookups. | +| **Immutable Snapshots** | Rates are frozen into `booking_rate_snapshot` at quote time. Rate changes never mutate historical bookings. | +| **Normalized Containers** | Containers are a child table `booking_container`, not a JSONB array, so wagon math and weight checks are proper SQL aggregates. | + +--- + +## 2. Entity Relationship Diagram + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ CONFIG LAYER │ +│ │ +│ service_types container_types cargo_types yards shipping_lines │ +│ │ │ │ │ │ │ +└───────┼────────────────┼────────────────┼───────────┼──────────┼─────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ BOOKING LAYER │ +│ │ +│ booking ◄──────────────────────────────────────┤ +│ (service_type_id FK) │ +│ (cargo_type_id FK) │ +│ (origin_yard_id FK) │ +│ (destination_yard_id FK) │ +│ (shipping_line_id FK) │ +│ │ │ +│ ┌───────────────┼──────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ booking_container booking_cargo_modifier booking_approval_step │ +│ (container_type_id FK) (surcharge_type_id FK) (approval_rule_id FK) │ +│ (weight_limit_rule_id FK) (rate_snapshot_id FK) │ +│ │ +│ booking_rate_snapshot │ +│ (booking_id FK, rate_id FK) │ +└──────────────────────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────────┐ +│ RATE / SURCHARGE LAYER │ +│ │ +│ rates ◄─── surcharge_types │ +│ (rate_id FK) │ +│ │ +│ weight_limit_rules │ +│ (container_type_id FK) │ +│ │ +│ priority_rules │ +│ approval_rules │ +└──────────────────────────────┘ +``` + +**Relationship summary:** + +| From | To | Cardinality | +|---|---|---| +| `booking` | `service_types` | M:1 | +| `booking` | `cargo_types` | M:1 | +| `booking` | `yards` (origin) | M:1 | +| `booking` | `yards` (destination) | M:1 | +| `booking` | `shipping_lines` | M:1 (nullable) | +| `booking` | `booking` (self — renewal) | M:1 (nullable) | +| `booking` | `booking` (self — consolidation) | M:1 (nullable) | +| `booking_container` | `booking` | M:1 | +| `booking_container` | `container_types` | M:1 | +| `booking_container` | `weight_limit_rules` | M:1 (nullable) | +| `booking_cargo_modifier` | `booking` | M:1 | +| `booking_cargo_modifier` | `surcharge_types` | M:1 | +| `booking_cargo_modifier` | `booking_rate_snapshot` | M:1 | +| `booking_approval_step` | `booking` | M:1 | +| `booking_approval_step` | `approval_rules` | M:1 | +| `booking_rate_snapshot` | `booking` | M:1 | +| `booking_rate_snapshot` | `rates` | M:1 | +| `surcharge_types` | `rates` | M:1 | +| `weight_limit_rules` | `container_types` | M:1 | +| `cargo_types` | `cargo_types` (self — parent) | M:1 (nullable) | + +--- + +## 3. Configuration Tables + +> All config tables share `id UUID PK`, `created_at TIMESTAMPTZ`, `updated_at TIMESTAMPTZ` from `BaseEntity`. + +--- + +### 3.1 `service_types` + +**Purpose:** Defines every bookable service combination. A single row fully describes what a service includes — no need for the `booking` table to carry redundant boolean flags. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(50) | UNIQUE NOT NULL | `RAIL`, `RAIL_CUSTOMS`, `RAIL_FIRST_MILE`, `RAIL_LAST_MILE`, `RAIL_FULL` | +| `service_name` | VARCHAR(255) | NOT NULL | Customer-facing display name | +| `description` | TEXT | NULL | Optional description | +| `can_be_booked_alone` | BOOLEAN | NOT NULL DEFAULT true | `false` for Customs — cannot be selected without Rail | +| `includes_first_mile` | BOOLEAN | NOT NULL DEFAULT false | If true, `booking.first_mile_pickup_address` is mandatory | +| `includes_last_mile` | BOOLEAN | NOT NULL DEFAULT false | If true, `booking.last_mile_delivery_address` is mandatory | +| `includes_customs` | BOOLEAN | NOT NULL DEFAULT false | If true, customs clearing is bundled | +| `priority_bonus_points` | INT | NOT NULL DEFAULT 0 | Added to `booking.priority_score` when this service is selected | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | | +| `display_order` | INT | NOT NULL DEFAULT 1 | UI sort order | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `is_active`, `display_order` + +**Seed data:** + +| code | service_name | can_be_booked_alone | includes_first_mile | includes_last_mile | includes_customs | priority_bonus_points | +|---|---|---|---|---|---|---| +| `RAIL` | Rail Transport Only | true | false | false | false | 0 | +| `RAIL_CUSTOMS` | Rail + Customs Clearing | false | false | false | true | 0 | +| `RAIL_FIRST_MILE` | Rail + First-Mile | true | true | false | false | 0 | +| `RAIL_LAST_MILE` | Rail + Last-Mile | true | false | true | false | 0 | +| `RAIL_FULL` | Rail + First-Mile + Last-Mile + Customs | true | true | true | true | 100 | + +**Business rules owned by this table:** +- `can_be_booked_alone = false` → system blocks standalone selection of that service (US-02 Step 1.1) +- `includes_first_mile = true` → renders mandatory Pick-Up Address field on booking form +- `includes_last_mile = true` → renders mandatory Delivery Address field + prompts Container Return selection +- `priority_bonus_points` → feeds directly into `booking.priority_score` formula + +> ✅ **Current code status:** `service-type.entity.ts` matches this design exactly. No changes needed. + +--- + +### 3.2 `container_types` + +**Purpose:** Defines each physical container variant. Wagon math and reefer surcharge logic flow entirely from this table. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(20) | UNIQUE NOT NULL | e.g. `20DV`, `40HC`, `40FR`, `20RF`, `40OT`, `TK20`, `OS20` | +| `label` | VARCHAR(100) | NOT NULL | e.g. `"20ft Dry Container"`, `"40ft High Cube"` | +| `size_ft` | SMALLINT | NOT NULL | `20` or `40` | +| `wagons_per_unit` | NUMERIC(4,2) | NOT NULL | `40ft = 1.00`; `20ft = 0.50` | +| `is_reefer` | BOOLEAN | NOT NULL DEFAULT false | Triggers reefer surcharge automatically | +| `is_open_top` | BOOLEAN | NOT NULL DEFAULT false | | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | | +| `display_order` | INT | NOT NULL DEFAULT 1 | UI sort order | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `is_active` + +**Wagon formula (pure SQL, no application logic):** +```sql +CEILING( SUM(booking_container.quantity * container_types.wagons_per_unit) ) +``` + +**Seed data:** + +| code | label | size_ft | wagons_per_unit | is_reefer | is_open_top | +|---|---|---|---|---|---| +| `20DV` | 20ft Dry Container | 20 | 0.50 | false | false | +| `40HC` | 40ft High Cube Container | 40 | 1.00 | false | false | +| `20RF` | 20ft Reefer Container | 20 | 0.50 | true | false | +| `40OT` | 40ft Open Top Container | 40 | 1.00 | false | true | +| `40FR` | 40ft Flat Rack Container | 40 | 1.00 | false | false | +| `TK20` | 20ft Tank Container | 20 | 0.50 | false | false | +| `OS20` | 20ft Open Side Container | 20 | 0.50 | false | false | + +**Business rules owned by this table:** +- `wagons_per_unit` → drives all wagon allocation math (2 × 20ft = 1 wagon; 1 × 40ft = 1 wagon) +- `is_reefer = true` → auto-triggers `REEFER` surcharge via `surcharge_types` +- `is_open_top = true` → can drive `LASHING` surcharge (via `surcharge_types` config) + +> ⚠️ **Gap — current `container-type.entity.ts`:** +> | Current field | Issue | +> |---|---| +> | `size_code` VARCHAR(20) | Rename to `code` | +> | `description` VARCHAR(100) | Rename to `label`; upgrade to VARCHAR(100) ✓ | +> | `containers_per_wagon` INT | Replace with `wagons_per_unit NUMERIC(4,2)` (inverted logic: current stores containers-per-wagon; target stores wagon fraction per container) | +> | — | **Add:** `size_ft SMALLINT` | +> | — | **Add:** `is_reefer BOOLEAN DEFAULT false` | +> | — | **Add:** `is_open_top BOOLEAN DEFAULT false` | +> | — | **Add:** `display_order INT DEFAULT 1` | + +--- + +### 3.3 `cargo_types` + +**Purpose:** Two-level cargo taxonomy. Self-referencing via `parent_group_id`. The `requires_director_approval` boolean drives which approval chain is instantiated — no magic string matching. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(50) | UNIQUE NOT NULL | e.g. `GENERAL`, `BULK`, `BULK_COFFEE`, `BREAK_BULK` | +| `cargo_type_name` | VARCHAR(255) | NOT NULL | Display name | +| `parent_group_id` | UUID | NULL, FK → `cargo_types.id` | NULL = top-level group | +| `show_free_text_box` | BOOLEAN | NOT NULL DEFAULT false | `true` for `BULK_OTHERS`, `BREAK_BULK_OTHERS` — renders free-text input | +| `requires_director_approval` | BOOLEAN | NOT NULL DEFAULT false | `true` = Director + CEO chain; `false` = Line Staff + Director chain | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | | +| `display_order` | INT | NOT NULL DEFAULT 1 | UI sort order | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `parent_group_id`, `is_active`, `display_order` + +**Seed data:** + +| code | cargo_type_name | parent_group_id | requires_director_approval | show_free_text_box | +|---|---|---|---|---| +| `GENERAL` | General Cargo | NULL | false | false | +| `BULK` | Bulk Cargo | NULL | true | false | +| `BULK_COFFEE` | Coffee | uuid-BULK | true | false | +| `BULK_FERTILIZER` | Fertilizer | uuid-BULK | true | false | +| `BULK_SUGAR` | Sugar | uuid-BULK | true | false | +| `BULK_OIL` | Oil | uuid-BULK | true | false | +| `BULK_LIVESTOCK` | Livestock | uuid-BULK | true | false | +| `BULK_STEEL` | Steel | uuid-BULK | true | false | +| `BULK_OTHERS` | Others (Bulk) | uuid-BULK | true | true | +| `BREAK_BULK` | Break-Bulk | NULL | true | false | +| `BREAK_BULK_MACHINERY` | Machinery | uuid-BREAK_BULK | true | false | +| `BREAK_BULK_RORO` | Ro-Ro | uuid-BREAK_BULK | true | false | +| `BREAK_BULK_OTHERS` | Others (Break-Bulk) | uuid-BREAK_BULK | true | true | + +**Business rules owned by this table:** +- `requires_director_approval` → join key to `approval_rules` to determine which approval chain to instantiate +- `show_free_text_box = true` → renders `cargo_free_text` input on booking form (only for "Others" variants) +- `parent_group_id IS NULL` → top-level group shown as category header in UI + +> ✅ **Current code status:** `cargo-type.entity.ts` matches this design exactly. No changes needed. + +--- + +### 3.4 `yards` + +**Purpose:** All rail terminal locations selectable as booking origin or destination. Trade direction (`IMPORT`/`EXPORT`) is inferred by comparing `origin_yard.country` with `destination_yard.country` — no hard-coded corridor strings. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(20) | UNIQUE NOT NULL | e.g. `KALITY`, `DIRE_DAWA`, `DJIB_PORT`, `MOJO` | +| `label` | VARCHAR(100) | NOT NULL | Customer-facing display name | +| `country` | VARCHAR(50) | NOT NULL | `Ethiopia` or `Djibouti` | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | | +| `display_order` | INT | NOT NULL DEFAULT 1 | UI sort order | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `country`, `is_active` + +**Seed data:** + +| code | label | country | +|---|---|---| +| `KALITY` | Kality Rail Terminal | Ethiopia | +| `MOJO` | Mojo Dry Port | Ethiopia | +| `DIRE_DAWA` | Dire Dawa Yard | Ethiopia | +| `DJIB_PORT` | Djibouti Port Terminal | Djibouti | +| `NAGAD` | Nagad Terminal, Djibouti | Djibouti | + +**Business rules owned by this table:** +- `origin.country ≠ destination.country` → `trade_direction = IMPORT` (origin Djibouti, destination Ethiopia) or `EXPORT` (origin Ethiopia, destination Djibouti) +- `origin.country = destination.country` → intercity/domestic corridor — drives `INTERCITY_*` rate type selection + +> ⚠️ **Gap — no `yards` entity exists in the current codebase.** +> The current `booking` entity stores `origin_station VARCHAR(255)` and `destination_station VARCHAR(255)` as free strings. +> **Action required:** Create `Yard` entity + migration; replace both booking columns with `origin_yard_id UUID FK` and `destination_yard_id UUID FK`. + +--- + +### 3.5 `shipping_lines` + +**Purpose:** Shipping line catalogue including the PIL→Maersk silent mapping rule and the extra-fee notice flag. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(20) | UNIQUE NOT NULL | e.g. `MSC`, `CMA_CGM`, `PIL`, `MAERSK`, `ESLSE` | +| `label` | VARCHAR(100) | NOT NULL | Customer-facing name | +| `mapped_to_code` | VARCHAR(20) | NULL | `PIL → MAERSK`; backend uses this code for pricing tier lookup | +| `show_extra_fee_notice` | BOOLEAN | NOT NULL DEFAULT false | If true, quotation renders additional fee notice to customer | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `is_active` + +**Seed data:** + +| code | label | mapped_to_code | show_extra_fee_notice | +|---|---|---|---| +| `MSC` | MSC | NULL | false | +| `CMA_CGM` | CMA CGM | NULL | false | +| `EVERGREEN` | Evergreen | NULL | false | +| `COSCO` | COSCO | NULL | false | +| `HAPAG_LLOYD` | Hapag-Lloyd | NULL | false | +| `ONE` | ONE | NULL | false | +| `YANG_MING` | Yang Ming | NULL | false | +| `ZIM` | ZIM | NULL | false | +| `MESSINA` | Messina Line | NULL | false | +| `SAFMARINE` | Safmarine | NULL | false | +| `WAN_HAI` | Wan Hai | NULL | false | +| `ESLSE` | Ethiopian Shipping Lines | NULL | false | +| `PIL` | Pacific International Lines | `MAERSK` | true | +| `MAERSK` | Maersk | NULL | false | + +**Business rules owned by this table:** +- `mapped_to_code IS NOT NULL` → backend silently uses the mapped code for all pricing tier lookups +- `show_extra_fee_notice = true` → quotation document explicitly states additional fee to customer (PIL rule, US-02 Step 4B-ii) + +> ⚠️ **Gap — no `shipping_lines` entity exists in the current codebase.** +> **Action required:** Create `ShippingLine` entity + migration; add `shipping_line_id UUID NULL FK → shipping_lines` to `booking`. + +--- + +### 3.6 `weight_limit_rules` + +**Purpose:** Maximum VGM per container type per trade direction. Rules are per exact container variant FK — not just by size — so different container codes can have different limits. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `container_type_id` | UUID | NOT NULL, FK → `container_types.id` | | +| `trade_direction` | VARCHAR(10) | NOT NULL | `IMPORT`, `EXPORT`, or `ANY` | +| `max_vgm_tons` | NUMERIC(8,3) | NOT NULL | Structural maximum VGM | +| `effective_from` | DATE | NOT NULL | Allows pre-loading future rule changes | +| `effective_to` | DATE | NULL | NULL = currently active rule | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `container_type_id`, `trade_direction`, `effective_from` + +**Unique constraint:** `(container_type_id, trade_direction, effective_from)` — prevents duplicate active rules per container/direction. + +**Seed data:** + +| container_type (code) | trade_direction | max_vgm_tons | effective_from | effective_to | +|---|---|---|---|---| +| `40HC` | ANY | 32.500 | 2024-01-01 | NULL | +| `40OT` | ANY | 32.500 | 2024-01-01 | NULL | +| `40FR` | ANY | 32.500 | 2024-01-01 | NULL | +| `20DV` | IMPORT | 20.000 | 2024-01-01 | NULL | +| `20DV` | EXPORT | 25.000 | 2024-01-01 | NULL | +| `20RF` | IMPORT | 20.000 | 2024-01-01 | NULL | +| `20RF` | EXPORT | 25.000 | 2024-01-01 | NULL | +| `TK20` | IMPORT | 20.000 | 2024-01-01 | NULL | +| `TK20` | EXPORT | 25.000 | 2024-01-01 | NULL | +| `OS20` | IMPORT | 20.000 | 2024-01-01 | NULL | +| `OS20` | EXPORT | 25.000 | 2024-01-01 | NULL | + +**Business rules owned by this table:** +- `max_vgm_tons` → compared against `booking_container.total_vgm_tons`; excess drives overweight surcharge calculation +- `effective_from` / `effective_to` → allows rule rotation without deleting historical data; query with `WHERE effective_from <= NOW() AND (effective_to IS NULL OR effective_to > NOW())` +- `trade_direction = ANY` → single rule covers both import and export for that container type + +> ⚠️ **Gap — current `weight-limit-rule.entity.ts` diverges:** +> | Current field | Issue | +> |---|---| +> | `max_weight_tons NUMERIC(10,2)` | Rename to `max_vgm_tons NUMERIC(8,3)` | +> | `warning_threshold_tons NUMERIC(10,2)` | **Remove** — not in design; VGM check is binary (exceeded or not) | +> | `exceeded_action ENUM` | **Remove** — exceeded action is always "apply surcharge via `surcharge_types`" | +> | `surcharge_id UUID FK → surcharges` | **Remove** — surcharge linking goes through `surcharge_types`, not directly from weight rule | +> | — | **Add:** `effective_from DATE NOT NULL` | +> | — | **Add:** `effective_to DATE NULL` | +> | `is_active BOOLEAN` | **Remove** — replaced by `effective_to IS NULL` logic | + +--- + +### 3.7 `rates` + +**Purpose:** Master rate matrix. All rate types live as rows. Status lifecycle enforces Director-proposes / CEO-approves workflow. Rate changes never mutate historical bookings (see `booking_rate_snapshot`). + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `rate_type` | VARCHAR(50) | NOT NULL | See enumeration below | +| `container_type_id` | UUID | NULL, FK → `container_types.id` | NULL for non-container rates (e.g. bulk, flat fees) | +| `trade_direction` | VARCHAR(10) | NULL | `IMPORT`, `EXPORT`, `ANY`, or NULL for direction-agnostic rates | +| `currency` | VARCHAR(5) | NOT NULL | `ETB` or `USD` | +| `rate_value` | NUMERIC(14,4) | NOT NULL | | +| `rate_unit` | VARCHAR(30) | NOT NULL | `PER_WAGON`, `PER_TON`, `PER_CONTAINER`, `PER_KM`, `FLAT` | +| `status` | VARCHAR(20) | NOT NULL DEFAULT 'DRAFT' | `DRAFT` → `PENDING_APPROVAL` → `LIVE` → `SUPERSEDED` | +| `proposed_by_staff_id` | UUID | NOT NULL | Director who submitted the rate | +| `approved_by_ceo_id` | UUID | NULL | CEO who authorized | +| `approved_at` | TIMESTAMPTZ | NULL | | +| `effective_from` | DATE | NOT NULL | | +| `effective_to` | DATE | NULL | NULL = currently active | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `rate_type`, `status`, `effective_from`, `container_type_id` + +**`rate_type` enumeration:** + +| Value | Description | +|---|---| +| `CONTAINER_IMPORT` | Rail rate per container, import direction | +| `CONTAINER_EXPORT` | Rail rate per container, export direction | +| `BULK_IMPORT` | Bulk cargo rail rate, import | +| `BULK_EXPORT` | Bulk cargo rail rate, export | +| `INTERCITY_BULK` | Domestic bulk corridor rate | +| `INTERCITY_CONTAINER` | Domestic container corridor rate | +| `FIRST_MILE` | Truck pick-up from warehouse to origin yard | +| `LAST_MILE` | Truck delivery from destination yard to final address | +| `DEMURRAGE` | Container detention fee | +| `LASHING` | Cargo securing/lashing fee | +| `DOUBLE_HANDLING` | Extra handling surcharge | +| `CONTAINER_WITH_RETURN` | Equipment return cost | +| `CANCELLATION_FEE` | Booking cancellation penalty | +| `OVERWEIGHT_PER_TON` | Per-ton fee for VGM exceeding limit | +| `HAZARD_SURCHARGE` | Flat surcharge for hazardous goods | +| `REEFER_SURCHARGE` | Flat surcharge for reefer containers | +| `PIL_EXTRA_FEE` | Additional fee for PIL-mapped shipping line | + +**Rate approval lifecycle:** +``` +Director inputs values → status = DRAFT +Director clicks "Submit for Approval" → status = PENDING_APPROVAL (locked, no edits) +CEO reviews & digitally approves → status = LIVE (applied to all new quotations) +When a new rate supersedes: old row → status = SUPERSEDED; new row → LIVE +``` + +**Business rules owned by this table:** +- Rate matrix covers all 17 rate types; Director proposes, CEO approves (US-07) +- `status = LIVE AND effective_from <= NOW() AND (effective_to IS NULL OR effective_to > NOW())` → the active rate query +- `SUPERSEDED` rows are never deleted — they back `booking_rate_snapshot` for historical accuracy + +> ⚠️ **Gap — no `rates` entity exists in the current codebase.** +> The current `surcharge.entity.ts` stores a `rate` NUMERIC field directly on the surcharge — this is a partial, non-scalable substitute. +> **Action required:** Create `Rate` entity; separate rate management from surcharge configuration entirely. + +--- + +### 3.8 `surcharge_types` + +**Purpose:** Each row is one auto-trigger rule. Defines the condition that fires a surcharge and points to the `rates` row used to price it via a real FK — not a string match. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(40) | UNIQUE NOT NULL | e.g. `HAZARD`, `REEFER`, `OVERWEIGHT`, `PIL_FEE`, `CONSOLIDATION` | +| `label` | VARCHAR(100) | NOT NULL | | +| `trigger_condition` | VARCHAR(50) | NOT NULL | See enumeration below | +| `rate_id` | UUID | NOT NULL, FK → `rates.id` | The `LIVE` rate used to price this surcharge | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | Toggle to globally disable a surcharge without code deploy | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `is_active`, `rate_id` + +**`trigger_condition` enumeration:** + +| Value | Fires When | +|---|---| +| `CARGO_FLAG_HAZARDOUS` | `booking.is_hazardous = true` | +| `CARGO_FLAG_REEFER` | Any `booking_container.container_type.is_reefer = true` | +| `VGM_EXCEEDS_LIMIT` | `booking_container.is_overweight = true` | +| `SHIPPING_LINE_MAPPED` | `booking.shipping_line.mapped_to_code IS NOT NULL` | +| `CONSOLIDATION_ENABLED` | `booking.allow_consolidation = true` | + +**Seed data:** + +| code | label | trigger_condition | rate_id | +|---|---|---|---| +| `HAZARD` | Hazardous Goods Surcharge | `CARGO_FLAG_HAZARDOUS` | FK→ rates(HAZARD_SURCHARGE, LIVE) | +| `REEFER` | Reefer Container Surcharge | `CARGO_FLAG_REEFER` | FK→ rates(REEFER_SURCHARGE, LIVE) | +| `OVERWEIGHT` | Overweight Per-Ton Surcharge | `VGM_EXCEEDS_LIMIT` | FK→ rates(OVERWEIGHT_PER_TON, LIVE) | +| `PIL_FEE` | PIL Extra Fee | `SHIPPING_LINE_MAPPED` | FK→ rates(PIL_EXTRA_FEE, LIVE) | + +> ⚠️ **Gap — current `surcharge-type.entity.ts` and `surcharge.entity.ts` diverge significantly:** +> - Current design has a two-level hierarchy: `surcharge_types` (category) → `surcharges` (instance with rate). The target collapses this into a single `surcharge_types` table with a direct `rate_id FK → rates`. +> - Current `SurchargeType` is missing: `trigger_condition`, `rate_id`. +> - Current `Surcharge` entity (`fee_name`, `calculation_method`, `rate`, `currency`, `apply_to_rail`, `apply_to_first_mile`, `apply_to_last_mile`) is **replaced entirely** by the `rates` table + `surcharge_types.rate_id FK`. +> - **Action required:** Refactor `surcharge_types` to add `trigger_condition` and `rate_id FK`; remove `surcharges` table; migrate rate data to `rates` table. + +--- + +### 3.9 `priority_rules` + +**Purpose:** Scoring rules for the queue engine. Each active row contributes points to `booking.priority_score` when its condition matches. Feature flags control which rules are live without code deployment. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(40) | UNIQUE NOT NULL | e.g. `USD_PAYER`, `GOV_REQUEST`, `FREQUENT_USER_GOLD` | +| `label` | VARCHAR(100) | NOT NULL | | +| `score` | INT | NOT NULL DEFAULT 0 | Points added to `booking.priority_score` when condition matches | +| `condition_currency` | VARCHAR(5) | NULL | `USD` = matches only USD-paying bookings; NULL = matches all | +| `is_active` | BOOLEAN | NOT NULL DEFAULT false | Feature flag — toggle per sprint without code deploy | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `is_active` + +**Seed data:** + +| code | label | score | condition_currency | is_active | +|---|---|---|---|---| +| `USD_PAYER` | USD Currency Payer | 100 | `USD` | **true** (ACTIVE) | +| `GOV_REQUEST` | Government Freight Request | 300 | NULL | false (DEACTIVATED) | +| `FREQUENT_USER_GOLD` | Gold-Tier Frequent User | 150 | NULL | false (DEACTIVATED) | + +**Priority score formula:** +``` +booking.priority_score = + SUM(priority_rules.score WHERE is_active = true AND condition matches booking) + + service_types.priority_bonus_points +``` + +**Queue sort order (current active rules):** +``` +1. USD payers with RAIL_FULL service (score = 100 + 100 = 200) ← HIGHEST +2. USD payers with other Rail service (score = 100 + 0 = 100) +3. ETB payers with RAIL_FULL (score = 0 + 100 = 100) +4. All other ETB payers (score = 0) ← LOWEST +Within each tier: sorted by created_at ASC (oldest first) +``` + +> ⚠️ **Gap — current `priority-rule.entity.ts` diverges:** +> | Current field | Issue | +> |---|---| +> | `priority_type ENUM` | Replace with `code VARCHAR(40) UNIQUE` — free text code is more flexible and matches the design | +> | `rule_name VARCHAR(255)` | Rename to `label VARCHAR(100)` | +> | `bonus_points INT` | Rename to `score INT` | +> | `activation_condition TEXT` | **Remove** — replaced by `condition_currency VARCHAR(5) NULL` (structured, queryable) | +> | `description TEXT` | **Remove** — not in design | +> | — | **Add:** `condition_currency VARCHAR(5) NULL` | + +--- + +### 3.10 `approval_rules` + +**Purpose:** Two approval chains stored as ordered step rows. Linked to `cargo_types` via the shared boolean `requires_director_approval` — no string matching required. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `requires_director_approval` | BOOLEAN | NOT NULL | Join key: matches `cargo_types.requires_director_approval` | +| `step_order` | SMALLINT | NOT NULL | `1` or `2` — sequence of approval steps | +| `required_role` | VARCHAR(30) | NOT NULL | `LINE_STAFF`, `DIRECTOR`, `CEO` | +| `action_label` | VARCHAR(50) | NOT NULL | e.g. `"Review & Approve"`, `"Final Signature"` | +| `blocks_role` | VARCHAR(30) | NULL | Role explicitly blocked from acting at this step | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `requires_director_approval`, `step_order` + +**Unique constraint:** `(requires_director_approval, step_order)` — each chain has exactly one row per step. + +**Seed data:** + +| requires_director_approval | step_order | required_role | action_label | blocks_role | +|---|---|---|---|---| +| false | 1 | `LINE_STAFF` | Review & Approve | NULL | +| false | 2 | `DIRECTOR` | Final Signature | `LINE_STAFF` | +| true | 1 | `DIRECTOR` | Review & Approve | `LINE_STAFF` | +| true | 2 | `CEO` | Final Signature | NULL | + +**Approval chain lookup query:** +```sql +SELECT * FROM approval_rules +WHERE requires_director_approval = :cargo_type_requires_director_approval +ORDER BY step_order ASC; +``` + +> ⚠️ **Gap — no `approval_rules` entity exists in the current codebase.** +> **Action required:** Create `ApprovalRule` entity + migration. + +--- + +## 4. Core Booking Tables + +--- + +### 4.1 `booking` + +**Purpose:** The central booking record. References all config tables via FKs. Contains no denormalized copies of config data. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `reference` | VARCHAR(64) | UNIQUE NOT NULL | System-generated human-readable reference | +| `customer_id` | UUID | NOT NULL | FK → `customers.id` | +| `train_id` | UUID | NULL | FK → `trains.id` | +| `origin_yard_id` | UUID | NOT NULL, FK → `yards.id` | ► Replaces `origin_station VARCHAR` | +| `destination_yard_id` | UUID | NOT NULL, FK → `yards.id` | ► Replaces `destination_station VARCHAR` | +| `trade_direction` | VARCHAR(10) | NOT NULL | `IMPORT` or `EXPORT` — derived from yards, stored for fast query | +| `status` | VARCHAR(40) | NOT NULL DEFAULT 'DRAFT' | Full lifecycle — see below | +| `contract_type` | VARCHAR(20) | NOT NULL | `NEW` or `RENEWAL` | +| `previous_contract_id` | UUID | NULL, FK → `booking.id` | Self-ref for contract renewals | +| `version_number` | INT | NOT NULL DEFAULT 1 | Increments on renewal | +| `service_type_id` | UUID | NOT NULL, FK → `service_types.id` | ► Replaces `service_type VARCHAR` | +| `first_mile_pickup_address` | TEXT | NULL | Required when `service_types.includes_first_mile = true` | +| `last_mile_delivery_address` | TEXT | NULL | Required when `service_types.includes_last_mile = true` | +| `equipment_return` | VARCHAR(20) | NOT NULL DEFAULT 'NA' | `WITH_RETURN`, `WITHOUT_RETURN`, `NA` | +| `cargo_type_id` | UUID | NOT NULL, FK → `cargo_types.id` | ► Replaces `freight_type + freight_subtype VARCHAR` | +| `cargo_free_text` | VARCHAR(200) | NULL | Only populated when `cargo_types.show_free_text_box = true` | +| `shipping_line_id` | UUID | NULL, FK → `shipping_lines.id` | ► New field (US-02 Step 4B-ii) | +| `is_hazardous` | BOOLEAN | NOT NULL DEFAULT false | | +| `cargo_total_weight_vgm` | NUMERIC(12,3) | NOT NULL DEFAULT 0 | Sum of all container VGMs | +| `allow_consolidation` | BOOLEAN | NOT NULL DEFAULT false | Customer opted into wagon sharing | +| `consolidation_partner_id` | UUID | NULL, FK → `booking.id` | Matched consolidation partner booking | +| `payment_currency` | VARCHAR(5) | NOT NULL | `ETB` or `USD` | +| `payment_status` | VARCHAR(20) | NOT NULL DEFAULT 'PENDING' | `PENDING`, `PNR_GENERATED`, `PAID`, `FAILED` | +| `pnr_code` | VARCHAR(50) | NULL | ► New: system-generated PNR for ETB bank payment (US-09) | +| `total_amount` | NUMERIC(14,2) | NOT NULL DEFAULT 0 | | +| `financial_terms` | TEXT | NULL | | +| `scheduled_date` | TIMESTAMPTZ | NOT NULL | | +| `start_date` | DATE | NULL | | +| `end_date` | DATE | NULL | | +| `priority_score` | INT | NOT NULL DEFAULT 0 | Computed at booking creation from priority_rules + service_types.priority_bonus_points | +| `approved_by_staff_id` | UUID | NULL | | +| `approved_by_staff_at` | TIMESTAMPTZ | NULL | | +| `signed_by_director_id` | UUID | NULL | | +| `signed_by_director_at` | TIMESTAMPTZ | NULL | | +| `signed_by_ceo_id` | UUID | NULL | | +| `signed_by_ceo_at` | TIMESTAMPTZ | NULL | | +| `customer_signed_at` | TIMESTAMPTZ | NULL | ► New: when customer applied digital signature (US-11) | +| `fully_executed_at` | TIMESTAMPTZ | NULL | ► New: when contract reached "Fully Executed" state (US-11) | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `reference`, `customer_id`, `status`, `trade_direction`, `priority_score DESC`, `service_type_id`, `cargo_type_id`, `origin_yard_id`, `destination_yard_id`, `payment_status` + +**Booking status lifecycle:** +``` +DRAFT + └→ RFQ_SUBMITTED + └→ QUOTATION_SENT + ├→ QUOTATION_APPROVED + │ └→ PENDING_APPROVAL + │ └→ APPROVED + │ └→ SIGNED_CUSTOMER + │ └→ FULLY_EXECUTED + │ └→ PAID + │ └→ IN_TRANSIT + │ └→ COMPLETED + └→ QUOTATION_REJECTED → (archived) + └→ CANCELLED (from any active state) +``` + +**Removed fields vs original entity:** + +| Removed | Reason | +|---|---| +| `origin_station VARCHAR(255)` | Replaced by `origin_yard_id FK → yards` | +| `destination_station VARCHAR(255)` | Replaced by `destination_yard_id FK → yards` | +| `service_type VARCHAR(30)` | Replaced by `service_type_id FK → service_types` | +| `freight_type VARCHAR(20)` | Replaced by `cargo_type_id FK → cargo_types` | +| `freight_subtype VARCHAR(100)` | Replaced by `cargo_free_text VARCHAR(200)` (only for "Others") | +| `containers JSONB` | Replaced by normalized `booking_container` table | +| `first_mile_enabled BOOLEAN` | Redundant — read from `service_types.includes_first_mile` | +| `last_mile_enabled BOOLEAN` | Redundant — read from `service_types.includes_last_mile` | +| `is_refrigerated BOOLEAN` | Redundant — read from `container_types.is_reefer` | + +> ⚠️ **Gap — current `booking.entity.ts` has 9 fields to remove, 7 fields to add, and 5 FKs to introduce:** +> +> **Remove:** +> - `origin_station VARCHAR` +> - `destination_station VARCHAR` +> - `service_type VARCHAR` +> - `freight_type VARCHAR` +> - `freight_subtype VARCHAR` +> - `containers JSONB` +> - `first_mile_enabled BOOLEAN` +> - `last_mile_enabled BOOLEAN` +> - `is_refrigerated BOOLEAN` +> +> **Add:** +> - `origin_yard_id UUID FK → yards` +> - `destination_yard_id UUID FK → yards` +> - `service_type_id UUID FK → service_types` +> - `cargo_type_id UUID FK → cargo_types` +> - `cargo_free_text VARCHAR(200) NULL` +> - `shipping_line_id UUID NULL FK → shipping_lines` +> - `pnr_code VARCHAR(50) NULL` +> - `customer_signed_at TIMESTAMPTZ NULL` +> - `fully_executed_at TIMESTAMPTZ NULL` + +--- + +### 4.2 `booking_container` + +**Purpose:** One row per container line item on a booking. Replaces the `containers JSONB` array. Enables SQL-level wagon math and per-container weight enforcement. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `booking_id` | UUID | NOT NULL, FK → `booking.id` ON DELETE CASCADE | | +| `container_type_id` | UUID | NOT NULL, FK → `container_types.id` | | +| `quantity` | SMALLINT | NOT NULL | Number of containers of this type | +| `vgm_per_unit_tons` | NUMERIC(10,3) | NOT NULL | VGM for one container | +| `total_vgm_tons` | NUMERIC(12,3) | NOT NULL GENERATED | `quantity × vgm_per_unit_tons` (computed or app-maintained) | +| `wagons_required` | NUMERIC(6,2) | NOT NULL GENERATED | `CEILING(quantity × container_types.wagons_per_unit)` | +| `weight_limit_rule_id` | UUID | NULL, FK → `weight_limit_rules.id` | Rule applied at time of entry — immutable snapshot reference | +| `is_overweight` | BOOLEAN | NOT NULL DEFAULT false | `true` when `total_vgm_tons > weight_limit_rules.max_vgm_tons × quantity` | +| `overweight_excess_tons` | NUMERIC(10,3) | NULL | `MAX(0, total_vgm_tons − max_vgm_tons × quantity)` | +| `created_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `booking_id`, `container_type_id`, `is_overweight` + +**Wagon math (SQL aggregate across all lines):** +```sql +SELECT CEILING(SUM(bc.quantity * ct.wagons_per_unit)) AS total_wagons +FROM booking_container bc +JOIN container_types ct ON ct.id = bc.container_type_id +WHERE bc.booking_id = :booking_id; +``` + +**Overweight check (per line):** +```sql +UPDATE booking_container +SET + total_vgm_tons = quantity * vgm_per_unit_tons, + is_overweight = (quantity * vgm_per_unit_tons) > (wlr.max_vgm_tons * quantity), + overweight_excess_tons = GREATEST(0, (quantity * vgm_per_unit_tons) - (wlr.max_vgm_tons * quantity)) +FROM weight_limit_rules wlr +WHERE booking_container.weight_limit_rule_id = wlr.id; +``` + +> ⚠️ **Gap — `booking_container` table does not exist in the current codebase.** +> The current `booking` entity stores `containers JSONB` — a non-queryable, non-validated array. +> **Action required:** Create `BookingContainer` entity + migration; remove `containers` from `booking`. + +--- + +### 4.3 `booking_cargo_modifier` + +**Purpose:** One row per surcharge applied to a booking. Created automatically by the rule engine when a `surcharge_types.trigger_condition` is matched. Links to the frozen rate snapshot for immutable billing. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `booking_id` | UUID | NOT NULL, FK → `booking.id` ON DELETE CASCADE | | +| `surcharge_type_id` | UUID | NOT NULL, FK → `surcharge_types.id` | The rule that was triggered | +| `trigger_value` | NUMERIC(14,4) | NULL | Contextual value — e.g. excess tons for overweight surcharge | +| `calculated_amount` | NUMERIC(14,2) | NOT NULL | Charge in booking currency | +| `rate_snapshot_id` | UUID | NOT NULL, FK → `booking_rate_snapshot.id` | The frozen rate used to compute this charge | +| `created_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `booking_id`, `surcharge_type_id` + +**Trigger logic (application layer):** +``` +For each active surcharge_type WHERE is_active = true: + Evaluate trigger_condition against booking state: + CARGO_FLAG_HAZARDOUS → booking.is_hazardous = true + CARGO_FLAG_REEFER → any booking_container.container_type.is_reefer = true + VGM_EXCEEDS_LIMIT → any booking_container.is_overweight = true + SHIPPING_LINE_MAPPED → booking.shipping_line.mapped_to_code IS NOT NULL + CONSOLIDATION_ENABLED → booking.allow_consolidation = true + + If triggered: + calculated_amount = trigger_value × rate_snapshot.rate_value + Insert row into booking_cargo_modifier +``` + +> ⚠️ **Gap — `booking_cargo_modifier` table does not exist in the current codebase.** +> **Action required:** Create `BookingCargoModifier` entity + migration. + +--- + +## 5. Supporting Tables + +--- + +### 5.1 `booking_approval_step` + +**Purpose:** One row per approval step per booking. Instantiated from `approval_rules` when a booking is submitted. Columns are partially copied from `approval_rules` to create an immutable audit trail — the audit record is immune to future changes in `approval_rules`. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `booking_id` | UUID | NOT NULL, FK → `booking.id` ON DELETE CASCADE | | +| `approval_rule_id` | UUID | NOT NULL, FK → `approval_rules.id` | Source rule — for traceability | +| `step_order` | SMALLINT | NOT NULL | Copied from `approval_rules` at creation — immutable | +| `required_role` | VARCHAR(30) | NOT NULL | Copied from `approval_rules` at creation — immutable | +| `status` | VARCHAR(20) | NOT NULL DEFAULT 'PENDING' | `PENDING`, `APPROVED`, `REJECTED`, `SKIPPED` | +| `actioned_by_staff_id` | UUID | NULL | | +| `actioned_at` | TIMESTAMPTZ | NULL | | +| `remarks` | TEXT | NULL | Optional reviewer notes | +| `created_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `booking_id`, `status`, `step_order` + +**Instantiation logic (on RFQ submission):** +```sql +INSERT INTO booking_approval_step (booking_id, approval_rule_id, step_order, required_role) +SELECT :booking_id, ar.id, ar.step_order, ar.required_role +FROM approval_rules ar +WHERE ar.requires_director_approval = ( + SELECT ct.requires_director_approval + FROM cargo_types ct + WHERE ct.id = :cargo_type_id +) +ORDER BY ar.step_order; +``` + +> ⚠️ **Gap — `booking_approval_step` table does not exist in the current codebase.** +> **Action required:** Create `BookingApprovalStep` entity + migration. + +--- + +### 5.2 `booking_rate_snapshot` + +**Purpose:** Immutable copy of every `LIVE` rate at the moment a quotation is sent. Protects historical billing accuracy — subsequent rate changes never retroactively alter past bookings. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `booking_id` | UUID | NOT NULL, FK → `booking.id` ON DELETE CASCADE | | +| `rate_id` | UUID | NOT NULL, FK → `rates.id` | Source rate row — for audit traceability | +| `rate_type` | VARCHAR(50) | NOT NULL | Copied from `rates` at snapshot time | +| `rate_value` | NUMERIC(14,4) | NOT NULL | Copied from `rates` at snapshot time | +| `rate_unit` | VARCHAR(30) | NOT NULL | Copied from `rates` at snapshot time | +| `currency` | VARCHAR(5) | NOT NULL | Copied from `rates` at snapshot time | +| `snapshotted_at` | TIMESTAMPTZ | NOT NULL | When this copy was created | +| `created_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `booking_id`, `rate_id`, `rate_type` + +**Snapshot creation (on quotation send):** +```sql +INSERT INTO booking_rate_snapshot + (booking_id, rate_id, rate_type, rate_value, rate_unit, currency, snapshotted_at) +SELECT + :booking_id, r.id, r.rate_type, r.rate_value, r.rate_unit, r.currency, NOW() +FROM rates r +WHERE r.status = 'LIVE' + AND r.effective_from <= NOW() + AND (r.effective_to IS NULL OR r.effective_to > NOW()); +``` + +> ⚠️ **Gap — `booking_rate_snapshot` table does not exist in the current codebase.** +> **Action required:** Create `BookingRateSnapshot` entity + migration. + +--- + +## 6. Business Rule Traceability + +| Business Rule | User Story | Config Table(s) That Own It | +|---|---|---| +| Customs cannot be selected without Rail | US-02 | `service_types.can_be_booked_alone = false` | +| First-Mile address field is mandatory when selected | US-02 | `service_types.includes_first_mile = true` | +| Last-Mile address field + return prompt is mandatory when selected | US-02 | `service_types.includes_last_mile = true` | +| 1 × 40ft = 1 wagon; 2 × 20ft = 1 wagon | US-02, US-07 | `container_types.wagons_per_unit` | +| 20ft Import max VGM = 20T; Export max = 25T; 40ft max = 32.5T | US-07 | `weight_limit_rules` (FK → `container_types`) | +| Overweight surcharge per excess ton | US-07 | `surcharge_types(OVERWEIGHT)` + `rates(OVERWEIGHT_PER_TON)` via FK | +| Hazardous goods surcharge | US-02, US-07 | `surcharge_types(HAZARD)` + `rates(HAZARD_SURCHARGE)` via FK | +| Reefer container surcharge | US-02, US-07 | `surcharge_types(REEFER)` triggered by `container_types.is_reefer` | +| PIL → Maersk silent mapping + fee notice | US-02 | `shipping_lines.mapped_to_code` + `show_extra_fee_notice` | +| Consolidation surcharge | US-08 | `surcharge_types(CONSOLIDATION)` | +| USD payers ranked higher in queue | US-06 | `priority_rules(USD_PAYER, is_active=true)` | +| RAIL_FULL service boosts queue score | US-06 | `service_types.priority_bonus_points = 100` | +| Bulk cargo → Director + CEO approval chain | US-06 | `cargo_types.requires_director_approval = true` + `approval_rules` | +| Standard cargo → Line Staff + Director chain | US-06 | `cargo_types.requires_director_approval = false` + `approval_rules` | +| Line Staff blocked from Bulk approval Step 1 | US-06 | `approval_rules.blocks_role = 'LINE_STAFF'` | +| Director proposes rates; CEO approves | US-07 | `rates.status` lifecycle (`DRAFT → PENDING_APPROVAL → LIVE`) | +| Old booking rates never change | US-07, US-10 | `booking_rate_snapshot` (frozen copy at quote time) | +| "Others" free-text cargo input | US-02 | `cargo_types.show_free_text_box = true` | +| Trade direction inferred from yard countries | US-02 | `yards.country` comparison | +| ETB payment via PNR code | US-09 | `booking.pnr_code` (system-generated) | +| Contract locked after both signatures | US-11 | `booking.fully_executed_at IS NOT NULL` | + +--- + +## 7. Key Formulas + +### Wagon Allocation + +``` +For 40ft containers: + wagons = quantity × 1.00 → CEILING(sum) + +For 20ft containers: + wagons = quantity × 0.50 → CEILING(sum) + +Mixed example: 3 × 40ft + 5 × 20ft + wagons = CEILING(3 × 1.00 + 5 × 0.50) = CEILING(3.0 + 2.5) = CEILING(5.5) = 6 wagons + +SQL: + SELECT CEILING(SUM(bc.quantity * ct.wagons_per_unit)) + FROM booking_container bc + JOIN container_types ct ON ct.id = bc.container_type_id + WHERE bc.booking_id = :id +``` + +### Overweight Surcharge + +``` +excess_tons = MAX(0, total_vgm_tons − (max_vgm_tons × quantity)) +surcharge = excess_tons × rates(OVERWEIGHT_PER_TON).rate_value + +Example: + 1 × 20ft on IMPORT, VGM = 23T, max = 20T + excess = 23 − 20 = 3T + surcharge = 3 × overweight_rate +``` + +### Priority Score + +``` +priority_score = + SUM(pr.score FROM priority_rules pr + WHERE pr.is_active = true + AND (pr.condition_currency IS NULL OR pr.condition_currency = booking.payment_currency)) + + service_types.priority_bonus_points + +Active examples: + USD payer + RAIL only: 100 + 0 = 100 + USD payer + RAIL_FULL: 100 + 100 = 200 + ETB payer + RAIL_FULL: 0 + 100 = 100 + ETB payer + RAIL: 0 + 0 = 0 + +Queue sort: priority_score DESC, created_at ASC +``` + +### Trade Direction Inference + +``` +IF origin_yard.country = 'Djibouti' AND destination_yard.country = 'Ethiopia' + THEN trade_direction = 'IMPORT' +ELSE IF origin_yard.country = 'Ethiopia' AND destination_yard.country = 'Djibouti' + THEN trade_direction = 'EXPORT' +ELSE + THEN trade_direction = 'DOMESTIC' -- intercity corridor +``` + +--- + +## 8. Index Strategy + +| Table | Index Columns | Type | Rationale | +|---|---|---|---| +| `service_types` | `code` | UNIQUE | Config lookup | +| `service_types` | `is_active`, `display_order` | COMPOSITE | UI list query | +| `container_types` | `code` | UNIQUE | Config lookup | +| `cargo_types` | `code` | UNIQUE | Config lookup | +| `cargo_types` | `parent_group_id` | BTREE | Hierarchy traversal | +| `cargo_types` | `requires_director_approval` | BTREE | Approval chain join | +| `yards` | `code` | UNIQUE | Config lookup | +| `yards` | `country` | BTREE | Trade direction inference | +| `shipping_lines` | `code` | UNIQUE | Config lookup | +| `weight_limit_rules` | `(container_type_id, trade_direction)` | COMPOSITE | Rate lookup per container/direction | +| `rates` | `(rate_type, status, effective_from)` | COMPOSITE | Active rate query | +| `surcharge_types` | `code` | UNIQUE | Trigger lookup | +| `surcharge_types` | `trigger_condition`, `is_active` | COMPOSITE | Rule engine scan | +| `priority_rules` | `is_active` | BTREE | Score computation filter | +| `approval_rules` | `(requires_director_approval, step_order)` | COMPOSITE | Chain instantiation | +| `booking` | `reference` | UNIQUE | Human-readable lookup | +| `booking` | `customer_id`, `status` | COMPOSITE | Customer dashboard | +| `booking` | `priority_score DESC`, `created_at ASC` | COMPOSITE | Queue sort | +| `booking` | `origin_yard_id`, `destination_yard_id` | BTREE | Route filtering | +| `booking_container` | `booking_id` | BTREE | Child lookup | +| `booking_container` | `is_overweight` | BTREE | Overweight report | +| `booking_cargo_modifier` | `booking_id` | BTREE | Surcharge aggregation | +| `booking_approval_step` | `booking_id`, `status` | COMPOSITE | Pending step lookup | +| `booking_rate_snapshot` | `booking_id` | BTREE | Rate reconstruction | + +--- + +*End of Document — ITMLS Booking & Configuration Domain DB Design* +*Generated from: `ITMLS_Entity_Design.md` + `ITMLS_User_Stories_Full_Updated.md` + codebase analysis* +*Schema: `freight` | ORM: TypeORM (NestJS) | DB: PostgreSQL* diff --git a/apps/edr-freight-api/nest-cli.json b/apps/edr-freight-api/nest-cli.json index f9aa683b1..6c524a8a1 100644 --- a/apps/edr-freight-api/nest-cli.json +++ b/apps/edr-freight-api/nest-cli.json @@ -3,6 +3,8 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "assets": [{ "include": "migrations/**/*", "outDir": "dist" }], + "watchAssets": true } } diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 1bef01eca..a97adf263 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -102,14 +102,14 @@ export default registerAs( entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], autoLoadEntities: true, migrations: [ - // IAM schema + tables must be created before freight entity sync + // IAM schema + tables must be created before freight migrations __dirname + "/../../node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.js", - __dirname + "/../../migrations/*.{ts,js}", + __dirname + "/../migrations/*.js", ], migrationsRun: true, - // Never enable synchronize in production. Use migrations. - synchronize: process.env.NODE_ENV === "development", + // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). + synchronize: false, logging: process.env.NODE_ENV === "development", }), ); diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts index 54ce5b656..65052bad4 100644 --- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -5,7 +5,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter public async up(queryRunner: QueryRunner): Promise { // Create service_types table - await queryRunner.createTable( + if (!(await queryRunner.hasTable("freight.service_types"))) await queryRunner.createTable( new Table({ name: "service_types", schema: "freight", @@ -109,7 +109,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter ); // Create cargo_types table - await queryRunner.createTable( + if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable( new Table({ name: "cargo_types", schema: "freight", diff --git a/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts b/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts index 786a8c752..e1a9f6aeb 100644 --- a/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts +++ b/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts @@ -13,53 +13,69 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf public async up(queryRunner: QueryRunner): Promise { // ── 1. Add `code` column to existing tables ─────────────────────────── - await queryRunner.addColumn( - 'freight.service_types', - new TableColumn({ - name: 'code', - type: 'varchar', - length: '50', - isNullable: true, - }), + 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 = upper(replace(service_name, ' ', '_')) WHERE code IS NULL`, + `UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`, ); - await queryRunner.changeColumn( - 'freight.service_types', - 'code', - new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }), + await queryRunner.query( + `ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`, ); - await queryRunner.createIndex( - 'freight.service_types', - new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }), + 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 }), + ); + } - await queryRunner.addColumn( - 'freight.cargo_types', - new TableColumn({ - name: 'code', - type: 'varchar', - length: '50', - isNullable: 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 = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL`, + `UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`, ); - await queryRunner.changeColumn( - 'freight.cargo_types', - 'code', - new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }), + await queryRunner.query( + `ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`, ); - await queryRunner.createIndex( - 'freight.cargo_types', - new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }), + 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 ──────────────────────────────────────────────── - await queryRunner.createTable( + if (!(await queryRunner.hasTable('freight.surcharge_types'))) await queryRunner.createTable( new Table({ name: 'surcharge_types', schema: 'freight', @@ -87,7 +103,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf // ── 3. surcharges ───────────────────────────────────────────────────── - await queryRunner.createTable( + if (!(await queryRunner.hasTable('freight.surcharges'))) await queryRunner.createTable( new Table({ name: 'surcharges', schema: 'freight', @@ -136,7 +152,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf // ── 4. container_types ──────────────────────────────────────────────── - await queryRunner.createTable( + if (!(await queryRunner.hasTable('freight.container_types'))) await queryRunner.createTable( new Table({ name: 'container_types', schema: 'freight', @@ -164,7 +180,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf // ── 5. weight_limit_rules ───────────────────────────────────────────── - await queryRunner.createTable( + if (!(await queryRunner.hasTable('freight.weight_limit_rules'))) await queryRunner.createTable( new Table({ name: 'weight_limit_rules', schema: 'freight', @@ -229,7 +245,7 @@ export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterf // ── 6. priority_rules ───────────────────────────────────────────────── - await queryRunner.createTable( + if (!(await queryRunner.hasTable('freight.priority_rules'))) await queryRunner.createTable( new Table({ name: 'priority_rules', schema: 'freight', diff --git a/apps/edr-freight-api/src/migrations/1748600000000-ItmlsFullSchemaRewrite.ts b/apps/edr-freight-api/src/migrations/1748600000000-ItmlsFullSchemaRewrite.ts new file mode 100644 index 000000000..ce5b38c15 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748600000000-ItmlsFullSchemaRewrite.ts @@ -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 { + // ── 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 { + 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); + } +} diff --git a/apps/edr-freight-api/src/migrations/1748700000000-AddBookingsConfigForeignKeys.ts b/apps/edr-freight-api/src/migrations/1748700000000-AddBookingsConfigForeignKeys.ts new file mode 100644 index 000000000..79f3cfb94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748700000000-AddBookingsConfigForeignKeys.ts @@ -0,0 +1,94 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingsConfigForeignKeys1748700000000 implements MigrationInterface { + name = 'AddBookingsConfigForeignKeys1748700000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + 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"; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index f6a04fc30..3f5bbe507 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -92,8 +92,8 @@ export class BookingsController { @ApiOperation({ summary: "List freight bookings (paginated)", description: - "Filter by status, customerId, contractType, serviceType, tradeDirection, " + - "paymentCurrency, freightType, containerType, allowConsolidation, consolidationPaired. " + + "Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " + + "paymentCurrency, allowConsolidation, consolidationPaired. " + "Sort by createdAt or priorityScore.", }) findAll(@Query() filter: FilterBookingDto) { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 23d36b222..43b38d123 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,17 +1,33 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; -import { CustomersModule } from "../customers/customers.module"; -import { FilesModule } from "../files/files.module"; -import { MinioModule } from "../minio/minio.module"; -import { RuleEngineModule } from "../rule-engine/rule-engine.module"; -import { BookingsController } from "./bookings.controller"; -import { BookingsRepository } from "./bookings.repository"; -import { BookingsService } from "./bookings.service"; -import { Booking } from "./entities/booking.entity"; +import { CustomersModule } from '../customers/customers.module'; +import { FilesModule } from '../files/files.module'; +import { MinioModule } from '../minio/minio.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { BookingsController } from './bookings.controller'; +import { BookingsRepository } from './bookings.repository'; +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 { Booking } from './entities/booking.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule, CustomersModule, RuleEngineModule], + imports: [ + TypeOrmModule.forFeature([ + Booking, + BookingContainer, + BookingCargoModifier, + BookingApprovalStep, + BookingRateSnapshot, + ]), + FilesModule, + MinioModule, + CustomersModule, + RuleEngineModule, + ], controllers: [BookingsController], providers: [BookingsService, BookingsRepository], exports: [BookingsService], diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index d91adccec..60980952e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,16 +1,23 @@ -import { BaseRepository } from "@edr/api-common"; -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { In, IsNull, Not, Repository } from "typeorm"; +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, IsNull, Not, Repository } from 'typeorm'; -import { Booking } from "./entities/booking.entity"; -import { FileRecord } from "../files/entities/file.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 { Booking } from './entities/booking.entity'; +import { FileRecord } from '../files/entities/file.entity'; +import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; @Injectable() export class BookingsRepository extends BaseRepository { constructor( @InjectRepository(Booking) repository: Repository, + private readonly dataSource: DataSource, ) { super(repository); } @@ -24,59 +31,114 @@ export class BookingsRepository extends BaseRepository { async countByYear(year: number): Promise { 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 }) + .createQueryBuilder('booking') + .where('booking.created_at >= :startDate', { startDate }) + .andWhere('booking.created_at < :endDate', { endDate }) .getCount(); } - /** Find a booking by reference with associated files (polymorphic join). */ + /** Find a booking by reference with files and relations. */ async findByReferenceWithFiles(reference: string): Promise { - const booking = await this.repository - .createQueryBuilder("booking") - .where("booking.reference = :reference", { reference }) - .leftJoinAndMapMany( - "booking.files", - FileRecord, - "file", - "file.resource_id = booking.id AND file.resource = 'bookings'" - ) - .getOne(); - return booking ?? null; + return this.findByIdWithFiles( + ( + await this.repository.findOne({ where: { reference }, select: ['id'] }) + )?.id ?? '', + ); } - /** Find a booking by ID with associated files (polymorphic join). */ + /** Find a booking by ID with files, containers, and config relations. */ async findByIdWithFiles(id: string): Promise { + if (!id) return null; + const booking = await this.repository - .createQueryBuilder("booking") - .where("booking.id = :id", { id }) + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.bookingContainers', 'bc') + .leftJoinAndSelect('bc.containerType', 'ct') + .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') + .where('booking.id = :id', { id }) .leftJoinAndMapMany( - "booking.files", + 'booking.files', FileRecord, - "file", - "file.resource_id = booking.id AND file.resource = 'bookings'" + 'file', + "file.resource_id = booking.id AND file.resource = 'bookings'", ) .getOne(); + return booking ?? null; } - /** Find a compatible consolidation partner for the given booking. */ + /** Persist booking container rows with weight rule results. */ + async createContainers( + bookingId: string, + containers: Array<{ + containerTypeId: string; + quantity: number; + vgmPerUnitTons: number; + weightResult: ContainerWeightResult; + }>, + ): Promise { + 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 { + 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 a compatible consolidation partner. */ async findConsolidationPartner(booking: Booking): Promise { return this.repository.findOne({ where: { allowConsolidation: true, - // Check if containers JSONB contains at least one 20FT entry with odd qty - containers: Not(IsNull()), - originStation: booking.originStation, - destinationStation: booking.destinationStation, + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, tradeDirection: booking.tradeDirection, consolidationPartnerId: IsNull(), - status: In(["DRAFT", "PENDING_CONSOLIDATION"]), + status: In(['DRAFT', 'PENDING_CONSOLIDATION']), id: Not(booking.id), }, - order: { createdAt: "ASC" }, + order: { createdAt: 'ASC' }, }); } @@ -84,23 +146,90 @@ export class BookingsRepository extends BaseRepository { async pairConsolidation(bookingId: string, partnerId: string): Promise { await this.repository.update(bookingId, { consolidationPartnerId: partnerId, - status: "CONSOLIDATED", + status: 'CONSOLIDATED', } as never); await this.repository.update(partnerId, { consolidationPartnerId: bookingId, - status: "CONSOLIDATED", + status: 'CONSOLIDATED', } as never); } - /** Un-pair a consolidation. Returns both booking IDs. */ + /** Un-pair a consolidation. */ async unpairConsolidation(bookingId: string, partnerId: string): Promise { await this.repository.update(bookingId, { consolidationPartnerId: null, - status: "PENDING_CONSOLIDATION", + status: 'PENDING_CONSOLIDATION', } as never); await this.repository.update(partnerId, { consolidationPartnerId: null, - status: "PENDING_CONSOLIDATION", + status: 'PENDING_CONSOLIDATION', } as never); } + + /** Delete all containers for a booking (used on draft update). */ + async deleteContainers(bookingId: string): Promise { + await this.dataSource.getRepository(BookingContainer).delete({ bookingId }); + } + + /** Get pending approval step for a role. */ + async findPendingApprovalStep( + bookingId: string, + requiredRole: string, + ): Promise { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, requiredRole, status: 'PENDING' }, + order: { stepOrder: 'ASC' }, + }); + } + + /** Mark an approval step complete. */ + async completeApprovalStep( + stepId: string, + actorId: string, + status: 'APPROVED' | 'REJECTED', + remarks?: string, + ): Promise { + 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 { + 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 { + 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 { + return this.dataSource.getRepository(BookingRateSnapshot).findOne({ + where: { bookingId, rateId }, + }); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 1029b4560..616960358 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -3,20 +3,24 @@ import { ConflictException, Injectable, NotFoundException, -} from "@nestjs/common"; -import { IsNull, Not } from "typeorm"; +} from '@nestjs/common'; +import { IsNull, Not } from 'typeorm'; -import { CustomersService } from "../customers/customers.service"; -import { FilesService } from "../files/files.service"; -import { MinioService } from "../minio/minio.service"; -import { RuleEngineService } from "../rule-engine/rule-engine.service"; -import { BookingsRepository } from "./bookings.repository"; -import { CreateBookingDto } from "./dto/create-booking.dto"; -import { FilterBookingDto } from "./dto/filter-booking.dto"; -import { UpdateBookingDto } from "./dto/update-booking.dto"; -import { UpdateStatusDto } from "./dto/update-status.dto"; -import { Booking } from "./entities/booking.entity"; -import { FileRecord } from "../files/entities/file.entity"; +import { CustomersService } from '../customers/customers.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 { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; +import { FilterBookingDto } from './dto/filter-booking.dto'; +import { UpdateBookingDto } from './dto/update-booking.dto'; +import { UpdateStatusDto } from './dto/update-status.dto'; +import { Booking } from './entities/booking.entity'; +import { FileRecord } from '../files/entities/file.entity'; @Injectable() export class BookingsService { @@ -26,53 +30,68 @@ export class BookingsService { private readonly minioService: MinioService, private readonly customersService: CustomersService, private readonly ruleEngineService: RuleEngineService, + private readonly containerTypesService: ContainerTypesService, ) {} - // ── helpers ────────────────────────────────────────────────────────── - /** Generate a unique booking reference number. */ private async generateReference(): Promise { const year = new Date().getFullYear(); - const prefix = `BK-${year}`; - - // Get the count of bookings created this year const count = await this.bookingsRepository.countByYear(year); - const sequenceNumber = String(count + 1).padStart(6, '0'); - - return `${prefix}-${sequenceNumber}`; + return `BK-${year}-${String(count + 1).padStart(6, '0')}`; } - /** Resolve auto-consolidation flag. */ - private resolveConsolidation( - containers: Array<{ type: string; qty: number }> | undefined | null, - explicit?: boolean, - ): boolean { - if (explicit === false) return false; - if (!containers || containers.length === 0) return explicit ?? false; - // Auto-enable if any 20FT container has odd quantity - const needsConsolidation = containers.some( - (c) => c.type === "20FT" && c.qty % 2 !== 0 + /** Build evaluation input from DTO containers. */ + private async buildEvalInput( + dto: Pick< + CreateBookingDto, + | 'cargoTypeId' + | 'serviceTypeId' + | 'paymentCurrency' + | 'tradeDirection' + | 'isHazardous' + | 'allowConsolidation' + | 'shippingLineId' + | 'containers' + >, + ): Promise { + const containers = await Promise.all( + dto.containers.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, + }; + }), ); - if (needsConsolidation) return true; + return { + cargoTypeId: dto.cargoTypeId, + serviceTypeId: dto.serviceTypeId, + paymentCurrency: dto.paymentCurrency, + tradeDirection: dto.tradeDirection, + isHazardous: dto.isHazardous ?? false, + allowConsolidation: dto.allowConsolidation, + shippingLineId: dto.shippingLineId, + containers, + }; + } + + /** Resolve auto-consolidation for odd-quantity 20ft containers. */ + private async resolveConsolidation( + containers: CreateBookingContainerDto[], + explicit?: boolean, + ): Promise { + if (explicit === false) return false; + for (const c of containers) { + const ct = await this.containerTypesService.findById(c.containerTypeId); + if (ct.sizeFt === 20 && c.quantity % 2 !== 0) return true; + } return explicit ?? false; } - /** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */ - private calculateWagonCount( - containers: Array<{ type: string; qty: number }>, - ): number { - return containers.reduce((total, container) => { - if (container.type === "40FT") { - return total + container.qty; - } - // 20FT: 1 wagon per 2 containers (rounded up) - return total + Math.ceil(container.qty / 2); - }, 0); - } - - - // ── CRUD ───────────────────────────────────────────────────────────── - /** Create a new freight booking. */ async create( dto: CreateBookingDto, @@ -81,65 +100,83 @@ export class BookingsService { ): Promise<{ booking: Booking; warnings: string[] }> { const warnings: string[] = []; - // Resolve customerId: use provided value (admin) or look up by IAM userId let customerId = dto.customerId; if (!customerId) { if (!userId) { - throw new BadRequestException('customerId is required or must be resolvable from auth token'); + throw new BadRequestException( + 'customerId is required or must be resolvable from auth token', + ); } const customer = await this.customersService.findByUserId(userId); customerId = customer.id; } - // Generate reference if not provided - const reference = dto.reference || await this.generateReference(); - - const allowConsolidation = this.resolveConsolidation( + const reference = dto.reference || (await this.generateReference()); + const allowConsolidation = await this.resolveConsolidation( dto.containers, dto.allowConsolidation, ); - // ── Rule engine evaluation ────────────────────────────────────────── - const ruleResult = await this.ruleEngineService.evaluate({ - freightType: dto.freightType, - serviceType: dto.serviceType, - paymentCurrency: dto.paymentCurrency, - cargoTotalWeightVgm: dto.cargoTotalWeightVgm, - tradeDirection: dto.tradeDirection, - isHazardous: dto.isHazardous ?? false, - isRefrigerated: dto.isRefrigerated ?? false, - containers: dto.containers, - }); + const evalInput = await this.buildEvalInput({ ...dto, allowConsolidation }); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); this.ruleEngineService.assertNoHardBlocks(ruleResult); + warnings.push(...ruleResult.warnings); - const wagonCount = this.calculateWagonCount(dto.containers); - warnings.push(`Estimated wagons required: ${wagonCount}`); - const booking = await this.bookingsRepository.create({ - ...dto, reference, customerId, - totalAmount: 0, - paymentStatus: "PENDING", + 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, + cargoTypeId: dto.cargoTypeId, + 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", + status: 'DRAFT', allowConsolidation, priorityScore: ruleResult.priorityScore, + totalAmount: 0, + paymentStatus: 'PENDING', }); + await this.bookingsRepository.createContainers( + booking.id, + dto.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 (err) { - console.error('[BookingsService] File upload failed, booking still created:', err); + await this.filesService.uploadMany(booking.id, 'bookings', files); + } catch { warnings.push('File upload failed — booking was created without attached files.'); } } - return { booking, warnings }; + const full = await this.findById(booking.id); + return { booking: full, warnings }; } /** Update a draft booking. */ @@ -149,46 +186,67 @@ export class BookingsService { files: Express.Multer.File[], ): Promise<{ booking: Booking; warnings: string[] }> { const existing = await this.findById(id); - if (existing.status !== "DRAFT") { - throw new BadRequestException("Only DRAFT bookings can be updated"); + if (existing.status !== 'DRAFT') { + throw new BadRequestException('Only DRAFT bookings can be updated'); } const warnings: string[] = []; - const updates: Record = { ...dto }; + const containers = dto.containers ?? existing.bookingContainers?.map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + vgmPerUnitTons: Number(bc.vgmPerUnitTons), + })) ?? []; + const allowConsolidation = await this.resolveConsolidation( + containers, + dto.allowConsolidation ?? existing.allowConsolidation, + ); + + const evalInput = await this.buildEvalInput({ + cargoTypeId: dto.cargoTypeId ?? existing.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 = { + ...dto, + 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; - // Re-evaluate consolidation if containers changed - const containers = dto.containers ?? existing.containers ?? []; - updates.allowConsolidation = this.resolveConsolidation( - containers, - dto.allowConsolidation, - ); + await this.bookingsRepository.update(id, updates); - // ── Rule engine re-evaluation ──────────────────────────────────────── - const ruleResult = await this.ruleEngineService.evaluate({ - freightType: dto.freightType ?? existing.freightType, - serviceType: dto.serviceType ?? existing.serviceType, - paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, - cargoTotalWeightVgm: dto.cargoTotalWeightVgm ?? existing.cargoTotalWeightVgm, - tradeDirection: dto.tradeDirection ?? existing.tradeDirection, - isHazardous: dto.isHazardous ?? existing.isHazardous ?? false, - isRefrigerated: dto.isRefrigerated ?? existing.isRefrigerated ?? false, - containers, - }); - this.ruleEngineService.assertNoHardBlocks(ruleResult); - warnings.push(...ruleResult.warnings); - updates.priorityScore = ruleResult.priorityScore; - - if (files.length > 0) { - await this.filesService.uploadMany(id, "bookings", files); + if (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], + })), + ); } - const booking = await this.bookingsRepository.update(id, updates); - if (!booking) throw new NotFoundException(`Booking ${id} not found`); + if (files.length > 0) { + await this.filesService.uploadMany(id, 'bookings', files); + } + const booking = await this.findById(id); return { booking, warnings }; } @@ -203,19 +261,21 @@ export class BookingsService { if (filter.status) where.status = filter.status; if (filter.customerId) where.customerId = filter.customerId; if (filter.contractType) where.contractType = filter.contractType; - if (filter.serviceType) where.serviceType = filter.serviceType; + if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId; + if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId; if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency; - if (filter.freightType) where.freightType = filter.freightType; - if (filter.allowConsolidation !== undefined) + if (filter.allowConsolidation !== undefined) { where.allowConsolidation = filter.allowConsolidation; - if (filter.consolidationPaired === "true") + } + if (filter.consolidationPaired === 'true') { where.consolidationPartnerId = Not(IsNull()); - else if (filter.consolidationPaired === "false") + } else if (filter.consolidationPaired === 'false') { where.consolidationPartnerId = IsNull(); + } - const sortField = filter.sortBy ?? "createdAt"; - const sortDir = filter.sortOrder ?? "DESC"; + const sortField = filter.sortBy ?? 'createdAt'; + const sortDir = filter.sortOrder ?? 'DESC'; const [items, total] = await this.bookingsRepository.findAndCount({ where, @@ -226,245 +286,244 @@ export class BookingsService { return { items, total }; } - /** Get a single booking by ID with files, throwing if not found. */ + /** Get a single booking by ID with files. */ async findById(id: string): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { throw new NotFoundException(`Booking ${id} not found`); } - // Add signed URLs for files (5-minute expiration) if (booking.files && booking.files.length > 0) { booking.files = await Promise.all( booking.files.map(async (file: FileRecord) => { const objectName = this.extractObjectName(file.url); const signedUrl = await this.minioService.getSignedUrl(objectName, 300); return { ...file, signedUrl }; - }) + }), ); } return booking; } - /** Extract object name from Minio URL. */ private extractObjectName(url: string): string { - const parts = url.split("/"); - return parts.slice(4).join("/"); + const parts = url.split('/'); + return parts.slice(4).join('/'); } - /** Find booking by reference with files. */ async findByReference(reference: string): Promise { const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); if (!booking) { throw new NotFoundException(`Booking with reference "${reference}" not found`); } - - // Add signed URLs for files (5-minute expiration) - if (booking.files && booking.files.length > 0) { - booking.files = await Promise.all( - booking.files.map(async (file: FileRecord) => { - const objectName = this.extractObjectName(file.url); - const signedUrl = await this.minioService.getSignedUrl(objectName, 300); - return { ...file, signedUrl }; - }) - ); - } - - return booking; + return this.findById(booking.id); } - /** Soft-delete a booking (DRAFT only). */ async remove(id: string): Promise { const booking = await this.findById(id); - if (booking.status !== "DRAFT") { - throw new BadRequestException("Only DRAFT bookings can be deleted"); + if (booking.status !== 'DRAFT') { + throw new BadRequestException('Only DRAFT bookings can be deleted'); } await this.bookingsRepository.softDelete(id); } - // ── status workflow ────────────────────────────────────────────────── - /** Unified status transition handler. */ async updateStatus(id: string, dto: UpdateStatusDto): Promise { const booking = await this.findById(id); - const { action, actorId, reason } = dto; + const { action, actorId, reason, requiredRole } = dto; switch (action) { - case "SUBMIT": + case 'SUBMIT': return this.handleSubmit(booking); - case "APPROVE_STAFF": - return this.handleApproveStaff(booking, actorId); - case "APPROVE_DIRECTOR": - return this.handleApproveDirector(booking, actorId); - case "APPROVE_CEO": - return this.handleApproveCeo(booking, actorId); - case "REJECT": + case 'SEND_QUOTATION': + return this.handleSendQuotation(booking); + case 'APPROVE_QUOTATION': + return this.handleApproveQuotation(booking); + case 'REJECT_QUOTATION': + return this.handleRejectQuotation(booking, reason); + case 'APPROVE_STEP': + return this.handleApproveStep(booking, actorId, requiredRole); + case 'APPROVE': + return this.handleFullyApproved(booking); + case 'CUSTOMER_SIGN': + return this.handleCustomerSign(booking); + case 'MARK_FULLY_EXECUTED': + return this.handleFullyExecuted(booking); + case 'MARK_PAID': + return this.handleMarkPaid(booking); + case 'START_TRANSIT': + return this.handleStartTransit(booking); + case 'COMPLETE': + return this.handleComplete(booking); + case 'REJECT': return this.handleReject(booking, actorId, reason); - case "CANCEL": - return this.handleCancel(booking, actorId, reason); - case "ACTIVATE": - return this.handleActivate(booking); - case "EXPIRE": - return this.handleExpire(booking); + case 'CANCEL': + return this.handleCancel(booking, reason); default: throw new BadRequestException(`Unknown action: ${action}`); } } - /** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (cargo routing from rule engine). */ + /** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */ private async handleSubmit(booking: Booking): Promise { - this.assertStatus(booking, ["DRAFT"]); - const ruleResult = await this.ruleEngineService.evaluate(booking); - const nextStatus = ruleResult.requiresDirectorApproval - ? "PENDING_DIRECTOR" - : "PENDING_LINE_STAFF"; + this.assertStatus(booking, ['DRAFT']); + + await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never); + await this.ruleEngineService.snapshotLiveRates(booking.id); + await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId); + const updated = await this.bookingsRepository.update(booking.id, { - status: nextStatus, + status: 'PENDING_APPROVAL', } as never); return updated!; } - /** APPROVE_STAFF: PENDING_LINE_STAFF → APPROVED_PENDING_SIGNATURE. */ - private async handleApproveStaff( + private async handleSendQuotation(booking: Booking): Promise { + this.assertStatus(booking, ['RFQ_SUBMITTED']); + const updated = await this.bookingsRepository.update(booking.id, { + status: 'QUOTATION_SENT', + } as never); + return updated!; + } + + private async handleApproveQuotation(booking: Booking): Promise { + this.assertStatus(booking, ['QUOTATION_SENT']); + const updated = await this.bookingsRepository.update(booking.id, { + status: 'QUOTATION_APPROVED', + } as never); + return updated!; + } + + private async handleRejectQuotation(booking: Booking, reason?: string): Promise { + this.assertStatus(booking, ['QUOTATION_SENT']); + if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION'); + const updated = await this.bookingsRepository.update(booking.id, { + status: 'QUOTATION_REJECTED', + } as never); + return updated!; + } + + private async handleApproveStep( booking: Booking, actorId?: string, + requiredRole?: string, ): Promise { - this.assertStatus(booking, ["PENDING_LINE_STAFF"]); - if (!actorId) - throw new BadRequestException("actorId is required for APPROVE_STAFF"); - - // Line staff cannot approve bookings that require director approval - const ruleResult = await this.ruleEngineService.evaluate(booking); - if (ruleResult.requiresDirectorApproval) { - throw new BadRequestException( - "Line staff cannot approve bookings that require director approval", - ); + this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']); + if (!actorId || !requiredRole) { + throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP'); } + const step = await this.bookingsRepository.findPendingApprovalStep( + booking.id, + requiredRole, + ); + if (!step) { + throw new BadRequestException(`No pending approval step for role ${requiredRole}`); + } + + await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + + const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id); + if (allDone) { + const updated = await this.bookingsRepository.update(booking.id, { + status: 'APPROVED', + } as never); + return updated!; + } + + return this.findById(booking.id); + } + + private async handleFullyApproved(booking: Booking): Promise { + this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']); const updated = await this.bookingsRepository.update(booking.id, { - status: "APPROVED_PENDING_SIGNATURE", - approvedByStaffId: actorId, - approvedByStaffAt: new Date(), + status: 'APPROVED', } as never); return updated!; } - /** APPROVE_DIRECTOR: APPROVED_PENDING_SIGNATURE|PENDING_DIRECTOR → SIGNED or PENDING_CEO. */ - private async handleApproveDirector( - booking: Booking, - actorId?: string, - ): Promise { - this.assertStatus(booking, [ - "APPROVED_PENDING_SIGNATURE", - "PENDING_DIRECTOR", - ]); - if (!actorId) - throw new BadRequestException("actorId is required for APPROVE_DIRECTOR"); - - const ruleResult = await this.ruleEngineService.evaluate(booking); - const nextStatus = ruleResult.requiresDirectorApproval ? "PENDING_CEO" : "SIGNED"; - + private async handleCustomerSign(booking: Booking): Promise { + this.assertStatus(booking, ['APPROVED']); const updated = await this.bookingsRepository.update(booking.id, { - status: nextStatus, - signedByDirectorId: actorId, - signedByDirectorAt: new Date(), + status: 'SIGNED_CUSTOMER', + customerSignedAt: new Date(), } as never); return updated!; } - /** APPROVE_CEO: PENDING_CEO → SIGNED. */ - private async handleApproveCeo( - booking: Booking, - actorId?: string, - ): Promise { - this.assertStatus(booking, ["PENDING_CEO"]); - if (!actorId) - throw new BadRequestException("actorId is required for APPROVE_CEO"); - + private async handleFullyExecuted(booking: Booking): Promise { + this.assertStatus(booking, ['SIGNED_CUSTOMER']); const updated = await this.bookingsRepository.update(booking.id, { - status: "SIGNED", - signedByCeoId: actorId, - signedByCeoAt: new Date(), + status: 'FULLY_EXECUTED', + fullyExecutedAt: new Date(), } as never); return updated!; } - /** REJECT: PENDING_* → CANCELLED. */ - private async handleReject( - booking: Booking, - actorId?: string, - reason?: string, - ): Promise { - this.assertStatus(booking, [ - "PENDING_LINE_STAFF", - "PENDING_DIRECTOR", - "PENDING_CEO", - "APPROVED_PENDING_SIGNATURE", - ]); - if (!actorId || !reason) - throw new BadRequestException("actorId and reason are required for REJECT"); - + private async handleMarkPaid(booking: Booking): Promise { + this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']); const updated = await this.bookingsRepository.update(booking.id, { - status: "CANCELLED", + status: 'PAID', + paymentStatus: 'PAID', } as never); return updated!; } - /** CANCEL: DRAFT|PENDING_* → CANCELLED. */ - private async handleCancel( - booking: Booking, - _actorId?: string, - reason?: string, - ): Promise { - this.assertStatus(booking, [ - "DRAFT", - "PENDING_LINE_STAFF", - "PENDING_DIRECTOR", - "PENDING_CEO", - "APPROVED_PENDING_SIGNATURE", - ]); - if (!reason) - throw new BadRequestException("reason is required for CANCEL"); - + private async handleStartTransit(booking: Booking): Promise { + this.assertStatus(booking, ['PAID']); const updated = await this.bookingsRepository.update(booking.id, { - status: "CANCELLED", + status: 'IN_TRANSIT', } as never); return updated!; } - /** ACTIVATE: SIGNED → ACTIVE. */ - private async handleActivate(booking: Booking): Promise { - this.assertStatus(booking, ["SIGNED"]); + private async handleComplete(booking: Booking): Promise { + this.assertStatus(booking, ['IN_TRANSIT']); const updated = await this.bookingsRepository.update(booking.id, { - status: "ACTIVE", - startDate: booking.startDate ?? new Date(), - } as never); - return updated!; - } - - /** EXPIRE: ACTIVE → EXPIRED. */ - private async handleExpire(booking: Booking): Promise { - this.assertStatus(booking, ["ACTIVE"]); - const updated = await this.bookingsRepository.update(booking.id, { - status: "EXPIRED", + status: 'COMPLETED', endDate: new Date(), } as never); return updated!; } - /** Guard: ensure current status is one of the allowed values. */ + private async handleReject( + booking: Booking, + actorId?: string, + reason?: string, + ): Promise { + this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']); + if (!actorId || !reason) { + throw new BadRequestException('actorId and reason are required for REJECT'); + } + const updated = await this.bookingsRepository.update(booking.id, { + status: 'CANCELLED', + } as never); + return updated!; + } + + private async handleCancel(booking: Booking, reason?: string): Promise { + this.assertStatus(booking, [ + 'DRAFT', + 'RFQ_SUBMITTED', + 'QUOTATION_SENT', + 'QUOTATION_APPROVED', + 'PENDING_APPROVAL', + ]); + if (!reason) throw new BadRequestException('reason is required for CANCEL'); + const updated = await this.bookingsRepository.update(booking.id, { + status: 'CANCELLED', + } as never); + return updated!; + } + private assertStatus(booking: Booking, allowed: string[]): void { if (!allowed.includes(booking.status)) { throw new ConflictException( - `Cannot perform this action on a booking with status "${booking.status}". Allowed: ${allowed.join(", ")}`, + `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`, ); } } - // ── consolidation ──────────────────────────────────────────────────── - - /** Request consolidation — auto-pair if a partner exists, else queue. */ async requestConsolidation(id: string): Promise<{ booking: Booking; partner: Booking | null; @@ -473,61 +532,63 @@ export class BookingsService { const booking = await this.findById(id); if (!booking.allowConsolidation) { - throw new BadRequestException("Booking is not eligible for consolidation"); + throw new BadRequestException('Booking is not eligible for consolidation'); } - // Check if any 20FT container has odd quantity - const hasOdd20FT = booking.containers?.some( - (c) => c.type === "20FT" && c.qty % 2 !== 0 - ) ?? false; - - if (!hasOdd20FT) { + const hasOdd20Ft = await this.hasOdd20FtContainer(booking); + if (!hasOdd20Ft) { throw new BadRequestException( - "Only bookings with odd-quantity 20FT containers need consolidation", + 'Only bookings with odd-quantity 20ft containers need consolidation', ); } if (booking.consolidationPartnerId) { - throw new ConflictException("Booking is already paired for consolidation"); + throw new ConflictException('Booking is already paired for consolidation'); } - const partner = - await this.bookingsRepository.findConsolidationPartner(booking); + const partner = await this.bookingsRepository.findConsolidationPartner(booking); if (partner) { await this.bookingsRepository.pairConsolidation(booking.id, partner.id); - const updated = await this.findById(id); - const updatedPartner = await this.findById(partner.id); - return { booking: updated, partner: updatedPartner, paired: true }; + return { + booking: await this.findById(id), + partner: await this.findById(partner.id), + paired: true, + }; } - // No partner found — enter queue await this.bookingsRepository.update(booking.id, { - status: "PENDING_CONSOLIDATION", + status: 'PENDING_CONSOLIDATION', } as never); - const updated = await this.findById(id); - return { booking: updated, partner: null, paired: false }; + return { booking: await this.findById(id), partner: null, paired: false }; } - /** Remove consolidation pairing. */ - async removeConsolidation(id: string): Promise<{ - booking: Booking; - partner: Booking; - }> { + private async hasOdd20FtContainer(booking: Booking): Promise { + const containers = booking.bookingContainers ?? []; + for (const bc of containers) { + const ct = + bc.containerType ?? + (await this.containerTypesService.findById(bc.containerTypeId)); + if (ct.sizeFt === 20 && bc.quantity % 2 !== 0) return true; + } + return false; + } + + 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"); + throw new BadRequestException('Booking has no consolidation partner'); } const partnerId = booking.consolidationPartnerId; await this.bookingsRepository.unpairConsolidation(id, partnerId); - const updated = await this.findById(id); - const updatedPartner = await this.findById(partnerId); - return { booking: updated, partner: updatedPartner }; + return { + booking: await this.findById(id), + partner: await this.findById(partnerId), + }; } - /** Get consolidation details for a booking. */ async getConsolidationDetails(id: string): Promise<{ booking: Booking; partner: Booking | null; @@ -540,11 +601,13 @@ export class BookingsService { } const partner = await this.findById(booking.consolidationPartnerId); - const splitBilling = { - bookingShare: booking.totalAmount, - partnerShare: partner.totalAmount, + return { + booking, + partner, + splitBilling: { + bookingShare: Number(booking.totalAmount), + partnerShare: Number(partner.totalAmount), + }, }; - - return { booking, partner, splitBilling }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 259c8eafe..0d61752f4 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Transform, Type } from "class-transformer"; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; import { IsArray, IsBoolean, @@ -12,117 +12,81 @@ import { IsUUID, Min, ValidateNested, -} from "class-validator"; +} from 'class-validator'; +import { BOOKING_STATUSES } from '../entities/booking.entity'; -const BOOKING_STATUSES = [ - "DRAFT", - "PENDING_LINE_STAFF", - "PENDING_DIRECTOR", - "PENDING_CEO", - "APPROVED_PENDING_SIGNATURE", - "SIGNED", - "ACTIVE", - "EXPIRED", - "CANCELLED", - "PENDING_CONSOLIDATION", - "CONSOLIDATED", -] as const; - -const CONTRACT_TYPES = ["NEW", "RENEWAL"] as const; -const SERVICE_TYPES = ["RAIL_ONLY", "RAIL_AND_FORWARDING"] as const; -const EQUIPMENT_RETURNS = ["WITH_RETURN", "WITHOUT_RETURN"] as const; -const FREIGHT_TYPES = ["BULK", "BREAK_BULK"] as const; -const TRADE_DIRECTIONS = ["IMPORT", "EXPORT"] as const; -const PAYMENT_CURRENCIES = ["ETB", "USD"] as const; -const PAYMENT_STATUSES = ["PENDING", "PAID", "OVERDUE", "CANCELLED", "REFUNDED"] as const; -const CONTAINER_TYPES = ["20FT", "40FT"] as const; +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, - SERVICE_TYPES, EQUIPMENT_RETURNS, - FREIGHT_TYPES, TRADE_DIRECTIONS, PAYMENT_CURRENCIES, - PAYMENT_STATUSES, - CONTAINER_TYPES, }; -export class ContainerItem { - @ApiProperty({ enum: CONTAINER_TYPES, description: "Container type (20FT or 40FT)" }) - @IsIn([...CONTAINER_TYPES]) - type!: string; +export class CreateBookingContainerDto { + @ApiProperty({ format: 'uuid', description: 'FK to container_types.id' }) + @IsUUID() + containerTypeId!: string; - @ApiProperty({ description: "Quantity of containers", minimum: 1 }) + @ApiProperty({ description: 'Quantity of containers', minimum: 1 }) @IsInt() @Min(1) @Transform(({ value }) => Number(value)) - qty!: number; + quantity!: number; - @ApiProperty({ description: "VGM per container in tons", minimum: 0 }) + @ApiProperty({ description: 'VGM per container in tons', minimum: 0 }) @IsNumber() @Min(0) @Transform(({ value }) => Number(value)) - vgm!: number; + vgmPerUnitTons!: number; } export class CreateBookingDto { - // ── core ───────────────────────────────────────────────────────────── - @ApiPropertyOptional({ description: "Unique booking reference (auto-generated if not provided)" }) + @ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' }) @IsOptional() @IsString() - @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) reference?: string; - @ApiPropertyOptional({ format: "uuid", description: "Admin only: target customer. Omit to resolve from auth token." }) + @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer' }) @IsOptional() @IsUUID() customerId?: string; - @ApiPropertyOptional({ format: "uuid" }) + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() trainId?: string; - @ApiProperty({ example: "2026-06-15T00:00:00.000Z" }) + @ApiProperty({ example: '2026-06-15T00:00:00.000Z' }) @IsDateString() scheduledDate!: string; - - // ── contract ───────────────────────────────────────────────────────── @ApiProperty({ enum: CONTRACT_TYPES }) @IsIn([...CONTRACT_TYPES]) contractType!: string; - @ApiPropertyOptional({ format: "uuid", description: "For RENEWAL — previous contract/booking ID" }) + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() - @Transform(({ value }) => (value === "" || value == null ? undefined : value)) + @Transform(({ value }) => (value === '' || value == null ? undefined : value)) previousContractId?: string; - @ApiProperty({ enum: SERVICE_TYPES }) - @IsIn([...SERVICE_TYPES]) - serviceType!: string; + @ApiProperty({ format: 'uuid', description: 'FK to service_types.id' }) + @IsUUID() + serviceTypeId!: string; - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === "true" || value === true) - firstMileEnabled?: boolean; - - @ApiPropertyOptional({ description: "Required when firstMileEnabled is true" }) + @ApiPropertyOptional() @IsOptional() @IsString() firstMilePickupAddress?: string; - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === "true" || value === true) - lastMileEnabled?: boolean; - - @ApiPropertyOptional({ description: "Required when lastMileEnabled is true" }) + @ApiPropertyOptional() @IsOptional() @IsString() lastMileDeliveryAddress?: string; @@ -131,55 +95,59 @@ export class CreateBookingDto { @IsIn([...EQUIPMENT_RETURNS]) equipmentReturn!: string; - @ApiProperty({ description: "Origin station name or code" }) - @IsString() - originStation!: string; + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' }) + @IsUUID() + originYardId!: string; - @ApiProperty({ description: "Destination station name or code" }) - @IsString() - destinationStation!: string; - - @ApiProperty({ description: "Total cargo weight in VGM tons", minimum: 0 }) - @IsNumber() - @Min(0) - @Transform(({ value }) => Number(value)) - cargoTotalWeightVgm!: number; - - @ApiProperty({ enum: FREIGHT_TYPES }) - @IsIn([...FREIGHT_TYPES]) - freightType!: string; - - @ApiPropertyOptional({ description: "Coffee, Beans, Machinery, Ro-Ro, etc." }) - @IsOptional() - @IsString() - freightSubtype?: string; - - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === "true" || value === true) - isHazardous?: boolean; - - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === "true" || value === true) - isRefrigerated?: boolean; + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' }) + @IsUUID() + destinationYardId!: string; @ApiProperty({ enum: TRADE_DIRECTIONS }) @IsIn([...TRADE_DIRECTIONS]) tradeDirection!: string; + @ApiProperty({ format: 'uuid', description: 'FK to cargo_types.id' }) + @IsUUID() + cargoTypeId!: string; + + @ApiPropertyOptional({ maxLength: 200 }) + @IsOptional() + @IsString() + cargoFreeText?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'FK to shipping_lines.id' }) + @IsOptional() + @IsUUID() + shippingLineId?: string; + + @ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + cargoTotalWeightVgm!: number; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isHazardous?: boolean; + @ApiProperty({ enum: PAYMENT_CURRENCIES }) @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency!: string; - @ApiPropertyOptional({ example: "2026-06-15" }) + @ApiPropertyOptional() + @IsOptional() + @IsString() + pnrCode?: string; + + @ApiPropertyOptional() @IsOptional() @IsDateString() startDate?: string; - @ApiPropertyOptional({ example: "2027-06-15" }) + @ApiPropertyOptional() @IsOptional() @IsDateString() endDate?: string; @@ -189,19 +157,15 @@ export class CreateBookingDto { @IsString() financialTerms?: string; - // ── containers ──────────────────────────────────────────────────────── - @ApiProperty({ type: [ContainerItem], description: "Array of container specifications" }) + @ApiProperty({ type: [CreateBookingContainerDto] }) @IsArray() @ValidateNested({ each: true }) - @Type(() => ContainerItem) - containers!: ContainerItem[]; + @Type(() => CreateBookingContainerDto) + containers!: CreateBookingContainerDto[]; - @ApiPropertyOptional({ - default: false, - description: "Auto-set to true when any 20FT container has odd quantity. User may override.", - }) + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() - @Transform(({ value }) => value === "true" || value === true) + @Transform(({ value }) => value === 'true' || value === true) allowConsolidation?: boolean; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index b5b23a80d..912064905 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -1,15 +1,7 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { Transform, Type } from "class-transformer"; -import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator"; - -import { - BOOKING_STATUSES, - CONTRACT_TYPES, - FREIGHT_TYPES, - PAYMENT_CURRENCIES, - SERVICE_TYPES, - TRADE_DIRECTIONS, -} from "./create-booking.dto"; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; +import { BOOKING_STATUSES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS } from './create-booking.dto'; export class FilterBookingDto { @ApiPropertyOptional({ enum: BOOKING_STATUSES }) @@ -17,20 +9,24 @@ export class FilterBookingDto { @IsIn([...BOOKING_STATUSES]) status?: string; - @ApiPropertyOptional({ format: "uuid" }) + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() customerId?: string; - @ApiPropertyOptional({ enum: CONTRACT_TYPES }) + @ApiPropertyOptional() @IsOptional() - @IsIn([...CONTRACT_TYPES]) contractType?: string; - @ApiPropertyOptional({ enum: SERVICE_TYPES }) + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() - @IsIn([...SERVICE_TYPES]) - serviceType?: string; + @IsUUID() + serviceTypeId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string; @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) @IsOptional() @@ -42,43 +38,31 @@ export class FilterBookingDto { @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency?: string; - @ApiPropertyOptional({ enum: FREIGHT_TYPES }) + @ApiPropertyOptional() @IsOptional() - @IsIn([...FREIGHT_TYPES]) - freightType?: string; - - @ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === "true" || value === true) + @Transform(({ value }) => value === 'true' || value === true) allowConsolidation?: boolean; - @ApiPropertyOptional({ description: "Filter by consolidation partner presence (true = paired, false = unpaired)" }) + @ApiPropertyOptional({ description: 'true | false — filter paired consolidation' }) @IsOptional() - @IsString() consolidationPaired?: string; - @ApiPropertyOptional({ enum: ["createdAt", "priorityScore"], default: "createdAt" }) + @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() - @IsIn(["createdAt", "priorityScore"]) sortBy?: string; - @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) + @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) @IsOptional() - @IsIn(["ASC", "DESC"]) - sortOrder?: "ASC" | "DESC"; - - @ApiPropertyOptional({ default: 1, minimum: 1 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - page?: number = 1; - - @ApiPropertyOptional({ default: 20, minimum: 1 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - pageSize?: number = 20; + @IsIn(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts index 9cd796840..a2d6c192d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts @@ -1,41 +1,40 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsOptional, IsString, IsUUID } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; const STATUS_ACTIONS = [ - "SUBMIT", - "APPROVE_STAFF", - "APPROVE_DIRECTOR", - "APPROVE_CEO", - "REJECT", - "CANCEL", - "ACTIVATE", - "EXPIRE", + 'SUBMIT', + 'SEND_QUOTATION', + 'APPROVE_QUOTATION', + 'REJECT_QUOTATION', + 'APPROVE_STEP', + 'APPROVE', + 'CUSTOMER_SIGN', + 'MARK_FULLY_EXECUTED', + 'MARK_PAID', + 'START_TRANSIT', + 'COMPLETE', + 'REJECT', + 'CANCEL', ] as const; export { STATUS_ACTIONS }; export class UpdateStatusDto { - @ApiProperty({ - enum: STATUS_ACTIONS, - description: - "SUBMIT — send to approval queue | " + - "APPROVE_STAFF — line-staff approval | " + - "APPROVE_DIRECTOR — director approval / signature | " + - "APPROVE_CEO — CEO final signature | " + - "REJECT — reject at any pending stage | " + - "CANCEL — customer / admin cancellation | " + - "ACTIVATE — activate a signed booking | " + - "EXPIRE — mark an active booking as expired", - }) + @ApiProperty({ enum: STATUS_ACTIONS }) @IsIn([...STATUS_ACTIONS]) action!: string; - @ApiPropertyOptional({ format: "uuid", description: "Actor performing the action (staff/director/CEO)" }) + @ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' }) @IsOptional() @IsUUID() actorId?: string; - @ApiPropertyOptional({ description: "Required for REJECT and CANCEL actions" }) + @ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' }) + @IsOptional() + @IsString() + requiredRole?: string; + + @ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' }) @IsOptional() @IsString() reason?: string; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts new file mode 100644 index 000000000..4f3032b73 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts @@ -0,0 +1,45 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity'; +import { Booking } from './booking.entity'; + +export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const; +export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number]; + +@Entity({ schema: 'freight', name: 'booking_approval_step' }) +@Index(['bookingId']) +@Index(['status']) +@Index(['bookingId', 'stepOrder']) +export class BookingApprovalStep extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'approval_rule_id', type: 'uuid' }) + approvalRuleId!: string; + + @ManyToOne(() => ApprovalRule) + @JoinColumn({ name: 'approval_rule_id' }) + approvalRule?: ApprovalRule; + + @Column({ name: 'step_order', type: 'smallint' }) + stepOrder!: number; + + @Column({ name: 'required_role', type: 'varchar', length: 30 }) + requiredRole!: string; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) + status!: ApprovalStepStatus; + + @Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true }) + actionedByStaffId?: string | null; + + @Column({ name: 'actioned_at', type: 'timestamptz', nullable: true }) + actionedAt?: Date | null; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts new file mode 100644 index 000000000..5933abae2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { SurchargeType } from '../../rule-engine/entities/surcharge-type.entity'; +import { Booking } from './booking.entity'; +import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; + +@Entity({ schema: 'freight', name: 'booking_cargo_modifier' }) +@Index(['bookingId']) +@Index(['surchargeTypeId']) +export class BookingCargoModifier extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.cargoModifiers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'surcharge_type_id', type: 'uuid' }) + surchargeTypeId!: string; + + @ManyToOne(() => SurchargeType) + @JoinColumn({ name: 'surcharge_type_id' }) + surchargeType?: SurchargeType; + + @Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true }) + triggerValue?: number | null; + + @Column({ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 }) + calculatedAmount!: number; + + @Column({ name: 'rate_snapshot_id', type: 'uuid' }) + rateSnapshotId!: string; + + @ManyToOne(() => BookingRateSnapshot) + @JoinColumn({ name: 'rate_snapshot_id' }) + rateSnapshot?: BookingRateSnapshot; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts new file mode 100644 index 000000000..dc7691456 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -0,0 +1,49 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity'; +import { Booking } from './booking.entity'; + +@Entity({ schema: 'freight', name: 'booking_container' }) +@Index(['bookingId']) +@Index(['isOverweight']) +export class BookingContainer extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.bookingContainers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'container_type_id', type: 'uuid' }) + containerTypeId!: string; + + @ManyToOne(() => ContainerType) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType; + + @Column({ name: 'quantity', type: 'smallint' }) + quantity!: number; + + @Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 }) + vgmPerUnitTons!: number; + + @Column({ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 }) + totalVgmTons!: number; + + @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 }) + wagonsRequired!: number; + + @Column({ name: 'weight_limit_rule_id', type: 'uuid', nullable: true }) + weightLimitRuleId?: string | null; + + @ManyToOne(() => WeightLimitRule, { nullable: true }) + @JoinColumn({ name: 'weight_limit_rule_id' }) + weightLimitRule?: WeightLimitRule | null; + + @Column({ name: 'is_overweight', type: 'boolean', default: false }) + isOverweight!: boolean; + + @Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + overweightExcessTons?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-rate-snapshot.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-rate-snapshot.entity.ts new file mode 100644 index 000000000..ab5b086df --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-rate-snapshot.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Rate } from '../../rule-engine/entities/rate.entity'; +import { Booking } from './booking.entity'; + +@Entity({ schema: 'freight', name: 'booking_rate_snapshot' }) +@Index(['bookingId']) +@Index(['rateId']) +@Index(['rateType']) +export class BookingRateSnapshot extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.rateSnapshots, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'rate_id', type: 'uuid' }) + rateId!: string; + + @ManyToOne(() => Rate) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate; + + @Column({ name: 'rate_type', type: 'varchar', length: 50 }) + rateType!: string; + + @Column({ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 }) + rateValue!: number; + + @Column({ name: 'rate_unit', type: 'varchar', length: 30 }) + rateUnit!: string; + + @Column({ name: 'currency', type: 'varchar', length: 5 }) + currency!: string; + + @Column({ name: 'snapshotted_at', type: 'timestamptz' }) + snapshottedAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 75f4abe96..e7c456375 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -1,148 +1,183 @@ -import { BaseEntity } from "@edr/api-common"; -import { Column, Entity, OneToMany } from "typeorm"; -import { FileRecord } from "../../files/entities/file.entity"; +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; +import { ServiceType } from '../../rule-engine/entities/service-type.entity'; +import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { FileRecord } from '../../files/entities/file.entity'; +import { BookingApprovalStep } from './booking-approval-step.entity'; +import { BookingCargoModifier } from './booking-cargo-modifier.entity'; +import { BookingContainer } from './booking-container.entity'; +import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; -@Entity({ schema:"freight",name: "bookings" }) +export const BOOKING_STATUSES = [ + 'DRAFT', + 'RFQ_SUBMITTED', + 'QUOTATION_SENT', + 'QUOTATION_APPROVED', + 'QUOTATION_REJECTED', + 'PENDING_APPROVAL', + 'APPROVED', + 'SIGNED_CUSTOMER', + 'FULLY_EXECUTED', + 'PAID', + 'IN_TRANSIT', + 'COMPLETED', + 'CANCELLED', + 'PENDING_CONSOLIDATION', + 'CONSOLIDATED', +] as const; + +@Entity({ schema: 'freight', name: 'bookings' }) export class Booking extends BaseEntity { - // ── core ─────────────────────────────────────────────────────────────── - @Column({ name: "reference", type: "varchar", length: 64, unique: true }) + @Column({ name: 'reference', type: 'varchar', length: 64, unique: true }) reference!: string; - @Column({ name: "customer_id", type: "uuid" }) + @Column({ name: 'customer_id', type: 'uuid' }) customerId!: string; - @Column({ name: "train_id", type: "uuid", nullable: true }) + @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId?: string | null; - @Column({ name: "status", type: "varchar", length: 40, default: "DRAFT" }) + @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) status!: string; - @Column({ name: "scheduled_date", type: "timestamptz" }) + @Column({ name: 'scheduled_date', type: 'timestamptz' }) scheduledDate!: Date; - @Column({ - name: "total_amount", - type: "numeric", - precision: 14, - scale: 2, - default: 0, - }) + @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) totalAmount!: number; - @Column({ - name: "payment_status", - type: "varchar", - length: 20, - default: "PENDING", - }) + @Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' }) paymentStatus!: string; - // ── contract ─────────────────────────────────────────────────────────── - @Column({ name: "contract_type", type: "varchar", length: 20 }) + @Column({ name: 'contract_type', type: 'varchar', length: 20 }) contractType!: string; - @Column({ name: "previous_contract_id", type: "uuid", nullable: true }) + @Column({ name: 'previous_contract_id', type: 'uuid', nullable: true }) previousContractId?: string | null; - @Column({ name: "service_type", type: "varchar", length: 30 }) - serviceType!: string; + @Column({ name: 'service_type_id', type: 'uuid' }) + serviceTypeId!: string; - @Column({ name: "first_mile_enabled", type: "boolean", default: false }) - firstMileEnabled!: boolean; + @ManyToOne(() => ServiceType) + @JoinColumn({ name: 'service_type_id' }) + serviceType?: ServiceType; - @Column({ name: "first_mile_pickup_address", type: "text", nullable: true }) + @Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true }) firstMilePickupAddress?: string | null; - @Column({ name: "last_mile_enabled", type: "boolean", default: false }) - lastMileEnabled!: boolean; - - @Column({ name: "last_mile_delivery_address", type: "text", nullable: true }) + @Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true }) lastMileDeliveryAddress?: string | null; - @Column({ name: "equipment_return", type: "varchar", length: 20 }) + @Column({ name: 'equipment_return', type: 'varchar', length: 20 }) equipmentReturn!: string; - - @Column({ name: "origin_station", type: "varchar", length: 255 }) - originStation!: string; - @Column({ name: "destination_station", type: "varchar", length: 255 }) - destinationStation!: string; + @Column({ name: 'origin_yard_id', type: 'uuid' }) + originYardId!: string; - @Column({ - name: "cargo_total_weight_vgm", - type: "numeric", - precision: 12, - scale: 3, - }) - cargoTotalWeightVgm!: number; + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard; - @Column({ name: "freight_type", type: "varchar", length: 20 }) - freightType!: string; + @Column({ name: 'destination_yard_id', type: 'uuid' }) + destinationYardId!: string; - @Column({ name: "freight_subtype", type: "varchar", length: 100, nullable: true }) - freightSubtype?: string | null; + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard; - @Column({ name: "is_hazardous", type: "boolean", default: false }) - isHazardous!: boolean; - - @Column({ name: "is_refrigerated", type: "boolean", default: false }) - isRefrigerated!: boolean; - - @Column({ name: "trade_direction", type: "varchar", length: 10 }) + @Column({ name: 'trade_direction', type: 'varchar', length: 10 }) tradeDirection!: string; - @Column({ name: "payment_currency", type: "varchar", length: 5 }) + @Column({ name: 'cargo_type_id', type: 'uuid' }) + cargoTypeId!: string; + + @ManyToOne(() => CargoType) + @JoinColumn({ name: 'cargo_type_id' }) + cargoType?: CargoType; + + @Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true }) + cargoFreeText?: string | null; + + @Column({ name: 'shipping_line_id', type: 'uuid', nullable: true }) + shippingLineId?: string | null; + + @ManyToOne(() => ShippingLine, { nullable: true }) + @JoinColumn({ name: 'shipping_line_id' }) + shippingLine?: ShippingLine | null; + + @Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 }) + cargoTotalWeightVgm!: number; + + @Column({ name: 'is_hazardous', type: 'boolean', default: false }) + isHazardous!: boolean; + + @Column({ name: 'payment_currency', type: 'varchar', length: 5 }) paymentCurrency!: string; - @Column({ name: "start_date", type: "date", nullable: true }) + @Column({ name: 'pnr_code', type: 'varchar', length: 50, nullable: true }) + pnrCode?: string | null; + + @Column({ name: 'start_date', type: 'date', nullable: true }) startDate?: Date | null; - @Column({ name: "end_date", type: "date", nullable: true }) + @Column({ name: 'end_date', type: 'date', nullable: true }) endDate?: Date | null; - @Column({ name: "financial_terms", type: "text", nullable: true }) + @Column({ name: 'financial_terms', type: 'text', nullable: true }) financialTerms?: string | null; - @Column({ name: "version_number", type: "int", default: 1 }) + @Column({ name: 'version_number', type: 'int', default: 1 }) versionNumber!: number; - // ── containers ───────────────────────────────────────────────────────── - @Column({ name: "containers", type: "jsonb", nullable: true }) - containers!: Array<{ type: string; qty: number; vgm: number }> | null; - - // ── approval ─────────────────────────────────────────────────────────── - @Column({ name: "approved_by_staff_id", type: "uuid", nullable: true }) + @Column({ name: 'approved_by_staff_id', type: 'uuid', nullable: true }) approvedByStaffId?: string | null; - @Column({ name: "approved_by_staff_at", type: "timestamptz", nullable: true }) + @Column({ name: 'approved_by_staff_at', type: 'timestamptz', nullable: true }) approvedByStaffAt?: Date | null; - @Column({ name: "signed_by_director_id", type: "uuid", nullable: true }) + @Column({ name: 'signed_by_director_id', type: 'uuid', nullable: true }) signedByDirectorId?: string | null; - @Column({ name: "signed_by_director_at", type: "timestamptz", nullable: true }) + @Column({ name: 'signed_by_director_at', type: 'timestamptz', nullable: true }) signedByDirectorAt?: Date | null; - @Column({ name: "signed_by_ceo_id", type: "uuid", nullable: true }) + @Column({ name: 'signed_by_ceo_id', type: 'uuid', nullable: true }) signedByCeoId?: string | null; - @Column({ name: "signed_by_ceo_at", type: "timestamptz", nullable: true }) + @Column({ name: 'signed_by_ceo_at', type: 'timestamptz', nullable: true }) signedByCeoAt?: Date | null; - @Column({ name: "priority_score", type: "int", default: 0 }) + @Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true }) + customerSignedAt?: Date | null; + + @Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true }) + fullyExecutedAt?: Date | null; + + @Column({ name: 'priority_score', type: 'int', default: 0 }) priorityScore!: number; - // ── consolidation ────────────────────────────────────────────────────── - @Column({ name: "allow_consolidation", type: "boolean", default: false }) + @Column({ name: 'allow_consolidation', type: 'boolean', default: false }) allowConsolidation!: boolean; - @Column({ name: "consolidation_partner_id", type: "uuid", nullable: true }) + @Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true }) consolidationPartnerId?: string | null; - // ── files ──────────────────────────────────────────────────────────── + @OneToMany(() => BookingContainer, (bc) => bc.booking) + bookingContainers?: BookingContainer[]; + + @OneToMany(() => BookingCargoModifier, (m) => m.booking) + cargoModifiers?: BookingCargoModifier[]; + + @OneToMany(() => BookingApprovalStep, (s) => s.booking) + approvalSteps?: BookingApprovalStep[]; + + @OneToMany(() => BookingRateSnapshot, (s) => s.booking) + rateSnapshots?: BookingRateSnapshot[]; + @OneToMany(() => FileRecord, (file) => file.resourceId, { createForeignKeyConstraints: false, }) files?: FileRecord[]; - } diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts new file mode 100644 index 000000000..3c34cc71b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts @@ -0,0 +1,60 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; +import { ApprovalRulesService } from '../services/approval-rules.service'; + +@ApiTags('approval-rules') +@Controller('approval-rules') +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class ApprovalRulesController { + constructor(private readonly service: ApprovalRulesService) {} + + @Get() + @ApiOperation({ summary: 'List approval rules' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + requiresDirectorApproval: + query['requiresDirectorApproval'] !== undefined + ? query['requiresDirectorApproval'] === 'true' + : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get('chain') + @ApiOperation({ summary: 'Get approval chain for cargo routing flag' }) + findChain(@Query('requiresDirectorApproval') flag: string) { + return this.service.findChain(flag === 'true'); + } + + @Get(':id') + @ApiOperation({ summary: 'Get an approval rule by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create an approval rule step' }) + create(@Body() dto: CreateApprovalRuleDto) { + return this.service.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an approval rule' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete an approval rule' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts new file mode 100644 index 000000000..02408e53d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts @@ -0,0 +1,70 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto'; +import { UpdateRateDto } from '../dto/update-rate.dto'; +import { RatesService } from '../services/rates.service'; + +@ApiTags('rates') +@Controller('rates') +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class RatesController { + constructor(private readonly service: RatesService) {} + + @Get() + @ApiOperation({ summary: 'List rates' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + status: query['status'], + rateType: query['rateType'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get('live') + @ApiOperation({ summary: 'List all LIVE rates effective now' }) + findLive() { + return this.service.findLiveRates(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a rate by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a rate (DRAFT)' }) + create(@Body() dto: CreateRateDto) { + return this.service.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a DRAFT rate' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) { + return this.service.update(id, dto); + } + + @Post(':id/submit') + @ApiOperation({ summary: 'Submit rate for CEO approval' }) + submit(@Param('id', ParseUUIDPipe) id: string) { + return this.service.submitForApproval(id); + } + + @Post(':id/approve') + @ApiOperation({ summary: 'CEO approves a rate' }) + approve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveRateDto) { + return this.service.approve(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a rate' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts new file mode 100644 index 000000000..fc022725c --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -0,0 +1,51 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; +import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; +import { ShippingLinesService } from '../services/shipping-lines.service'; + +@ApiTags('shipping-lines') +@Controller('shipping-lines') +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class ShippingLinesController { + constructor(private readonly service: ShippingLinesService) {} + + @Get() + @ApiOperation({ summary: 'List shipping lines' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a shipping line by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a shipping line' }) + create(@Body() dto: CreateShippingLineDto) { + return this.service.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a shipping line' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a shipping line' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts index 9a0570ff7..2d4372a76 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts @@ -18,7 +18,7 @@ export class WeightLimitRulesController { @ApiOperation({ summary: 'List weight limit rules' }) findAll(@Query() query: Record) { return this.service.findAll({ - isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + tradeDirection: query['tradeDirection'], containerTypeId: query['containerTypeId'], page: query['page'] ? parseInt(query['page'], 10) : undefined, pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharges.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts similarity index 59% rename from apps/edr-freight-api/src/modules/rule-engine/controllers/surcharges.controller.ts rename to apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index 1da10c560..89fa002a5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharges.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -3,49 +3,49 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { CreateSurchargeDto } from '../dto/create-surcharge.dto'; -import { UpdateSurchargeDto } from '../dto/update-surcharge.dto'; -import { SurchargesService } from '../services/surcharges.service'; +import { CreateYardDto } from '../dto/create-yard.dto'; +import { UpdateYardDto } from '../dto/update-yard.dto'; +import { YardsService } from '../services/yards.service'; -@ApiTags('surcharges') -@Controller('surcharges') +@ApiTags('yards') +@Controller('yards') // @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() -export class SurchargesController { - constructor(private readonly service: SurchargesService) {} +export class YardsController { + constructor(private readonly service: YardsService) {} @Get() - @ApiOperation({ summary: 'List surcharges' }) + @ApiOperation({ summary: 'List yards' }) findAll(@Query() query: Record) { return this.service.findAll({ isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, - surchargeTypeId: query['surchargeTypeId'], + country: query['country'], page: query['page'] ? parseInt(query['page'], 10) : undefined, pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, }); } @Get(':id') - @ApiOperation({ summary: 'Get a surcharge by ID' }) + @ApiOperation({ summary: 'Get a yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() - @ApiOperation({ summary: 'Create a surcharge' }) - create(@Body() dto: CreateSurchargeDto) { + @ApiOperation({ summary: 'Create a yard' }) + create(@Body() dto: CreateYardDto) { return this.service.create(dto); } @Patch(':id') - @ApiOperation({ summary: 'Update a surcharge' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeDto) { + @ApiOperation({ summary: 'Update a yard' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) { return this.service.update(id, dto); } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Soft-delete a surcharge' }) + @ApiOperation({ summary: 'Soft-delete a yard' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.service.remove(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts new file mode 100644 index 000000000..6ccc95384 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const; + +export class CreateApprovalRuleDto { + @ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' }) + @IsBoolean() + requiresDirectorApproval!: boolean; + + @ApiProperty({ description: 'Step sequence number (1 = first, 2 = second)', minimum: 1 }) + @IsInt() + @Min(1) + stepOrder!: number; + + @ApiProperty({ enum: ROLES, description: 'Role required to action this step' }) + @IsString() + @MaxLength(30) + requiredRole!: string; + + @ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 }) + @IsString() + @MaxLength(50) + actionLabel!: string; + + @ApiPropertyOptional({ enum: ROLES, description: 'Role explicitly blocked from actioning this step' }) + @IsOptional() + @IsString() + @MaxLength(30) + blocksRole?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index 76d82a450..08f90c607 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -1,25 +1,48 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; export class CreateContainerTypeDto { - @ApiProperty({ description: 'Size code, e.g. 20FT or 40FT', maxLength: 20 }) + @ApiProperty({ description: 'Unique container code, e.g. 20DV, 40HC', maxLength: 20 }) @IsString() @MaxLength(20) - sizeCode!: string; + code!: string; - @ApiPropertyOptional({ description: 'Human-readable description', maxLength: 100 }) - @IsOptional() + @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @IsString() @MaxLength(100) - description?: string; + label!: string; - @ApiProperty({ description: 'Number of containers that fit per rail wagon (2 for 20FT, 1 for 40FT)' }) + @ApiProperty({ description: 'Container size in feet: 20 or 40', enum: [20, 40] }) @IsInt() - @Min(1) - containersPerWagon!: number; + @Min(20) + @Max(40) + sizeFt!: number; + + @ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' }) + @IsNumber() + @Min(0.01) + @Transform(({ value }) => Number(value)) + wagonsPerUnit!: number; + + @ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' }) + @IsOptional() + @IsBoolean() + isReefer?: boolean; + + @ApiPropertyOptional({ default: false, description: 'True if this is an open-top container' }) + @IsOptional() + @IsBoolean() + isOpenTop?: boolean; @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() isActive?: boolean; + + @ApiPropertyOptional({ default: 1, description: 'UI display sort order' }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-rule.dto.ts index 2b55d388c..f56cf3e47 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-rule.dto.ts @@ -1,33 +1,32 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; -import { Freight } from '@edr/types'; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; export class CreatePriorityRuleDto { - @ApiProperty({ enum: Freight.PriorityType, description: 'Priority type (unique per rule)' }) - @IsEnum(Freight.PriorityType) - priorityType!: Freight.PriorityType; - - @ApiProperty({ description: 'Human-readable rule name', maxLength: 255 }) + @ApiProperty({ description: 'Unique rule code, e.g. USD_PAYER, GOV_REQUEST', maxLength: 40 }) @IsString() - @MaxLength(255) - ruleName!: string; + @MaxLength(40) + code!: string; - @ApiPropertyOptional({ description: 'Explanation of when this rule is triggered' }) - @IsOptional() + @ApiProperty({ description: 'Human-readable label', maxLength: 100 }) @IsString() - description?: string; + @MaxLength(100) + label!: string; - @ApiPropertyOptional({ description: 'Technical expression describing the activation condition' }) - @IsOptional() - @IsString() - activationCondition?: string; - - @ApiProperty({ description: 'Points added to booking.priorityScore when this rule matches', default: 0 }) + @ApiProperty({ description: 'Points added to booking.priority_score when condition matches', default: 0 }) @IsInt() @Min(0) - bonusPoints!: number; + score!: number; - @ApiPropertyOptional({ default: false }) + @ApiPropertyOptional({ + description: 'If set, rule only matches bookings with this payment currency (e.g. USD). Null = matches all.', + maxLength: 5, + }) + @IsOptional() + @IsString() + @MaxLength(5) + conditionCurrency?: string; + + @ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' }) @IsOptional() @IsBoolean() isActive?: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts new file mode 100644 index 000000000..a6ded833e --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity'; + +const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'ANY'] as const; +const CURRENCIES = ['ETB', 'USD'] as const; + +export class CreateRateDto { + @ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' }) + @IsIn([...RATE_TYPES]) + rateType!: string; + + @ApiPropertyOptional({ description: 'FK to container_types.id — null for non-container rates' }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS, description: 'Trade direction. Null = direction-agnostic' }) + @IsOptional() + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection?: string; + + @ApiProperty({ enum: CURRENCIES }) + @IsIn([...CURRENCIES]) + currency!: string; + + @ApiProperty({ description: 'Numeric rate value', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + rateValue!: number; + + @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) + @IsIn([...RATE_UNITS]) + rateUnit!: string; + + @ApiProperty({ description: 'ID of the staff member (Director) proposing this rate' }) + @IsUUID() + proposedByStaffId!: string; + + @ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' }) + @IsDateString() + effectiveFrom!: string; + + @ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' }) + @IsOptional() + @IsDateString() + effectiveTo?: string; +} + +export class ApproveRateDto { + @ApiProperty({ description: 'ID of the CEO approving this rate' }) + @IsUUID() + approvedByCeoId!: string; +} + +export class SubmitRateForApprovalDto { + @ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-shipping-line.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-shipping-line.dto.ts new file mode 100644 index 000000000..c5ccef22b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-shipping-line.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateShippingLineDto { + @ApiProperty({ description: 'Unique shipping line code, e.g. MSC, PIL, MAERSK', maxLength: 20 }) + @IsString() + @MaxLength(20) + code!: string; + + @ApiProperty({ description: 'Customer-facing label', maxLength: 100 }) + @IsString() + @MaxLength(100) + label!: string; + + @ApiPropertyOptional({ + description: 'If set, backend silently uses this code for pricing tier lookups (e.g. PIL → MAERSK)', + maxLength: 20, + }) + @IsOptional() + @IsString() + @MaxLength(20) + mappedToCode?: string; + + @ApiPropertyOptional({ + default: false, + description: 'If true, quotation renders additional fee notice to customer', + }) + @IsOptional() + @IsBoolean() + showExtraFeeNotice?: boolean; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts index 4c27e368f..39ceb51c4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts @@ -1,21 +1,32 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +const TRIGGER_CONDITIONS = [ + 'CARGO_FLAG_HAZARDOUS', + 'CARGO_FLAG_REEFER', + 'VGM_EXCEEDS_LIMIT', + 'SHIPPING_LINE_MAPPED', + 'CONSOLIDATION_ENABLED', +] as const; export class CreateSurchargeTypeDto { - @ApiProperty({ description: 'Unique code, e.g. HAZARDOUS, REFRIGERATED', maxLength: 50 }) + @ApiProperty({ description: 'Unique code, e.g. HAZARD, REEFER, OVERWEIGHT', maxLength: 40 }) @IsString() - @MaxLength(50) + @MaxLength(40) code!: string; - @ApiProperty({ description: 'Display name', maxLength: 100 }) + @ApiProperty({ description: 'Human-readable label', maxLength: 100 }) @IsString() @MaxLength(100) - name!: string; + label!: string; - @ApiPropertyOptional({ description: 'Description of when this surcharge type is triggered' }) - @IsOptional() - @IsString() - description?: string; + @ApiProperty({ enum: TRIGGER_CONDITIONS, description: 'Condition that auto-fires this surcharge' }) + @IsIn([...TRIGGER_CONDITIONS]) + triggerCondition!: string; + + @ApiProperty({ description: 'FK to rates.id — the LIVE rate used to price this surcharge' }) + @IsUUID() + rateId!: string; @ApiPropertyOptional({ default: true }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge.dto.ts deleted file mode 100644 index 1122ba39f..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge.dto.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsBoolean, - IsEnum, - IsNumber, - IsOptional, - IsString, - IsUUID, - Length, - MaxLength, - Min, -} from 'class-validator'; -import { Freight } from '@edr/types'; - -export class CreateSurchargeDto { - @ApiProperty({ description: 'FK to surcharge_types.id' }) - @IsUUID() - surchargeTypeId!: string; - - @ApiProperty({ description: 'Display name for this surcharge line item', maxLength: 255 }) - @IsString() - @MaxLength(255) - feeName!: string; - - @ApiPropertyOptional({ description: 'Human-readable description of when this surcharge is triggered' }) - @IsOptional() - @IsString() - triggerDescription?: string; - - @ApiProperty({ enum: Freight.CalculationMethod, default: Freight.CalculationMethod.PER_TON }) - @IsEnum(Freight.CalculationMethod) - calculationMethod!: Freight.CalculationMethod; - - @ApiProperty({ description: 'Rate amount (per ton, flat, or percentage)' }) - @IsNumber() - @Min(0) - rate!: number; - - @ApiProperty({ description: 'ISO 4217 currency code', default: 'USD' }) - @IsString() - @Length(3, 3) - currency!: string; - - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - applyToRail?: boolean; - - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - applyToFirstMile?: boolean; - - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - applyToLastMile?: boolean; - - @ApiPropertyOptional({ default: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean; -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index eb5bfc14c..f7e395f64 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -1,38 +1,30 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsEnum, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; -import { Freight } from '@edr/types'; +import { Transform } from 'class-transformer'; +import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; + +const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'ANY'] as const; export class CreateWeightLimitRuleDto { @ApiProperty({ description: 'FK to container_types.id' }) @IsUUID() containerTypeId!: string; - @ApiProperty({ enum: Freight.TradeDirection, description: 'Trade direction this rule applies to' }) - @IsEnum(Freight.TradeDirection) - tradeDirection!: Freight.TradeDirection; + @ApiProperty({ enum: TRADE_DIRECTIONS, description: 'Trade direction: IMPORT, EXPORT, or ANY' }) + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection!: string; - @ApiProperty({ description: 'Maximum allowed weight in tons before surcharge is applied' }) + @ApiProperty({ description: 'Maximum allowed VGM in tons', minimum: 0 }) @IsNumber() @Min(0) - maxWeightTons!: number; + @Transform(({ value }) => Number(value)) + maxVgmTons!: number; - @ApiProperty({ description: 'Weight at which a warning is issued (must be ≤ maxWeightTons)' }) - @IsNumber() - @Min(0) - warningThresholdTons!: number; + @ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' }) + @IsDateString() + effectiveFrom!: string; - @ApiPropertyOptional({ enum: Freight.ExceededAction, default: Freight.ExceededAction.WARNING_ONLY }) + @ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' }) @IsOptional() - @IsEnum(Freight.ExceededAction) - exceededAction?: Freight.ExceededAction; - - @ApiPropertyOptional({ description: 'FK to surcharges.id — surcharge billed when max is exceeded' }) - @IsOptional() - @IsUUID() - surchargeId?: string; - - @ApiPropertyOptional({ default: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean; + @IsDateString() + effectiveTo?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts new file mode 100644 index 000000000..1bb39f12a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class CreateYardDto { + @ApiProperty({ description: 'Unique yard code, e.g. KALITY, DJIB_PORT', maxLength: 20 }) + @IsString() + @MaxLength(20) + code!: string; + + @ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 }) + @IsString() + @MaxLength(100) + label!: string; + + @ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 }) + @IsString() + @MaxLength(50) + country!: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ default: 1, description: 'UI display sort order' }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-approval-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-approval-rule.dto.ts new file mode 100644 index 000000000..74a4e9f58 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-approval-rule.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateApprovalRuleDto } from './create-approval-rule.dto'; + +export class UpdateApprovalRuleDto extends PartialType(CreateApprovalRuleDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-rate.dto.ts new file mode 100644 index 000000000..daaf2af05 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-rate.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateRateDto } from './create-rate.dto'; + +export class UpdateRateDto extends PartialType(CreateRateDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-shipping-line.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-shipping-line.dto.ts new file mode 100644 index 000000000..7b839a0c2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-shipping-line.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateShippingLineDto } from './create-shipping-line.dto'; + +export class UpdateShippingLineDto extends PartialType(CreateShippingLineDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge.dto.ts deleted file mode 100644 index c87a7b5a5..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateSurchargeDto } from './create-surcharge.dto'; - -export class UpdateSurchargeDto extends PartialType(CreateSurchargeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard.dto.ts new file mode 100644 index 000000000..f077559fe --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateYardDto } from './create-yard.dto'; + +export class UpdateYardDto extends PartialType(CreateYardDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts new file mode 100644 index 000000000..94fb4355d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts @@ -0,0 +1,23 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, Unique } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'approval_rules' }) +@Unique(['requiresDirectorApproval', 'stepOrder']) +@Index(['requiresDirectorApproval']) +@Index(['stepOrder']) +export class ApprovalRule extends BaseEntity { + @Column({ name: 'requires_director_approval', type: 'boolean' }) + requiresDirectorApproval!: boolean; + + @Column({ name: 'step_order', type: 'smallint' }) + stepOrder!: number; + + @Column({ name: 'required_role', type: 'varchar', length: 30 }) + requiredRole!: string; + + @Column({ name: 'action_label', type: 'varchar', length: 50 }) + actionLabel!: string; + + @Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true }) + blocksRole?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index 5bebb8e65..e03078c19 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -3,21 +3,33 @@ import { Column, Entity, Index, OneToMany } from 'typeorm'; import { WeightLimitRule } from './weight-limit-rule.entity'; @Entity({ schema: 'freight', name: 'container_types' }) -@Index(['sizeCode']) +@Index(['code']) @Index(['isActive']) export class ContainerType extends BaseEntity { - @Column({ name: 'size_code', type: 'varchar', length: 20, unique: true }) - sizeCode!: string; + @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) + code!: string; - @Column({ name: 'description', type: 'varchar', length: 100, nullable: true }) - description?: string | null; + @Column({ name: 'label', type: 'varchar', length: 100, nullable: true }) + label!: string; - @Column({ name: 'containers_per_wagon', type: 'int' }) - containersPerWagon!: number; + @Column({ name: 'size_ft', type: 'smallint', nullable: true }) + sizeFt!: number; + + @Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true }) + wagonsPerUnit!: number; + + @Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true }) + isReefer!: boolean; + + @Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true }) + isOpenTop!: boolean; @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + @Column({ name: 'display_order', type: 'int', default: 1, nullable: true }) + displayOrder!: number; + @OneToMany(() => WeightLimitRule, (rule) => rule.containerType) weightLimitRules?: WeightLimitRule[]; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule.entity.ts index c9159c2ec..b04cca95d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule.entity.ts @@ -1,25 +1,21 @@ import { BaseEntity } from '@edr/api-common'; -import { Freight } from '@edr/types'; import { Column, Entity, Index } from 'typeorm'; @Entity({ schema: 'freight', name: 'priority_rules' }) -@Index(['priorityType']) +@Index(['code']) @Index(['isActive']) export class PriorityRule extends BaseEntity { - @Column({ name: 'priority_type', type: 'enum', enum: Freight.PriorityType, unique: true }) - priorityType!: Freight.PriorityType; + @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) + code!: string; - @Column({ name: 'rule_name', type: 'varchar', length: 255 }) - ruleName!: string; + @Column({ name: 'label', type: 'varchar', length: 100, nullable: true }) + label!: string; - @Column({ name: 'description', type: 'text', nullable: true }) - description?: string | null; + @Column({ name: 'score', type: 'int', default: 0, nullable: true }) + score!: number; - @Column({ name: 'activation_condition', type: 'text', nullable: true }) - activationCondition?: string | null; - - @Column({ name: 'bonus_points', type: 'int', default: 0 }) - bonusPoints!: number; + @Column({ name: 'condition_currency', type: 'varchar', length: 5, nullable: true }) + conditionCurrency?: string | null; @Column({ name: 'is_active', type: 'boolean', default: false }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts new file mode 100644 index 000000000..33030e95f --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -0,0 +1,78 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ContainerType } from './container-type.entity'; + +export const RATE_TYPES = [ + 'CONTAINER_IMPORT', + 'CONTAINER_EXPORT', + 'BULK_IMPORT', + 'BULK_EXPORT', + 'INTERCITY_BULK', + 'INTERCITY_CONTAINER', + 'FIRST_MILE', + 'LAST_MILE', + 'DEMURRAGE', + 'LASHING', + 'DOUBLE_HANDLING', + 'CONTAINER_WITH_RETURN', + 'CANCELLATION_FEE', + 'OVERWEIGHT_PER_TON', + 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', + 'PIL_EXTRA_FEE', +] as const; + +export type RateType = typeof RATE_TYPES[number]; + +export const RATE_STATUSES = ['DRAFT', 'PENDING_APPROVAL', 'LIVE', 'SUPERSEDED'] as const; +export type RateStatus = typeof RATE_STATUSES[number]; + +export const RATE_UNITS = ['PER_WAGON', 'PER_TON', 'PER_CONTAINER', 'PER_KM', 'FLAT'] as const; +export type RateUnit = typeof RATE_UNITS[number]; + +@Entity({ schema: 'freight', name: 'rates' }) +@Index(['rateType']) +@Index(['status']) +@Index(['effectiveFrom']) +@Index(['containerTypeId']) +export class Rate extends BaseEntity { + @Column({ name: 'rate_type', type: 'varchar', length: 50 }) + rateType!: RateType; + + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true, eager: false }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + @Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true }) + tradeDirection?: string | null; + + @Column({ name: 'currency', type: 'varchar', length: 5 }) + currency!: string; + + @Column({ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 }) + rateValue!: number; + + @Column({ name: 'rate_unit', type: 'varchar', length: 30 }) + rateUnit!: RateUnit; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: RateStatus; + + @Column({ name: 'proposed_by_staff_id', type: 'uuid' }) + proposedByStaffId!: string; + + @Column({ name: 'approved_by_ceo_id', type: 'uuid', nullable: true }) + approvedByCeoId?: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt?: Date | null; + + @Column({ name: 'effective_from', type: 'date' }) + effectiveFrom!: Date; + + @Column({ name: 'effective_to', type: 'date', nullable: true }) + effectiveTo?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/shipping-line.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/shipping-line.entity.ts new file mode 100644 index 000000000..9be7a8b92 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/shipping-line.entity.ts @@ -0,0 +1,22 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'shipping_lines' }) +@Index(['code']) +@Index(['isActive']) +export class ShippingLine extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) + code!: string; + + @Column({ name: 'label', type: 'varchar', length: 100 }) + label!: string; + + @Column({ name: 'mapped_to_code', type: 'varchar', length: 20, nullable: true }) + mappedToCode?: string | null; + + @Column({ name: 'show_extra_fee_notice', type: 'boolean', default: false }) + showExtraFeeNotice!: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts index b8c7679a2..2b9934a1e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts @@ -1,23 +1,38 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, OneToMany } from 'typeorm'; -import { Surcharge } from './surcharge.entity'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Rate } from './rate.entity'; + +const TRIGGER_CONDITIONS = [ + 'CARGO_FLAG_HAZARDOUS', + 'CARGO_FLAG_REEFER', + 'VGM_EXCEEDS_LIMIT', + 'SHIPPING_LINE_MAPPED', + 'CONSOLIDATION_ENABLED', +] as const; + +export type TriggerCondition = typeof TRIGGER_CONDITIONS[number]; @Entity({ schema: 'freight', name: 'surcharge_types' }) @Index(['code']) @Index(['isActive']) +@Index(['rateId']) export class SurchargeType extends BaseEntity { - @Column({ name: 'code', type: 'varchar', length: 50, unique: true }) + @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) code!: string; - @Column({ name: 'name', type: 'varchar', length: 100 }) - name!: string; + @Column({ name: 'label', type: 'varchar', length: 100, nullable: true }) + label!: string; - @Column({ name: 'description', type: 'text', nullable: true }) - description?: string | null; + @Column({ name: 'trigger_condition', type: 'varchar', length: 50, nullable: true }) + triggerCondition!: TriggerCondition; + + @Column({ name: 'rate_id', type: 'uuid', nullable: true }) + rateId!: string; + + @ManyToOne(() => Rate, { eager: false }) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate; @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; - - @OneToMany(() => Surcharge, (s) => s.surchargeType) - surcharges?: Surcharge[]; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge.entity.ts deleted file mode 100644 index e8f33f42d..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge.entity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Freight } from '@edr/types'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; -import { SurchargeType } from './surcharge-type.entity'; -import { WeightLimitRule } from './weight-limit-rule.entity'; - -@Entity({ schema: 'freight', name: 'surcharges' }) -@Index(['surchargeTypeId']) -@Index(['isActive']) -export class Surcharge extends BaseEntity { - @Column({ name: 'surcharge_type_id', type: 'uuid' }) - surchargeTypeId!: string; - - @ManyToOne(() => SurchargeType, (st) => st.surcharges) - @JoinColumn({ name: 'surcharge_type_id' }) - surchargeType!: SurchargeType; - - @Column({ name: 'fee_name', type: 'varchar', length: 255 }) - feeName!: string; - - @Column({ name: 'trigger_description', type: 'text', nullable: true }) - triggerDescription?: string | null; - - @Column({ - name: 'calculation_method', - type: 'enum', - enum: Freight.CalculationMethod, - default: Freight.CalculationMethod.PER_TON, - }) - calculationMethod!: Freight.CalculationMethod; - - @Column({ name: 'rate', type: 'numeric', precision: 10, scale: 2 }) - rate!: number; - - @Column({ name: 'currency', type: 'char', length: 3, default: 'USD' }) - currency!: string; - - @Column({ name: 'apply_to_rail', type: 'boolean', default: false }) - applyToRail!: boolean; - - @Column({ name: 'apply_to_first_mile', type: 'boolean', default: false }) - applyToFirstMile!: boolean; - - @Column({ name: 'apply_to_last_mile', type: 'boolean', default: false }) - applyToLastMile!: boolean; - - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive!: boolean; - - @OneToMany(() => WeightLimitRule, (rule) => rule.surcharge) - weightLimitRules?: WeightLimitRule[]; -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts index cc8cfeb12..39557eec9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -1,13 +1,11 @@ import { BaseEntity } from '@edr/api-common'; -import { Freight } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { ContainerType } from './container-type.entity'; -import { Surcharge } from './surcharge.entity'; @Entity({ schema: 'freight', name: 'weight_limit_rules' }) @Index(['containerTypeId']) -@Index(['surchargeId']) -@Index(['isActive']) +@Index(['tradeDirection']) +@Index(['effectiveFrom']) export class WeightLimitRule extends BaseEntity { @Column({ name: 'container_type_id', type: 'uuid' }) containerTypeId!: string; @@ -16,30 +14,15 @@ export class WeightLimitRule extends BaseEntity { @JoinColumn({ name: 'container_type_id' }) containerType!: ContainerType; - @Column({ name: 'trade_direction', type: 'enum', enum: Freight.TradeDirection }) - tradeDirection!: Freight.TradeDirection; + @Column({ name: 'trade_direction', type: 'varchar', length: 10 }) + tradeDirection!: string; - @Column({ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2 }) - maxWeightTons!: number; + @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) + maxVgmTons!: number; - @Column({ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2 }) - warningThresholdTons!: number; + @Column({ name: 'effective_from', type: 'date', nullable: true }) + effectiveFrom!: Date; - @Column({ - name: 'exceeded_action', - type: 'enum', - enum: Freight.ExceededAction, - default: Freight.ExceededAction.WARNING_ONLY, - }) - exceededAction!: Freight.ExceededAction; - - @Column({ name: 'surcharge_id', type: 'uuid', nullable: true }) - surchargeId?: string | null; - - @ManyToOne(() => Surcharge, (s) => s.weightLimitRules, { nullable: true }) - @JoinColumn({ name: 'surcharge_id' }) - surcharge?: Surcharge | null; - - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive!: boolean; + @Column({ name: 'effective_to', type: 'date', nullable: true }) + effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts new file mode 100644 index 000000000..249aa1847 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts @@ -0,0 +1,23 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'yards' }) +@Index(['code']) +@Index(['country']) +@Index(['isActive']) +export class Yard extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) + code!: string; + + @Column({ name: 'label', type: 'varchar', length: 100 }) + label!: string; + + @Column({ name: 'country', type: 'varchar', length: 50 }) + country!: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'display_order', type: 'int', default: 1 }) + displayOrder!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts new file mode 100644 index 000000000..95c8d2568 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { ApprovalRule } from '../entities/approval-rule.entity'; + +export interface IApprovalRulesRepository { + findById(id: string): Promise; + findChainForCargo(requiresDirectorApproval: boolean): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[ApprovalRule[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const APPROVAL_RULES_REPOSITORY = Symbol('APPROVAL_RULES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts index cc1d3f010..f8e097309 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts @@ -3,7 +3,7 @@ import { ContainerType } from '../entities/container-type.entity'; export interface IContainerTypesRepository { findById(id: string): Promise; - findBySizeCode(sizeCode: string): Promise; + findByCode(code: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[ContainerType[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts new file mode 100644 index 000000000..52b991155 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { Rate } from '../entities/rate.entity'; + +export interface IRatesRepository { + findById(id: string): Promise; + findLiveRates(): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts new file mode 100644 index 000000000..88f36933d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { ShippingLine } from '../entities/shipping-line.entity'; + +export interface IShippingLinesRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[ShippingLine[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const SHIPPING_LINES_REPOSITORY = Symbol('SHIPPING_LINES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts index 03c76f408..a6931aaf2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts @@ -4,6 +4,7 @@ import { SurchargeType } from '../entities/surcharge-type.entity'; export interface ISurchargeTypesRepository { findById(id: string): Promise; findByCode(code: string): Promise; + findAllActiveWithRate(): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[SurchargeType[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharges.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharges.repository.interface.ts deleted file mode 100644 index 96243ba34..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharges.repository.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { FindManyOptions } from 'typeorm'; -import { Surcharge } from '../entities/surcharge.entity'; - -export interface ISurchargesRepository { - findById(id: string): Promise; - findByTypeCode(typeCode: string): Promise; - findAll(options?: FindManyOptions): Promise; - findAndCount(options?: FindManyOptions): Promise<[Surcharge[], number]>; - create(data: Partial): Promise; - update(id: string, data: Partial): Promise; - softDelete(id: string): Promise; -} - -export const SURCHARGES_REPOSITORY = Symbol('SURCHARGES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts index 84216841d..cedbd1eee 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -3,8 +3,8 @@ import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; export interface IWeightLimitRulesRepository { findById(id: string): Promise; - findActiveByContainerTypeAndDirection( - sizeCode: string, + findActiveByContainerTypeId( + containerTypeId: string, tradeDirection: string, ): Promise; findAll(options?: FindManyOptions): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts new file mode 100644 index 000000000..9cfcfd940 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { Yard } from '../entities/yard.entity'; + +export interface IYardsRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[Yard[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const YARDS_REPOSITORY = Symbol('YARDS_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts new file mode 100644 index 000000000..c77695395 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { ApprovalRule } from '../entities/approval-rule.entity'; +import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface'; + +@Injectable() +export class ApprovalRulesRepository implements IApprovalRulesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ApprovalRule); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findChainForCargo(requiresDirectorApproval: boolean): Promise { + return this.repo.find({ + where: { requiresDirectorApproval }, + order: { stepOrder: 'ASC' }, + }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[ApprovalRule[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts index 726fa0f37..fe0a8f41e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts @@ -15,8 +15,8 @@ export class ContainerTypesRepository implements IContainerTypesRepository { return this.repo.findOne({ where: { id } }); } - findBySizeCode(sizeCode: string): Promise { - return this.repo.findOne({ where: { sizeCode } }); + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); } findAll(options?: FindManyOptions): Promise { diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts new file mode 100644 index 000000000..0d49a0bf3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { Rate } from '../entities/rate.entity'; +import { IRatesRepository } from '../interfaces/rates.repository.interface'; + +@Injectable() +export class RatesRepository implements IRatesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(Rate); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findLiveRates(): Promise { + const now = new Date(); + return this.repo + .createQueryBuilder('rate') + .where('rate.status = :status', { status: 'LIVE' }) + .andWhere('rate.effective_from <= :now', { now }) + .andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now }) + .getMany(); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[Rate[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts new file mode 100644 index 000000000..521a72b95 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { ShippingLine } from '../entities/shipping-line.entity'; +import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface'; + +@Injectable() +export class ShippingLinesRepository implements IShippingLinesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ShippingLine); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[ShippingLine[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts index 81dd89c1c..7b44e73e1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts @@ -19,6 +19,13 @@ export class SurchargeTypesRepository implements ISurchargeTypesRepository { return this.repo.findOne({ where: { code } }); } + findAllActiveWithRate(): Promise { + return this.repo.find({ + where: { isActive: true }, + relations: { rate: true }, + }); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharges.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharges.repository.ts deleted file mode 100644 index b78cfc482..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharges.repository.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource, FindManyOptions, Repository } from 'typeorm'; -import { Surcharge } from '../entities/surcharge.entity'; -import { ISurchargesRepository } from '../interfaces/surcharges.repository.interface'; - -@Injectable() -export class SurchargesRepository implements ISurchargesRepository { - private readonly repo: Repository; - - constructor(private readonly dataSource: DataSource) { - this.repo = this.dataSource.getRepository(Surcharge); - } - - findById(id: string): Promise { - return this.repo.findOne({ where: { id }, relations: { surchargeType: true } }); - } - - findByTypeCode(typeCode: string): Promise { - return this.repo.findOne({ - where: { isActive: true, surchargeType: { code: typeCode } }, - relations: { surchargeType: true }, - }); - } - - findAll(options?: FindManyOptions): Promise { - return this.repo.find(options); - } - - findAndCount(options?: FindManyOptions): Promise<[Surcharge[], number]> { - return this.repo.findAndCount(options); - } - - async create(data: Partial): Promise { - const entity = this.repo.create(data); - return this.repo.save(entity); - } - - async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); - return this.findById(id); - } - - async softDelete(id: string): Promise { - await this.repo.softDelete(id); - } -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts index 5966c0ceb..e236d5918 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -14,25 +14,25 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { findById(id: string): Promise { return this.repo.findOne({ where: { id }, - relations: { containerType: true, surcharge: { surchargeType: true } }, + relations: { containerType: true }, }); } - findActiveByContainerTypeAndDirection( - sizeCode: string, + findActiveByContainerTypeId( + containerTypeId: string, tradeDirection: string, ): Promise { + const now = new Date(); return this.repo .createQueryBuilder('rule') .innerJoinAndSelect('rule.containerType', 'ct') - .leftJoinAndSelect('rule.surcharge', 'surcharge') - .leftJoinAndSelect('surcharge.surchargeType', 'surchargeType') - .where('ct.size_code = :sizeCode', { sizeCode }) - .andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :both)', { + .where('rule.container_type_id = :containerTypeId', { containerTypeId }) + .andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :any)', { dir: tradeDirection, - both: 'BOTH', + any: 'ANY', }) - .andWhere('rule.is_active = true') + .andWhere('rule.effective_from <= :now', { now }) + .andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now }) .getMany(); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts new file mode 100644 index 000000000..c2f1c62f1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { Yard } from '../entities/yard.entity'; +import { IYardsRepository } from '../interfaces/yards.repository.interface'; + +@Injectable() +export class YardsRepository implements IYardsRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(Yard); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[Yard[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 153fa0b9b..018b56e43 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -1,48 +1,68 @@ import { Global, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { CargoType } from './entities/cargo-type.entity'; -import { ContainerType } from './entities/container-type.entity'; -import { PriorityRule } from './entities/priority-rule.entity'; -import { Surcharge } from './entities/surcharge.entity'; -import { SurchargeType } from './entities/surcharge-type.entity'; -import { ServiceType } from './entities/service-type.entity'; -import { WeightLimitRule } from './entities/weight-limit-rule.entity'; - -import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; -import { CONTAINER_TYPES_REPOSITORY } from './interfaces/container-types.repository.interface'; -import { PRIORITY_RULES_REPOSITORY } from './interfaces/priority-rules.repository.interface'; -import { SURCHARGES_REPOSITORY } from './interfaces/surcharges.repository.interface'; -import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface'; -import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface'; -import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface'; - -import { CargoTypesRepository } from './repositories/cargo-types.repository'; -import { ContainerTypesRepository } from './repositories/container-types.repository'; -import { PriorityRulesRepository } from './repositories/priority-rules.repository'; -import { SurchargesRepository } from './repositories/surcharges.repository'; -import { SurchargeTypesRepository } from './repositories/surcharge-types.repository'; -import { ServiceTypesRepository } from './repositories/service-types.repository'; -import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository'; - -import { CargoTypesService } from './services/cargo-types.service'; -import { ContainerTypesService } from './services/container-types.service'; -import { PriorityRulesService } from './services/priority-rules.service'; -import { SurchargesService } from './services/surcharges.service'; -import { SurchargeTypesService } from './services/surcharge-types.service'; -import { ServiceTypesService } from './services/service-types.service'; -import { WeightLimitRulesService } from './services/weight-limit-rules.service'; - +import { ApprovalRulesController } from './controllers/approval-rules.controller'; import { CargoTypesController } from './controllers/cargo-types.controller'; import { ContainerTypesController } from './controllers/container-types.controller'; import { PriorityRulesController } from './controllers/priority-rules.controller'; -import { SurchargesController } from './controllers/surcharges.controller'; -import { SurchargeTypesController } from './controllers/surcharge-types.controller'; +import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; +import { ShippingLinesController } from './controllers/shipping-lines.controller'; +import { SurchargeTypesController } from './controllers/surcharge-types.controller'; import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; +import { YardsController } from './controllers/yards.controller'; + +import { ApprovalRule } from './entities/approval-rule.entity'; +import { CargoType } from './entities/cargo-type.entity'; +import { ContainerType } from './entities/container-type.entity'; +import { PriorityRule } from './entities/priority-rule.entity'; +import { Rate } from './entities/rate.entity'; +import { ServiceType } from './entities/service-type.entity'; +import { ShippingLine } from './entities/shipping-line.entity'; +import { SurchargeType } from './entities/surcharge-type.entity'; +import { WeightLimitRule } from './entities/weight-limit-rule.entity'; +import { Yard } from './entities/yard.entity'; + +import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; +import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; +import { CONTAINER_TYPES_REPOSITORY } from './interfaces/container-types.repository.interface'; +import { PRIORITY_RULES_REPOSITORY } from './interfaces/priority-rules.repository.interface'; +import { RATES_REPOSITORY } from './interfaces/rates.repository.interface'; +import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface'; +import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface'; +import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface'; +import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface'; +import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface'; + +import { ApprovalRulesRepository } from './repositories/approval-rules.repository'; +import { CargoTypesRepository } from './repositories/cargo-types.repository'; +import { ContainerTypesRepository } from './repositories/container-types.repository'; +import { PriorityRulesRepository } from './repositories/priority-rules.repository'; +import { RatesRepository } from './repositories/rates.repository'; +import { ServiceTypesRepository } from './repositories/service-types.repository'; +import { ShippingLinesRepository } from './repositories/shipping-lines.repository'; +import { SurchargeTypesRepository } from './repositories/surcharge-types.repository'; +import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository'; +import { YardsRepository } from './repositories/yards.repository'; + +import { ApprovalRulesService } from './services/approval-rules.service'; +import { CargoTypesService } from './services/cargo-types.service'; +import { ContainerTypesService } from './services/container-types.service'; +import { PriorityRulesService } from './services/priority-rules.service'; +import { RatesService } from './services/rates.service'; +import { ServiceTypesService } from './services/service-types.service'; +import { ShippingLinesService } from './services/shipping-lines.service'; +import { SurchargeTypesService } from './services/surcharge-types.service'; +import { WeightLimitRulesService } from './services/weight-limit-rules.service'; +import { YardsService } from './services/yards.service'; import { RuleEngineService } from './rule-engine.service'; +import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; +import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; + @Global() @Module({ imports: [ @@ -50,46 +70,62 @@ import { RuleEngineService } from './rule-engine.service'; CargoType, ContainerType, PriorityRule, - Surcharge, SurchargeType, ServiceType, WeightLimitRule, + Yard, + ShippingLine, + Rate, + ApprovalRule, + BookingContainer, + BookingCargoModifier, + BookingApprovalStep, + BookingRateSnapshot, ]), ], controllers: [ CargoTypesController, ContainerTypesController, PriorityRulesController, - SurchargesController, SurchargeTypesController, ServiceTypesController, WeightLimitRulesController, + YardsController, + ShippingLinesController, + RatesController, + ApprovalRulesController, ], providers: [ - // Repositories CargoTypesRepository, { provide: CARGO_TYPES_REPOSITORY, useExisting: CargoTypesRepository }, ContainerTypesRepository, { provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository }, PriorityRulesRepository, { provide: PRIORITY_RULES_REPOSITORY, useExisting: PriorityRulesRepository }, - SurchargesRepository, - { provide: SURCHARGES_REPOSITORY, useExisting: SurchargesRepository }, SurchargeTypesRepository, { provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository }, ServiceTypesRepository, { provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository }, WeightLimitRulesRepository, { provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository }, - // CRUD services + YardsRepository, + { provide: YARDS_REPOSITORY, useExisting: YardsRepository }, + ShippingLinesRepository, + { provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository }, + RatesRepository, + { provide: RATES_REPOSITORY, useExisting: RatesRepository }, + ApprovalRulesRepository, + { provide: APPROVAL_RULES_REPOSITORY, useExisting: ApprovalRulesRepository }, CargoTypesService, ContainerTypesService, PriorityRulesService, - SurchargesService, SurchargeTypesService, ServiceTypesService, WeightLimitRulesService, - // Evaluation engine + YardsService, + ShippingLinesService, + RatesService, + ApprovalRulesService, RuleEngineService, ], exports: [ @@ -98,9 +134,12 @@ import { RuleEngineService } from './rule-engine.service'; ServiceTypesService, ContainerTypesService, SurchargeTypesService, - SurchargesService, WeightLimitRulesService, PriorityRulesService, + YardsService, + ShippingLinesService, + RatesService, + ApprovalRulesService, ], }) export class RuleEngineModule {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 3b08fd1bf..5fc27ecbd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -1,6 +1,8 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common'; -import { Freight } from '@edr/types'; -import { Booking } from '../bookings/entities/booking.entity'; +import { DataSource } from 'typeorm'; +import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; +import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; +import { TriggerCondition } from './entities/surcharge-type.entity'; import { ICargoTypesRepository, CARGO_TYPES_REPOSITORY, @@ -9,10 +11,6 @@ import { IServiceTypesRepository, SERVICE_TYPES_REPOSITORY, } from './interfaces/service-types.repository.interface'; -import { - ISurchargesRepository, - SURCHARGES_REPOSITORY, -} from './interfaces/surcharges.repository.interface'; import { IWeightLimitRulesRepository, WEIGHT_LIMIT_RULES_REPOSITORY, @@ -21,20 +19,64 @@ import { IPriorityRulesRepository, PRIORITY_RULES_REPOSITORY, } from './interfaces/priority-rules.repository.interface'; +import { + ISurchargeTypesRepository, + SURCHARGE_TYPES_REPOSITORY, +} from './interfaces/surcharge-types.repository.interface'; +import { + IRatesRepository, + RATES_REPOSITORY, +} from './interfaces/rates.repository.interface'; +import { + IApprovalRulesRepository, + APPROVAL_RULES_REPOSITORY, +} from './interfaces/approval-rules.repository.interface'; +import { + IShippingLinesRepository, + SHIPPING_LINES_REPOSITORY, +} from './interfaces/shipping-lines.repository.interface'; -export interface AppliedSurcharge { - feeName: string; - rate: number; +export interface BookingContainerEvalInput { + containerTypeId: string; + quantity: number; + vgmPerUnitTons: number; + totalVgmTons: number; + isReefer?: boolean; + isOverweight?: boolean; + overweightExcessTons?: number | null; +} + +export interface BookingEvaluationInput { + cargoTypeId: string; + serviceTypeId: string; + paymentCurrency: string; + tradeDirection: string; + isHazardous: boolean; + allowConsolidation?: boolean; + shippingLineId?: string | null; + containers: BookingContainerEvalInput[]; +} + +export interface AppliedCargoModifier { + surchargeTypeId: string; + surchargeTypeCode: string; + triggerValue: number | null; + calculatedAmount: number; + rateId: string; currency: string; - calculationMethod: Freight.CalculationMethod; - applyToRail: boolean; - applyToFirstMile: boolean; - applyToLastMile: boolean; +} + +export interface ContainerWeightResult { + containerTypeId: string; + weightLimitRuleId: string | null; + isOverweight: boolean; + overweightExcessTons: number | null; } export interface RuleEvaluationResult { priorityScore: number; - appliedSurcharges: AppliedSurcharge[]; + appliedModifiers: AppliedCargoModifier[]; + containerWeightResults: ContainerWeightResult[]; warnings: string[]; hardBlocked: string[]; requiresDirectorApproval: boolean; @@ -47,150 +89,233 @@ export class RuleEngineService { private readonly cargoTypesRepo: ICargoTypesRepository, @Inject(SERVICE_TYPES_REPOSITORY) private readonly serviceTypesRepo: IServiceTypesRepository, - @Inject(SURCHARGES_REPOSITORY) - private readonly surchargesRepo: ISurchargesRepository, @Inject(WEIGHT_LIMIT_RULES_REPOSITORY) private readonly weightLimitRulesRepo: IWeightLimitRulesRepository, @Inject(PRIORITY_RULES_REPOSITORY) private readonly priorityRulesRepo: IPriorityRulesRepository, + @Inject(SURCHARGE_TYPES_REPOSITORY) + private readonly surchargeTypesRepo: ISurchargeTypesRepository, + @Inject(RATES_REPOSITORY) + private readonly ratesRepo: IRatesRepository, + @Inject(APPROVAL_RULES_REPOSITORY) + private readonly approvalRulesRepo: IApprovalRulesRepository, + @Inject(SHIPPING_LINES_REPOSITORY) + private readonly shippingLinesRepo: IShippingLinesRepository, + private readonly dataSource: DataSource, ) {} /** * Evaluate all rule engine rules against a booking snapshot. - * Returns the computed priority score, surcharges to apply, warnings, - * hard-block messages, and whether director approval is required. - * Callers must throw BadRequestException if hardBlocked is non-empty. */ - async evaluate( - booking: Pick< - Booking, - | 'freightType' - | 'serviceType' - | 'paymentCurrency' - | 'cargoTotalWeightVgm' - | 'tradeDirection' - | 'isHazardous' - | 'isRefrigerated' - | 'containers' - >, - ): Promise { + async evaluate(input: BookingEvaluationInput): Promise { const warnings: string[] = []; const hardBlocked: string[] = []; - const appliedSurcharges: AppliedSurcharge[] = []; + const appliedModifiers: AppliedCargoModifier[] = []; + const containerWeightResults: ContainerWeightResult[] = []; let priorityScore = 0; let requiresDirectorApproval = false; - // ── 1. Cargo routing ───────────────────────────────────────────────── - // Look up CargoType by code to determine director-approval routing. - if (booking.freightType) { - const cargoType = await this.cargoTypesRepo.findByCode(booking.freightType); - if (cargoType?.requiresDirectorApproval) { - requiresDirectorApproval = true; - } + const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); + if (!cargoType) { + hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`); + } else if (cargoType.requiresDirectorApproval) { + requiresDirectorApproval = true; } - // ── 2. Weight-limit check ──────────────────────────────────────────── - // For each container group in the booking, find matching active rules - // and check whether the per-container VGM exceeds the max weight. - const containers = booking.containers ?? []; - for (const container of containers) { - const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeAndDirection( - container.type, - booking.tradeDirection, + for (const container of input.containers) { + const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( + container.containerTypeId, + input.tradeDirection, ); + const rule = rules[0]; + let isOverweight = container.isOverweight ?? false; + let excess = container.overweightExcessTons ?? null; - for (const rule of rules) { - if (container.vgm > rule.maxWeightTons) { - const msg = - `${container.type} container VGM ${container.vgm}t exceeds max ` + - `${rule.maxWeightTons}t (${booking.tradeDirection})`; - - if (rule.exceededAction === Freight.ExceededAction.HARD_BLOCK) { - hardBlocked.push(msg); - } else { - warnings.push(msg); - } - - if (rule.surcharge) { - appliedSurcharges.push(this.mapSurcharge(rule.surcharge)); - } - } else if (container.vgm > rule.warningThresholdTons) { + if (rule) { + const maxTotal = Number(rule.maxVgmTons) * container.quantity; + const totalVgm = container.totalVgmTons; + if (totalVgm > maxTotal) { + isOverweight = true; + excess = Math.max(0, totalVgm - maxTotal); warnings.push( - `${container.type} container VGM ${container.vgm}t is approaching limit ` + - `of ${rule.maxWeightTons}t (${booking.tradeDirection})`, + `Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`, ); } + containerWeightResults.push({ + containerTypeId: container.containerTypeId, + weightLimitRuleId: rule.id, + isOverweight, + overweightExcessTons: excess, + }); + } else { + containerWeightResults.push({ + containerTypeId: container.containerTypeId, + weightLimitRuleId: null, + isOverweight, + overweightExcessTons: excess, + }); } } - // ── 3. Surcharge flags ─────────────────────────────────────────────── - if (booking.isHazardous) { - const surcharge = await this.surchargesRepo.findByTypeCode('HAZARDOUS'); - if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge)); + const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId); + if (serviceType) { + priorityScore += serviceType.priorityBonusPoints; } - if (booking.isRefrigerated) { - const surcharge = await this.surchargesRepo.findByTypeCode('REFRIGERATED'); - if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge)); - } - - // ── 4. Priority scoring ────────────────────────────────────────────── const priorityRules = await this.priorityRulesRepo.findAllActive(); - for (const rule of priorityRules) { - switch (rule.priorityType) { - case Freight.PriorityType.USD_PAYER: - if (booking.paymentCurrency === 'USD') { - priorityScore += rule.bonusPoints; - } - break; - - case Freight.PriorityType.RAIL_AND_FORWARDING: { - // Read bonus points from the matching ServiceType DB row - const serviceType = await this.serviceTypesRepo.findByCode(booking.serviceType); - if (serviceType && serviceType.priorityBonusPoints > 0) { - priorityScore += serviceType.priorityBonusPoints; - } else if (booking.serviceType === 'RAIL_AND_FORWARDING') { - // Fall back to the rule's own bonus_points if no ServiceType found - priorityScore += rule.bonusPoints; - } - break; - } - - case Freight.PriorityType.HIGH_VOLUME_SHIPMENT: - if (booking.cargoTotalWeightVgm >= 300) { - priorityScore += rule.bonusPoints; - } - break; - - case Freight.PriorityType.GOVERNMENT_ACCOUNT: - // TODO: integrate customer accountTier — evaluate when Customer entity is extended - break; + if ( + rule.conditionCurrency === null || + rule.conditionCurrency === input.paymentCurrency + ) { + priorityScore += rule.score; } } - return { priorityScore, appliedSurcharges, warnings, hardBlocked, requiresDirectorApproval }; + let shippingLineMapped = false; + if (input.shippingLineId) { + const line = await this.shippingLinesRepo.findById(input.shippingLineId); + shippingLineMapped = Boolean(line?.mappedToCode); + } + + const hasReefer = input.containers.some((c) => c.isReefer); + const hasOverweight = containerWeightResults.some((r) => r.isOverweight); + + const surchargeTypes = await this.surchargeTypesRepo.findAllActiveWithRate(); + const liveRates = await this.ratesRepo.findLiveRates(); + const rateById = new Map(liveRates.map((r) => [r.id, r])); + + for (const st of surchargeTypes) { + const triggered = this.matchesTrigger(st.triggerCondition, { + isHazardous: input.isHazardous, + hasReefer, + hasOverweight, + shippingLineMapped, + allowConsolidation: input.allowConsolidation ?? false, + }); + if (!triggered) continue; + + const rate = st.rate ?? rateById.get(st.rateId); + if (!rate) continue; + + let triggerValue: number | null = null; + let calculatedAmount = Number(rate.rateValue); + + if (st.triggerCondition === 'VGM_EXCEEDS_LIMIT') { + triggerValue = containerWeightResults.reduce( + (sum, r) => sum + (r.overweightExcessTons ?? 0), + 0, + ); + if (rate.rateUnit === 'PER_TON') { + calculatedAmount = triggerValue * Number(rate.rateValue); + } + } + + appliedModifiers.push({ + surchargeTypeId: st.id, + surchargeTypeCode: st.code, + triggerValue, + calculatedAmount, + rateId: rate.id, + currency: rate.currency, + }); + } + + return { + priorityScore, + appliedModifiers, + containerWeightResults, + warnings, + hardBlocked, + requiresDirectorApproval, + }; } /** - * Guard helper — throws BadRequestException if hardBlocked is non-empty. - * Call this immediately after evaluate() in BookingsService. + * Instantiate booking_approval_step rows from approval_rules for a cargo type. */ + async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise { + const cargoType = await this.cargoTypesRepo.findById(cargoTypeId); + if (!cargoType) { + throw new BadRequestException(`Cargo type ${cargoTypeId} not found`); + } + + const chain = await this.approvalRulesRepo.findChainForCargo( + cargoType.requiresDirectorApproval, + ); + + const stepRepo = this.dataSource.getRepository(BookingApprovalStep); + const steps: BookingApprovalStep[] = []; + + for (const rule of chain) { + const step = stepRepo.create({ + bookingId, + approvalRuleId: rule.id, + stepOrder: rule.stepOrder, + requiredRole: rule.requiredRole, + status: 'PENDING', + }); + steps.push(await stepRepo.save(step)); + } + + return steps; + } + + /** + * Snapshot all LIVE rates into booking_rate_snapshot for a booking. + */ + async snapshotLiveRates(bookingId: string): Promise { + const liveRates = await this.ratesRepo.findLiveRates(); + const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot); + const now = new Date(); + const snapshots: BookingRateSnapshot[] = []; + + for (const rate of liveRates) { + const snapshot = snapshotRepo.create({ + bookingId, + rateId: rate.id, + rateType: rate.rateType, + rateValue: rate.rateValue, + rateUnit: rate.rateUnit, + currency: rate.currency, + snapshottedAt: now, + }); + snapshots.push(await snapshotRepo.save(snapshot)); + } + + return snapshots; + } + + /** Guard helper — throws BadRequestException if hardBlocked is non-empty. */ assertNoHardBlocks(result: RuleEvaluationResult): void { if (result.hardBlocked.length > 0) { throw new BadRequestException(result.hardBlocked.join('; ')); } } - private mapSurcharge(s: { feeName: string; rate: number; currency: string; calculationMethod: Freight.CalculationMethod; applyToRail: boolean; applyToFirstMile: boolean; applyToLastMile: boolean }): AppliedSurcharge { - return { - feeName: s.feeName, - rate: s.rate, - currency: s.currency, - calculationMethod: s.calculationMethod, - applyToRail: s.applyToRail, - applyToFirstMile: s.applyToFirstMile, - applyToLastMile: s.applyToLastMile, - }; + private matchesTrigger( + condition: TriggerCondition, + state: { + isHazardous: boolean; + hasReefer: boolean; + hasOverweight: boolean; + shippingLineMapped: boolean; + allowConsolidation: boolean; + }, + ): boolean { + switch (condition) { + case 'CARGO_FLAG_HAZARDOUS': + return state.isHazardous; + case 'CARGO_FLAG_REEFER': + return state.hasReefer; + case 'VGM_EXCEEDS_LIMIT': + return state.hasOverweight; + case 'SHIPPING_LINE_MAPPED': + return state.shippingLineMapped; + case 'CONSOLIDATION_ENABLED': + return state.allowConsolidation; + default: + return false; + } } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts new file mode 100644 index 000000000..4a4e33442 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts @@ -0,0 +1,75 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; +import { ApprovalRule } from '../entities/approval-rule.entity'; +import { + APPROVAL_RULES_REPOSITORY, + IApprovalRulesRepository, +} from '../interfaces/approval-rules.repository.interface'; + +@Injectable() +export class ApprovalRulesService { + constructor( + @Inject(APPROVAL_RULES_REPOSITORY) + private readonly repository: IApprovalRulesRepository, + ) {} + + /** List approval rules. */ + async findAll(filter: { + requiresDirectorApproval?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.requiresDirectorApproval !== undefined) { + where.requiresDirectorApproval = filter.requiresDirectorApproval; + } + + const [data, total] = await this.repository.findAndCount({ + where, + order: { requiresDirectorApproval: 'ASC', stepOrder: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get approval chain for a cargo type flag. */ + async findChain(requiresDirectorApproval: boolean): Promise { + return this.repository.findChainForCargo(requiresDirectorApproval); + } + + /** Get an approval rule by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Approval rule ${id} not found`); + return entity; + } + + /** Create an approval rule step. */ + async create(dto: CreateApprovalRuleDto): Promise { + return this.repository.create({ + requiresDirectorApproval: dto.requiresDirectorApproval, + stepOrder: dto.stepOrder, + requiredRole: dto.requiredRole, + actionLabel: dto.actionLabel, + blocksRole: dto.blocksRole, + }); + } + + /** Update an approval rule. */ + async update(id: string, dto: UpdateApprovalRuleDto): Promise { + await this.findById(id); + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Approval rule ${id} not found`); + return updated; + } + + /** Soft-delete an approval rule. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 9688d16ad..cbc4fbe86 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -27,7 +27,7 @@ export class ContainerTypesService { const [data, total] = await this.repository.findAndCount({ where, - order: { sizeCode: 'ASC' }, + order: { code: 'ASC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -43,23 +43,27 @@ export class ContainerTypesService { /** Create a new container type. */ async create(dto: CreateContainerTypeDto): Promise { - const existing = await this.repository.findBySizeCode(dto.sizeCode); - if (existing) throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`); + const existing = await this.repository.findByCode(dto.code); + if (existing) throw new ConflictException(`Container type with code "${dto.code}" already exists`); return this.repository.create({ - sizeCode: dto.sizeCode, - description: dto.description ?? null, - containersPerWagon: dto.containersPerWagon, + code: dto.code, + label: dto.label, + sizeFt: dto.sizeFt, + wagonsPerUnit: dto.wagonsPerUnit, + isReefer: dto.isReefer ?? false, + isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, + displayOrder: dto.displayOrder ?? 1, }); } /** Update an existing container type. */ async update(id: string, dto: UpdateContainerTypeDto): Promise { await this.findById(id); - if (dto.sizeCode) { - const conflict = await this.repository.findBySizeCode(dto.sizeCode); + if (dto.code) { + const conflict = await this.repository.findByCode(dto.code); if (conflict && conflict.id !== id) { - throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`); + throw new ConflictException(`Container type with code "${dto.code}" already exists`); } } const updated = await this.repository.update(id, dto); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rules.service.ts index 9f4add791..63ff03a8c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rules.service.ts @@ -27,7 +27,7 @@ export class PriorityRulesService { const [data, total] = await this.repository.findAndCount({ where, - order: { priorityType: 'ASC' }, + order: { code: 'ASC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -43,16 +43,15 @@ export class PriorityRulesService { /** Create a new priority rule. */ async create(dto: CreatePriorityRuleDto): Promise { - const existing = await this.repository.findAll({ where: { priorityType: dto.priorityType } }); + const existing = await this.repository.findAll({ where: { code: dto.code } }); if (existing.length > 0) { - throw new ConflictException(`Priority rule for type "${dto.priorityType}" already exists`); + throw new ConflictException(`Priority rule with code "${dto.code}" already exists`); } return this.repository.create({ - priorityType: dto.priorityType, - ruleName: dto.ruleName, - description: dto.description ?? null, - activationCondition: dto.activationCondition ?? null, - bonusPoints: dto.bonusPoints, + code: dto.code, + label: dto.label, + score: dto.score, + conditionCurrency: dto.conditionCurrency ?? null, isActive: dto.isActive ?? false, }); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts new file mode 100644 index 000000000..656802e3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -0,0 +1,114 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto'; +import { UpdateRateDto } from '../dto/update-rate.dto'; +import { Rate } from '../entities/rate.entity'; +import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; + +@Injectable() +export class RatesService { + constructor( + @Inject(RATES_REPOSITORY) + private readonly repository: IRatesRepository, + ) {} + + /** List rates with pagination. */ + async findAll(filter: { + status?: string; + rateType?: string; + page?: number; + pageSize?: number; + }): Promise<{ data: Rate[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.status) where.status = filter.status; + if (filter.rateType) where.rateType = filter.rateType; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { effectiveFrom: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Return all currently LIVE rates. */ + async findLiveRates(): Promise { + return this.repository.findLiveRates(); + } + + /** Get a rate by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Rate ${id} not found`); + return entity; + } + + /** Create a rate in DRAFT status. */ + async create(dto: CreateRateDto): Promise { + return this.repository.create({ + rateType: dto.rateType as Rate['rateType'], + containerTypeId: dto.containerTypeId, + tradeDirection: dto.tradeDirection, + currency: dto.currency, + rateValue: dto.rateValue, + rateUnit: dto.rateUnit as Rate['rateUnit'], + status: 'DRAFT', + proposedByStaffId: dto.proposedByStaffId, + effectiveFrom: new Date(dto.effectiveFrom), + effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined, + }); + } + + /** Update a DRAFT rate. */ + async update(id: string, dto: UpdateRateDto): Promise { + const existing = await this.findById(id); + if (existing.status !== 'DRAFT') { + throw new BadRequestException('Only DRAFT rates can be updated'); + } + const updates: Partial = {}; + if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType']; + if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId; + if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection; + if (dto.currency) updates.currency = dto.currency; + if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; + if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; + if (dto.proposedByStaffId) updates.proposedByStaffId = dto.proposedByStaffId; + if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); + if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo); + const updated = await this.repository.update(id, updates); + if (!updated) throw new NotFoundException(`Rate ${id} not found`); + return updated; + } + + /** Submit a DRAFT rate for CEO approval. */ + async submitForApproval(id: string): Promise { + const rate = await this.findById(id); + if (rate.status !== 'DRAFT') { + throw new BadRequestException('Only DRAFT rates can be submitted for approval'); + } + const updated = await this.repository.update(id, { status: 'PENDING_APPROVAL' }); + return updated!; + } + + /** CEO approves a rate — moves to LIVE. */ + async approve(id: string, dto: ApproveRateDto): Promise { + const rate = await this.findById(id); + if (rate.status !== 'PENDING_APPROVAL') { + throw new BadRequestException('Only PENDING_APPROVAL rates can be approved'); + } + const updated = await this.repository.update(id, { + status: 'LIVE', + approvedByCeoId: dto.approvedByCeoId, + approvedAt: new Date(), + }); + return updated!; + } + + /** Soft-delete a rate. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts new file mode 100644 index 000000000..4eaa47a26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts @@ -0,0 +1,76 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; +import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; +import { ShippingLine } from '../entities/shipping-line.entity'; +import { + IShippingLinesRepository, + SHIPPING_LINES_REPOSITORY, +} from '../interfaces/shipping-lines.repository.interface'; + +@Injectable() +export class ShippingLinesService { + constructor( + @Inject(SHIPPING_LINES_REPOSITORY) + private readonly repository: IShippingLinesRepository, + ) {} + + /** List shipping lines with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: ShippingLine[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { code: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a shipping line by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Shipping line ${id} not found`); + return entity; + } + + /** Create a shipping line. */ + async create(dto: CreateShippingLineDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) throw new ConflictException(`Shipping line with code "${dto.code}" already exists`); + return this.repository.create({ + code: dto.code, + label: dto.label, + mappedToCode: dto.mappedToCode, + showExtraFeeNotice: dto.showExtraFeeNotice ?? false, + isActive: dto.isActive ?? true, + }); + } + + /** Update a shipping line. */ + async update(id: string, dto: UpdateShippingLineDto): Promise { + await this.findById(id); + if (dto.code) { + const conflict = await this.repository.findByCode(dto.code); + if (conflict && conflict.id !== id) { + throw new ConflictException(`Shipping line with code "${dto.code}" already exists`); + } + } + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Shipping line ${id} not found`); + return updated; + } + + /** Soft-delete a shipping line. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts index 986c0081d..0160f3651 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts @@ -27,7 +27,7 @@ export class SurchargeTypesService { const [data, total] = await this.repository.findAndCount({ where, - order: { name: 'ASC' }, + order: { label: 'ASC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -47,8 +47,9 @@ export class SurchargeTypesService { if (existing) throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`); return this.repository.create({ code: dto.code, - name: dto.name, - description: dto.description ?? null, + label: dto.label, + triggerCondition: dto.triggerCondition as SurchargeType['triggerCondition'], + rateId: dto.rateId, isActive: dto.isActive ?? true, }); } @@ -62,7 +63,13 @@ export class SurchargeTypesService { throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`); } } - const updated = await this.repository.update(id, dto); + const patch: Partial = {}; + if (dto.code !== undefined) patch.code = dto.code; + if (dto.label !== undefined) patch.label = dto.label; + if (dto.triggerCondition !== undefined) patch.triggerCondition = dto.triggerCondition as SurchargeType['triggerCondition']; + if (dto.rateId !== undefined) patch.rateId = dto.rateId; + if (dto.isActive !== undefined) patch.isActive = dto.isActive; + const updated = await this.repository.update(id, patch); if (!updated) throw new NotFoundException(`Surcharge type ${id} not found`); return updated; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharges.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharges.service.ts deleted file mode 100644 index f584d86d2..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/services/surcharges.service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { CreateSurchargeDto } from '../dto/create-surcharge.dto'; -import { UpdateSurchargeDto } from '../dto/update-surcharge.dto'; -import { Surcharge } from '../entities/surcharge.entity'; -import { - ISurchargesRepository, - SURCHARGES_REPOSITORY, -} from '../interfaces/surcharges.repository.interface'; - -@Injectable() -export class SurchargesService { - constructor( - @Inject(SURCHARGES_REPOSITORY) - private readonly repository: ISurchargesRepository, - ) {} - - /** List surcharges with pagination. */ - async findAll(filter: { - isActive?: boolean; - surchargeTypeId?: string; - page?: number; - pageSize?: number; - }): Promise<{ data: Surcharge[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; - const where: Record = {}; - if (filter.isActive !== undefined) where.isActive = filter.isActive; - if (filter.surchargeTypeId) where.surchargeTypeId = filter.surchargeTypeId; - - const [data, total] = await this.repository.findAndCount({ - where, - relations: { surchargeType: true }, - order: { feeName: 'ASC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; - } - - /** Get a single surcharge by ID. */ - async findById(id: string): Promise { - const entity = await this.repository.findById(id); - if (!entity) throw new NotFoundException(`Surcharge ${id} not found`); - return entity; - } - - /** Create a new surcharge. */ - async create(dto: CreateSurchargeDto): Promise { - return this.repository.create({ - surchargeTypeId: dto.surchargeTypeId, - feeName: dto.feeName, - triggerDescription: dto.triggerDescription ?? null, - calculationMethod: dto.calculationMethod, - rate: dto.rate, - currency: dto.currency, - applyToRail: dto.applyToRail ?? false, - applyToFirstMile: dto.applyToFirstMile ?? false, - applyToLastMile: dto.applyToLastMile ?? false, - isActive: dto.isActive ?? true, - }); - } - - /** Update an existing surcharge. */ - async update(id: string, dto: UpdateSurchargeDto): Promise { - await this.findById(id); - const updated = await this.repository.update(id, dto); - if (!updated) throw new NotFoundException(`Surcharge ${id} not found`); - return updated; - } - - /** Soft-delete a surcharge. */ - async remove(id: string): Promise { - await this.findById(id); - await this.repository.softDelete(id); - } -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts index 33da4a7b9..d171f55aa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; @@ -16,20 +16,21 @@ export class WeightLimitRulesService { /** List weight limit rules with pagination. */ async findAll(filter: { - isActive?: boolean; containerTypeId?: string; + tradeDirection?: string; page?: number; pageSize?: number; }): Promise<{ data: WeightLimitRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const where: Record = {}; - if (filter.isActive !== undefined) where.isActive = filter.isActive; if (filter.containerTypeId) where.containerTypeId = filter.containerTypeId; + if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; const [data, total] = await this.repository.findAndCount({ where, - relations: { containerType: true, surcharge: { surchargeType: true } }, + relations: { containerType: true }, + order: { effectiveFrom: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -45,27 +46,25 @@ export class WeightLimitRulesService { /** Create a new weight limit rule. */ async create(dto: CreateWeightLimitRuleDto): Promise { - if (dto.warningThresholdTons > dto.maxWeightTons) { - throw new BadRequestException('warningThresholdTons must be ≤ maxWeightTons'); - } return this.repository.create({ containerTypeId: dto.containerTypeId, tradeDirection: dto.tradeDirection, - maxWeightTons: dto.maxWeightTons, - warningThresholdTons: dto.warningThresholdTons, - exceededAction: dto.exceededAction, - surchargeId: dto.surchargeId ?? null, - isActive: dto.isActive ?? true, + maxVgmTons: dto.maxVgmTons, + effectiveFrom: new Date(dto.effectiveFrom), + effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null, }); } /** Update an existing weight limit rule. */ async update(id: string, dto: UpdateWeightLimitRuleDto): Promise { - const existing = await this.findById(id); - const warning = dto.warningThresholdTons ?? existing.warningThresholdTons; - const max = dto.maxWeightTons ?? existing.maxWeightTons; - if (warning > max) throw new BadRequestException('warningThresholdTons must be ≤ maxWeightTons'); - const updated = await this.repository.update(id, dto); + await this.findById(id); + const patch: Partial = {}; + if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; + if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; + if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; + if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom); + if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo); + const updated = await this.repository.update(id, patch); if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`); return updated; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts new file mode 100644 index 000000000..d2977df2a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -0,0 +1,75 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateYardDto } from '../dto/create-yard.dto'; +import { UpdateYardDto } from '../dto/update-yard.dto'; +import { Yard } from '../entities/yard.entity'; +import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; + +@Injectable() +export class YardsService { + constructor( + @Inject(YARDS_REPOSITORY) + private readonly repository: IYardsRepository, + ) {} + + /** List yards with pagination. */ + async findAll(filter: { + isActive?: boolean; + country?: string; + page?: number; + pageSize?: number; + }): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + if (filter.country) where.country = filter.country; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { displayOrder: 'ASC', code: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a yard by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Yard ${id} not found`); + return entity; + } + + /** Create a yard. */ + async create(dto: CreateYardDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) throw new ConflictException(`Yard with code "${dto.code}" already exists`); + return this.repository.create({ + code: dto.code, + label: dto.label, + country: dto.country, + isActive: dto.isActive ?? true, + displayOrder: dto.displayOrder ?? 1, + }); + } + + /** Update a yard. */ + async update(id: string, dto: UpdateYardDto): Promise { + await this.findById(id); + if (dto.code) { + const conflict = await this.repository.findByCode(dto.code); + if (conflict && conflict.id !== id) { + throw new ConflictException(`Yard with code "${dto.code}" already exists`); + } + } + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Yard ${id} not found`); + return updated; + } + + /** Soft-delete a yard. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +}