diff --git a/.gitignore b/.gitignore index 132db3f2a..13865633d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,9 @@ coverage/ # OS/editor .DS_Store .idea/ -.vscode/ \ No newline at end of file +.vscode/ + +# emacs cache files +*~ +\#*\# +.\#* 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/app.module.ts b/apps/edr-freight-api/src/app.module.ts index b2a1f0c1a..0fcc7a546 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,6 +1,8 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { DataSource, DataSourceOptions } from "typeorm"; +import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas"; import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; @@ -21,6 +23,10 @@ import { OtpModule } from './modules/otp/otp.module'; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; +import { + EDR_FREIGHT_APPLICATION, + EDR_FREIGHT_PERMISSIONS, +} from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; @@ -34,9 +40,20 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder"; inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => config.get("database")!, + dataSourceFactory: async (options) => { + if (!options) { + throw new Error("Missing TypeORM DataSource options"); + } + await ensurePostgresSchemas(options as DataSourceOptions); + const dataSource = new DataSource(options as DataSourceOptions); + return dataSource.initialize(); + }, }), SharedAuthModule, - IamModule.forRoot(), + IamModule.forRoot({ + applications: [EDR_FREIGHT_APPLICATION], + permissions: EDR_FREIGHT_PERMISSIONS, + }), BookingsModule, FilesModule, ConsignmentsModule, diff --git a/apps/edr-freight-api/src/common/utils/generate-code.util.ts b/apps/edr-freight-api/src/common/utils/generate-code.util.ts new file mode 100644 index 000000000..26f978f02 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/generate-code.util.ts @@ -0,0 +1,15 @@ +/** + * Derives a stable, uppercase, underscore-separated code from a human-readable name. + * + * Examples: + * "Hazard Surcharge" → "HAZARD_SURCHARGE" + * "20ft Dry Container" → "20FT_DRY_CONTAINER" + * "Kality Yard (ET)" → "KALITY_YARD_ET" + */ +export function generateCode(name: string): string { + return name + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +} diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 1bef01eca..bbac17e7a 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -1,5 +1,6 @@ import { registerAs } from "@nestjs/config"; import { TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { join, dirname } from "path"; import { DefaultPosition, DefaultUnit, @@ -44,6 +45,7 @@ import { NotificationTemplate, } from "@tria-plc/iamapi-common"; import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity"; +import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas"; const iamEntities = [ DefaultPosition, @@ -90,26 +92,37 @@ const iamEntities = [ NotificationTemplate, ]; +const iamMigrationsGlob = join( + dirname(require.resolve("@tria-plc/iamapi-common/package.json")), + "dist/db/migrations/*.js", +); +const freightMigrationsGlob = join(__dirname, "../migrations/*.js"); + export default registerAs( "database", - (): TypeOrmModuleOptions => ({ + (): TypeOrmModuleOptions => { + return { type: "postgres", host: process.env.DB_HOST ?? "localhost", port: parseInt(process.env.DB_PORT ?? "5433", 10), username: process.env.DB_USER ?? "postgres", password: process.env.DB_PASSWORD ?? "", database: process.env.DB_NAME ?? "edr_freight", + schema: "public", + extra: { + options: `-c search_path=${APPLICATION_SEARCH_PATH}`, + }, entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], autoLoadEntities: true, migrations: [ - // IAM schema + tables must be created before freight entity sync - __dirname + - "/../../node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.js", - __dirname + "/../../migrations/*.{ts,js}", + // IAM schema + tables must be created before freight migrations + iamMigrationsGlob, + freightMigrationsGlob, ], 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/config/ensure-postgres-schemas.ts b/apps/edr-freight-api/src/config/ensure-postgres-schemas.ts new file mode 100644 index 000000000..99fbcab61 --- /dev/null +++ b/apps/edr-freight-api/src/config/ensure-postgres-schemas.ts @@ -0,0 +1,50 @@ +import { DataSource, DataSourceOptions } from "typeorm"; + +/** Schemas required before TypeORM migrations and entity access. */ +export const APPLICATION_SCHEMAS = [ + "public", + "iam", + "freight", + "audit", +] as const; + +export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(","); + +/** + * TypeORM creates the migrations table before any migration runs. If `public` was + * dropped, current_schema() is null and CREATE TABLE migrations fails. + * IAM/freight migrations assume their schemas already exist. + */ +export async function ensurePostgresSchemas( + options: DataSourceOptions, +): Promise { + const bootstrap = new DataSource({ + ...options, + entities: [], + migrations: [], + migrationsRun: false, + synchronize: false, + }); + + await bootstrap.initialize(); + + for (const schema of APPLICATION_SCHEMAS) { + if (schema === "public") { + await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS public`); + await bootstrap.query(`GRANT ALL ON SCHEMA public TO public`); + await bootstrap.query(`GRANT CREATE ON SCHEMA public TO public`); + } else { + await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`); + await bootstrap.query(`GRANT USAGE ON SCHEMA "${schema}" TO public`); + await bootstrap.query( + `GRANT CREATE ON SCHEMA "${schema}" TO public`, + ); + } + } + + await bootstrap.query( + `SET search_path TO ${APPLICATION_SEARCH_PATH}`, + ); + + await bootstrap.destroy(); +} 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/1748550000000-CreateFreightLegacyBaseline.ts b/apps/edr-freight-api/src/migrations/1748550000000-CreateFreightLegacyBaseline.ts new file mode 100644 index 000000000..2455727bf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748550000000-CreateFreightLegacyBaseline.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Pre-ITMLS baseline for fresh databases. Older environments created `freight.bookings` + * via synchronize or manual SQL; ItmlsFullSchemaRewrite only ALTERs that table. + */ +export class CreateFreightLegacyBaseline1748550000000 implements MigrationInterface { + name = 'CreateFreightLegacyBaseline1748550000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`); + + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.train_status AS ENUM ( + 'AVAILABLE', 'SCHEDULED', 'IN_SERVICE', 'UNDER_MAINTENANCE', 'OUT_OF_SERVICE' + ); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.trains ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + code VARCHAR(32) NOT NULL UNIQUE, + capacity_tons NUMERIC(10, 2) NOT NULL DEFAULT 0, + status freight.train_status NOT NULL DEFAULT 'AVAILABLE', + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.bookings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + reference VARCHAR(64) NOT NULL UNIQUE, + customer_id UUID NOT NULL, + train_id UUID, + status VARCHAR(40) NOT NULL DEFAULT 'DRAFT', + scheduled_date TIMESTAMPTZ NOT NULL DEFAULT now(), + total_amount NUMERIC(14, 2) NOT NULL DEFAULT 0, + payment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + contract_type VARCHAR(20) NOT NULL DEFAULT 'SPOT', + previous_contract_id UUID, + trade_direction VARCHAR(10) NOT NULL DEFAULT 'IMPORT', + equipment_return VARCHAR(20) NOT NULL DEFAULT 'RETURN', + first_mile_pickup_address TEXT, + last_mile_delivery_address TEXT, + cargo_total_weight_vgm NUMERIC(12, 3) NOT NULL DEFAULT 0, + is_hazardous BOOLEAN NOT NULL DEFAULT false, + payment_currency VARCHAR(5) NOT NULL DEFAULT 'USD', + start_date DATE, + end_date DATE, + financial_terms TEXT, + version_number INT NOT NULL DEFAULT 1, + approved_by_staff_id UUID, + approved_by_staff_at TIMESTAMPTZ, + signed_by_director_id UUID, + signed_by_director_at TIMESTAMPTZ, + signed_by_ceo_id UUID, + signed_by_ceo_at TIMESTAMPTZ, + priority_score INT NOT NULL DEFAULT 0, + allow_consolidation BOOLEAN NOT NULL DEFAULT false, + consolidation_partner_id UUID, + origin_station VARCHAR(255), + destination_station VARCHAR(255), + service_type VARCHAR(100), + freight_type VARCHAR(100), + freight_subtype VARCHAR(255), + containers JSONB, + first_mile_enabled BOOLEAN DEFAULT false, + last_mile_enabled BOOLEAN DEFAULT false, + is_refrigerated BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.bookings CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.trains CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS freight.train_status`); + } +} 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/migrations/1748800000000-AddBookingsRemainingForeignKeys.ts b/apps/edr-freight-api/src/migrations/1748800000000-AddBookingsRemainingForeignKeys.ts new file mode 100644 index 000000000..50d052a24 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748800000000-AddBookingsRemainingForeignKeys.ts @@ -0,0 +1,331 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingsRemainingForeignKeys1748800000000 implements MigrationInterface { + name = 'AddBookingsRemainingForeignKeys1748800000000'; + + public async up(queryRunner: QueryRunner): Promise { + const publicCustomersExists = await queryRunner.query(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'customers' + ) AS exists + `); + const hasPublicCustomers = Boolean(publicCustomersExists[0]?.exists); + + // ── freight.bookings: nullable FK cleanup ───────────────────────────── + await queryRunner.query(` + UPDATE freight.bookings b + SET train_id = NULL + WHERE b.train_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.id = b.train_id); + `); + await queryRunner.query(` + UPDATE freight.bookings b + SET previous_contract_id = NULL + WHERE b.previous_contract_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.previous_contract_id); + `); + await queryRunner.query(` + UPDATE freight.bookings b + SET consolidation_partner_id = NULL + WHERE b.consolidation_partner_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.consolidation_partner_id); + `); + + // Legacy DBs only: public.customers is created/moved in MoveCustomersToFreightSchema (174890). + if (hasPublicCustomers) { + await queryRunner.query(` + DELETE FROM freight.booking_cargo_modifier bcm + USING freight.bookings b + WHERE bcm.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_approval_step bas + USING freight.bookings b + WHERE bas.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_rate_snapshot brs + USING freight.bookings b + WHERE brs.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_container bc + USING freight.bookings b + WHERE bc.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.bookings b + WHERE NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_customer_id" + FOREIGN KEY (customer_id) + REFERENCES public.customers(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + } + + // ── freight.bookings FKs ──────────────────────────────────────────── + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_train_id" + FOREIGN KEY (train_id) + REFERENCES freight.trains(id) + ON DELETE SET NULL; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_previous_contract_id" + FOREIGN KEY (previous_contract_id) + REFERENCES freight.bookings(id) + ON DELETE SET NULL; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_consolidation_partner_id" + FOREIGN KEY (consolidation_partner_id) + REFERENCES freight.bookings(id) + ON DELETE SET NULL; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + // ── freight.booking_container ───────────────────────────────────────── + await queryRunner.query(` + UPDATE freight.booking_container bc + SET weight_limit_rule_id = NULL + WHERE bc.weight_limit_rule_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.weight_limit_rules wlr WHERE wlr.id = bc.weight_limit_rule_id + ); + `); + + await queryRunner.query(` + DELETE FROM freight.booking_container bc + WHERE NOT EXISTS (SELECT 1 FROM freight.container_types ct WHERE ct.id = bc.container_type_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_container + ADD CONSTRAINT "FK_booking_container_container_type_id" + FOREIGN KEY (container_type_id) + REFERENCES freight.container_types(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_container + ADD CONSTRAINT "FK_booking_container_weight_limit_rule_id" + FOREIGN KEY (weight_limit_rule_id) + REFERENCES freight.weight_limit_rules(id) + ON DELETE SET NULL; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + // ── freight.booking_rate_snapshot ───────────────────────────────────── + await queryRunner.query(` + DELETE FROM freight.booking_cargo_modifier bcm + USING freight.booking_rate_snapshot brs + WHERE bcm.rate_snapshot_id = brs.id + AND ( + NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id) + OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id) + ); + `); + await queryRunner.query(` + DELETE FROM freight.booking_rate_snapshot brs + WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id) + OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_rate_snapshot + ADD CONSTRAINT "FK_booking_rate_snapshot_booking_id" + FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ON DELETE CASCADE; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_rate_snapshot + ADD CONSTRAINT "FK_booking_rate_snapshot_rate_id" + FOREIGN KEY (rate_id) + REFERENCES freight.rates(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + // ── freight.booking_approval_step ───────────────────────────────────── + await queryRunner.query(` + DELETE FROM freight.booking_approval_step bas + WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bas.booking_id) + OR NOT EXISTS (SELECT 1 FROM freight.approval_rules ar WHERE ar.id = bas.approval_rule_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_approval_step + ADD CONSTRAINT "FK_booking_approval_step_booking_id" + FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ON DELETE CASCADE; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_approval_step + ADD CONSTRAINT "FK_booking_approval_step_approval_rule_id" + FOREIGN KEY (approval_rule_id) + REFERENCES freight.approval_rules(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + // ── freight.booking_cargo_modifier ──────────────────────────────────── + await queryRunner.query(` + DELETE FROM freight.booking_cargo_modifier bcm + WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bcm.booking_id) + OR NOT EXISTS (SELECT 1 FROM freight.surcharge_types st WHERE st.id = bcm.surcharge_type_id) + OR NOT EXISTS ( + SELECT 1 FROM freight.booking_rate_snapshot brs WHERE brs.id = bcm.rate_snapshot_id + ); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_cargo_modifier + ADD CONSTRAINT "FK_booking_cargo_modifier_booking_id" + FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ON DELETE CASCADE; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_cargo_modifier + ADD CONSTRAINT "FK_booking_cargo_modifier_surcharge_type_id" + FOREIGN KEY (surcharge_type_id) + REFERENCES freight.surcharge_types(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_cargo_modifier + ADD CONSTRAINT "FK_booking_cargo_modifier_rate_snapshot_id" + FOREIGN KEY (rate_snapshot_id) + REFERENCES freight.booking_rate_snapshot(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_snapshot_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_booking_id"; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_approval_rule_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_booking_id"; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_rate_snapshot + DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_rate_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_rate_snapshot + DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_booking_id"; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + DROP CONSTRAINT IF EXISTS "FK_booking_container_weight_limit_rule_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_container + DROP CONSTRAINT IF EXISTS "FK_booking_container_container_type_id"; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_consolidation_partner_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_previous_contract_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_train_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id"; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts b/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts new file mode 100644 index 000000000..43bb0eb02 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts @@ -0,0 +1,185 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveCustomersToFreightSchema1748900000000 implements MigrationInterface { + name = 'MoveCustomersToFreightSchema1748900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.customers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(150) NOT NULL UNIQUE, + phone VARCHAR(20) NOT NULL, + company_name VARCHAR(200) NOT NULL, + company_email VARCHAR(150) NOT NULL, + company_phone VARCHAR(20) NOT NULL, + company_location VARCHAR(100) NOT NULL, + company_address TEXT NOT NULL, + customer_type VARCHAR(32), + status VARCHAR(32), + contact_person_name VARCHAR(100) NOT NULL, + contact_person_phone VARCHAR(20) NOT NULL, + tin_number VARCHAR(10) NOT NULL UNIQUE, + vat_number VARCHAR(50), + fan_number VARCHAR(16) NOT NULL UNIQUE, + general_manager_name VARCHAR(100) NOT NULL, + general_manager_email VARCHAR(150) NOT NULL, + general_manager_phone VARCHAR(20) NOT NULL, + poa_name VARCHAR(100), + poa_phone VARCHAR(20), + poa_address TEXT, + poa_email VARCHAR(150), + poa_location VARCHAR(100), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_freight_customers_email" + ON freight.customers (email); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id" + ON freight.customers (user_id); + `); + + // Copy rows from public.customers when that legacy table exists + await queryRunner.query(` + DO $$ + DECLARE + has_public boolean; + has_user_id boolean; + has_userid boolean; + BEGIN + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'customers' + ) INTO has_public; + + IF NOT has_public THEN + RETURN; + END IF; + + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'user_id' + ) INTO has_user_id; + + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'userid' + ) INTO has_userid; + + IF has_user_id THEN + INSERT INTO freight.customers ( + id, user_id, first_name, last_name, email, phone, + company_name, company_email, company_phone, company_location, company_address, + contact_person_name, contact_person_phone, tin_number, vat_number, fan_number, + general_manager_name, general_manager_email, general_manager_phone, + poa_name, poa_phone, poa_address, poa_email, poa_location, notes, + created_at, updated_at + ) + SELECT + id, user_id, first_name, last_name, email, phone, + company_name, company_email, company_phone, company_location, company_address, + contact_person_name, contact_person_phone, tin_number, vat_number, fan_number, + general_manager_name, general_manager_email, general_manager_phone, + poa_name, poa_phone, poa_address, poa_email, poa_location, notes, + COALESCE(created_at, now()), COALESCE(updated_at, now()) + FROM public.customers + ON CONFLICT (id) DO NOTHING; + ELSIF has_userid THEN + INSERT INTO freight.customers ( + id, user_id, first_name, last_name, email, phone, + company_name, company_email, company_phone, company_location, company_address, + contact_person_name, contact_person_phone, tin_number, vat_number, fan_number, + general_manager_name, general_manager_email, general_manager_phone, + poa_name, poa_phone, poa_address, poa_email, poa_location, notes, + created_at, updated_at + ) + SELECT + id, userid, firstname, lastname, email, phone, + companyname, companyemail, companyphone, companylocation, companyaddress, + contactpersonname, contactpersonphone, tinnumber, vatnumber, fannumber, + generalmanagername, generalmanageremail, generalmanagerphone, + poaname, poaphone, poaaddress, poaemail, poalocation, notes, + COALESCE("createdAt", now()), COALESCE("updatedAt", now()) + FROM public.customers + ON CONFLICT (id) DO NOTHING; + END IF; + END $$; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id"; + `); + + await queryRunner.query(` + DELETE FROM freight.booking_cargo_modifier bcm + USING freight.bookings b + WHERE bcm.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_approval_step bas + USING freight.bookings b + WHERE bas.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_rate_snapshot brs + USING freight.bookings b + WHERE brs.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_container bc + USING freight.bookings b + WHERE bc.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.bookings b + WHERE NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_customer_id" + FOREIGN KEY (customer_id) + REFERENCES freight.customers(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id"; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_customer_id" + FOREIGN KEY (customer_id) + REFERENCES public.customers(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(`DROP TABLE IF EXISTS freight.customers CASCADE;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts b/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts new file mode 100644 index 000000000..e2587c35c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Align weight_limit_rules.trade_direction with app code: IMPORT, EXPORT, BOTH (not ANY). + */ +export class NormalizeWeightLimitTradeDirectionBoth1749000000000 + implements MigrationInterface +{ + name = 'NormalizeWeightLimitTradeDirectionBoth1749000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + UPDATE freight.weight_limit_rules + SET trade_direction = 'BOTH' + WHERE trade_direction::text = 'ANY'; + EXCEPTION WHEN undefined_table OR undefined_column THEN NULL; + END $$; + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // No-op: ANY is not a valid enum value in PostgreSQL. + } +} diff --git a/apps/edr-freight-api/src/migrations/1749100000000-CreateFreightFilesTable.ts b/apps/edr-freight-api/src/migrations/1749100000000-CreateFreightFilesTable.ts new file mode 100644 index 000000000..ebb194833 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749100000000-CreateFreightFilesTable.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateFreightFilesTable1749100000000 implements MigrationInterface { + name = 'CreateFreightFilesTable1749100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + resource_id UUID NOT NULL, + resource VARCHAR(100) NOT NULL, + code VARCHAR(100) NOT NULL, + name VARCHAR(500) NOT NULL, + url TEXT NOT NULL, + size INTEGER NOT NULL, + mime_type VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource" + ON freight.files (resource_id, resource); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource_code" + ON freight.files (resource_id, resource, code); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.files CASCADE`); + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts index 3459de0f9..395ff0386 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -4,11 +4,14 @@ import { Get, Param, ParseUUIDPipe, + Post, + Query, Put, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { BackofficeService } from "./backoffice.service"; +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; @ApiTags("backoffice") @@ -16,6 +19,28 @@ import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto export class BackofficeController { constructor(private readonly backofficeService: BackofficeService) {} + @Post("organizations/:orgId/users") + @ApiOperation({ summary: "Create an organization user without assigning positions" }) + createOrganizationUser( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Body() dto: CreateOrganizationUserDto, + ) { + return this.backofficeService.createOrganizationUser(organizationId, dto); + } + + @Get("organizations/:orgId/employees") + @ApiOperation({ summary: "Get deduplicated organization employees for backoffice" }) + getOrganizationEmployees( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Query("skip") skip?: string, + @Query("take") take?: string, + ) { + return this.backofficeService.getOrganizationEmployees(organizationId, { + skip, + take, + }); + } + @Get("organizations/:orgId/employee-users/:userId/roles") @ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" }) getEmployeeUserRoles( diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts index 18b4e6947..90c1a7c79 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts @@ -1,5 +1,10 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { + Employee, + Organization, + UserCredential, +} from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; @@ -9,7 +14,16 @@ import { BackofficeController } from "./backoffice.controller"; import { BackofficeService } from "./backoffice.service"; @Module({ - imports: [TypeOrmModule.forFeature([Role, UserRole, User])], + imports: [ + TypeOrmModule.forFeature([ + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, + ]), + ], controllers: [BackofficeController], providers: [BackofficeService], exports: [BackofficeService], diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index b5206be7d..7c7805b28 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -4,21 +4,33 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { DataSource, In, IsNull, Repository } from "typeorm"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; +import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; + const RESERVED_ROLE_KEYS = new Set([ "super_admin", "organization_admin", "unit_admin", ]); +const DEFAULT_USER_PASSWORD = "12345678"; +const ORGANIZATION_ADMIN_ROLE_KEY = "organization_admin"; +const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager"; @Injectable() export class BackofficeService { constructor( + @InjectRepository(Employee) + private readonly employeeRepository: Repository, + @InjectRepository(Organization) + private readonly organizationRepository: Repository, @InjectRepository(Role) private readonly roleRepository: Repository, @InjectRepository(UserRole) @@ -28,6 +40,153 @@ export class BackofficeService { private readonly dataSource: DataSource, ) {} + async createOrganizationUser( + organizationId: string, + dto: CreateOrganizationUserDto, + ) { + const organizationExists = await this.organizationRepository.exists({ + where: { id: organizationId }, + }); + + if (!organizationExists) { + throw new NotFoundException("organization_not_found"); + } + + const email = dto.email.trim().toLowerCase(); + const username = dto.username.trim().toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + const assignOrganizationAdmin = dto.assignOrganizationAdmin === true; + const name = { + en: dto.name.en.trim(), + ...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}), + }; + + const existingUsers = await this.userRepository.find({ + where: [{ email }, { username }], + select: { id: true, email: true, username: true }, + }); + + const emailUser = existingUsers.find((user) => user.email === email); + const usernameUser = existingUsers.find((user) => user.username === username); + + if (emailUser && usernameUser && emailUser.id !== usernameUser.id) { + throw new BadRequestException("email_or_username_already_in_use"); + } + + const existingUser = emailUser ?? usernameUser; + const hashedPassword = await hashPassword(DEFAULT_USER_PASSWORD); + + return this.dataSource.transaction(async (manager) => { + let user = existingUser; + + if (!user) { + user = await manager.getRepository(User).save( + manager.getRepository(User).create({ + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } else { + await manager.getRepository(User).update( + { id: user.id }, + { + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }, + ); + } + + const activeCredentialExists = await manager.getRepository(UserCredential).exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await manager.getRepository(UserCredential).insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + let employee = await manager.getRepository(Employee).findOne({ + where: { + userId: user.id, + organizationId, + isCurrent: true, + }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + + if (!employee) { + const insertResult = await manager.getRepository(Employee).insert({ + userId: user.id, + organizationId, + isCurrent: true, + name, + }); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: insertResult.identifiers[0]?.id as string }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } else { + await manager.getRepository(Employee).update( + { id: employee.id }, + { name }, + ); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: employee.id }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } + + if (!employee) { + throw new NotFoundException("employee_create_failed"); + } + + const userId = user.id; + + if (!userId) { + throw new NotFoundException("user_create_failed"); + } + + if (assignOrganizationAdmin) { + await this.ensureOrganizationAdminAccess(manager, organizationId, userId); + } + + return employee; + }); + } + async getEmployeeUserRoles(organizationId: string, userId: string) { await this.assertUserBelongsToOrganization(organizationId, userId); @@ -57,6 +216,45 @@ export class BackofficeService { })); } + async getOrganizationEmployees( + organizationId: string, + query: { skip?: string; take?: string }, + ) { + const organizationExists = await this.organizationRepository.exists({ + where: { id: organizationId }, + }); + + if (!organizationExists) { + throw new NotFoundException("organization_not_found"); + } + + const take = Number.parseInt(query.take ?? "1000", 10); + const skip = Number.parseInt(query.skip ?? "0", 10); + + const employees = await this.employeeRepository.find({ + where: { + organizationId, + isCurrent: true, + }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + order: { + createdAt: "DESC", + }, + }); + + const deduplicated = this.mergeEmployeesByUser(employees); + + return { + count: deduplicated.length, + items: deduplicated.slice(skip, skip + take), + }; + } + async replaceEmployeeUserRoles( organizationId: string, userId: string, @@ -124,4 +322,104 @@ export class BackofficeService { throw new NotFoundException("user_not_found_in_organization"); } } + + private mergeEmployeesByUser(employees: Employee[]) { + const employeesByUserId = new Map(); + + for (const employee of employees) { + const userId = employee.userId; + const employeeId = employee.id; + + if (!userId) { + if (employeeId) { + employeesByUserId.set(employeeId, employee); + } + continue; + } + + const existing = employeesByUserId.get(userId); + + if (!existing) { + employeesByUserId.set(userId, employee); + continue; + } + + const existingPositions = existing.employeePositions ?? []; + const nextPositions = employee.employeePositions ?? []; + const mergedEmployeePositions = Array.from( + new Map( + [...existingPositions, ...nextPositions].map((employeePosition) => [ + employeePosition.id, + employeePosition, + ]), + ).values(), + ); + + employeesByUserId.set(userId, { + ...existing, + ...employee, + id: existing.id, + user: existing.user ?? employee.user, + userId, + name: existing.name ?? employee.name, + status: existing.status ?? employee.status, + employeePositions: mergedEmployeePositions, + }); + } + + return [...employeesByUserId.values()]; + } + + private async ensureOrganizationAdminAccess( + manager: EntityManager, + organizationId: string, + userId: string, + ) { + const roles = await manager.getRepository(Role).find({ + where: [ + { key: ORGANIZATION_ADMIN_ROLE_KEY }, + { key: EDR_ORG_MANAGER_ROLE_KEY }, + ], + select: { id: true, key: true }, + }); + + const requiredRoles = [ORGANIZATION_ADMIN_ROLE_KEY, EDR_ORG_MANAGER_ROLE_KEY].map((key) => { + const role = roles.find((item) => item.key === key); + + if (!role?.id) { + throw new NotFoundException(`required_role_not_seeded:${key}`); + } + + return { + id: role.id, + key: role.key, + }; + }); + + const existingRoleIds = new Set( + ( + await manager.getRepository(UserRole).find({ + where: { + userId, + organizationId, + }, + select: { roleId: true }, + }) + ).map((userRole) => userRole.roleId), + ); + + const rolesToInsert = requiredRoles + .filter((role) => !existingRoleIds.has(role.id)) + .map((role) => ({ + userId, + roleId: role.id, + organizationId, + })); + + if (!rolesToInsert.length) { + return; + } + + await manager.getRepository(UserRole).insert(rolesToInsert); + } } diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts new file mode 100644 index 000000000..cf324a501 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; + +class CreateOrganizationUserNameDto { + @ApiProperty() + @IsString() + @MinLength(1) + en!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + am?: string; +} + +export class CreateOrganizationUserDto { + @ApiProperty() + @IsEmail() + email!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + username!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + phoneNumber?: string; + + @ApiProperty({ type: CreateOrganizationUserNameDto }) + @IsObject() + name!: CreateOrganizationUserNameDto; + + @ApiProperty({ required: false, default: false }) + @IsOptional() + @IsBoolean() + assignOrganizationAdmin?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts new file mode 100644 index 000000000..dc4f292b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -0,0 +1,186 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, Not } from 'typeorm'; + +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { + CARGO_TYPES_REPOSITORY, + ICargoTypesRepository, +} from '../rule-engine/interfaces/cargo-types.repository.interface'; +import { + CONTAINER_TYPES_REPOSITORY, + IContainerTypesRepository, +} from '../rule-engine/interfaces/container-types.repository.interface'; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from '../rule-engine/interfaces/service-types.repository.interface'; +import { + IShippingLinesRepository, + SHIPPING_LINES_REPOSITORY, +} from '../rule-engine/interfaces/shipping-lines.repository.interface'; +import { + IYardsRepository, + YARDS_REPOSITORY, +} from '../rule-engine/interfaces/yards.repository.interface'; +import { + BookingReferenceCargoTypeChildDto, + BookingReferenceCargoTypeGroupDto, + BookingReferenceContainerSizeGroupDto, + BookingReferenceContainerTypeDto, + BookingReferenceDataDto, + BookingReferenceServiceDto, + BookingReferenceShippingLineDto, + BookingReferenceYardDto, +} from './dto/booking-reference-data.dto'; + +const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const; + +export function buildCargoTypeTree( + rows: CargoType[], +): BookingReferenceCargoTypeGroupDto[] { + const active = rows.filter((r) => r.isActive); + const parents = active + .filter((r) => !r.parentGroupId) + .sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code)); + + return parents.map((parent) => { + const children = active + .filter((r) => r.parentGroupId === parent.id) + .sort( + (a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), + ) + .map( + (child): BookingReferenceCargoTypeChildDto => ({ + id: child.id, + name: child.cargoTypeName, + code: child.code, + show_free_text_box: child.showFreeTextBox, + }), + ); + + const group: BookingReferenceCargoTypeGroupDto = { + id: parent.id, + name: parent.cargoTypeName, + code: parent.code, + }; + if (children.length > 0) { + group.children = children; + } + return group; + }); +} + +export function groupContainersBySize( + rows: ContainerType[], +): BookingReferenceContainerSizeGroupDto[] { + const active = rows.filter((r) => r.isActive); + const bySize = new Map(); + + for (const ct of active) { + const sizeKey = + ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other'; + const list = bySize.get(sizeKey) ?? []; + list.push(ct); + bySize.set(sizeKey, list); + } + + const sortSizeKey = (key: string): number => { + if (key === 'other') return Number.MAX_SAFE_INTEGER; + const n = parseInt(key, 10); + return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n; + }; + + return [...bySize.entries()] + .sort(([a], [b]) => sortSizeKey(a) - sortSizeKey(b)) + .map(([size, types]) => ({ + size, + types: types + .sort( + (a, b) => + (a.displayOrder ?? 0) - (b.displayOrder ?? 0) || + a.code.localeCompare(b.code), + ) + .map( + (ct): BookingReferenceContainerTypeDto => ({ + id: ct.id, + name: ct.label?.trim() ? ct.label : ct.code, + code: ct.code, + is_reefer: ct.isReefer ?? false, + wagons_per_unit: Number(ct.wagonsPerUnit ?? 1), + }), + ), + })); +} + +@Injectable() +export class BookingReferenceDataService { + constructor( + @Inject(YARDS_REPOSITORY) + private readonly yardsRepository: IYardsRepository, + @Inject(CONTAINER_TYPES_REPOSITORY) + private readonly containerTypesRepository: IContainerTypesRepository, + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly serviceTypesRepository: IServiceTypesRepository, + @Inject(SHIPPING_LINES_REPOSITORY) + private readonly shippingLinesRepository: IShippingLinesRepository, + @Inject(CARGO_TYPES_REPOSITORY) + private readonly cargoTypesRepository: ICargoTypesRepository, + ) {} + + async getReferenceData(): Promise { + const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] = + await Promise.all([ + this.yardsRepository.findAll({ + where: { + isActive: true, + code: Not(In([...LEGACY_YARD_CODES])), + }, + order: { displayOrder: 'ASC', code: 'ASC' }, + }), + this.containerTypesRepository.findAll({ + where: { isActive: true }, + order: { displayOrder: 'ASC', code: 'ASC' }, + }), + this.serviceTypesRepository.findAll({ + where: { isActive: true }, + order: { displayOrder: 'ASC', code: 'ASC' }, + }), + this.shippingLinesRepository.findAll({ + where: { isActive: true }, + order: { label: 'ASC', code: 'ASC' }, + }), + this.cargoTypesRepository.findAll({ + where: { isActive: true }, + order: { displayOrder: 'ASC', code: 'ASC' }, + }), + ]); + + return { + yard: yards.map( + (y): BookingReferenceYardDto => ({ + id: y.id, + name: y.label, + code: y.code, + country: y.country, + }), + ), + containers: groupContainersBySize(containerTypes), + service: serviceTypes.map( + (s): BookingReferenceServiceDto => ({ + id: s.id, + name: s.serviceName, + code: s.code, + }), + ), + shipping_line: shippingLines.map( + (sl): BookingReferenceShippingLineDto => ({ + id: sl.id, + name: sl.label, + code: sl.code, + }), + ), + cargo_type: buildCargoTypeTree(cargoTypes), + }; + } +} 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..41a1a09c4 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -18,11 +18,14 @@ import { ApiBearerAuth, ApiBody, ApiConsumes, + ApiOkResponse, ApiOperation, ApiTags, } from "@nestjs/swagger"; +import { BookingReferenceDataService } from "./booking-reference-data.service"; import { BookingsService } from "./bookings.service"; +import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; import { CreateBookingDto } from "./dto/create-booking.dto"; import { FilterBookingDto } from "./dto/filter-booking.dto"; import { UpdateBookingDto } from "./dto/update-booking.dto"; @@ -32,7 +35,10 @@ import { UpdateStatusDto } from "./dto/update-status.dto"; @Controller("bookings") @ApiBearerAuth() export class BookingsController { - constructor(private readonly bookingsService: BookingsService) { } + constructor( + private readonly bookingsService: BookingsService, + private readonly bookingReferenceDataService: BookingReferenceDataService, + ) {} // ── 1. Create booking (multipart/form-data) ────────────────────────── @Post() @@ -42,7 +48,7 @@ export class BookingsController { summary: "Create a new freight booking", description: "Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " + - "Auto-enables consolidation when containerType=20FT and odd quantity.", + "Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.", }) @ApiBody({ description: @@ -92,14 +98,27 @@ 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) { return this.bookingsService.findAll(filter); } + // ── Booking form catalog (must be before :id) ───────────────────────── + @Get("reference-data") + @ApiOperation({ + summary: "Booking form catalog", + description: + "Returns yards, container types (grouped by size), service types, shipping lines, " + + "and hierarchical cargo types for the booking UI in a single payload.", + }) + @ApiOkResponse({ type: BookingReferenceDataDto }) + getReferenceData(): Promise { + return this.bookingReferenceDataService.getReferenceData(); + } + // ── 5. Lookup by reference (must be before :id to avoid conflict) ───── @Get("by-reference/:reference") @ApiOperation({ @@ -150,8 +169,8 @@ export class BookingsController { @ApiOperation({ summary: "Request freight consolidation", description: - "Searches for a compatible 20FT partner (same origin, destination, tradeDirection). " + - "If a partner is found, both bookings are paired. If not, the booking enters the consolidation queue.", + "Searches for a partner whose container quantity complements yours to fill whole wagon(s) " + + "(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.", }) requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); 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..9785ec270 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,19 +1,42 @@ -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 { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingsController } from './bookings.controller'; +import { BookingsRepository } from './bookings.repository'; +import { ConsolidationService } from './consolidation.service'; +import { BookingsService } from './bookings.service'; +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; +import { BookingContainer } from './entities/booking-container.entity'; +import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { 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], + providers: [ + BookingsService, + BookingsRepository, + ConsolidationService, + BookingReferenceDataService, + ], exports: [BookingsService], }) export class BookingsModule {} 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..f329c942f 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, 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,83 +31,250 @@ 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.customer', 'customer') + .leftJoinAndSelect('booking.train', 'train') + .leftJoinAndSelect('booking.serviceType', 'st') + .leftJoinAndSelect('booking.cargoType', 'cargo') + .leftJoinAndSelect('booking.originYard', 'oy') + .leftJoinAndSelect('booking.destinationYard', 'dy') + .leftJoinAndSelect('booking.shippingLine', 'sl') + .leftJoinAndSelect('booking.approvalSteps', 'steps') + .leftJoinAndSelect('booking.rateSnapshots', 'snapshots') + .leftJoinAndSelect('booking.cargoModifiers', 'modifiers') + .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. */ - 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, + /** 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 another booking whose container quantity complements this one to fill whole wagon(s) + * (same route, same container type, partial wagon on both sides). + */ + async findComplementaryConsolidationPartner( + booking: Booking, + slot: { + containerTypeId: string; + quantity: number; + containersPerWagon: number; + }, + ): Promise { + const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; + + return this.repository + .createQueryBuilder('b') + .innerJoinAndSelect('b.bookingContainers', 'bc') + .innerJoin('bc.containerType', 'ct') + .where('b.id != :bookingId', { bookingId: booking.id }) + .andWhere('b.allowConsolidation = true') + .andWhere('b.consolidationPartnerId IS NULL') + .andWhere('b.status IN (:...statuses)', { + statuses: ['DRAFT', 'PENDING_CONSOLIDATION'], + }) + .andWhere('b.originYardId = :originYardId', { + originYardId: booking.originYardId, + }) + .andWhere('b.destinationYardId = :destinationYardId', { + destinationYardId: booking.destinationYardId, + }) + .andWhere('b.tradeDirection = :tradeDirection', { tradeDirection: booking.tradeDirection, - consolidationPartnerId: IsNull(), - status: In(["DRAFT", "PENDING_CONSOLIDATION"]), - id: Not(booking.id), - }, - order: { createdAt: "ASC" }, - }); + }) + .andWhere('bc.containerTypeId = :containerTypeId', { containerTypeId }) + .andWhere('(bc.quantity % :perWagon) > 0', { perWagon }) + .andWhere('((:quantity + bc.quantity) % :perWagon) = 0', { + quantity, + perWagon, + }) + .orderBy('b.createdAt', 'ASC') + .getOne(); + } + + /** Try each partial-wagon line until a complementary partner booking is found. */ + async findConsolidationPartner( + booking: Booking, + slots: Array<{ + containerTypeId: string; + quantity: number; + containersPerWagon: number; + }>, + ): Promise { + for (const slot of slots) { + const partner = await this.findComplementaryConsolidationPartner(booking, slot); + if (partner) return partner; + } + return null; } /** Pair two bookings for consolidation. */ async pairConsolidation(bookingId: string, partnerId: string): Promise { 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..3aaa12a70 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,25 @@ 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 { ConsolidationService } from './consolidation.service'; +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 +31,116 @@ export class BookingsService { private readonly minioService: MinioService, private readonly customersService: CustomersService, private readonly ruleEngineService: RuleEngineService, + private readonly containerTypesService: ContainerTypesService, + private readonly consolidationService: ConsolidationService, ) {} - // ── 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, + }; + } + + /** + * Enable consolidation when any container line leaves a wagon partially filled + * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out. + */ + private async resolveConsolidation( + containers: CreateBookingContainerDto[], + explicit?: boolean, + ): Promise { + if (explicit === false) return false; + const needs = await this.consolidationService.needsConsolidation( + containers.map((c) => ({ + containerTypeId: c.containerTypeId, + quantity: c.quantity, + })), + ); + if (needs) return true; return explicit ?? false; } - /** 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); + /** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */ + private async tryAutoConsolidate(booking: Booking): Promise<{ + booking: Booking; + messages: string[]; + }> { + const messages: string[] = []; + + if (!booking.allowConsolidation || booking.consolidationPartnerId) { + return { booking, messages }; + } + + const slots = await this.consolidationService.slotsFromBooking(booking); + if (slots.length === 0) { + return { booking, messages }; + } + + const partner = await this.bookingsRepository.findConsolidationPartner( + booking, + slots, + ); + + if (partner) { + await this.bookingsRepository.pairConsolidation(booking.id, partner.id); + const paired = await this.findById(booking.id); + messages.push( + this.consolidationService.describePaired(partner.reference, slots), + ); + return { booking: paired, messages }; + } + + if (booking.status === 'DRAFT') { + await this.bookingsRepository.update(booking.id, { + status: 'PENDING_CONSOLIDATION', + } as never); + } + + const pending = await this.findById(booking.id); + messages.push(this.consolidationService.describePending(pending, slots)); + return { booking: pending, messages }; } - - // ── CRUD ───────────────────────────────────────────────────────────── - /** Create a new freight booking. */ async create( dto: CreateBookingDto, @@ -81,65 +149,90 @@ 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 }; + let full = await this.findById(booking.id); + + if (allowConsolidation) { + const consolidation = await this.tryAutoConsolidate(full); + full = consolidation.booking; + warnings.push(...consolidation.messages); + } + + return { booking: full, warnings }; } /** Update a draft booking. */ @@ -149,45 +242,73 @@ 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); + } + + let booking = await this.findById(id); + + if (allowConsolidation && !booking.consolidationPartnerId) { + const consolidation = await this.tryAutoConsolidate(booking); + booking = consolidation.booking; + warnings.push(...consolidation.messages); + } return { booking, warnings }; } @@ -203,19 +324,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,325 +349,336 @@ 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; paired: boolean; + message: string; }> { 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 needs = await this.consolidationService.needsConsolidationFromBooking( + booking, + ); + if (!needs) { throw new BadRequestException( - "Only bookings with odd-quantity 20FT containers need consolidation", + 'Booking already fills whole wagon(s) for all container lines; consolidation is not required', ); } if (booking.consolidationPartnerId) { - throw new ConflictException("Booking is already paired for consolidation"); + throw new ConflictException('Booking is already paired for consolidation'); } - const partner = - await this.bookingsRepository.findConsolidationPartner(booking); + const result = await this.tryAutoConsolidate(booking); + const partner = result.booking.consolidationPartnerId + ? await this.findById(result.booking.consolidationPartnerId) + : null; - 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 }; - } - - // No partner found — enter queue - await this.bookingsRepository.update(booking.id, { - status: "PENDING_CONSOLIDATION", - } as never); - const updated = await this.findById(id); - return { booking: updated, partner: null, paired: false }; + return { + booking: result.booking, + partner, + paired: partner !== null, + message: result.messages[0] ?? '', + }; } - /** Remove consolidation pairing. */ - async removeConsolidation(id: string): Promise<{ - booking: Booking; - partner: Booking; - }> { + 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; splitBilling: { bookingShare: number; partnerShare: number } | null; + wagonSlots: Awaited>; + statusMessage: string; }> { const booking = await this.findById(id); + const wagonSlots = await this.consolidationService.slotsFromBooking(booking); if (!booking.consolidationPartnerId) { - return { booking, partner: null, splitBilling: null }; + const statusMessage = + booking.status === 'PENDING_CONSOLIDATION' + ? this.consolidationService.describePending(booking, wagonSlots) + : wagonSlots.length > 0 + ? 'Consolidation may be required; no partner paired yet.' + : 'No wagon consolidation needed.'; + return { + booking, + partner: null, + splitBilling: null, + wagonSlots, + statusMessage, + }; } const partner = await this.findById(booking.consolidationPartnerId); - const splitBilling = { - bookingShare: booking.totalAmount, - partnerShare: partner.totalAmount, + return { + booking, + partner, + splitBilling: { + bookingShare: Number(booking.totalAmount), + partnerShare: Number(partner.totalAmount), + }, + wagonSlots, + statusMessage: this.consolidationService.describePaired( + partner.reference, + wagonSlots, + ), }; - - return { booking, partner, splitBilling }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts new file mode 100644 index 000000000..2d805ba8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -0,0 +1,123 @@ +import { Injectable } from '@nestjs/common'; + +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { Booking } from './entities/booking.entity'; + +export interface ConsolidationSlot { + containerTypeId: string; + containerTypeCode: string; + quantity: number; + containersPerWagon: number; + remainder: number; + slotsNeeded: number; +} + +export interface ConsolidationAttemptResult { + booking: Booking; + partner: Booking | null; + paired: boolean; + messages: string[]; +} + +/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */ +export function containersPerWagon(wagonsPerUnit: number): number { + const wpu = Number(wagonsPerUnit); + if (!wpu || wpu <= 0) return 1; + return Math.max(1, Math.round(1 / wpu)); +} + +export function wagonRemainder(quantity: number, perWagon: number): number { + const r = quantity % perWagon; + return r; +} + +export function slotsNeededToFillWagon(quantity: number, perWagon: number): number { + const remainder = wagonRemainder(quantity, perWagon); + if (remainder === 0) return 0; + return perWagon - remainder; +} + +/** Two bookings' quantities for the same type complete whole wagon(s). */ +export function quantitiesComplementWagon( + q1: number, + q2: number, + perWagon: number, +): boolean { + return ( + wagonRemainder(q1, perWagon) > 0 && + wagonRemainder(q2, perWagon) > 0 && + (q1 + q2) % perWagon === 0 + ); +} + +@Injectable() +export class ConsolidationService { + constructor(private readonly containerTypesService: ContainerTypesService) {} + + async slotsFromContainerLines( + lines: Array<{ containerTypeId: string; quantity: number }>, + ): Promise { + const slots: ConsolidationSlot[] = []; + for (const line of lines) { + const ct = await this.containerTypesService.findById(line.containerTypeId); + const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); + const remainder = wagonRemainder(line.quantity, perWagon); + if (remainder === 0) continue; + slots.push({ + containerTypeId: line.containerTypeId, + containerTypeCode: ct.code, + quantity: line.quantity, + containersPerWagon: perWagon, + remainder, + slotsNeeded: perWagon - remainder, + }); + } + return slots; + } + + async slotsFromBooking(booking: Booking): Promise { + const lines = + booking.bookingContainers?.map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + })) ?? []; + return this.slotsFromContainerLines(lines); + } + + async needsConsolidation( + lines: Array<{ containerTypeId: string; quantity: number }>, + ): Promise { + const slots = await this.slotsFromContainerLines(lines); + return slots.length > 0; + } + + async needsConsolidationFromBooking(booking: Booking): Promise { + const slots = await this.slotsFromBooking(booking); + return slots.length > 0; + } + + describePending(_booking: Booking, slots: ConsolidationSlot[]): string { + if (slots.length === 0) { + return 'Booking does not require wagon consolidation.'; + } + const parts = slots.map( + (s) => + `${s.slotsNeeded} more × ${s.containerTypeCode} (${s.containersPerWagon} per wagon; you have ${s.quantity})`, + ); + return ( + `No compatible partner found yet. Your booking is queued (PENDING_CONSOLIDATION). ` + + `Waiting for: ${parts.join('; ')}. You will be notified when another customer fills the wagon.` + ); + } + + describePaired(partnerReference: string, slots: ConsolidationSlot[]): string { + const parts = slots.map( + (s) => + `${s.containerTypeCode}: ${s.quantity} + partner fills wagon (${s.containersPerWagon} per wagon)`, + ); + return ( + `Consolidation partner found (${partnerReference}). ` + + `Shared wagon confirmed: ${parts.join('; ')}.` + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts new file mode 100644 index 000000000..0dc2bd255 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -0,0 +1,107 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BookingReferenceYardDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Mojo Dry Port' }) + name!: string; + + @ApiProperty({ example: 'MOJO' }) + code!: string; + + @ApiProperty({ example: 'Ethiopia' }) + country!: string; +} + +export class BookingReferenceContainerTypeDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Dry' }) + name!: string; + + @ApiProperty({ example: '20GP' }) + code!: string; + + @ApiProperty() + is_reefer!: boolean; + + @ApiProperty({ example: 0.5, description: 'Wagon fraction per container' }) + wagons_per_unit!: number; +} + +export class BookingReferenceContainerSizeGroupDto { + @ApiProperty({ example: '20ft' }) + size!: string; + + @ApiProperty({ type: [BookingReferenceContainerTypeDto] }) + types!: BookingReferenceContainerTypeDto[]; +} + +export class BookingReferenceServiceDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Rail Transport Only' }) + name!: string; + + @ApiProperty({ example: 'RAIL' }) + code!: string; +} + +export class BookingReferenceShippingLineDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'MSC' }) + name!: string; + + @ApiProperty({ example: 'MSC' }) + code!: string; +} + +export class BookingReferenceCargoTypeChildDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Coffee' }) + name!: string; + + @ApiProperty({ example: 'BULK_COFFEE' }) + code!: string; + + @ApiProperty() + show_free_text_box!: boolean; +} + +export class BookingReferenceCargoTypeGroupDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Bulk Cargo' }) + name!: string; + + @ApiProperty({ example: 'BULK' }) + code!: string; + + @ApiPropertyOptional({ type: [BookingReferenceCargoTypeChildDto] }) + children?: BookingReferenceCargoTypeChildDto[]; +} + +export class BookingReferenceDataDto { + @ApiProperty({ type: [BookingReferenceYardDto] }) + yard!: BookingReferenceYardDto[]; + + @ApiProperty({ type: [BookingReferenceContainerSizeGroupDto] }) + containers!: BookingReferenceContainerSizeGroupDto[]; + + @ApiProperty({ type: [BookingReferenceServiceDto] }) + service!: BookingReferenceServiceDto[]; + + @ApiProperty({ type: [BookingReferenceShippingLineDto] }) + shipping_line!: BookingReferenceShippingLineDto[]; + + @ApiProperty({ type: [BookingReferenceCargoTypeGroupDto] }) + cargo_type!: BookingReferenceCargoTypeGroupDto[]; +} 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..7875babbf 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,201 @@ -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 { Customer } from '../../customers/entities/customer.entity'; +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 { Train } from '../../trains/entities/train.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 }) + @ManyToOne(() => Customer) + @JoinColumn({ name: 'customer_id' }) + customer?: Customer; + + @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId?: string | null; - @Column({ name: "status", type: "varchar", length: 40, default: "DRAFT" }) + @ManyToOne(() => Train, { nullable: true }) + @JoinColumn({ name: 'train_id' }) + train?: Train | null; + + @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; + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'previous_contract_id' }) + previousContract?: Booking | null; - @Column({ name: "first_mile_enabled", type: "boolean", default: false }) - firstMileEnabled!: boolean; + @Column({ name: 'service_type_id', type: 'uuid' }) + serviceTypeId!: string; - @Column({ name: "first_mile_pickup_address", type: "text", nullable: true }) + @ManyToOne(() => ServiceType) + @JoinColumn({ name: 'service_type_id' }) + serviceType?: ServiceType; + + @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 ──────────────────────────────────────────────────────────── + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'consolidation_partner_id' }) + consolidationPartner?: Booking | null; + + @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/customers/customers.repository.ts b/apps/edr-freight-api/src/modules/customers/customers.repository.ts index 83a64b9da..5933cef61 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.repository.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.repository.ts @@ -44,7 +44,7 @@ export class CustomersRepository { async findByName(name: string): Promise { return await this.repository .createQueryBuilder("customer") - .where("customer.name ILIKE :name", { name: `%${name}%` }) + .where("customer.companyName ILIKE :name", { name: `%${name}%` }) .getMany(); } diff --git a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts index d6e9b9e17..98e350b96 100644 --- a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts +++ b/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts @@ -43,7 +43,7 @@ export class ResponseCustomerDto { this.contactPersonName = customer.contactPersonName; this.contactPersonPhone = customer.contactPersonPhone; this.tinNumber = customer.tinNumber; - this.vatNumber = customer.vatNumber; + this.vatNumber = customer.vatNumber ?? undefined; this.fanNumber = customer.fanNumber; this.generalManagerName = customer.generalManagerName; this.generalManagerEmail = customer.generalManagerEmail; diff --git a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts index 80041432e..fd2defac2 100644 --- a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts +++ b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts @@ -1,100 +1,87 @@ -import { - Column, - Entity, - CreateDateColumn, - UpdateDateColumn, - Index, - BaseEntity, - PrimaryGeneratedColumn, -} from "typeorm"; +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; -@Entity("customers") +@Entity({ schema: 'freight', name: 'customers' }) +@Index(['email']) +@Index(['userId']) +@Index(['tinNumber']) +@Index(['fanNumber']) export class Customer extends BaseEntity { - @PrimaryGeneratedColumn("uuid") - id!: string; - - @Column({ type: "uuid" }) - @Index() + @Column({ name: 'user_id', type: 'uuid' }) userId!: string; - @Column({ length: 100 }) - @Index() + @Column({ name: 'first_name', type: 'varchar', length: 100 }) firstName!: string; - @Column({ length: 100 }) - @Index() + @Column({ name: 'last_name', type: 'varchar', length: 100 }) lastName!: string; - @Column({ unique: true, length: 150 }) - @Index() + @Column({ name: 'email', type: 'varchar', length: 150, unique: true }) email!: string; - @Column({ length: 20 }) + @Column({ name: 'phone', type: 'varchar', length: 20 }) phone!: string; - @Column({ length: 200 }) - @Index() + @Column({ name: 'company_name', type: 'varchar', length: 200 }) companyName!: string; - @Column({ length: 150 }) + @Column({ name: 'company_email', type: 'varchar', length: 150 }) companyEmail!: string; - @Column({ length: 20 }) + @Column({ name: 'company_phone', type: 'varchar', length: 20 }) companyPhone!: string; - @Column({ length: 100 }) + @Column({ name: 'company_location', type: 'varchar', length: 100 }) companyLocation!: string; - @Column({ type: "text" }) + @Column({ name: 'company_address', type: 'text' }) companyAddress!: string; - @Column({ length: 100 }) + @Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true }) + customerType?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 32, nullable: true }) + status?: string | null; + + @Column({ name: 'contact_person_name', type: 'varchar', length: 100 }) contactPersonName!: string; - @Column({ length: 20 }) + @Column({ name: 'contact_person_phone', type: 'varchar', length: 20 }) contactPersonPhone!: string; - @Column({ length: 10, unique: true }) - @Index() + @Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true }) tinNumber!: string; - @Column({ length: 50, nullable: true }) - vatNumber?: string; + @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true }) + vatNumber?: string | null; - @Column({ length: 16, unique: true }) - @Index() + @Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true }) fanNumber!: string; - @Column({ length: 100 }) + @Column({ name: 'general_manager_name', type: 'varchar', length: 100 }) generalManagerName!: string; - @Column({ length: 150 }) + @Column({ name: 'general_manager_email', type: 'varchar', length: 150 }) generalManagerEmail!: string; - @Column({ length: 20 }) + @Column({ name: 'general_manager_phone', type: 'varchar', length: 20 }) generalManagerPhone!: string; - @Column({ length: 100, nullable: true }) - poaName?: string; + @Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true }) + poaName?: string | null; - @Column({ length: 20, nullable: true }) - poaPhone?: string; + @Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true }) + poaPhone?: string | null; - @Column({ type: "text", nullable: true }) - poaAddress?: string; + @Column({ name: 'poa_address', type: 'text', nullable: true }) + poaAddress?: string | null; - @Column({ nullable: true, length: 150 }) - poaEmail?: string; + @Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true }) + poaEmail?: string | null; - @Column({ length: 100, nullable: true }) - poaLocation?: string; + @Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true }) + poaLocation?: string | null; - @Column({ type: "text", nullable: true }) - notes?: string; - - @CreateDateColumn() - createdAt!: Date; - - @UpdateDateColumn() - updatedAt!: Date; -} \ No newline at end of file + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/customers2/customers.controller.ts b/apps/edr-freight-api/src/modules/customers2/customers.controller.ts deleted file mode 100644 index 85b470453..000000000 --- a/apps/edr-freight-api/src/modules/customers2/customers.controller.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseUUIDPipe, - Patch, - Post, -} from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; - -import { CustomersService } from "./customers.service"; -import { CreateCustomerDto } from "./dto/create-customer.dto"; -import { UpdateCustomerDto } from "./dto/update-customer.dto"; - -@ApiTags("customers") -@Controller("customers") -export class CustomersController { - constructor(private readonly customersService: CustomersService) {} - - @Post() - @ApiOperation({ summary: "Create a new customer" }) - create(@Body() dto: CreateCustomerDto) { - return this.customersService.create(dto); - } - - @Get() - @ApiOperation({ summary: "List all customers" }) - findAll() { - return this.customersService.findAll(); - } - - @Get(":id") - @ApiOperation({ summary: "Get a customer by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.customersService.findById(id); - } - - @Patch(":id") - @ApiOperation({ summary: "Update a customer" }) - update( - @Param("id", ParseUUIDPipe) id: string, - @Body() dto: UpdateCustomerDto, - ) { - return this.customersService.update(id, dto); - } - - @Delete(":id") - @ApiOperation({ summary: "Soft-delete a customer" }) - @HttpCode(HttpStatus.NO_CONTENT) - remove(@Param("id", ParseUUIDPipe) id: string) { - return this.customersService.remove(id); - } -} diff --git a/apps/edr-freight-api/src/modules/customers2/customers.module.ts b/apps/edr-freight-api/src/modules/customers2/customers.module.ts deleted file mode 100644 index 28c6b7c89..000000000 --- a/apps/edr-freight-api/src/modules/customers2/customers.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { CustomersController } from "./customers.controller"; -import { CustomersRepository } from "./customers.repository"; -import { CustomersService } from "./customers.service"; -import { Customer } from "./entities/customer.entity"; - -@Module({ - imports: [TypeOrmModule.forFeature([Customer])], - controllers: [CustomersController], - providers: [CustomersService, CustomersRepository], - exports: [CustomersService], -}) -export class CustomersModule {} diff --git a/apps/edr-freight-api/src/modules/customers2/customers.repository.ts b/apps/edr-freight-api/src/modules/customers2/customers.repository.ts deleted file mode 100644 index c6cb72fcf..000000000 --- a/apps/edr-freight-api/src/modules/customers2/customers.repository.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BaseRepository } from "@edr/api-common"; -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - -import { Customer } from "./entities/customer.entity"; - -@Injectable() -export class CustomersRepository extends BaseRepository { - constructor( - @InjectRepository(Customer) - repository: Repository, - ) { - super(repository); - } - - /** Find a customer by their unique email. */ - findByEmail(email: string): Promise { - return this.repository.findOne({ where: { email } }); - } -} diff --git a/apps/edr-freight-api/src/modules/customers2/customers.service.ts b/apps/edr-freight-api/src/modules/customers2/customers.service.ts deleted file mode 100644 index 6394e1ad9..000000000 --- a/apps/edr-freight-api/src/modules/customers2/customers.service.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - ConflictException, - Injectable, - NotFoundException, -} from "@nestjs/common"; - -import { CustomersRepository } from "./customers.repository"; -import { CreateCustomerDto } from "./dto/create-customer.dto"; -import { UpdateCustomerDto } from "./dto/update-customer.dto"; -import { Customer } from "./entities/customer.entity"; - -@Injectable() -export class CustomersService { - constructor(private readonly customersRepository: CustomersRepository) {} - - async create(dto: CreateCustomerDto): Promise { - const existing = await this.customersRepository.findByEmail(dto.email); - if (existing) { - throw new ConflictException( - `Customer with email "${dto.email}" already exists`, - ); - } - return this.customersRepository.create(dto); - } - - findAll(): Promise { - return this.customersRepository.findAll({ order: { name: "ASC" } }); - } - - async findById(id: string): Promise { - const customer = await this.customersRepository.findById(id); - if (!customer) { - throw new NotFoundException(`Customer ${id} not found`); - } - return customer; - } - - async update(id: string, dto: UpdateCustomerDto): Promise { - await this.findById(id); - - if (dto.email) { - const conflict = await this.customersRepository.findByEmail(dto.email); - if (conflict && conflict.id !== id) { - throw new ConflictException( - `Customer with email "${dto.email}" already exists`, - ); - } - } - - const updated = await this.customersRepository.update(id, dto); - if (!updated) { - throw new NotFoundException(`Customer ${id} not found`); - } - return updated; - } - - async remove(id: string): Promise { - await this.findById(id); - await this.customersRepository.softDelete(id); - } -} diff --git a/apps/edr-freight-api/src/modules/customers2/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers2/dto/create-customer.dto.ts deleted file mode 100644 index 854b3eaf1..000000000 --- a/apps/edr-freight-api/src/modules/customers2/dto/create-customer.dto.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - IsEmail, - IsEnum, - IsOptional, - IsString, - MaxLength, -} from "class-validator"; - -export enum CustomerStatusDto { - Active = "Active", - Pending = "Pending", - Inactive = "Inactive", -} - -export enum CustomerTypeDto { - Importer = "Importer", - Exporter = "Exporter", - Supplier = "Supplier", -} - -export class CreateCustomerDto { - @IsString() - @MaxLength(256) - name!: string; - - @IsEmail() - email!: string; - - @IsString() - @MaxLength(32) - phone!: string; - - @IsOptional() - @IsString() - @MaxLength(256) - company?: string; - - @IsOptional() - @IsEnum(CustomerTypeDto) - customerType?: CustomerTypeDto; - - @IsOptional() - @IsEnum(CustomerStatusDto) - status?: CustomerStatusDto; - - @IsOptional() - @IsString() - @MaxLength(64) - tinNumber?: string; - - @IsOptional() - @IsString() - @MaxLength(128) - city?: string; - - @IsOptional() - @IsString() - @MaxLength(128) - country?: string; - - @IsOptional() - @IsString() - address?: string; - - @IsOptional() - @IsString() - @MaxLength(64) - taxId?: string; - - @IsOptional() - @IsString() - notes?: string; -} diff --git a/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts deleted file mode 100644 index 499d0ef9b..000000000 --- a/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PartialType } from "@nestjs/mapped-types"; - -import { CreateCustomerDto } from "./create-customer.dto"; - -export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {} diff --git a/apps/edr-freight-api/src/modules/customers2/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers2/entities/customer.entity.ts deleted file mode 100644 index 98a248c97..000000000 --- a/apps/edr-freight-api/src/modules/customers2/entities/customer.entity.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { BaseEntity } from "@edr/api-common"; -import { Column, Entity } from "typeorm"; - -export type CustomerStatus = "Active" | "Pending" | "Inactive"; -export type CustomerType = "Importer" | "Exporter" | "Supplier"; - -@Entity({schema:"freight", name: "customers" }) -export class Customer extends BaseEntity { - @Column({ name: "name", type: "varchar", length: 256 }) - name!: string; - - @Column({ name: "email", type: "varchar", length: 256, unique: true }) - email!: string; - - @Column({ name: "phone", type: "varchar", length: 32 }) - phone!: string; - - @Column({ name: "company", type: "varchar", length: 256, nullable: true }) - company?: string | null; - - @Column({ - name: "customer_type", - type: "varchar", - length: 32, - default: "Importer", - }) - customerType!: CustomerType; - - @Column({ - name: "status", - type: "varchar", - length: 32, - default: "Active", - }) - status!: CustomerStatus; - - @Column({ name: "tin_number", type: "varchar", length: 64, nullable: true }) - tinNumber?: string | null; - - @Column({ name: "city", type: "varchar", length: 128, nullable: true }) - city?: string | null; - - @Column({ name: "country", type: "varchar", length: 128, nullable: true }) - country?: string | null; - - @Column({ name: "address", type: "text", nullable: true }) - address?: string | null; - - @Column({ name: "tax_id", type: "varchar", length: 64, nullable: true }) - taxId?: string | null; - - @Column({ name: "notes", type: "text", nullable: true }) - notes?: string | null; -} 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/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index ed1f661c2..de855c7ef 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -3,7 +3,7 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -// import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoTypesService } from '../services/cargo-types.service'; @@ -39,8 +39,7 @@ export class CargoTypesController { @Post() @ApiOperation({ summary: 'Create a cargo type' }) - create(@Body() dto: any) { - return dto; + create(@Body() dto: CreateCargoTypeDto) { return this.service.create(dto); } 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-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 53a007b80..ae2e23c33 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -2,11 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateCargoTypeDto { - @ApiProperty({ description: 'Machine-readable code, e.g. BULK, BREAK_BULK', maxLength: 50 }) - @IsString() - @MaxLength(50) - code!: string; - @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @IsString() @MaxLength(255) 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..dbfb5ca2b 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,43 @@ 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 }) - @IsString() - @MaxLength(20) - sizeCode!: 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..16b01fc81 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,27 @@ 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: 'Human-readable label', maxLength: 100 }) @IsString() - @MaxLength(255) - ruleName!: string; + @MaxLength(100) + label!: string; - @ApiPropertyOptional({ description: 'Explanation of when this rule is triggered' }) - @IsOptional() - @IsString() - description?: 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..fe9084395 --- /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', 'BOTH'] 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-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index 6fe8a3227..b20203e13 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -2,11 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; export class CreateServiceTypeDto { - @ApiProperty({ description: 'Machine-readable code, e.g. RAIL_ONLY', maxLength: 50 }) - @IsString() - @MaxLength(50) - code!: string; - @ApiProperty({ description: 'Service type display name', maxLength: 255 }) @IsString() @MaxLength(255) 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..7aef9bbde 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,27 @@ 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 }) - @IsString() - @MaxLength(50) - 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..87cd8ccdf 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', 'BOTH'] 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 BOTH' }) + @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..f0d9ff012 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class CreateYardDto { + @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..0d151c561 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 }) + .where('rule.container_type_id = :containerTypeId', { containerTypeId }) .andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :both)', { dir: tradeDirection, both: 'BOTH', }) - .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..9657e6865 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,17 @@ import { RuleEngineService } from './rule-engine.service'; ServiceTypesService, ContainerTypesService, SurchargeTypesService, - SurchargesService, WeightLimitRulesService, PriorityRulesService, + YardsService, + ShippingLinesService, + RatesService, + ApprovalRulesService, + CARGO_TYPES_REPOSITORY, + CONTAINER_TYPES_REPOSITORY, + SERVICE_TYPES_REPOSITORY, + SHIPPING_LINES_REPOSITORY, + YARDS_REPOSITORY, ], }) 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/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 3ac0f3e67..130b1d605 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -1,5 +1,6 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ILike } from 'typeorm'; +import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoType } from '../entities/cargo-type.entity'; @@ -58,14 +59,15 @@ export class CargoTypesService { /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { - const existing = await this.repository.findByCode(dto.code); - if (existing) throw new ConflictException(`Cargo type with code "${dto.code}" already exists`); + const code = generateCode(dto.cargoTypeName); + const existing = await this.repository.findByCode(code); + if (existing) throw new ConflictException(`Cargo type with name "${dto.cargoTypeName}" conflicts with existing code "${code}"`); if (dto.parentGroupId) { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } return this.repository.create({ - code: dto.code, + code, cargoTypeName: dto.cargoTypeName, parentGroupId: dto.parentGroupId ?? null, showFreeTextBox: dto.showFreeTextBox ?? false, @@ -78,12 +80,6 @@ export class CargoTypesService { /** Update an existing cargo type. */ async update(id: string, dto: UpdateCargoTypeDto): Promise { await this.findById(id); - if (dto.code) { - const conflict = await this.repository.findByCode(dto.code); - if (conflict && conflict.id !== id) { - throw new ConflictException(`Cargo type with code "${dto.code}" already exists`); - } - } if (dto.parentGroupId) { if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent'); const parent = await this.repository.findById(dto.parentGroupId); 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..9b0209311 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -1,4 +1,5 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { ContainerType } from '../entities/container-type.entity'; @@ -27,7 +28,7 @@ export class ContainerTypesService { const [data, total] = await this.repository.findAndCount({ where, - order: { sizeCode: 'ASC' }, + order: { displayOrder: 'ASC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -43,25 +44,24 @@ 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 code = generateCode(dto.label); + const existing = await this.repository.findByCode(code); + if (existing) throw new ConflictException(`Container type with label "${dto.label}" conflicts with existing code "${code}"`); return this.repository.create({ - sizeCode: dto.sizeCode, - description: dto.description ?? null, - containersPerWagon: dto.containersPerWagon, + 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 (conflict && conflict.id !== id) { - throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`); - } - } const updated = await this.repository.update(id, dto); if (!updated) throw new NotFoundException(`Container type ${id} not found`); return updated; 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..07e282aba 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 @@ -1,4 +1,5 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { generateCode } from '../../../common/utils/generate-code.util'; import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto'; import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto'; import { PriorityRule } from '../entities/priority-rule.entity'; @@ -27,7 +28,7 @@ export class PriorityRulesService { const [data, total] = await this.repository.findAndCount({ where, - order: { priorityType: 'ASC' }, + order: { label: 'ASC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -43,16 +44,16 @@ export class PriorityRulesService { /** Create a new priority rule. */ async create(dto: CreatePriorityRuleDto): Promise { - const existing = await this.repository.findAll({ where: { priorityType: dto.priorityType } }); + const code = generateCode(dto.label); + const existing = await this.repository.findAll({ where: { code } }); if (existing.length > 0) { - throw new ConflictException(`Priority rule for type "${dto.priorityType}" already exists`); + throw new ConflictException(`Priority rule with label "${dto.label}" conflicts with existing code "${code}"`); } return this.repository.create({ - priorityType: dto.priorityType, - ruleName: dto.ruleName, - description: dto.description ?? null, - activationCondition: dto.activationCondition ?? null, - bonusPoints: dto.bonusPoints, + code, + label: dto.label, + score: dto.score, + conditionCurrency: dto.conditionCurrency ?? null, isActive: dto.isActive ?? false, }); } @@ -60,7 +61,8 @@ export class PriorityRulesService { /** Update an existing priority rule. */ async update(id: string, dto: UpdatePriorityRuleDto): Promise { await this.findById(id); - const updated = await this.repository.update(id, dto); + const { ...patch } = dto; + const updated = await this.repository.update(id, patch); if (!updated) throw new NotFoundException(`Priority rule ${id} not found`); return updated; } 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/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts index 011cf0e95..1d54582a1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -1,5 +1,6 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ILike } from 'typeorm'; +import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; import { ServiceType } from '../entities/service-type.entity'; @@ -55,10 +56,11 @@ export class ServiceTypesService { /** Create a new service type. */ async create(dto: CreateServiceTypeDto): Promise { - const existing = await this.repository.findByCode(dto.code); - if (existing) throw new ConflictException(`Service type with code "${dto.code}" already exists`); + const code = generateCode(dto.serviceName); + const existing = await this.repository.findByCode(code); + if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`); return this.repository.create({ - code: dto.code, + code, serviceName: dto.serviceName, description: dto.description ?? null, canBeBookedAlone: dto.canBeBookedAlone ?? true, @@ -74,13 +76,8 @@ export class ServiceTypesService { /** Update an existing service type. */ async update(id: string, dto: UpdateServiceTypeDto): Promise { await this.findById(id); - if (dto.code) { - const conflict = await this.repository.findByCode(dto.code); - if (conflict && conflict.id !== id) { - throw new ConflictException(`Service type with code "${dto.code}" already exists`); - } - } - const updated = await this.repository.update(id, dto); + const { ...patch } = dto; + const updated = await this.repository.update(id, patch); if (!updated) throw new NotFoundException(`Service type ${id} not found`); return updated; } 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..98e5b1642 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 @@ -1,4 +1,5 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto'; import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto'; import { SurchargeType } from '../entities/surcharge-type.entity'; @@ -27,7 +28,7 @@ export class SurchargeTypesService { const [data, total] = await this.repository.findAndCount({ where, - order: { name: 'ASC' }, + order: { label: 'ASC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -43,12 +44,14 @@ export class SurchargeTypesService { /** Create a new surcharge type. */ async create(dto: CreateSurchargeTypeDto): Promise { - const existing = await this.repository.findByCode(dto.code); - if (existing) throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`); + const code = generateCode(dto.label); + const existing = await this.repository.findByCode(code); + if (existing) throw new ConflictException(`Surcharge type with label "${dto.label}" conflicts with existing code "${code}"`); return this.repository.create({ - code: dto.code, - name: dto.name, - description: dto.description ?? null, + code, + label: dto.label, + triggerCondition: dto.triggerCondition as SurchargeType['triggerCondition'], + rateId: dto.rateId, isActive: dto.isActive ?? true, }); } @@ -56,13 +59,12 @@ export class SurchargeTypesService { /** Update an existing surcharge type. */ async update(id: string, dto: UpdateSurchargeTypeDto): Promise { await this.findById(id); - if (dto.code) { - const conflict = await this.repository.findByCode(dto.code); - if (conflict && conflict.id !== id) { - throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`); - } - } - const updated = await this.repository.update(id, dto); + const patch: Partial = {}; + 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..5e53cb1fd --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -0,0 +1,71 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { generateCode } from '../../../common/utils/generate-code.util'; +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', label: '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 code = generateCode(dto.label); + const existing = await this.repository.findByCode(code); + if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`); + return this.repository.create({ + 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); + 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); + } +} diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts new file mode 100644 index 000000000..dda476841 --- /dev/null +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -0,0 +1,229 @@ +export type FreightSeedRole = { + key: string; + name: { en: string }; + permissionKeys: string[]; +}; + +const IAM_PERMISSION_KEYS = { + activateEmployee: "can:activateEmployee", + activateUser: "can:activateUser", + createEmployee: "can:createEmployee", + createPositionPermission: "can:create:position_permission", + createUnit: "can:create:unit", + createUserRole: "can:create:user_role", + deactivateEmployee: "can:deactivateEmployee", + deletePositionPermission: "can:delete:position_permission", + deleteUnit: "can:delete:unit", + deleteUserRole: "can:delete:user_role", + findAllOrganization: "can:find_all:organization", + manageOrganizationAdmin: "manage:organizationAdmin", + manageUnitAdmin: "manage:unitAdmin", + updateUnit: "can:update:unit", + viewPositionPermission: "can:view:position_permission", + viewUserRole: "can:view:user_role", +} as const; + +export const EDR_FREIGHT_APPLICATION = { + id: "7f5a2175-c270-495b-bec9-d59ddbdab5d1", + key: "edr_freight_app", + name: { + am: "EDR Freight App", + en: "EDR Freight App", + }, +} as const; + +const EMPLOYEE_REGISTRATION_PERMISSIONS = [ + { + id: "62b5aa2d-4ef6-474d-913a-994568dce1c8", + key: "edr_freight_app:employee_registration:view", + name: { am: "View employee registration", en: "View employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "8072204d-26de-4e62-88aa-74afd916a0cb", + key: "edr_freight_app:employee_registration:create", + name: { am: "Create employee registration", en: "Create employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "b7dc55a6-ae7c-4558-8c4e-7d8ce5c7fa08", + key: "edr_freight_app:employee_registration:update", + name: { am: "Update employee registration", en: "Update employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "7ef06121-bd31-4c0d-b36d-5401b4bfd05c", + key: "edr_freight_app:employee_registration:activate", + name: { am: "Activate employee registration", en: "Activate employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "2688e144-7f0c-4704-8d59-e92b0c08117a", + key: "edr_freight_app:employee_registration:deactivate", + name: { am: "Deactivate employee registration", en: "Deactivate employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const ROLE_ASSIGNMENT_PERMISSIONS = [ + { + id: "4de87873-e00d-4330-9b4f-f4fb065f49e0", + key: "edr_freight_app:role_assignment:view", + name: { am: "View role assignment", en: "View role assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "36f022b4-4b94-4220-a46c-df7bd1a1b184", + key: "edr_freight_app:role_assignment:assign", + name: { am: "Assign role", en: "Assign role" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "c1f34177-a0ae-4a46-a24a-3281b9137bab", + key: "edr_freight_app:role_assignment:replace", + name: { am: "Replace role assignment", en: "Replace role assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_UNIT_PERMISSIONS = [ + { + id: "2bfa2428-ec40-4588-9b01-dfacce6a2b82", + key: "edr_freight_app:hierarchy_units:view", + name: { am: "View hierarchy units", en: "View hierarchy units" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "1e92daff-9cc7-4a67-9994-879f34bfda16", + key: "edr_freight_app:hierarchy_units:create", + name: { am: "Create hierarchy unit", en: "Create hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "4ef2d8ad-c627-4448-b4b6-dd6b8b602dc1", + key: "edr_freight_app:hierarchy_units:update", + name: { am: "Update hierarchy unit", en: "Update hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "15353ac5-246b-42e6-9ac3-eb61c4f1cd22", + key: "edr_freight_app:hierarchy_units:delete", + name: { am: "Delete hierarchy unit", en: "Delete hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_POSITION_PERMISSIONS = [ + { + id: "37ff6f5b-9fb0-4139-af99-22fe54703029", + key: "edr_freight_app:hierarchy_positions:view", + name: { am: "View hierarchy positions", en: "View hierarchy positions" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "af6c091a-6448-4459-a635-c2181efd1de0", + key: "edr_freight_app:hierarchy_positions:create", + name: { am: "Create hierarchy position", en: "Create hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "e78f624d-b570-4cd6-8f16-12090a4a9d31", + key: "edr_freight_app:hierarchy_positions:update", + name: { am: "Update hierarchy position", en: "Update hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "7fba7887-a365-4281-96ea-fb14582b047e", + key: "edr_freight_app:hierarchy_positions:delete", + name: { am: "Delete hierarchy position", en: "Delete hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "a33905ff-f2b8-40b9-a8cf-e2968f6f46fb", + key: "edr_freight_app:hierarchy_positions:change_parent", + name: { am: "Change hierarchy position parent", en: "Change hierarchy position parent" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS = [ + { + id: "b6ca90ff-3e95-4af2-bac8-fb298ca62080", + key: "edr_freight_app:hierarchy_employee_assignment:view", + name: { am: "View hierarchy employee assignment", en: "View hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "0637472f-d6b7-4332-85bb-eaa6a02205c1", + key: "edr_freight_app:hierarchy_employee_assignment:invite", + name: { am: "Invite hierarchy employee assignment", en: "Invite hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "de366c81-b6d1-4cf9-a5f1-a5c8a6fb5e7b", + key: "edr_freight_app:hierarchy_employee_assignment:assign", + name: { am: "Assign hierarchy employee assignment", en: "Assign hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const POSITION_TYPE_PERMISSIONS = [ + { + id: "f258fb51-2890-4c93-b024-271b09d705d0", + key: "edr_freight_app:position_types:view", + name: { am: "View position types", en: "View position types" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +export const EDR_FREIGHT_PERMISSIONS = [ + ...EMPLOYEE_REGISTRATION_PERMISSIONS, + ...ROLE_ASSIGNMENT_PERMISSIONS, + ...HIERARCHY_UNIT_PERMISSIONS, + ...HIERARCHY_POSITION_PERMISSIONS, + ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS, + ...POSITION_TYPE_PERMISSIONS, +]; + +export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ + { + key: "edr_employee", + name: { en: "EDR Employee" }, + permissionKeys: [ + "edr_freight_app:employee_registration:view", + "edr_freight_app:role_assignment:view", + "edr_freight_app:hierarchy_units:view", + "edr_freight_app:hierarchy_positions:view", + "edr_freight_app:hierarchy_employee_assignment:view", + "edr_freight_app:position_types:view", + ], + }, + { + key: "edr_org_manager", + name: { en: "EDR Org Manager" }, + permissionKeys: [ + ...EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key), + IAM_PERMISSION_KEYS.createEmployee, + IAM_PERMISSION_KEYS.deactivateEmployee, + IAM_PERMISSION_KEYS.activateEmployee, + IAM_PERMISSION_KEYS.activateUser, + IAM_PERMISSION_KEYS.createUserRole, + IAM_PERMISSION_KEYS.deleteUserRole, + IAM_PERMISSION_KEYS.viewUserRole, + IAM_PERMISSION_KEYS.manageOrganizationAdmin, + IAM_PERMISSION_KEYS.manageUnitAdmin, + IAM_PERMISSION_KEYS.createUnit, + IAM_PERMISSION_KEYS.updateUnit, + IAM_PERMISSION_KEYS.deleteUnit, + IAM_PERMISSION_KEYS.createPositionPermission, + IAM_PERMISSION_KEYS.deletePositionPermission, + IAM_PERMISSION_KEYS.viewPositionPermission, + IAM_PERMISSION_KEYS.findAllOrganization, + ], + }, + { + key: "edr_customer", + name: { en: "EDR Customer" }, + permissionKeys: [], + }, +]; diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 63b464090..49620b593 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -2,23 +2,22 @@ import { Injectable, Logger } from "@nestjs/common"; import { Organization, OrganizationConfiguration, + Permission, Role, + RolePermission, } from "@tria-plc/iamapi-common"; -import { DataSource } from "typeorm"; +import { DataSource, EntityManager, In } from "typeorm"; + +import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; const SEED_FLAG = "SEED_EDR_ORG"; -const EDR_ROLES = [ - { - key: "edr_employee", - name: { en: "EDR Employee" }, - }, - { - key: "edr_customer", - name: { en: "EDR Customer" }, - }, -]; + +type SeedOrganization = { + id: string; + key: string; +}; @Injectable() export class EdrOrgSeeder { @@ -27,24 +26,30 @@ export class EdrOrgSeeder { constructor(private readonly dataSource: DataSource) {} async run() { - const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; - - if (!shouldSeed) { + if (!this.shouldSeed()) { this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`); return; } - const roleRepository = this.dataSource.getRepository(Role); - const organizationRepository = this.dataSource.getRepository(Organization); - const organizationConfigurationRepository = - this.dataSource.getRepository(OrganizationConfiguration); + await this.dataSource.transaction(async (manager) => { + const organization = await this.ensureOrganization(manager); - await roleRepository.upsert(EDR_ROLES, { - conflictPaths: { key: true }, + await this.ensureOrganizationConfiguration(manager, organization.id); + await this.ensureRoles(manager, EDR_FREIGHT_ROLES); + await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); }); - this.logger.log("Ensured EDR roles 'edr_employee' and 'edr_customer'"); + this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); + } + private shouldSeed() { + return process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + } + + private async ensureOrganization( + manager: EntityManager, + ): Promise { + const organizationRepository = manager.getRepository(Organization); let organization = await organizationRepository.findOne({ where: { key: EDR_ORG_KEY }, select: { id: true, key: true }, @@ -57,18 +62,31 @@ export class EdrOrgSeeder { isGovernmentOrganization: true, }); - organization = { + this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`); + + return { id: insertResult.identifiers[0]?.id as string, key: EDR_ORG_KEY, - } as Organization; - - this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`); - } else { - this.logger.log(`Ensured EDR organization '${EDR_ORG_KEY}'`); + }; } + this.logger.log(`Ensured EDR organization '${EDR_ORG_KEY}'`); + + return { + id: organization.id as string, + key: EDR_ORG_KEY, + }; + } + + private async ensureOrganizationConfiguration( + manager: EntityManager, + organizationId: string, + ) { + const organizationConfigurationRepository = + manager.getRepository(OrganizationConfiguration); + await organizationConfigurationRepository.upsert({ - organizationId: organization.id, + organizationId, canCreateBranchByItself: true, canStartReceivingRecord: true, }, { @@ -79,4 +97,73 @@ export class EdrOrgSeeder { `Ensured organization configuration for '${EDR_ORG_KEY}'`, ); } + + private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) { + await manager.getRepository(Role).upsert( + seedRoles.map(({ key, name }) => ({ key, name })), + { + conflictPaths: { key: true }, + }, + ); + + this.logger.log( + `Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`, + ); + } + + private async ensureRolePermissions( + manager: EntityManager, + seedRoles: FreightSeedRole[], + ) { + const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))]; + + if (!permissionKeys.length) { + this.logger.log("No EDR role permissions configured; skipping role-permission links"); + return; + } + + const roleRepository = manager.getRepository(Role); + const rolePermissionRepository = manager.getRepository(RolePermission); + + const roles = await roleRepository.find({ + where: { key: In(seedRoles.map((role) => role.key)) }, + select: { id: true, key: true }, + }); + const seededPermissions = await manager.getRepository(Permission).find({ + where: { key: In(permissionKeys) }, + select: { id: true, key: true }, + }); + + const roleByKey = new Map(roles.map((role) => [role.key, role])); + const permissionByKey = new Map( + seededPermissions.map((permission) => [permission.key, permission]), + ); + + const rolePermissions = seedRoles.flatMap((role) => { + const seededRole = roleByKey.get(role.key); + + if (!seededRole) { + throw new Error(`missing_role:${role.key}`); + } + + return role.permissionKeys.map((permissionKey) => { + const seededPermission = permissionByKey.get(permissionKey); + + if (!seededPermission) { + throw new Error(`missing_permission:${permissionKey}`); + } + + return { + roleId: seededRole.id, + permissionId: seededPermission.id, + }; + }); + }); + + await rolePermissionRepository.upsert(rolePermissions, { + conflictPaths: { roleId: true, permissionId: true }, + }); + + this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`); + } } diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css index 4909e33bd..d232351ed 100644 --- a/apps/edr-freight-web/backoffice/index.css +++ b/apps/edr-freight-web/backoffice/index.css @@ -1,2 +1,9 @@ @import "tailwindcss"; @import "@edr/ui-common/theme.css" layer(theme); + +html, +body, +#root { + height: 100%; + overflow: hidden; +} diff --git a/apps/edr-freight-web/backoffice/public/assets/login.png b/apps/edr-freight-web/backoffice/public/assets/login.png new file mode 100644 index 000000000..f4854a45c Binary files /dev/null and b/apps/edr-freight-web/backoffice/public/assets/login.png differ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 75a233955..7cc6a9935 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,104 +1,124 @@ import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; -import { LayoutDashboard, Network, Paperclip, Settings } from "lucide-react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + Boxes, + FileText, + LayoutDashboard, + Network, + Paperclip, + Settings, + SlidersHorizontal, +} from "lucide-react"; + +import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import LoadingScreen from "./components/LoadingScreen"; import { useAuth } from "./auth/useAuth"; import LoginPage from "./pages/auth/LoginPage"; +import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; +import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import OverviewPage from "./pages/dashboard/OverviewPage"; import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; +import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; -import LoadingScreen from "./components/LoadingScreen"; -import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import UsersPage from "./pages/dashboard/user-management/UsersPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; -import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; -import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -import { RuleEnginePage } from "./pages/ruleEngine/RuleEngine"; +import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; +import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -// Create a QueryClient instance const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, - staleTime: 5 * 60 * 1000, // 5 minutes + staleTime: 5 * 60 * 1000, }, }, }); -// const sidebarItems: SidebarItem[] = [ -// { -// label: "Overview", -// href: "/dashboard/overview", -// icon: , -// }, -// { -// label: "User management", -// href: "/dashboard/user-management", -// icon: , -// children: [ -// { -// label: "Employees", -// href: "/dashboard/user-management/employees", -// }, -// { -// label: "Permissions", -// href: "/dashboard/user-management/permissions", -// }, -// { -// label: "Roles", -// href: "/dashboard/user-management/roles", -// }, -// ], -// }, -// { -// label: "Rule Engine", -// href: "/dashboard/rule-engine", -// icon: , -// }, -// ]; - -const sidebarItems: SidebarItem[] = [ +const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { - label: "Overview", - href: "/dashboard/overview", - icon: , + title: "Main menu", + mutedTitle: true, + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + ...demoItems, + ], }, { - label: "User management", - href: "/dashboard/user-management", - icon: , - children: [ + title: "Administration", + items: [ { - label: "Employees", - href: "/dashboard/user-management/employees", + label: "User management", + href: "/dashboard/user-management", + icon: , + children: [ + { + label: "Users", + href: "/dashboard/user-management/users", + }, + { + label: "Employees", + href: "/dashboard/user-management/employees", + }, + { + label: "Position Types", + href: "/dashboard/user-management/position-types", + }, + { + label: "Permissions", + href: "/dashboard/user-management/permissions", + }, + { + label: "Roles", + href: "/dashboard/user-management/roles", + }, + ], }, { - label: "Permissions", - href: "/dashboard/user-management/permissions", + label: "File settings", + href: "/dashboard/file-settings", + icon: , }, { - label: "Roles", - href: "/dashboard/user-management/roles", + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , }, ], }, { - label: "File Settings", - href: "/dashboard/file-settings", - icon: , + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: getCategorySidebarChildren("configuration"), + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + ], }, - { - label: "Dropdown Settings", - href: "/dashboard/dropdown-settings", - icon: , - }, - { - label: "Rule Engine", - href: "/dashboard/rule-engine", - icon: , - } ]; const hasPermission = ( @@ -107,22 +127,46 @@ const hasPermission = ( ) => { if (!user) return false; if (user.permissions?.some((p) => p.key === key)) return true; + return (user.employee ?? []).some((emp) => (emp.positions ?? []).some((pos) => (pos.permissions ?? []).some((p) => p.key === key), ), ); }; + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); const { user, logout } = useAuth(); + + const demoItems: SidebarItem[] = [ + ...(hasPermission(user, "can:demo:user1") + ? [ + { + label: "User1", + href: "/dashboard/user1", + icon: , + }, + ] + : []), + ...(hasPermission(user, "can:demo:user2") + ? [ + { + label: "User2", + href: "/dashboard/user2", + icon: , + }, + ] + : []), + ]; + + const sidebarSections = buildSidebarSections(demoItems); const displayName = user?.name?.en || user?.username || user?.email || "User"; return ( - { onLogout={logout} > - + ); }; @@ -158,24 +202,58 @@ const App = () => { } /> } /> + }> } /> + + } /> + } /> + } /> - } /> - } /> + } /> + } /> + {/* } /> */} } /> } /> - } /> - } /> + } /> } /> - } /> - } /> + + } + /> + } /> + + } + /> + } /> + + } + /> + } /> + + } /> + } /> + + } + /> + } + /> + } /> ); }; -export default App; \ No newline at end of file +export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx new file mode 100644 index 000000000..48f13f4ff --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -0,0 +1,173 @@ +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { + Bell, + ChevronDown, + Languages, + LogOut, + MessageSquare, + Moon, + Sun, + User, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; + +import type { PageMeta } from "./types"; + +const iconButtonClass = + "relative inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm transition hover:border-primary/30 hover:bg-gray-50 hover:text-gray-900"; + +export interface FreightDashboardHeaderProps { + pageMeta: PageMeta; + headerRight?: ReactNode; + enableThemeToggle?: boolean; + userName?: string; + userEmail?: string; + userInitials?: string; + onLogout?: () => void; + theme: "light" | "dark"; + onToggleTheme: () => void; +} + +const FreightDashboardHeader = ({ + pageMeta, + headerRight, + enableThemeToggle = false, + userName = "User", + userEmail, + userInitials, + onLogout, + theme, + onToggleTheme, +}: FreightDashboardHeaderProps) => { + const initials = + userInitials ?? + userName + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((n) => n[0].toUpperCase()) + .join(""); + + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); + const userMenuRef = useRef(null); + + useEffect(() => { + if (!isUserMenuOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) { + setIsUserMenuOpen(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setIsUserMenuOpen(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isUserMenuOpen]); + + return ( +
+
+

{pageMeta.title}

+

{pageMeta.subtitle}

+
+ +
+ {enableThemeToggle ? ( + + ) : null} + + + + + + + +
+ + + {isUserMenuOpen ? ( +
+
+

{userName}

+ {userEmail ?

{userEmail}

: null} +
+ setIsUserMenuOpen(false)} + className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-50" + > + + Profile + + +
+ ) : null} +
+ + {headerRight} +
+
+ ); +}; + +export default FreightDashboardHeader; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx new file mode 100644 index 000000000..657b56ea7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx @@ -0,0 +1,111 @@ +import { type ReactNode, useEffect, useState } from "react"; + +import FreightDashboardHeader from "./FreightDashboardHeader"; +import FreightSidebar from "./FreightSidebar"; +import { getPageMeta } from "./route-meta"; +import type { SidebarSection } from "./types"; + +type Theme = "light" | "dark"; +const THEME_STORAGE_KEY = "edr-theme"; + +function getInitialTheme(): Theme { + if (typeof window === "undefined") return "light"; + const stored = window.localStorage.getItem(THEME_STORAGE_KEY); + if (stored === "dark" || stored === "light") return stored; + return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export interface FreightDashboardLayoutProps { + sidebarSections: SidebarSection[]; + activeHref?: string; + onNavigate?: (href: string) => void; + headerRight?: ReactNode; + enableThemeToggle?: boolean; + userName?: string; + userEmail?: string; + userInitials?: string; + onLogout?: () => void; + children: ReactNode; +} + +const panelClass = + "rounded-2xl border border-gray-200/80 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.06)]"; + +const FreightDashboardLayout = ({ + sidebarSections, + activeHref = "", + onNavigate, + headerRight, + enableThemeToggle = false, + userName, + userEmail, + userInitials, + onLogout, + children, +}: FreightDashboardLayoutProps) => { + const pageMeta = getPageMeta(activeHref); + const [theme, setTheme] = useState(() => + enableThemeToggle ? getInitialTheme() : "light", + ); + + useEffect(() => { + if (!enableThemeToggle) return; + const root = document.documentElement; + if (theme === "dark") { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } + window.localStorage.setItem(THEME_STORAGE_KEY, theme); + }, [theme, enableThemeToggle]); + + const toggleTheme = () => setTheme((current) => (current === "dark" ? "light" : "dark")); + + return ( + <> + + + + +
+
+ + +
+
+ +
+ +
+ {children} +
+
+
+
+ + ); +}; + +export default FreightDashboardLayout; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx new file mode 100644 index 000000000..399f61778 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -0,0 +1,286 @@ +import { type MouseEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +import type { SidebarItem, SidebarSection } from "./types"; + +const EDR_LOGO = "/assets/logo.svg"; + +export interface FreightSidebarProps { + sections: SidebarSection[]; + activeHref?: string; + onNavigate?: (href: string) => void; +} + +const sidebarItemKey = (item: SidebarItem, parentKey: string) => + item.href ?? `${parentKey}::${item.label}`; + +const collectSidebarHrefs = (items: SidebarItem[]): string[] => + items.flatMap((item) => { + const hrefs: string[] = []; + if (item.href) hrefs.push(item.href.toLowerCase()); + if (item.children?.length) hrefs.push(...collectSidebarHrefs(item.children)); + return hrefs; + }); + +const flattenSectionItems = (sections: SidebarSection[]) => + sections.flatMap((section) => section.items); + +const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProps) => { + const items = useMemo(() => flattenSectionItems(sections), [sections]); + const activePath = activeHref?.toLowerCase() ?? ""; + + const isHrefActive = useCallback( + (href: string) => { + const normalized = href.toLowerCase(); + return activePath === normalized || activePath.startsWith(`${normalized}/`); + }, + [activePath], + ); + + const branchContainsActive = useCallback( + (branch: SidebarItem[]) => + collectSidebarHrefs(branch).some((href) => isHrefActive(href)), + [isHrefActive], + ); + + const defaultExpanded = useMemo(() => { + const acc: Record = {}; + + const walk = (entries: SidebarItem[], parentKey: string) => { + for (const entry of entries) { + if (!entry.children?.length) continue; + const key = sidebarItemKey(entry, parentKey); + acc[key] = + branchContainsActive(entry.children) || + (entry.href ? isHrefActive(entry.href) : false); + walk(entry.children, key); + } + }; + + for (const item of items) { + if (!item.children?.length) continue; + const key = item.href ?? item.label; + acc[key] = + activePath === key.toLowerCase() || + activePath.startsWith(`${key.toLowerCase()}/`) || + branchContainsActive(item.children); + walk(item.children, key); + } + + return acc; + }, [activePath, branchContainsActive, isHrefActive, items]); + + const [expanded, setExpanded] = useState>(defaultExpanded); + + useEffect(() => { + setExpanded((current) => ({ ...defaultExpanded, ...current })); + }, [defaultExpanded]); + + const navigateTo = (event: MouseEvent, href: string) => { + if (onNavigate) { + event.preventDefault(); + onNavigate(href); + } + }; + + const toggleExpanded = (key: string) => { + setExpanded((current) => ({ ...current, [key]: !current[key] })); + }; + + const navLinkClass = (active: boolean, depth: number) => + cn( + "flex items-center justify-between rounded-md px-3 py-2.5 text-base font-medium leading-snug transition-colors", + active + ? "bg-primary text-primary-foreground shadow-sm" + : "text-gray-900 hover:bg-gray-100", + depth > 0 && "text-[15px]", + ); + + const iconClass = (active: boolean, sectionActive: boolean) => + cn( + "flex h-5 w-5 shrink-0 items-center justify-center [&_svg]:h-5 [&_svg]:w-5", + active + ? "text-primary-foreground" + : sectionActive + ? "text-gray-900" + : "text-gray-900", + ); + + const renderNavBranch = (children: SidebarItem[], depth: number, parentKey: string) => + children.map((child) => { + const key = sidebarItemKey(child, parentKey); + const isGroup = Boolean(child.children?.length) && !child.href; + + if (isGroup) { + const isOpen = expanded[key] ?? false; + const groupActive = branchContainsActive(child.children!); + + return ( +
+ + {isOpen ? ( +
+ {renderNavBranch(child.children!, depth + 1, key)} +
+ ) : null} +
+ ); + } + + if (!child.href) return null; + + const childHref = child.href.toLowerCase(); + const childActiveHref = isHrefActive(childHref); + + return ( + navigateTo(event, child.href!)} + aria-current={childActiveHref ? "page" : undefined} + className={navLinkClass(childActiveHref, depth)} + > + {child.label} + + + ); + }); + + const renderTopLevelItem = (item: SidebarItem) => { + if (!item.href) return null; + + const hasChildren = Boolean(item.children?.length); + const itemHref = item.href.toLowerCase(); + const childActive = hasChildren ? branchContainsActive(item.children!) : false; + const isCurrentItem = hasChildren + ? activePath === itemHref + : isHrefActive(itemHref); + const isSectionActive = childActive && !isCurrentItem; + const isActive = isCurrentItem || isSectionActive; + const isOpen = expanded[item.href] ?? false; + const leafActive = isCurrentItem && !hasChildren; + + return ( +
+
+ navigateTo(event, item.href!)} + aria-current={isCurrentItem ? "page" : undefined} + className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-base font-medium leading-snug" + > + {item.icon ? ( + {item.icon} + ) : null} + {item.label} + + + {hasChildren ? ( + + ) : ( + + + + )} +
+ + {hasChildren && isOpen ? ( +
+ {renderNavBranch(item.children!, 0, item.href)} +
+ ) : null} +
+ ); + }; + + return ( + + ); +}; + +export default FreightSidebar; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/index.ts b/apps/edr-freight-web/backoffice/src/components/layout/index.ts new file mode 100644 index 000000000..7a02cfbaf --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/index.ts @@ -0,0 +1,6 @@ +export { default as FreightDashboardLayout } from "./FreightDashboardLayout"; +export type { FreightDashboardLayoutProps } from "./FreightDashboardLayout"; +export { default as FreightSidebar } from "./FreightSidebar"; +export { default as FreightDashboardHeader } from "./FreightDashboardHeader"; +export { getPageMeta } from "./route-meta"; +export type { SidebarItem, SidebarSection, PageMeta } from "./types"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts new file mode 100644 index 000000000..517ca6468 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -0,0 +1,125 @@ +import type { PageMeta } from "./types"; +import { + RULE_ENGINE_CATEGORY_BASE_PATH, + RULE_ENGINE_RESOURCES, +} from "@/pages/ruleEngine/config/resources"; + +const APP_TITLE = "EDR Freight Backoffice"; +const APP_SUBTITLE = "Manage freight operations and platform settings"; + +const configurationRouteMeta = RULE_ENGINE_RESOURCES.filter( + (r) => r.category === "configuration", +).map((resource) => ({ + prefix: `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${resource.slug}`, + meta: { + title: resource.label, + subtitle: resource.subtitle, + }, +})); + +const rulesRouteMeta = RULE_ENGINE_RESOURCES.filter((r) => r.category === "rules").map( + (resource) => ({ + prefix: `${RULE_ENGINE_CATEGORY_BASE_PATH.rules}/${resource.slug}`, + meta: { + title: resource.label, + subtitle: resource.subtitle, + }, + }), +); + +const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ + { + prefix: "/dashboard/overview", + meta: { + title: "Overview", + subtitle: "Dashboard summary and key metrics", + }, + }, + { + prefix: "/dashboard/user-management/employees", + meta: { + title: "Employees", + subtitle: "Manage employee accounts and assignments", + }, + }, + { + prefix: "/dashboard/user-management/permissions", + meta: { + title: "Permissions", + subtitle: "Configure access permissions for roles and users", + }, + }, + { + prefix: "/dashboard/user-management/roles", + meta: { + title: "Roles", + subtitle: "Manage roles and their permission sets", + }, + }, + { + prefix: "/dashboard/user-management", + meta: { + title: "User management", + subtitle: "Organization structure, employees, roles, and permissions", + }, + }, + { + prefix: "/dashboard/file-settings", + meta: { + title: "File Settings", + subtitle: "Configure file upload rules and document fields", + }, + }, + { + prefix: "/dashboard/dropdown-settings", + meta: { + title: "Dropdown Settings", + subtitle: "Manage dropdown options used across the platform", + }, + }, + { + prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration, + meta: { + title: "Configuration", + subtitle: "Master data: cargo, containers, services, surcharges, yards, and shipping lines", + }, + }, + ...configurationRouteMeta, + { + prefix: RULE_ENGINE_CATEGORY_BASE_PATH.rules, + meta: { + title: "Rules", + subtitle: "Priority, weight limits, rates, and approval workflows", + }, + }, + ...rulesRouteMeta, + { + prefix: "/dashboard/user1", + meta: { + title: "Demo User 1", + subtitle: "Demo workspace", + }, + }, + { + prefix: "/dashboard/user2", + meta: { + title: "Demo User 2", + subtitle: "Demo workspace", + }, + }, +]; + +export const getPageMeta = (pathname: string): PageMeta => { + const normalized = pathname.toLowerCase(); + const sorted = [...ROUTE_META].sort((a, b) => b.prefix.length - a.prefix.length); + const match = sorted.find(({ prefix }) => normalized.startsWith(prefix.toLowerCase())); + + if (match) { + return match.meta; + } + + return { + title: APP_TITLE, + subtitle: APP_SUBTITLE, + }; +}; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/types.ts b/apps/edr-freight-web/backoffice/src/components/layout/types.ts new file mode 100644 index 000000000..ba37f33e5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/types.ts @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; + +export interface SidebarItem { + label: string; + /** Omit for non-navigable group headers (e.g. Rule Engine categories). */ + href?: string; + icon?: ReactNode; + children?: SidebarItem[]; +} + +export interface SidebarSection { + /** Section label shown above a group of nav items (e.g. "Main menu"). */ + title: string; + items: SidebarItem[]; + /** When true, section title uses muted grey instead of dark text. */ + mutedTitle?: boolean; +} + +export interface PageMeta { + title: string; + subtitle: string; +} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy 2.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy 2.tsx deleted file mode 100644 index d6275d717..000000000 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy 2.tsx +++ /dev/null @@ -1,718 +0,0 @@ -// src/components/ruleEngine/ContractType.tsx -import { useState, useEffect } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; - -// ==================== Toast Notification Component ==================== -const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => { - useEffect(() => { - const timer = setTimeout(onClose, 3000); - return () => clearTimeout(timer); - }, [onClose]); - - const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'; - - return ( -
- {message} -
- ); -}; - -// ==================== API Service ==================== -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; - -const apiService = { - // Cargo Types - getCargoTypes: () => fetch(`${API_BASE_URL}/cargo-types`).then(res => res.json()), - createCargoType: (data: any) => fetch(`${API_BASE_URL}/cargo-types`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - updateCargoType: (id: string, data: any) => fetch(`${API_BASE_URL}/cargo-types/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - deleteCargoType: (id: string) => fetch(`${API_BASE_URL}/cargo-types/${id}`, { - method: 'DELETE' - }).then(res => res.json()), - - // Container Types - getContainerTypes: () => fetch(`${API_BASE_URL}/container-types`).then(res => res.json()), - createContainerType: (data: any) => fetch(`${API_BASE_URL}/container-types`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - updateContainerType: (id: string, data: any) => fetch(`${API_BASE_URL}/container-types/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - deleteContainerType: (id: string) => fetch(`${API_BASE_URL}/container-types/${id}`, { - method: 'DELETE' - }).then(res => res.json()), - - // Priority Rules - getPriorityRules: () => fetch(`${API_BASE_URL}/priority-rules`).then(res => res.json()), - createPriorityRule: (data: any) => fetch(`${API_BASE_URL}/priority-rules`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - updatePriorityRule: (id: string, data: any) => fetch(`${API_BASE_URL}/priority-rules/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - deletePriorityRule: (id: string) => fetch(`${API_BASE_URL}/priority-rules/${id}`, { - method: 'DELETE' - }).then(res => res.json()), - - // Service Types - getServiceTypes: () => fetch(`${API_BASE_URL}/service-types`).then(res => res.json()), - createServiceType: (data: any) => fetch(`${API_BASE_URL}/service-types`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - updateServiceType: (id: string, data: any) => fetch(`${API_BASE_URL}/service-types/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - deleteServiceType: (id: string) => fetch(`${API_BASE_URL}/service-types/${id}`, { - method: 'DELETE' - }).then(res => res.json()), - - // Surcharge Types - getSurchargeTypes: () => fetch(`${API_BASE_URL}/surcharge-types`).then(res => res.json()), - createSurchargeType: (data: any) => fetch(`${API_BASE_URL}/surcharge-types`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - updateSurchargeType: (id: string, data: any) => fetch(`${API_BASE_URL}/surcharge-types/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - deleteSurchargeType: (id: string) => fetch(`${API_BASE_URL}/surcharge-types/${id}`, { - method: 'DELETE' - }).then(res => res.json()), - - // Surcharges - getSurcharges: () => fetch(`${API_BASE_URL}/surcharges`).then(res => res.json()), - createSurcharge: (data: any) => fetch(`${API_BASE_URL}/surcharges`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - updateSurcharge: (id: string, data: any) => fetch(`${API_BASE_URL}/surcharges/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - deleteSurcharge: (id: string) => fetch(`${API_BASE_URL}/surcharges/${id}`, { - method: 'DELETE' - }).then(res => res.json()), - - // Weight Limit Rules - getWeightLimitRules: () => fetch(`${API_BASE_URL}/weight-limit-rules`).then(res => res.json()), - createWeightLimitRule: (data: any) => fetch(`${API_BASE_URL}/weight-limit-rules`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - updateWeightLimitRule: (id: string, data: any) => fetch(`${API_BASE_URL}/weight-limit-rules/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }).then(res => res.json()), - deleteWeightLimitRule: (id: string) => fetch(`${API_BASE_URL}/weight-limit-rules/${id}`, { - method: 'DELETE' - }).then(res => res.json()), -}; - -// ==================== Entity Table Component ==================== -const EntityTable = ({ - title, - data, - columns, - onAdd, - onEdit, - onDelete, - isLoading -}: any) => { - const [expanded, setExpanded] = useState(true); - const [searchTerm, setSearchTerm] = useState(''); - - const filteredData = data?.filter((item: any) => - Object.values(item).some(value => - String(value).toLowerCase().includes(searchTerm.toLowerCase()) - ) - ) || []; - - if (isLoading) { - return ( -
-
setExpanded(!expanded)} - > -
- {expanded ? '▼' : '▶'} -

{title}

-
-
- {expanded && ( -
-
-

Loading...

-
- )} -
- ); - } - - return ( -
-
setExpanded(!expanded)} - > -
- - {expanded ? '▼' : '▶'} - -

{title}

- - {filteredData.length} items - -
-
- - {expanded && ( -
-
- -
- setSearchTerm(e.target.value)} - /> - - - -
-
- -
- - - - {columns.map((col: any) => ( - - ))} - - - - - {filteredData.map((item: any) => ( - - {columns.map((col: any) => ( - - ))} - - - ))} - -
- {col.label} - - Actions -
- {col.render ? col.render(item[col.key], item) : item[col.key]} - - - -
- {filteredData.length === 0 && ( -
- - - -

No data found

-
- )} -
-
- )} -
- ); -}; - -// ==================== Main Component ==================== -const ContractTypePage = () => { - const [activeTab, setActiveTab] = useState('cargo-types'); - const [modalOpen, setModalOpen] = useState(false); - const [editingItem, setEditingItem] = useState(null); - const [currentEntity, setCurrentEntity] = useState(''); - const [formData, setFormData] = useState({}); - const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); - const queryClient = useQueryClient(); - - const showToast = (message: string, type: 'success' | 'error') => { - setToast({ message, type }); - }; - - // Fetch all data - const { data: cargoTypes = [], isLoading: cargoLoading } = useQuery({ - queryKey: ['cargo-types'], - queryFn: apiService.getCargoTypes, - }); - - const { data: containerTypes = [], isLoading: containerLoading } = useQuery({ - queryKey: ['container-types'], - queryFn: apiService.getContainerTypes, - }); - - const { data: priorityRules = [], isLoading: priorityLoading } = useQuery({ - queryKey: ['priority-rules'], - queryFn: apiService.getPriorityRules, - }); - - const { data: serviceTypes = [], isLoading: serviceLoading } = useQuery({ - queryKey: ['service-types'], - queryFn: apiService.getServiceTypes, - }); - - const { data: surchargeTypes = [], isLoading: surchargeTypeLoading } = useQuery({ - queryKey: ['surcharge-types'], - queryFn: apiService.getSurchargeTypes, - }); - - const { data: surcharges = [], isLoading: surchargeLoading } = useQuery({ - queryKey: ['surcharges'], - queryFn: apiService.getSurcharges, - }); - - const { data: weightLimitRules = [], isLoading: weightLimitLoading } = useQuery({ - queryKey: ['weight-limit-rules'], - queryFn: apiService.getWeightLimitRules, - }); - - // Mutations for Cargo Types - const createCargoType = useMutation({ - mutationFn: apiService.createCargoType, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cargo-types'] }); - showToast('Cargo type created successfully', 'success'); - setModalOpen(false); - setFormData({}); - }, - onError: () => showToast('Failed to create cargo type', 'error'), - }); - - const updateCargoType = useMutation({ - mutationFn: ({ id, data }: { id: string; data: any }) => apiService.updateCargoType(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cargo-types'] }); - showToast('Cargo type updated successfully', 'success'); - setModalOpen(false); - setFormData({}); - setEditingItem(null); - }, - onError: () => showToast('Failed to update cargo type', 'error'), - }); - - const deleteCargoType = useMutation({ - mutationFn: apiService.deleteCargoType, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cargo-types'] }); - showToast('Cargo type deleted successfully', 'success'); - }, - onError: () => showToast('Failed to delete cargo type', 'error'), - }); - - const handleAdd = (entity: string) => { - setCurrentEntity(entity); - setEditingItem(null); - setFormData(getDefaultFormData(entity)); - setModalOpen(true); - }; - - const handleEdit = (entity: string, item: any) => { - setCurrentEntity(entity); - setEditingItem(item); - setFormData(item); - setModalOpen(true); - }; - - const handleDelete = (entity: string, item: any) => { - if (window.confirm(`Are you sure you want to delete this ${entity}?`)) { - if (entity === 'cargo-types') deleteCargoType.mutate(item.id); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (currentEntity === 'cargo-types') { - if (editingItem) { - updateCargoType.mutate({ id: editingItem.id, data: formData }); - } else { - createCargoType.mutate(formData); - } - } - }; - - const getDefaultFormData = (entity: string) => { - switch(entity) { - case 'cargo-types': - return { code: '', cargoTypeName: '', showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 1 }; - default: - return {}; - } - }; - - const getEntityData = (entity: string) => { - switch(entity) { - case 'cargo-types': return cargoTypes; - case 'container-types': return containerTypes; - case 'priority-rules': return priorityRules; - case 'service-types': return serviceTypes; - case 'surcharge-types': return surchargeTypes; - case 'surcharges': return surcharges; - case 'weight-limit-rules': return weightLimitRules; - default: return []; - } - }; - - const getEntityLoading = (entity: string) => { - switch(entity) { - case 'cargo-types': return cargoLoading; - case 'container-types': return containerLoading; - case 'priority-rules': return priorityLoading; - case 'service-types': return serviceLoading; - case 'surcharge-types': return surchargeTypeLoading; - case 'surcharges': return surchargeLoading; - case 'weight-limit-rules': return weightLimitLoading; - default: return false; - } - }; - - const getColumns = (entity: string) => { - switch(entity) { - case 'cargo-types': - return [ - { key: 'code', label: 'Code' }, - { key: 'cargoTypeName', label: 'Name' }, - { key: 'displayOrder', label: 'Order' }, - { - key: 'isActive', - label: 'Status', - render: (val: boolean) => ( - - {val ? 'Active' : 'Inactive'} - - ) - } - ]; - case 'container-types': - return [ - { key: 'sizeCode', label: 'Size Code' }, - { key: 'description', label: 'Description' }, - { key: 'containersPerWagon', label: 'Containers/Wagon' }, - { - key: 'isActive', - label: 'Status', - render: (val: boolean) => ( - - {val ? 'Active' : 'Inactive'} - - ) - } - ]; - case 'priority-rules': - return [ - { key: 'priorityType', label: 'Priority Type' }, - { key: 'ruleName', label: 'Rule Name' }, - { key: 'bonusPoints', label: 'Bonus Points' }, - { - key: 'isActive', - label: 'Status', - render: (val: boolean) => ( - - {val ? 'Active' : 'Inactive'} - - ) - } - ]; - case 'service-types': - return [ - { key: 'code', label: 'Code' }, - { key: 'serviceName', label: 'Service Name' }, - { key: 'displayOrder', label: 'Order' }, - { - key: 'isActive', - label: 'Status', - render: (val: boolean) => ( - - {val ? 'Active' : 'Inactive'} - - ) - } - ]; - case 'surcharge-types': - return [ - { key: 'code', label: 'Code' }, - { key: 'name', label: 'Name' }, - { - key: 'isActive', - label: 'Status', - render: (val: boolean) => ( - - {val ? 'Active' : 'Inactive'} - - ) - } - ]; - case 'surcharges': - return [ - { key: 'feeName', label: 'Fee Name' }, - { key: 'calculationMethod', label: 'Method' }, - { - key: 'rate', - label: 'Rate', - render: (val: number, item: any) => `${val} ${item.currency}` - }, - { - key: 'isActive', - label: 'Status', - render: (val: boolean) => ( - - {val ? 'Active' : 'Inactive'} - - ) - } - ]; - case 'weight-limit-rules': - return [ - { key: 'tradeDirection', label: 'Direction' }, - { - key: 'maxWeightTons', - label: 'Max Weight', - render: (val: number) => `${val} tons` - }, - { key: 'exceededAction', label: 'Action' }, - { - key: 'isActive', - label: 'Status', - render: (val: boolean) => ( - - {val ? 'Active' : 'Inactive'} - - ) - } - ]; - default: - return []; - } - }; - - const tabs = [ - { id: 'cargo-types', label: 'Cargo Types' }, - { id: 'container-types', label: 'Container Types' }, - { id: 'priority-rules', label: 'Priority Rules' }, - { id: 'service-types', label: 'Service Types' }, - { id: 'surcharge-types', label: 'Surcharge Types' }, - { id: 'surcharges', label: 'Surcharges' }, - { id: 'weight-limit-rules', label: 'Weight Limit Rules' }, - ]; - - const isLoading = cargoLoading || containerLoading || priorityLoading || serviceLoading || surchargeTypeLoading || surchargeLoading || weightLimitLoading; - - if (isLoading) { - return ( -
-
-
-

Loading master data...

-
-
- ); - } - - return ( -
- {toast && ( - setToast(null)} - /> - )} - -
-
- {tabs.map((tab) => ( - - ))} -
-
- -
- {tabs.map((tab) => ( -
- handleAdd(tab.id)} - onEdit={(item: any) => handleEdit(tab.id, item)} - onDelete={(item: any) => handleDelete(tab.id, item)} - isLoading={getEntityLoading(tab.id)} - /> -
- ))} -
- - {modalOpen && currentEntity === 'cargo-types' && ( -
-
-
-

- {editingItem ? 'Edit Cargo Type' : 'Add Cargo Type'} -

- -
-
-
-
- - setFormData({...formData, code: e.target.value.toUpperCase()})} - required - /> -
-
- - setFormData({...formData, cargoTypeName: e.target.value})} - required - /> -
-
- - setFormData({...formData, displayOrder: parseInt(e.target.value)})} - /> -
-
- setFormData({...formData, showFreeTextBox: e.target.checked})} - /> - -
-
- setFormData({...formData, requiresDirectorApproval: e.target.checked})} - /> - -
-
- setFormData({...formData, isActive: e.target.checked})} - /> - -
-
-
- - -
-
-
-
- )} -
- ); -}; - -export default ContractTypePage; - diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy 3.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy 3.tsx deleted file mode 100644 index 208423745..000000000 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy 3.tsx +++ /dev/null @@ -1,366 +0,0 @@ -// src/components/ruleEngine/ContractType.tsx -import { useState } from 'react'; - -// ==================== MOCK DATA (Replace with your API calls later) ==================== -const mockCargoTypes = [ - { id: '1', code: 'BULK', cargoTypeName: 'Bulk Cargo', displayOrder: 1, isActive: true }, - { id: '2', code: 'BREAK_BULK', cargoTypeName: 'Break Bulk', displayOrder: 2, isActive: true }, - { id: '3', code: 'CONTAINER', cargoTypeName: 'Containerized', displayOrder: 3, isActive: false }, - { id: '4', code: 'LIQUID', cargoTypeName: 'Liquid Bulk', displayOrder: 4, isActive: true }, -]; - -const mockContainerTypes = [ - { id: '1', sizeCode: '20FT', description: '20 Foot Standard Container', containersPerWagon: 2, isActive: true }, - { id: '2', sizeCode: '40FT', description: '40 Foot Standard Container', containersPerWagon: 1, isActive: true }, - { id: '3', sizeCode: '20RF', description: '20 Foot Refrigerated', containersPerWagon: 2, isActive: true }, -]; - -const mockPriorityRules = [ - { id: '1', priorityType: 'HIGH', ruleName: 'High Priority Booking', bonusPoints: 100, isActive: true }, - { id: '2', priorityType: 'URGENT', ruleName: 'Urgent Delivery', bonusPoints: 200, isActive: true }, - { id: '3', priorityType: 'LOW', ruleName: 'Standard Booking', bonusPoints: 0, isActive: true }, -]; - -const mockServiceTypes = [ - { id: '1', code: 'RAIL', serviceName: 'Rail Only', displayOrder: 1, isActive: true }, - { id: '2', code: 'RAIL_FIRST', serviceName: 'Rail + First Mile', displayOrder: 2, isActive: true }, - { id: '3', code: 'RAIL_LAST', serviceName: 'Rail + Last Mile', displayOrder: 3, isActive: false }, -]; - -const mockSurchargeTypes = [ - { id: '1', code: 'HAZ', name: 'Hazardous Material', isActive: true }, - { id: '2', code: 'REF', name: 'Refrigerated', isActive: true }, - { id: '3', code: 'OVR', name: 'Overweight', isActive: true }, -]; - -const mockSurcharges = [ - { id: '1', feeName: 'Hazardous Fee', calculationMethod: 'FLAT', rate: 150, currency: 'USD', isActive: true }, - { id: '2', feeName: 'Refrigeration Fee', calculationMethod: 'PER_TON', rate: 25, currency: 'USD', isActive: true }, -]; - -const mockWeightLimitRules = [ - { id: '1', tradeDirection: 'IMPORT', maxWeightTons: 20, exceededAction: 'WARNING_ONLY', isActive: true }, - { id: '2', tradeDirection: 'EXPORT', maxWeightTons: 22, exceededAction: 'BLOCK', isActive: true }, -]; - -// ==================== Toast Component ==================== -const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => { - setTimeout(onClose, 3000); - const bgColor = type === 'success' ? 'bg-green-500' : 'bg-red-500'; - return ( -
- {message} -
- ); -}; - -// ==================== Entity Table Component ==================== -const EntityTable = ({ title, data, columns, onAdd, onEdit, onDelete }: any) => { - const [expanded, setExpanded] = useState(true); - const [searchTerm, setSearchTerm] = useState(''); - - const filteredData = Array.isArray(data) ? data.filter((item: any) => - Object.values(item).some(value => - String(value).toLowerCase().includes(searchTerm.toLowerCase()) - ) - ) : []; - - return ( -
-
setExpanded(!expanded)} - > -
- {expanded ? '▼' : '▶'} -

{title}

- - {filteredData.length} items - -
-
- - {expanded && ( -
-
- -
- setSearchTerm(e.target.value)} - /> - - - -
-
- -
- - - - {columns.map((col: any) => ( - - ))} - - - - - {filteredData.map((item: any) => ( - - {columns.map((col: any) => ( - - ))} - - - ))} - -
- {col.label} - Actions
- {col.render ? col.render(item[col.key], item) : item[col.key]} - - - -
- {filteredData.length === 0 && ( -
No data found
- )} -
-
- )} -
- ); -}; - -// ==================== Main Component ==================== -const ContractTypePage = () => { - const [activeTab, setActiveTab] = useState('cargo-types'); - const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); - - // State for each entity - const [cargoTypes, setCargoTypes] = useState(mockCargoTypes); - const [containerTypes, setContainerTypes] = useState(mockContainerTypes); - const [priorityRules, setPriorityRules] = useState(mockPriorityRules); - const [serviceTypes, setServiceTypes] = useState(mockServiceTypes); - const [surchargeTypes, setSurchargeTypes] = useState(mockSurchargeTypes); - const [surcharges, setSurcharges] = useState(mockSurcharges); - const [weightLimitRules, setWeightLimitRules] = useState(mockWeightLimitRules); - - const showToast = (message: string, type: 'success' | 'error') => { - setToast({ message, type }); - setTimeout(() => setToast(null), 3000); - }; - - const handleAdd = (entity: string) => { - const newId = String(Date.now()); - let newItem; - - switch(entity) { - case 'cargo-types': - newItem = { id: newId, code: 'NEW', cargoTypeName: 'New Type', displayOrder: cargoTypes.length + 1, isActive: true }; - setCargoTypes([...cargoTypes, newItem]); - break; - case 'container-types': - newItem = { id: newId, sizeCode: 'NEW', description: 'New Container', containersPerWagon: 1, isActive: true }; - setContainerTypes([...containerTypes, newItem]); - break; - case 'priority-rules': - newItem = { id: newId, priorityType: 'MEDIUM', ruleName: 'New Rule', bonusPoints: 0, isActive: true }; - setPriorityRules([...priorityRules, newItem]); - break; - case 'service-types': - newItem = { id: newId, code: 'NEW', serviceName: 'New Service', displayOrder: serviceTypes.length + 1, isActive: true }; - setServiceTypes([...serviceTypes, newItem]); - break; - case 'surcharge-types': - newItem = { id: newId, code: 'NEW', name: 'New Surcharge Type', isActive: true }; - setSurchargeTypes([...surchargeTypes, newItem]); - break; - case 'surcharges': - newItem = { id: newId, feeName: 'New Fee', calculationMethod: 'FLAT', rate: 0, currency: 'USD', isActive: true }; - setSurcharges([...surcharges, newItem]); - break; - case 'weight-limit-rules': - newItem = { id: newId, tradeDirection: 'IMPORT', maxWeightTons: 20, exceededAction: 'WARNING_ONLY', isActive: true }; - setWeightLimitRules([...weightLimitRules, newItem]); - break; - } - showToast(`${entity} added successfully`, 'success'); - }; - - const handleEdit = (entity: string, item: any) => { - showToast(`Edit ${item.code || item.sizeCode || item.ruleName || item.serviceName || item.name || item.feeName}`, 'success'); - }; - - const handleDelete = (entity: string, item: any) => { - if (confirm('Are you sure you want to delete this item?')) { - switch(entity) { - case 'cargo-types': - setCargoTypes(cargoTypes.filter(c => c.id !== item.id)); - break; - case 'container-types': - setContainerTypes(containerTypes.filter(c => c.id !== item.id)); - break; - case 'priority-rules': - setPriorityRules(priorityRules.filter(p => p.id !== item.id)); - break; - case 'service-types': - setServiceTypes(serviceTypes.filter(s => s.id !== item.id)); - break; - case 'surcharge-types': - setSurchargeTypes(surchargeTypes.filter(s => s.id !== item.id)); - break; - case 'surcharges': - setSurcharges(surcharges.filter(s => s.id !== item.id)); - break; - case 'weight-limit-rules': - setWeightLimitRules(weightLimitRules.filter(w => w.id !== item.id)); - break; - } - showToast(`${entity} deleted successfully`, 'success'); - } - }; - - const getColumns = (entity: string) => { - switch(entity) { - case 'cargo-types': - return [ - { key: 'code', label: 'Code' }, - { key: 'cargoTypeName', label: 'Name' }, - { key: 'displayOrder', label: 'Order' }, - { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' } - ]; - case 'container-types': - return [ - { key: 'sizeCode', label: 'Size Code' }, - { key: 'description', label: 'Description' }, - { key: 'containersPerWagon', label: 'Containers/Wagon' }, - { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' } - ]; - case 'priority-rules': - return [ - { key: 'priorityType', label: 'Priority Type' }, - { key: 'ruleName', label: 'Rule Name' }, - { key: 'bonusPoints', label: 'Bonus Points' }, - { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' } - ]; - case 'service-types': - return [ - { key: 'code', label: 'Code' }, - { key: 'serviceName', label: 'Service Name' }, - { key: 'displayOrder', label: 'Order' }, - { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' } - ]; - case 'surcharge-types': - return [ - { key: 'code', label: 'Code' }, - { key: 'name', label: 'Name' }, - { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' } - ]; - case 'surcharges': - return [ - { key: 'feeName', label: 'Fee Name' }, - { key: 'calculationMethod', label: 'Method' }, - { key: 'rate', label: 'Rate', render: (val: number, item: any) => `${val} ${item.currency}` }, - { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' } - ]; - case 'weight-limit-rules': - return [ - { key: 'tradeDirection', label: 'Direction' }, - { key: 'maxWeightTons', label: 'Max Weight', render: (val: number) => `${val} tons` }, - { key: 'exceededAction', label: 'Action' }, - { key: 'isActive', label: 'Status', render: (val: boolean) => val ? '✅ Active' : '❌ Inactive' } - ]; - default: - return []; - } - }; - - const getEntityData = (entity: string) => { - switch(entity) { - case 'cargo-types': return cargoTypes; - case 'container-types': return containerTypes; - case 'priority-rules': return priorityRules; - case 'service-types': return serviceTypes; - case 'surcharge-types': return surchargeTypes; - case 'surcharges': return surcharges; - case 'weight-limit-rules': return weightLimitRules; - default: return []; - } - }; - - const tabs = [ - { id: 'cargo-types', label: 'Cargo Types' }, - { id: 'container-types', label: 'Container Types' }, - { id: 'priority-rules', label: 'Priority Rules' }, - { id: 'service-types', label: 'Service Types' }, - { id: 'surcharge-types', label: 'Surcharge Types' }, - { id: 'surcharges', label: 'Surcharges' }, - { id: 'weight-limit-rules', label: 'Weight Limit Rules' }, - ]; - - return ( -
- {toast && setToast(null)} />} - -
-

Rule Engine - Master Data

-

Manage cargo types, container types, priority rules, and more

-
- -
-
- {tabs.map((tab) => ( - - ))} -
-
- -
- {tabs.map((tab) => ( -
- handleAdd(tab.id)} - onEdit={(item: any) => handleEdit(tab.id, item)} - onDelete={(item: any) => handleDelete(tab.id, item)} - /> -
- ))} -
-
- ); -}; - -export default ContractTypePage; \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy 4.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy 4.tsx deleted file mode 100644 index e7b815ae9..000000000 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy 4.tsx +++ /dev/null @@ -1,874 +0,0 @@ -// src/components/ruleEngine/ContractType.tsx -import { createCargoType } from '@/services/rule.engine/cargoType'; -import { useState, useEffect } from 'react'; - -// ==================== API Service ==================== -const API_BASE_URL = 'http://localhost:3001/api'; - -const apiFetch = async (endpoint: string, options?: RequestInit): Promise => { - try { - const url = `${API_BASE_URL}${endpoint}`; - const response = await fetch(url, { - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - ...options, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP ${response.status}: ${errorText || response.statusText}`); - } - - return await response.json(); - } catch (error) { - console.error(`API Error (${endpoint}):`, error); - throw error; - } -}; - -const apiService = { - getCargoTypes: (): Promise => apiFetch('/cargo-types'), - createCargoType: (data: any): Promise => apiFetch('/cargo-types', { method: 'POST', body: JSON.stringify(data) }), - updateCargoType: (id: string, data: any): Promise => apiFetch(`/cargo-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), - deleteCargoType: (id: string): Promise => apiFetch(`/cargo-types/${id}`, { method: 'DELETE' }), - - getContainerTypes: (): Promise => apiFetch('/container-types'), - createContainerType: (data: any): Promise => apiFetch('/container-types', { method: 'POST', body: JSON.stringify(data) }), - updateContainerType: (id: string, data: any): Promise => apiFetch(`/container-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), - deleteContainerType: (id: string): Promise => apiFetch(`/container-types/${id}`, { method: 'DELETE' }), - - getPriorityRules: (): Promise => apiFetch('/priority-rules'), - createPriorityRule: (data: any): Promise => apiFetch('/priority-rules', { method: 'POST', body: JSON.stringify(data) }), - updatePriorityRule: (id: string, data: any): Promise => apiFetch(`/priority-rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), - deletePriorityRule: (id: string): Promise => apiFetch(`/priority-rules/${id}`, { method: 'DELETE' }), - - getServiceTypes: (): Promise => apiFetch('/service-types'), - createServiceType: (data: any): Promise => apiFetch('/service-types', { method: 'POST', body: JSON.stringify(data) }), - updateServiceType: (id: string, data: any): Promise => apiFetch(`/service-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), - deleteServiceType: (id: string): Promise => apiFetch(`/service-types/${id}`, { method: 'DELETE' }), - - getSurchargeTypes: (): Promise => apiFetch('/surcharge-types'), - createSurchargeType: (data: any): Promise => apiFetch('/surcharge-types', { method: 'POST', body: JSON.stringify(data) }), - updateSurchargeType: (id: string, data: any): Promise => apiFetch(`/surcharge-types/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), - deleteSurchargeType: (id: string): Promise => apiFetch(`/surcharge-types/${id}`, { method: 'DELETE' }), - - getSurcharges: (): Promise => apiFetch('/surcharges'), - createSurcharge: (data: any): Promise => apiFetch('/surcharges', { method: 'POST', body: JSON.stringify(data) }), - updateSurcharge: (id: string, data: any): Promise => apiFetch(`/surcharges/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), - deleteSurcharge: (id: string): Promise => apiFetch(`/surcharges/${id}`, { method: 'DELETE' }), - - getWeightLimitRules: (): Promise => apiFetch('/weight-limit-rules'), - createWeightLimitRule: (data: any): Promise => apiFetch('/weight-limit-rules', { method: 'POST', body: JSON.stringify(data) }), - updateWeightLimitRule: (id: string, data: any): Promise => apiFetch(`/weight-limit-rules/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), - deleteWeightLimitRule: (id: string): Promise => apiFetch(`/weight-limit-rules/${id}`, { method: 'DELETE' }), -}; - -// ==================== Toast Component ==================== -const Toast = ({ message, type, onClose }: { message: string; type: string; onClose: () => void }) => { - useEffect(() => { - const timer = setTimeout(onClose, 3000); - return () => clearTimeout(timer); - }, [onClose]); - - const bgColor = type === 'success' ? 'bg-green-500' : 'bg-red-500'; - return ( -
- {message} -
- ); -}; - -// ==================== Modal Component ==================== -const Modal = ({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) => { - if (!isOpen) return null; - - return ( -
-
-
-

{title}

- -
-
{children}
-
-
- ); -}; - -// ==================== Form Components ==================== - -// 1. Cargo Type Form -const CargoTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => { - const [formData, setFormData] = useState({ - code: initialData?.code || '', - cargoTypeName: initialData?.cargoTypeName || '', - parentGroupId: initialData?.parentGroupId || '', - showFreeTextBox: initialData?.showFreeTextBox || false, - requiresDirectorApproval: initialData?.requiresDirectorApproval || false, - isActive: initialData?.isActive !== undefined ? initialData.isActive : true, - displayOrder: initialData?.displayOrder || 1, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const submitData = { - code: formData.code.toUpperCase(), - cargoTypeName: formData.cargoTypeName, - parentGroupId: formData.parentGroupId || undefined, - showFreeTextBox: formData.showFreeTextBox, - requiresDirectorApproval: formData.requiresDirectorApproval, - isActive: formData.isActive, - displayOrder: Number(formData.displayOrder), - }; - onSubmit(submitData); - }; - - return ( -
-
-
- - setFormData({...formData, code: e.target.value})} required /> -
-
- - setFormData({...formData, cargoTypeName: e.target.value})} required /> -
-
-
- - setFormData({...formData, parentGroupId: e.target.value})} /> -
-
-
- - setFormData({...formData, displayOrder: parseInt(e.target.value)})} /> -
-
-
- - - -
-
- - -
-
- ); -}; - -// 2. Container Type Form -const ContainerTypeForm = ({ initialData, onSubmit, onCancel, isSubmitting }: any) => { - const [formData, setFormData] = useState({ - sizeCode: initialData?.sizeCode || '', - description: initialData?.description || '', - containersPerWagon: initialData?.containersPerWagon || 1, - isActive: initialData?.isActive !== undefined ? initialData.isActive : true, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - onSubmit(formData); - }; - - return ( -
-
- - setFormData({...formData, sizeCode: e.target.value})} required /> -
-
- -