diff --git a/.gitignore b/.gitignore index 8eea260e2..9f6cafc6d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,8 @@ coverage/ branch_structure.json temp_auto_push.bat temp_interactive_push.bat + +# emacs cache files +*~ +\#*\# +.\#* diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 164947d90..000000000 --- a/.npmrc +++ /dev/null @@ -1,11 +0,0 @@ -# Increase fetch timeouts for network resilience -fetch-timeout=60000 -fetch-retry-mintimeout=20000 -fetch-retry-maxtimeout=120000 - -# GitHub Packages configuration for @tria-plc scope -@tria-plc:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGE_TOKEN} - -# Default registry for other packages -registry=https://registry.npmjs.org/ 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/README.md b/README.md index 785c9a620..0daf7a2ab 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,194 @@ cd edr-platform ``` ### 2. Install Dependencies +# EDR Platform + +Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library. + +--- + +## Tech Stack + +### Backend + +| Layer | Tech | +| ---------------- | ----------------------------------------------- | +| Runtime | Node.js ≥ 20 | +| Framework | NestJS 11 (modular architecture) | +| Language | TypeScript 5 (strict mode, project-wide) | +| ORM | TypeORM 0.3 (UUID PKs, soft deletes, `snake_case` columns) | +| Database | PostgreSQL 16 (one DB per domain) | +| Validation | class-validator + class-transformer | +| API docs | Swagger via `@nestjs/swagger` | +| Messaging | `@nestjs/microservices` (inter-service ready) | +| Testing | Jest + Supertest | + +### Frontend + +| Layer | Tech | +| ---------------- | ----------------------------------------------- | +| Framework | React 18 + Vite 5 | +| Language | TypeScript 5 (strict) | +| Routing | React Router v6 | +| Styling | Tailwind CSS v4 (`@tailwindcss/vite`) + `tailwind-merge` + `class-variance-authority` | +| UI primitives | Radix UI (meta `radix-ui` package, shadcn-style components) | +| Icons | lucide-react | +| State / Data | Zustand (client state) · TanStack Query (server state) | +| HTTP | Axios | +| Auth UI | `@tria-plc/iamui-common` (external IAM) | + +### Shared Packages + +| Package | Purpose | +| ---------------------- | -------------------------------------------------------------------- | +| `@edr/types` | Shared TypeScript interfaces and enums | +| `@edr/api-common` | NestJS decorators, filters, interceptors, pipes, `BaseEntity`, `BaseRepository` | +| `@edr/ui-common` | Shared React components (`DashboardLayout`, `Sidebar`, `Button`, `Modal`, etc.) and theme tokens | +| `@edr/eslint-config` | Shared ESLint configs (base / nestjs / react) | +| `@edr/tsconfig` | Shared TypeScript configs | +| `@edr/prettier-config` | Shared Prettier configuration | + +### Tooling + +- **pnpm 9** — workspace package manager (sole supported PM) +- **Turborepo 2** — task orchestrator with caching +- **Husky + lint-staged + commitlint** — pre-commit ESLint/Prettier and conventional-commit enforcement +- **Docker Compose** — local Postgres instances + production stack (see `infrastructure/`) +- **Nginx** — reverse proxy / static asset server in production + +### Planned Integrations + +- **MinIO** — S3-compatible object storage for documents (object keys already shaped under `edr-freight/{linkedType}/{ref}/{filename}`) + +--- + +## Architecture + +### High-level layout + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ EDR Platform (monorepo) │ +├──────────────────────┬───────────────────────────────────────────┤ +│ Freight domain │ Passenger domain │ +│ ┌────────────────┐ │ ┌────────────────┐ │ +│ │ freight-portal │ │ │ passenger- │ │ +│ │ (React) │ │ │ portal (React)│ │ +│ ├────────────────┤ │ ├────────────────┤ │ +│ │ freight- │ │ │ passenger- │ │ +│ │ backoffice │ │ │ backoffice │ │ +│ └───────┬────────┘ │ └───────┬────────┘ │ +│ │ │ │ │ +│ ▼ │ ▼ │ +│ ┌────────────────┐ │ ┌────────────────┐ │ +│ │ freight-api │ │ │ passenger-api │ │ +│ │ (NestJS) │ │ │ (NestJS) │ │ +│ └───────┬────────┘ │ └───────┬────────┘ │ +│ │ │ │ │ +│ ▼ │ ▼ │ +│ ┌────────────────┐ │ ┌────────────────┐ │ +│ │ postgres- │ │ │ postgres- │ │ +│ │ freight │ │ │ passenger │ │ +│ └────────────────┘ │ └────────────────┘ │ +└──────────────────────┴───────────────────────────────────────────┘ + Shared (workspace) packages + @edr/types · @edr/api-common · @edr/ui-common · @edr/{eslint,tsconfig,prettier}-config +``` + +### Domain isolation + +- **One database per domain.** `postgres-freight` (port 5433, db `edr_freight`) and `postgres-passenger` (port 5434, db `edr_passenger`). No cross-database joins. Cross-domain data flows only through API calls or message queues. +- **Each domain owns its data model.** Freight bookings/consignments/shipments/trains/invoices/documents live only in the freight DB; passenger journeys/tickets live only in the passenger DB. + +### NestJS module pattern (per feature) + +``` +modules// + entities/.entity.ts // extends BaseEntity (UUID, timestamps, soft delete) + dto/-.dto.ts // class-validator DTOs + .module.ts // wires controller + service + repository + .controller.ts // HTTP layer only — no business logic + .service.ts // business logic + .repository.ts // extends BaseRepository; services inject this, NEVER `Repository` directly +``` + +Conventions enforced across the codebase: + +- All entities have UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). +- All entities inherit `createdAt` / `updatedAt` / `deletedAt` from `BaseEntity` (soft delete). +- DB columns use `snake_case` via `@Column({ name: '...' })`; TS properties stay `camelCase`. +- **No `synchronize: true`** in production — schema changes go through TypeORM migrations. +- ESLint + Prettier run on pre-commit via Husky + lint-staged. +- Conventional-commits enforced via commitlint. + +### Frontend application structure + +``` +apps/edr-freight-web/portal/src/ + App.tsx // routes + sidebar definition + main.tsx // React Router + QueryClient providers + components/ + Breadcrumbs.tsx // shared local UI + ui/ // shadcn-style primitives (Button, Input, Dialog, Label, Textarea) + pages/ + customers/ // CustomersPage + CustomerDetailPage + NewCustomerPage (dialog) + bookings/ // ... + multimodal Transport Legs editor + consignments/ + tracking/ // Shipment grid + table view toggle + trains/ // Fleet roster + billing/ // Invoices + documents/ // MinIO-shaped document library + hooks/ // TanStack Query hooks (per feature) + services/ // Axios clients (per feature) + store/ // Zustand stores + lib/ // utilities (`cn` helper, formatters) +``` + +Each feature folder typically contains: `*Page.tsx` (list), `*DetailPage.tsx`, `New*Page.tsx` (create/edit dialog with `mode: "create" | "edit"`), `Delete*Dialog.tsx`, and a `*.mock.ts` seed file used by the current mock UI. + +### Shared layout (`@edr/ui-common`) + +`DashboardLayout` provides the sidebar + header shell shared across all freight and passenger web apps: + +- **Sidebar** — brand-tinted, icon-led navigation. Main brand color: `#10B981` (`rgb(16, 185, 129)` — emerald-500). Icon containers use the filled style: brand-color background with a white icon. +- **Header** — language picker, notifications, user dropdown (click-driven, click-outside / Escape close); host apps opt into a light/dark theme toggle via `enableThemeToggle` (Tailwind class-based, persisted in `localStorage`). + +### Auth integration (planned) + +Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamapi-common` package. **Do not** implement login/JWT/password logic in this repo. Use placeholder TODO comments next to controllers and `@CurrentUser` decorators (in `@edr/api-common`) until the integration ships. + +--- + +## Apps & Ports + +| App | Package name | Purpose | Port | +| ------------------------------ | --------------------------- | ---------------------------------------- | ---- | +| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight | 3001 | +| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customers | 5173 | +| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight employees | 5183 | +| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passengers | 3002 | +| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customers | 5174 | +| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger employees | 5184 | + +`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds independent `portal/` and `backoffice/` workspace packages declared in `pnpm-workspace.yaml`. + +--- + +## Getting Started + +### Prerequisites + +- Node.js ≥ 20 +- pnpm 9 (`corepack enable && corepack prepare pnpm@9.12.0 --activate`) +- Docker (for local Postgres) + +### Install + ```bash pnpm install ``` +<<<<<<< HEAD ### 3. Environment Configuration ```bash # Copy environment template @@ -765,3 +949,77 @@ For technical support or questions: --- **Built with ❤️ for Ethio-Djibouti Railway** +======= +### Start local databases + +```bash +docker compose -f infrastructure/docker/docker-compose.db.dev.yml up -d +``` + +### Run apps + +```bash +pnpm dev # every app +pnpm dev:freight # freight API + portal + backoffice +pnpm dev:passenger # passenger API + portal + backoffice +``` + +### Common scripts + +| Script | Description | +| ------------------- | ------------------------------------ | +| `pnpm install` | Install workspace dependencies | +| `pnpm dev` | Run every app in watch mode | +| `pnpm build` | Build every package and app | +| `pnpm test` | Run all tests | +| `pnpm lint` | Lint everything | +| `pnpm type-check` | Type-check every package | +| `pnpm format` | Format with Prettier | + +--- + +## Repository Layout + +``` +. +├── apps/ +│ ├── edr-freight-api/ NestJS — freight backend +│ ├── edr-freight-web/ +│ │ ├── portal/ React — freight customer portal +│ │ └── backoffice/ React — freight back-office +│ ├── edr-passenger-api/ NestJS — passenger backend +│ └── edr-passenger-web/ +│ ├── portal/ React — passenger customer portal +│ └── backoffice/ React — passenger back-office +├── packages/ +│ ├── api-common/ Shared NestJS utilities + BaseEntity/Repository +│ ├── types/ Shared TS types/enums +│ ├── ui-common/ Shared React components + theme +│ └── config/ +│ ├── eslint/ @edr/eslint-config +│ ├── tsconfig/ @edr/tsconfig +│ └── prettier/ @edr/prettier-config +├── infrastructure/ +│ ├── docker/ docker-compose files (db.dev / dev / prod) +│ └── nginx/ Production nginx config +├── CLAUDE.md Developer guide for AI-assisted work +├── turbo.json Turborepo task pipeline +├── pnpm-workspace.yaml Workspace manifest +└── README.md +``` + +--- + +## Standards (recap) + +- **TypeScript strict mode** is enabled in every package. +- **pnpm only** — never run `npm install` or `yarn`. +- **Conventional commits** — enforced via commitlint on every commit. +- **NestJS 4-layer pattern** — `module → controller → service → repository`. +- **Repository injection** — services inject the custom `*Repository` class, not `Repository`. +- **Controllers are thin** — no business logic; delegate to services. +- **Migrations only** — never enable TypeORM `synchronize` in production. +- **One DB per domain** — no cross-database joins. + +See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions. +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 diff --git a/WagonForm.tsx b/WagonForm.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index a3ddffc40..73e122a1d 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -5,3 +5,45 @@ DB_PORT=5433 DB_USER=postgres DB_PASSWORD= DB_NAME=edr_freight + +# Telebirr payment gateway (freight merchant credentials) +TELEBIRR_BASE_URL= +TELEBIRR_WEB_BASE_URL= +TELEBIRR_FABRIC_APP_ID= +TELEBIRR_APP_SECRET= +TELEBIRR_MERCHANT_APP_ID= +TELEBIRR_MERCHANT_CODE= +TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr +TELEBIRR_RETURN_URL= +TELEBIRR_TIMEOUT_EXPRESS=15m +TELEBIRR_PRIVATE_KEY= +TELEBIRR_PUBLIC_KEY= +TELEBIRR_INSECURE_TLS=false +# JWT (used by @tria-plc/api-common SharedAuthModule) +JWT_SECRET= +JWT_ACCESS_TOKEN_SECRET= +JWT_REFRESH_TOKEN_SECRET= +JWT_EXPIRES_IN=3600 +# JWT expiry for @tria-plc/api-common token utils (jsonwebtoken timespan format) +JWT_ACCESS_TOKEN_EXPIRES=1h +JWT_REFRESH_TOKEN_EXPIRES=7d + +# IAM seed defaults (used by @tria-plc/iamapi-common on first boot) +SUPER_ADMIN_EMAIL=superadmin@tria.com +SUPER_ADMIN_PHONE= +DEFAULT_PASSWORD=password@tria + +# Freight org + staff (bookings / rule-engine IAM) +SEED_EDR_ORG=true +SEED_FREIGHT_STAFF=true + +# MinIO (used by @tria-plc/iamapi-common for file storage) +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 diff --git a/apps/edr-freight-api/nest-cli.json b/apps/edr-freight-api/nest-cli.json index f9aa683b1..4df6a9aef 100644 --- a/apps/edr-freight-api/nest-cli.json +++ b/apps/edr-freight-api/nest-cli.json @@ -3,6 +3,21 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": false, + "assets": [ + { + "include": "migrations/**/*", + "outDir": "dist" + }, + { + "include": "contracts/templates/**/*", + "watchAssets": true + }, + { + "include": "modules/payment/templates/**/*", + "watchAssets": true + } + ], + "watchAssets": true } } diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 09a312ad6..cd7b0fde7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -4,7 +4,10 @@ "private": true, "description": "EDR Freight Management API", "scripts": { + "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", + "predev": "pnpm run clean", "dev": "nest start --watch", + "prebuild": "pnpm run clean", "build": "nest build", "start": "node dist/main.js", "lint": "eslint src", @@ -13,35 +16,48 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@tria-plc/api-common": "^0.1.0", - "@tria-plc/iamapi-common": "^0.1.0", "@edr/api-common": "workspace:*", + "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", + "@nestjs/event-emitter": "^2.0.4", + "@nestjs/mapped-types": "^2.1.1", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", + "@tria-plc/api-common": "^1.4.0", + "@tria-plc/iamapi-common": "^0.5.1", + "amqp-connection-manager": "^5.0.0", + "amqplib": "^2.0.1", + "axios": "^1.16.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "dotenv": "^17.4.2", + "handlebars": "^4.7.9", + "minio": "7.1.3", "pg": "^8.13.0", + "puppeteer": "^24.2.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", - "typeorm": "^0.3.20" + "rxjs": "^7.8.1" }, "devDependencies": { "@edr/api-common": "workspace:*", - "@edr/types": "workspace:*", "@edr/eslint-config": "workspace:*", "@edr/tsconfig": "workspace:*", + "@edr/types": "workspace:*", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.0", + "@types/amqplib": "^0.10.8", "@types/express": "^5.0.0", "@types/jest": "^29.5.13", + "@types/multer": "^2.1.0", "@types/node": "^20.14.0", + "@types/pg": "^8.6.7", "@types/supertest": "^6.0.2", "jest": "^29.7.0", "supertest": "^7.0.0", @@ -49,6 +65,7 @@ "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", + "typeorm": "^0.3.30", "typescript": "^5.5.4" }, "jest": { diff --git a/apps/edr-freight-api/pnpm-lock.yaml b/apps/edr-freight-api/pnpm-lock.yaml new file mode 100644 index 000000000..7a7a3beb2 --- /dev/null +++ b/apps/edr-freight-api/pnpm-lock.yaml @@ -0,0 +1,206 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@edr/api-common': + specifier: workspace:* + version: link:../../packages/api-common + '@edr/types': + specifier: workspace:* + version: link:../../packages/types + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': + specifier: ^2.1.1 + version: 2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../../packages/config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + +packages: + + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@nestjs/common@11.1.24': + resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + +snapshots: + + '@borewit/text-codec@0.2.2': {} + + '@lukeed/csprng@1.1.0': {} + + '@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + ieee754@1.2.1: {} + + iterare@1.2.1: {} + + load-esm@1.0.3: {} + + ms@2.1.3: {} + + reflect-metadata@0.2.2: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tslib@2.8.1: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3eb7d30ec..76909771d 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,36 +1,128 @@ -import { Module } from "@nestjs/common"; +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"; import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; +import telebirrConfig from "./config/telebirr.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; +import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; -import { TrainsModule } from "./modules/trains/trains.module"; + +// import { TrainsModule } from "./modules/trains/trains.module"; +import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; +import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; +import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; +import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; +import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; import { CustomersModule } from "./modules/customers/customers.module"; +import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; +import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; +import { OtpModule } from './modules/otp/otp.module'; +import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; +import { BackofficeModule } from "./modules/backoffice/backoffice.module"; +import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; +import { FreightAuthModule } from "./modules/auth/freight-auth.module"; +import { + EDR_FREIGHT_APPLICATION, + EDR_FREIGHT_PERMISSIONS, +} from "./seed/edr-freight.seed"; +import { EdrOrgSeeder } from "./seed/edr-org.seeder"; +import { DemoUsersSeeder } from "./seed/demo-users.seeder"; +import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; +import { PaymentModule } from "./modules/payment/payment.module"; +import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder"; +import { PricingDataSeeder } from "./seed/pricing-data.seeder"; +import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +//New Trains, Wagons, Container and Cargo management modules +import { TrainsModule } from "./modules/trains/trains.module"; +import { WagonsModule } from './modules/wagons/wagons.module'; +import { ContainersModule } from './modules/container-management/containers.module'; +import { CargoesModule } from './modules/cargoes/cargoes.module'; +import { RoutesModule } from './modules/routes/routes.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig], + load: [appConfig, databaseConfig, telebirrConfig], }), + // EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ 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({ + applications: [EDR_FREIGHT_APPLICATION], + permissions: EDR_FREIGHT_PERMISSIONS, }), BookingsModule, + FilesModule, ConsignmentsModule, - TrainsModule, + LocomotivesModule, + WagonTypesModule, + TrainSetsModule, + TrainSchedulesModule, + TrainSchedulingModule, CustomersModule, + CompaniesModule, TrackingModule, BillingModule, NotificationsModule, + FileUploadSettingsModule, + DropdownSettingsModule, + OtpModule, + RuleEngineModule, + BackofficeModule, + DemoPermissionsModule, + FreightAuthModule, + PaymentModule, + //New Modules + TrainsModule, + WagonsModule, + ContainersModule, + CargoesModule, + RoutesModule, ], + providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], }) -export class AppModule {} +export class AppModule implements OnApplicationBootstrap { + constructor( + private readonly seeder: DataSeeder, + private readonly edrOrgSeeder: EdrOrgSeeder, + private readonly demoUsersSeeder: DemoUsersSeeder, + private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, + private readonly demoBookingsSeeder: DemoBookingsSeeder, + private readonly pricingDataSeeder: PricingDataSeeder, + private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + ) { } + + async onApplicationBootstrap() { + await this.seeder.run(); + await this.edrOrgSeeder.run(); + await this.demoUsersSeeder.run(); + await this.freightStaffUsersSeeder.run(); + await this.demoBookingsSeeder.run(); + await this.pricingDataSeeder.run(); + await this.fileUploadSettingsSeeder.run(); + } +} diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts new file mode 100644 index 000000000..2ebae8175 --- /dev/null +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -0,0 +1,17 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +import { FreightPermissionGuard } from './freight-permission.guard'; +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +export const BookingStaff = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + FreightPermissionGuard( + Array.isArray(permission) ? permission : [permission], + ), + ), + ); + +export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts new file mode 100644 index 000000000..68def6440 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -0,0 +1,38 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Type, + UnauthorizedException, +} from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { hasFreightPermission } from './freight-permission.util'; + +export function FreightPermissionGuard( + permissions: string[], +): Type { + @Injectable() + class FreightPermissionsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const user = request.user; + + if (!permissions?.length) return true; + if (!user) { + throw new UnauthorizedException('Authentication required'); + } + + if (permissions.some((p) => hasFreightPermission(user, p))) { + return true; + } + + throw new ForbiddenException( + `Missing permission. Required one of: ${permissions.join(', ')}`, + ); + } + } + + return FreightPermissionsGuard; +} diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts new file mode 100644 index 000000000..69596c21d --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -0,0 +1,109 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +const SUPER_ADMIN_ROLE = 'super_admin'; +const ORGANIZATION_ADMIN_ROLE = 'organization_admin'; + +type PermissionLike = { key?: string }; +type MeLikeUser = { + roles?: { key?: string }[]; + permissions?: PermissionLike[]; + employee?: + | { + position?: { permissions?: PermissionLike[] }; + delegatedPositions?: { permissions?: PermissionLike[] }[]; + } + | { + positions?: { permissions?: PermissionLike[] }[]; + }[] + | null; +}; + +export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean { + if (!user?.roles?.length) return false; + return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE); +} + +export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean { + if (!user?.roles?.length) return false; + return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE); +} + +export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean { + return isSuperAdmin(user) || isOrganizationAdmin(user); +} + +/** Flat permission keys from JWT / session user (roles + position permissions). */ +export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { + if (!user) return []; + + const keys = new Set(); + + for (const p of user.permissions ?? []) { + if (p.key) keys.add(p.key); + } + + const employee = user.employee; + if (!employee) { + return [...keys]; + } + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const pos of emp.positions ?? []) { + for (const p of pos.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + } + return [...keys]; + } + + for (const p of employee.position?.permissions ?? []) { + if (p.key) keys.add(p.key); + } + for (const delegated of employee.delegatedPositions ?? []) { + for (const p of delegated.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + + return [...keys]; +} + +export function hasFreightPermission( + user: MeLikeUser | null | undefined, + permissionKey: string, +): boolean { + if (!user) return false; + if (isSuperAdmin(user)) return true; + return collectPermissionKeys(user).includes(permissionKey); +} + +export function assertFreightPermission( + user: TCurrentUser | MeLikeUser | null | undefined, + permissionKey: string, +): void { + if (hasFreightPermission(user, permissionKey)) return; + throw new ForbiddenException(`Missing permission: ${permissionKey}`); +} + +const APPROVE_ROLE_PERMISSION: Record = { + LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff, + DIRECTOR: FREIGHT_PERMS.bookings.approveDirector, + CEO: FREIGHT_PERMS.bookings.approveCeo, +}; + +export function assertCanApproveBookingStep( + user: TCurrentUser | MeLikeUser | null | undefined, + requiredRole: string, +): void { + if (isFreightApprovalAdmin(user)) return; + const perm = APPROVE_ROLE_PERMISSION[requiredRole]; + if (!perm) { + throw new ForbiddenException(`Unknown approval role: ${requiredRole}`); + } + assertFreightPermission(user, perm); +} diff --git a/apps/edr-freight-api/src/common/resolve-auth-user-id.ts b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts new file mode 100644 index 000000000..cab29b671 --- /dev/null +++ b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts @@ -0,0 +1,12 @@ +import { UnauthorizedException } from '@nestjs/common'; + +export type AuthUserPayload = { id?: string; sub?: string } | null | undefined; + +/** Resolve IAM user id from JWT payload attached by JwtGuard. */ +export function resolveAuthUserId(user: AuthUserPayload): string { + const id = user?.id ?? user?.sub; + if (!id) { + throw new UnauthorizedException('Authentication required'); + } + return id; +} diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts new file mode 100644 index 000000000..12ba30e11 --- /dev/null +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -0,0 +1,18 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +import { FreightPermissionGuard } from './freight-permission.guard'; +import { + FREIGHT_PERMS, + type RuleEngineResourceSlug, +} from '../seed/freight-permissions.registry'; + +export const RuleEngineView = (slug: RuleEngineResourceSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])), + ); + +export const RuleEngineManage = (slug: RuleEngineResourceSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), + ); 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 fbcbeb1be..0e7375b19 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -1,19 +1,123 @@ import { registerAs } from "@nestjs/config"; import { TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { join, dirname } from "path"; +import { + DefaultPosition, + DefaultUnit, + EmployeePosition, + Employee, + OrganizationConfiguration, + GlobalOrganizationConfiguration, + OrganizationType, + Organization, + PositionConfiguration, + PositionPermission, + PositionTypeConfiguration, + PositionTypePermission, + PositionType, + Position, + Project, + GlobalUnitConfiguration, + Unit, + EmployeeSignature, + EmployeeStamp, + RecordFooter, + RecordHeader, + Seal, + AccountConfiguration, + Application, + DocumentaryRequirement, + Permission, + RolePermission, + Role, + Session, + UserCredential, + UserDocument, + UserRole, + UserVerification, + User, + Notification, + NotificationEvent, + NotificationPlaceholder, + NotificationReceiverField, + NotificationReceiver, + NotificationTemplate, +} from "@tria-plc/iamapi-common"; +import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity"; +import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas"; -export default registerAs( - "database", - (): TypeOrmModuleOptions => ({ +const iamEntities = [ + DefaultPosition, + DefaultUnit, + EmployeePosition, + Employee, + OrganizationConfiguration, + GlobalOrganizationConfiguration, + OrganizationSetting, + OrganizationType, + Organization, + PositionConfiguration, + PositionPermission, + PositionTypeConfiguration, + PositionTypePermission, + PositionType, + Position, + Project, + GlobalUnitConfiguration, + Unit, + EmployeeSignature, + EmployeeStamp, + RecordFooter, + RecordHeader, + Seal, + AccountConfiguration, + Application, + DocumentaryRequirement, + Permission, + RolePermission, + Role, + Session, + UserCredential, + UserDocument, + UserRole, + UserVerification, + User, + Notification, + NotificationEvent, + NotificationPlaceholder, + NotificationReceiverField, + NotificationReceiver, + NotificationTemplate, +]; + +const iamMigrationsGlob = join( + dirname(require.resolve("@tria-plc/iamapi-common/package.json")), + "dist/db/migrations/*.js", +); +const freightMigrationsGlob = join(__dirname, "../migrations/*.js"); + +export default registerAs("database", (): TypeOrmModuleOptions => { + return { type: "postgres", host: process.env.DB_HOST ?? "localhost", port: parseInt(process.env.DB_PORT ?? "5433", 10), username: process.env.DB_USER ?? "postgres", password: process.env.DB_PASSWORD ?? "", database: process.env.DB_NAME ?? "edr_freight", - entities: [__dirname + "/../**/*.entity.{ts,js}"], - migrations: [__dirname + "/../../migrations/*.{ts,js}"], - // Never enable synchronize in production. Use migrations. - synchronize: process.env.NODE_ENV === "development", + schema: "public", + extra: { + options: `-c search_path=${APPLICATION_SEARCH_PATH}`, + }, + entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], + autoLoadEntities: true, + migrations: [ + // IAM schema + tables must be created before freight migrations + iamMigrationsGlob, + freightMigrationsGlob, + ], + migrationsRun: true, + // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). + synchronize: false, logging: process.env.NODE_ENV === "development", - }), -); + }; +}); diff --git a/apps/edr-freight-api/src/config/dmoney.config.ts b/apps/edr-freight-api/src/config/dmoney.config.ts new file mode 100644 index 000000000..7922b4aae --- /dev/null +++ b/apps/edr-freight-api/src/config/dmoney.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("dmoney", () => ({ + baseUrl: process.env.DMONEY_BASE_URL ?? "", + appId: process.env.DMONEY_APP_ID ?? "", + appSecret: process.env.DMONEY_APP_SECRET ?? "", + publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", + privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "" +})); 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/config/telebirr.config.ts b/apps/edr-freight-api/src/config/telebirr.config.ts new file mode 100644 index 000000000..8e5d1712a --- /dev/null +++ b/apps/edr-freight-api/src/config/telebirr.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("telebirr", () => ({ + baseUrl: process.env.TELEBIRR_BASE_URL ?? "", + webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "", + fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "", + appSecret: process.env.TELEBIRR_APP_SECRET ?? "", + merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "", + merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "", + notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "", + returnUrl: process.env.TELEBIRR_RETURN_URL ?? "", + timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m", + privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "", + publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "", + insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true", +})); diff --git a/apps/edr-freight-api/src/contracts/contract-clause-packs.ts b/apps/edr-freight-api/src/contracts/contract-clause-packs.ts new file mode 100644 index 000000000..2d73cd50d --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-clause-packs.ts @@ -0,0 +1,224 @@ +import type { + Article1Clause, + ContractClausePack, + ContractDirection, + ContractFreight, + ContractServiceScope, +} from './contract-template.types'; + +const STANDARD_CONTRACT_DOCUMENTS = [ + 'Amendments (if any)', + 'This Contract Agreement', + 'Final Minutes of Negotiation (if any)', +]; + +const PAYMENT_OBLIGATION = + 'Pay 100% transportation fees in advance per train set in accordance with Article 5.'; + +const HAZARDOUS_OBLIGATION = + 'Notify EDR 48 hours in advance for hazardous or valuable cargo.'; + +function clonePack(pack: ContractClausePack): ContractClausePack { + return { + article1: { + objective: pack.article1.objective, + scope: [...pack.article1.scope], + }, + clientObligations: [...pack.clientObligations], + providerObligations: [...pack.providerObligations], + contractDocuments: [...pack.contractDocuments], + }; +} + +function applyForwardingOverlay( + pack: ContractClausePack, + service: ContractServiceScope, +): ContractClausePack { + if (service !== 'FORWARDING') return pack; + + const next = clonePack(pack); + next.article1.scope.push( + 'First-mile and/or last-mile coordination, documentation, and handover with road or port partners where included in the agreed service scope.', + ); + next.providerObligations.push( + 'Coordinate first-mile and last-mile logistics with designated partners and keep the Client informed of handover milestones.', + ); + return next; +} + +function buildImportContainerPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.', + scope: [ + 'Railway transport service on the agreed import corridor.', + 'Cargo handling at Galaan Multipurpose port (GMP) where applicable.', + ], + }, + clientObligations: [ + 'Provide shipment instructions to EDR for container movements on the agreed corridor.', + 'Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.', + 'Submit required documents to Djibouti Nagad station at least 24 hours before loading.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign voyage per operational schedule and notify train schedule 48 hours in advance.', + 'Provide safe transportation and deliver within agreed timelines when documents are complete.', + 'Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.', + 'Maintain cargo liability insurance per wagon.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +function buildImportBulkPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for bulk cargo from SGTD railway freight station at Djibouti to designated Ethiopian rail terminals on the import corridor.', + scope: [ + 'Railway bulk transport service on the agreed import corridor.', + 'Loading and unloading coordination at designated terminals per EDR operational rules.', + ], + }, + clientObligations: [ + 'Provide accurate commodity description, weight, and shipment instructions for each train movement.', + 'Ensure cargo is prepared and available at origin per the agreed loading window.', + 'Submit required customs and operational documents at least 24 hours before loading where applicable.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.', + 'Provide safe bulk transportation and deliver within agreed timelines when documents are complete.', + 'Maintain cargo liability insurance per wagon or train consist as applicable.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +function buildExportContainerPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for 40ft and/or 20ft full containers from designated Ethiopian dry ports and terminals to SGTD and related export corridors.', + scope: [ + 'Railway export transport service on the agreed corridor.', + 'Terminal coordination at origin yards for export dispatch where applicable.', + ], + }, + clientObligations: [ + 'Provide export shipment instructions and container release details for each movement.', + 'Ensure containers are available at origin terminals per EDR operational windows.', + 'Submit required export, customs, and operational documents at origin at least 24 hours before loading.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign voyage per operational schedule and notify train schedule 48 hours in advance.', + 'Provide safe transportation to SGTD and hand over for export processing when documents are complete.', + 'Maintain cargo liability insurance per wagon.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +function buildExportBulkPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for bulk export cargo from designated Ethiopian rail terminals to SGTD and related export corridors.', + scope: [ + 'Railway bulk export transport on the agreed corridor.', + 'Loading coordination at origin terminals per EDR operational rules.', + ], + }, + clientObligations: [ + 'Provide accurate commodity description, weight, and export shipment instructions.', + 'Ensure bulk cargo is prepared and available at origin per the agreed loading window.', + 'Submit required export and customs documents at least 24 hours before loading where applicable.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.', + 'Provide safe bulk transportation to SGTD within agreed timelines when documents are complete.', + 'Maintain cargo liability insurance per wagon or train consist as applicable.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +function buildDomesticContainerPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for 40ft and/or 20ft containers between designated Ethiopian rail terminals on the domestic corridor.', + scope: ['Domestic railway container transport between agreed origin and destination yards.'], + }, + clientObligations: [ + 'Provide shipment instructions for each domestic container movement.', + 'Ensure containers are available at origin per EDR operational rules.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign voyage per operational schedule and notify train schedule 48 hours in advance where practicable.', + 'Provide safe transportation and deliver within agreed timelines when instructions are complete.', + 'Maintain cargo liability insurance per wagon.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +function buildDomesticBulkPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for bulk cargo between designated Ethiopian rail terminals on the domestic corridor.', + scope: ['Domestic railway bulk transport between agreed origin and destination terminals.'], + }, + clientObligations: [ + 'Provide commodity description, weight, and shipment instructions for each movement.', + 'Ensure cargo is prepared at origin per the agreed loading window.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.', + 'Provide safe bulk transportation within agreed timelines.', + 'Maintain cargo liability insurance per wagon or train consist as applicable.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +const BASE_PACKS: Record ContractClausePack>> = { + IMP: { + CON: buildImportContainerPack, + BULK: buildImportBulkPack, + }, + EXP: { + CON: buildExportContainerPack, + BULK: buildExportBulkPack, + }, + DOM: { + CON: buildDomesticContainerPack, + BULK: buildDomesticBulkPack, + }, +}; + +export function buildClausePack( + direction: ContractDirection, + freight: ContractFreight, + service: ContractServiceScope, +): ContractClausePack { + const base = BASE_PACKS[direction][freight](); + return applyForwardingOverlay(base, service); +} + +export function article1ObjectiveFromClause(article1: Article1Clause): string { + return article1.objective; +} diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts new file mode 100644 index 000000000..9e0acc6fb --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts @@ -0,0 +1,116 @@ +import { existsSync } from 'fs'; + +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; + +const MIN_VALID_PDF_BYTES = 2_000; + +const PDF_PRINT_STYLES = ` +`; + +@Injectable() +export class ContractPdfService { + private readonly logger = new Logger(ContractPdfService.name); + + async htmlToPdfBuffer(html: string): Promise { + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + + try { + const puppeteer = await import('puppeteer'); + const launchOptions: import('puppeteer').LaunchOptions = { + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + ], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { + waitUntil: 'load', + timeout: 60_000, + }); + await page.emulateMediaType('print'); + await new Promise((resolve) => setTimeout(resolve, 400)); + + const pdf = await page.pdf({ + format: 'A4', + printBackground: true, + displayHeaderFooter: true, + headerTemplate: '', + footerTemplate: + '
Page of
', + margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error( + `Puppeteer produced invalid PDF (${buffer.length} bytes)`, + ); + } + this.logger.log( + `Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (err) { + this.logger.error( + `Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`, + ); + throw new InternalServerErrorException( + 'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes('contract-pdf-print-fix')) return html; + if (html.includes('')) { + return html.replace('', `${PDF_PRINT_STYLES}`); + } + return `${PDF_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + ]; + return candidates.find((p) => existsSync(p)); + } + + private isValidPdf(buffer: Buffer): boolean { + return ( + buffer.length >= MIN_VALID_PDF_BYTES && + buffer.subarray(0, 5).toString('ascii') === '%PDF-' + ); + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts new file mode 100644 index 000000000..dd961a14b --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; + +import { BookingPricingService } from '../modules/bookings/booking-pricing.service'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { PriceLineItemDto } from '../modules/bookings/dto/generate-price-response.dto'; + +export interface PricingScheduleRow { + label: string; + description: string; + amount: number; + currency: string; +} + +export interface PricingSchedule { + lineItems: PricingScheduleRow[]; + surcharges: PricingScheduleRow[]; + totalAmount: number; + currency: string; + equipmentReturn?: string; + originLabel: string; + destinationLabel: string; + containerLines: Array<{ + label: string; + quantity: number; + vgmPerUnitTons: number; + }>; +} + +@Injectable() +export class ContractPricingScheduleBuilder { + constructor(private readonly pricingService: BookingPricingService) {} + + async build(booking: Booking): Promise { + const { lineItems, totalAmount, currency } = + await this.pricingService.computeContractLineItems(booking); + + const isSurcharge = (l: PriceLineItemDto) => + l.code.includes('SURCHARGE') || l.description.toLowerCase().includes('surcharge'); + + const baseLines = lineItems.filter((l) => !isSurcharge(l)); + const surchargeLines = lineItems.filter(isSurcharge); + + return { + lineItems: baseLines.map((l) => ({ + label: l.code, + description: l.description, + amount: l.amount, + currency: l.currency, + })), + surcharges: surchargeLines.map((l) => ({ + label: l.code, + description: l.description, + amount: l.amount, + currency: l.currency, + })), + totalAmount, + currency, + equipmentReturn: booking.equipmentReturn ?? '—', + originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—', + destinationLabel: + booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—', + containerLines: (booking.bookingContainers ?? []).map((c) => ({ + label: + c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId, + quantity: c.quantity, + vgmPerUnitTons: Number(c.vgmPerUnitTons), + })), + }; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts new file mode 100644 index 000000000..a66a516c0 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts @@ -0,0 +1,82 @@ +import { ContractRendererService } from './contract-renderer.service'; +import { getTemplateMeta } from './contract-template.registry'; +import type { ContractViewModel } from './contract-view-model.builder'; + +describe('ContractRendererService', () => { + const renderer = new ContractRendererService(); + renderer.onModuleInit(); + + function minimalView(templateKey: string): ContractViewModel { + const template = getTemplateMeta(templateKey); + return { + bookingId: 'test-id', + reference: 'BK-TEST-001', + status: 'CONTRACT_READY', + templateKey, + template, + contractDate: '1 January 2026', + contractYear: 2026, + client: { + companyName: 'Test Co', + companyAddress: 'Addis Ababa', + companyLocation: 'Ethiopia', + phone: '+251900000000', + email: 'test@example.com', + tinNumber: '1234567890', + vatNumber: 'VAT-001', + fanNumber: 'FAN-001', + businessLicense: 'BL-001', + }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: { + originLabel: 'SGTD', + destinationLabel: 'Modjo', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + serviceType: 'Rail transport', + scheduledDate: '1 January 2026', + contractType: 'NEW', + cargoDescription: 'Container cargo', + totalWeightVgm: '24 tons', + equipmentReturn: 'RETURN', + hazardousLabel: 'No', + firstMilePickupAddress: '—', + lastMileDeliveryAddress: '—', + }, + pricing: { + lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }], + surcharges: [], + totalAmount: 1000, + currency: 'ETB', + originLabel: 'SGTD', + destinationLabel: 'Modjo', + containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }], + }, + signatures: [], + canSignCustomer: true, + canSignStaff: false, + hasContractDocument: false, + hasCustomerSignature: false, + hasStaffSignature: false, + }; + } + + it('renders import flagship with Nagad and Article 5', () => { + const html = renderer.render(minimalView('IMP_CON_ETB_TRANSPORT_ONLY')); + expect(html).toContain('Djibouti Nagad'); + expect(html).toContain('Article 5: Contract Price'); + expect(html).toContain('Article 2: Obligations of the Client'); + }); + + it('renders export variant without import empty-return clause', () => { + const html = renderer.render(minimalView('EXP_CON_USD_TRANSPORT_ONLY')); + expect(html).toContain('export corridors'); + expect(html).not.toContain('Return empty containers from Dire Dawa'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts new file mode 100644 index 000000000..dd539df25 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts @@ -0,0 +1,51 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import Handlebars from 'handlebars'; + +import { ContractViewModel } from './contract-view-model.builder'; + +@Injectable() +export class ContractRendererService implements OnModuleInit { + private readonly templatesDir = path.join(__dirname, 'templates'); + private readonly compiled = new Map(); + + onModuleInit(): void { + Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b); + + const partialsDir = path.join(this.templatesDir, '_partials'); + if (fs.existsSync(partialsDir)) { + for (const file of fs.readdirSync(partialsDir)) { + if (!file.endsWith('.hbs')) continue; + const name = file.replace(/\.hbs$/, ''); + const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8'); + Handlebars.registerPartial(name, content); + } + } + } + + render(view: ContractViewModel): string { + const fileName = + view.template.templateFile ?? 'generic.hbs'; + const template = this.getCompiled(fileName); + return template({ + ...view, + paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD', + }); + } + + private getCompiled(fileName: string): Handlebars.TemplateDelegate { + const cached = this.compiled.get(fileName); + if (cached) return cached; + + const filePath = path.join(this.templatesDir, fileName); + const fallbackPath = path.join(this.templatesDir, 'generic.hbs'); + const source = fs.existsSync(filePath) + ? fs.readFileSync(filePath, 'utf-8') + : fs.readFileSync(fallbackPath, 'utf-8'); + + const compiled = Handlebars.compile(source); + this.compiled.set(fileName, compiled); + return compiled; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts new file mode 100644 index 000000000..2c921889a --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts @@ -0,0 +1,68 @@ +import { + CONTRACT_TEMPLATE_KEYS, + CONTRACT_TEMPLATE_REGISTRY, + getTemplateMeta, + isValidTemplateKey, +} from './contract-template.registry'; + +describe('ContractTemplateRegistry', () => { + it('defines exactly 24 template keys', () => { + expect(CONTRACT_TEMPLATE_KEYS).toHaveLength(24); + expect(Object.keys(CONTRACT_TEMPLATE_REGISTRY)).toHaveLength(24); + }); + + it('keys match the direction_freight_currency_service pattern', () => { + for (const key of CONTRACT_TEMPLATE_KEYS) { + expect(isValidTemplateKey(key)).toBe(true); + } + }); + + it('each meta has non-empty obligations and article1 scope', () => { + for (const key of CONTRACT_TEMPLATE_KEYS) { + const meta = CONTRACT_TEMPLATE_REGISTRY[key]!; + expect(meta.clientObligations.length).toBeGreaterThan(0); + expect(meta.providerObligations.length).toBeGreaterThan(0); + expect(meta.article1.scope.length).toBeGreaterThan(0); + expect(meta.article1.objective.length).toBeGreaterThan(0); + expect(meta.contractDocuments.length).toBeGreaterThan(0); + } + }); + + it('IMP_CON_ETB_TRANSPORT_ONLY retains import container flagship clauses', () => { + const meta = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY'); + expect(meta.direction).toBe('IMP'); + expect(meta.freight).toBe('CON'); + expect(meta.article1.objective).toContain('SGTD'); + expect(meta.article1.objective).toContain('empty container return'); + const clientText = meta.clientObligations.join(' '); + expect(clientText).toContain('Djibouti Nagad'); + const providerText = meta.providerObligations.join(' '); + expect(providerText).toContain('seven (7) calendar days'); + }); + + it('EXP_CON_USD_TRANSPORT_ONLY uses export-oriented article1', () => { + const meta = getTemplateMeta('EXP_CON_USD_TRANSPORT_ONLY'); + expect(meta.direction).toBe('EXP'); + expect(meta.article1.objective).toContain('SGTD'); + expect(meta.providerObligations.join(' ')).not.toContain( + 'Return empty containers from Dire Dawa', + ); + }); + + it('FORWARDING adds scope and provider obligations', () => { + const transport = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY'); + const forwarding = getTemplateMeta('IMP_CON_ETB_FORWARDING'); + expect(forwarding.article1.scope.length).toBeGreaterThan( + transport.article1.scope.length, + ); + expect(forwarding.providerObligations.length).toBeGreaterThan( + transport.providerObligations.length, + ); + }); + + it('getTemplateMeta fallback includes clause arrays for unknown keys', () => { + const meta = getTemplateMeta('UNKNOWN_KEY'); + expect(meta.clientObligations.length).toBeGreaterThan(0); + expect(meta.article1.scope.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-template.registry.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.ts new file mode 100644 index 000000000..3e91e1b4f --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.registry.ts @@ -0,0 +1,119 @@ +import { + article1ObjectiveFromClause, + buildClausePack, +} from './contract-clause-packs'; +import type { + ContractDirection, + ContractFreight, + ContractServiceScope, + ContractTemplateMeta, +} from './contract-template.types'; + +export type { ContractTemplateMeta } from './contract-template.types'; + +const DIRECTION_LABELS: Record = { + IMP: 'Import', + EXP: 'Export', + DOM: 'Domestic', +}; + +const FREIGHT_LABELS: Record = { + CON: 'Container', + BULK: 'Bulk', +}; + +const DIRECTIONS: ContractDirection[] = ['IMP', 'EXP', 'DOM']; +const FREIGHTS: ContractFreight[] = ['CON', 'BULK']; +const CURRENCIES = ['ETB', 'USD'] as const; +const SERVICES: ContractServiceScope[] = ['TRANSPORT_ONLY', 'FORWARDING']; + +const KEY_PATTERN = + /^(IMP|EXP|DOM)_(CON|BULK)_(ETB|USD)_(TRANSPORT_ONLY|FORWARDING)$/; + +function buildMeta( + dir: ContractDirection, + freight: ContractFreight, + currency: string, + service: ContractServiceScope, +): ContractTemplateMeta { + const key = `${dir}_${freight}_${currency}_${service}`; + const dirLabel = DIRECTION_LABELS[dir]; + const freightLabel = FREIGHT_LABELS[freight]; + const serviceLabel = + service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only'; + + const corridor = + dir === 'IMP' + ? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable' + : dir === 'EXP' + ? 'from Ethiopian dry ports to SGTD and related export corridors' + : 'between designated Ethiopian rail terminals'; + + const clauses = buildClausePack(dir, freight, service); + + return { + key, + direction: dir, + freight, + currency, + serviceScope: service, + title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`, + directionLabel: dirLabel, + freightLabel, + whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti Railway line. The Service Provider has agreed to provide services per this contract.`, + article1Objective: article1ObjectiveFromClause(clauses.article1), + article1: clauses.article1, + clientObligations: clauses.clientObligations, + providerObligations: clauses.providerObligations, + contractDocuments: clauses.contractDocuments, + }; +} + +/** Full template matrix (24 keys). */ +export const CONTRACT_TEMPLATE_REGISTRY: Record = + {}; + +for (const dir of DIRECTIONS) { + for (const freight of FREIGHTS) { + for (const currency of CURRENCIES) { + for (const service of SERVICES) { + const meta = buildMeta(dir, freight, currency, service); + CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta; + } + } + } +} + +export const CONTRACT_TEMPLATE_KEYS = Object.keys(CONTRACT_TEMPLATE_REGISTRY); + +export function listTemplateKeys(): string[] { + return CONTRACT_TEMPLATE_KEYS; +} + +export function getTemplateMeta(key: string): ContractTemplateMeta { + const found = CONTRACT_TEMPLATE_REGISTRY[key]; + if (found) return found; + + const fallbackClauses = buildClausePack('IMP', 'CON', 'TRANSPORT_ONLY'); + return { + key, + direction: 'IMP', + freight: 'CON', + currency: 'USD', + serviceScope: 'TRANSPORT_ONLY', + title: 'Freight Contract Agreement', + directionLabel: 'Freight', + freightLabel: 'Cargo', + whereas: + 'The parties agree to railway freight services as described in the schedule below.', + article1Objective: article1ObjectiveFromClause(fallbackClauses.article1), + article1: fallbackClauses.article1, + clientObligations: fallbackClauses.clientObligations, + providerObligations: fallbackClauses.providerObligations, + contractDocuments: fallbackClauses.contractDocuments, + }; +} + +export function isValidTemplateKey(key: string): boolean { + return KEY_PATTERN.test(key); +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts b/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts new file mode 100644 index 000000000..f4cb1c4ee --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts @@ -0,0 +1,65 @@ +import { ContractTemplateResolver } from './contract-template.resolver'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; + +describe('ContractTemplateResolver', () => { + const resolver = new ContractTemplateResolver(); + + function booking(partial: Partial): Booking { + return partial as Booking; + } + + it('resolves import container ETB transport-only', () => { + const key = resolver.resolve( + booking({ + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + paymentCurrency: 'ETB', + serviceType: { code: 'RAIL_ONLY', includesFirstMile: false, includesLastMile: false } as ServiceType, + }), + ); + expect(key).toBe('IMP_CON_ETB_TRANSPORT_ONLY'); + }); + + it('resolves export bulk USD forwarding', () => { + const key = resolver.resolve( + booking({ + tradeDirection: 'EXPORT', + freightType: 'BULK', + paymentCurrency: 'USD', + serviceType: { + code: 'RAIL_FORWARDING', + includesFirstMile: true, + includesLastMile: false, + } as ServiceType, + }), + ); + expect(key).toBe('EXP_BULK_USD_FORWARDING'); + }); + + it('maps BREAK_BULK cargo to BULK freight', () => { + const key = resolver.resolve( + booking({ + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + paymentCurrency: 'ETB', + cargoType: { code: 'BREAK_BULK_GENERAL' } as CargoType, + serviceType: undefined, + }), + ); + expect(key).toBe('IMP_BULK_ETB_TRANSPORT_ONLY'); + }); + + it('resolves domestic container', () => { + const key = resolver.resolve( + booking({ + tradeDirection: 'DOMESTIC', + freightType: 'CONTAINER', + paymentCurrency: 'USD', + serviceType: undefined, + }), + ); + expect(key).toBe('DOM_CON_USD_TRANSPORT_ONLY'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-template.resolver.ts b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts new file mode 100644 index 000000000..daa48a4e7 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; + +@Injectable() +export class ContractTemplateResolver { + resolve(booking: Booking): string { + const dir = + booking.tradeDirection === 'IMPORT' + ? 'IMP' + : booking.tradeDirection === 'EXPORT' + ? 'EXP' + : 'DOM'; + + let freight = booking.freightType === 'BULK' ? 'BULK' : 'CON'; + const cargoCode = (booking.cargoType as CargoType | undefined)?.code ?? ''; + if (cargoCode.startsWith('BREAK_BULK')) { + freight = 'BULK'; + } + + const currency = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; + const service = this.resolveServiceScope(booking.serviceType); + + return `${dir}_${freight}_${currency}_${service}`; + } + + private resolveServiceScope( + serviceType?: ServiceType | null, + ): 'TRANSPORT_ONLY' | 'FORWARDING' { + if (!serviceType) return 'TRANSPORT_ONLY'; + const code = (serviceType.code ?? '').toUpperCase(); + if ( + serviceType.includesFirstMile || + serviceType.includesLastMile || + code.includes('FORWARD') + ) { + return 'FORWARDING'; + } + return 'TRANSPORT_ONLY'; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.types.ts b/apps/edr-freight-api/src/contracts/contract-template.types.ts new file mode 100644 index 000000000..b056b4c69 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.types.ts @@ -0,0 +1,35 @@ +export type ContractDirection = 'IMP' | 'EXP' | 'DOM'; +export type ContractFreight = 'CON' | 'BULK'; +export type ContractServiceScope = 'TRANSPORT_ONLY' | 'FORWARDING'; + +export interface Article1Clause { + objective: string; + scope: string[]; +} + +export interface ContractClausePack { + article1: Article1Clause; + clientObligations: string[]; + providerObligations: string[]; + contractDocuments: string[]; +} + +export interface ContractTemplateMeta { + key: string; + direction: ContractDirection; + freight: ContractFreight; + currency: string; + serviceScope: ContractServiceScope; + title: string; + directionLabel: string; + freightLabel: string; + whereas: string; + /** Summary line for APIs; mirrors article1.objective */ + article1Objective: string; + article1: Article1Clause; + clientObligations: string[]; + providerObligations: string[]; + contractDocuments: string[]; + /** Optional dedicated .hbs file; otherwise uses generic.hbs */ + templateFile?: string; +} diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts new file mode 100644 index 000000000..676e4f4ba --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -0,0 +1,207 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { BookingsRepository } from '../modules/bookings/bookings.repository'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { + BookingContractSignature, + ContractSignerRole, +} from '../modules/bookings/entities/booking-contract-signature.entity'; +import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder'; +import { ContractTemplateResolver } from './contract-template.resolver'; +import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; + +export interface ContractSignatureView { + role: ContractSignerRole; + signerDisplayName: string; + signedAt: string; + signatureImageUrl?: string | null; +} + +export interface ContractViewModel { + bookingId: string; + reference: string; + status: string; + templateKey: string; + template: ContractTemplateMeta; + contractDate: string; + contractYear: number; + client: { + companyName: string; + companyAddress: string; + companyLocation: string; + phone: string; + email: string; + tinNumber: string; + vatNumber: string; + fanNumber: string; + businessLicense: string; + }; + provider: { + name: string; + address: string; + phone: string; + email: string; + tinNumber: string; + }; + schedule: { + originLabel: string; + destinationLabel: string; + tradeDirection: string; + freightType: string; + serviceType: string; + scheduledDate: string; + contractType: string; + cargoDescription: string; + totalWeightVgm: string; + equipmentReturn: string; + hazardousLabel: string; + firstMilePickupAddress: string; + lastMileDeliveryAddress: string; + }; + pricing: PricingSchedule; + signatures: ContractSignatureView[]; + canSignCustomer: boolean; + canSignStaff: boolean; + hasContractDocument: boolean; + hasCustomerSignature: boolean; + hasStaffSignature: boolean; +} + +@Injectable() +export class ContractViewModelBuilder { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly templateResolver: ContractTemplateResolver, + private readonly pricingBuilder: ContractPricingScheduleBuilder, + ) {} + + async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + + const templateKey = + booking.contractTemplateKey ?? this.templateResolver.resolve(booking); + const template = getTemplateMeta(templateKey); + const pricing = await this.pricingBuilder.build(booking); + const signatures = await this.loadSignatures(bookingId); + + const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); + const hasStaff = signatures.some((s) => s.role === 'STAFF'); + const hasContractFile = Boolean( + booking.files?.some((f) => f.code === 'contract'), + ); + + const view: ContractViewModel = { + bookingId: booking.id, + reference: booking.reference, + status: booking.status, + templateKey, + template, + contractDate: new Date().toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }), + contractYear: new Date().getFullYear(), + client: { + companyName: booking.company?.name ?? 'Client', + companyAddress: this.valueOrDash(booking.company?.address), + companyLocation: this.valueOrDash(booking.company?.country), + phone: this.valueOrDash(booking.company?.phone), + email: this.valueOrDash(booking.company?.email), + tinNumber: this.valueOrDash(booking.company?.tin), + vatNumber: this.valueOrDash(booking.company?.vatNumber), + fanNumber: this.valueOrDash(booking.company?.fanNumber), + businessLicense: this.valueOrDash(booking.company?.businessLicense), + }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: this.buildSchedule(booking), + pricing, + signatures, + canSignCustomer: + booking.status === 'CONTRACT_READY' && !hasCustomer, + canSignStaff: + booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, + hasContractDocument: hasContractFile, + hasCustomerSignature: hasCustomer, + hasStaffSignature: hasStaff, + }; + + return { booking, view }; + } + + private async loadSignatures(bookingId: string): Promise { + const rows = await this.bookingsRepository.findContractSignatures(bookingId); + return rows.map((s) => this.toSignatureView(s)); + } + + toSignatureView(row: BookingContractSignature): ContractSignatureView { + return { + role: row.signerRole, + signerDisplayName: row.signerDisplayName, + signedAt: this.formatDate(row.signedAt), + signatureImageUrl: row.signatureFile?.url ?? null, + }; + } + + private buildSchedule(booking: Booking): ContractViewModel['schedule'] { + const cargoName = + booking.freightType === 'BULK' + ? booking.cargoFreeText || + booking.cargoType?.cargoTypeName || + 'Bulk commodity' + : booking.cargoType?.cargoTypeName || 'Container cargo'; + const totalWeight = Number(booking.cargoTotalWeightVgm || 0); + + return { + originLabel: this.yardLabel(booking.originYard), + destinationLabel: this.yardLabel(booking.destinationYard), + tradeDirection: this.valueOrDash(booking.tradeDirection), + freightType: this.valueOrDash(booking.freightType), + serviceType: this.valueOrDash( + booking.serviceType?.serviceName ?? booking.serviceType?.code, + ), + scheduledDate: this.formatDate(booking.scheduledDate), + contractType: this.valueOrDash(booking.contractType), + cargoDescription: this.valueOrDash(cargoName), + totalWeightVgm: + totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—', + equipmentReturn: this.valueOrDash(booking.equipmentReturn), + hazardousLabel: booking.isHazardous ? 'Yes' : 'No', + firstMilePickupAddress: this.valueOrDash( + booking.firstMilePickupAddress, + ), + lastMileDeliveryAddress: this.valueOrDash( + booking.lastMileDeliveryAddress, + ), + }; + } + + private yardLabel(yard?: { label?: string; code?: string } | null): string { + return this.valueOrDash(yard?.label ?? yard?.code); + } + + private formatDate(value?: Date | string | null): string { + if (!value) return '—'; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } + + private valueOrDash(value?: string | number | null): string { + if (value === undefined || value === null || value === '') return '—'; + return String(value); + } +} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs new file mode 100644 index 000000000..d8b3d3fe1 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs @@ -0,0 +1,15 @@ +
+

Article 1: Objective and Scope of Services

+

+ 1.1 Objective. + {{template.article1.objective}} +

+ {{#if template.article1.scope.length}} +

1.2 Scope of Services.

+
    + {{#each template.article1.scope}} +
  1. {{this}}
  2. + {{/each}} +
+ {{/if}} +
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs new file mode 100644 index 000000000..cb3440739 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -0,0 +1,66 @@ +

Article 5: Contract Price and Terms of Payment

+
+

Contract Price

+

+ The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate + schedule, and any approved operational surcharges. +

+ + + + + + + + + + + + + + + +
Corridor{{pricing.originLabel}} → {{pricing.destinationLabel}}Currency{{pricing.currency}}
Payment currency{{paymentArticle}}Equipment return{{pricing.equipmentReturn}}
+ {{#if pricing.equipmentReturn}} +

Equipment return: {{pricing.equipmentReturn}}

+ {{/if}} + +

Charges

+ + + + + + {{#each pricing.lineItems}} + + + + + + {{/each}} + {{#if pricing.surcharges.length}} + + + + {{#each pricing.surcharges}} + + + + + + {{/each}} + {{/if}} + + + + + +
ItemDescriptionAmount
{{label}}{{description}}{{currency}} {{amount}}
Surcharges and Adjustments
{{label}}{{description}}{{currency}} {{amount}}
Total contract value{{pricing.currency}} {{pricing.totalAmount}}
+

Terms of payment

+

+ Unless otherwise agreed in writing, the Client shall settle the contract value in + {{paymentArticle}} before the service is performed and in accordance with EDR payment + instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the + responsibility of the Client where applicable. +

+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs new file mode 100644 index 000000000..8a82c72d4 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs @@ -0,0 +1,19 @@ +
+

Article 2: Obligations of the Client

+

The Client shall perform the following obligations in good faith and within the operational timelines communicated by EDR:

+
    + {{#each template.clientObligations}} +
  1. {{this}}
  2. + {{/each}} +
+
+ +
+

Article 3: Obligations of the Service Provider

+

EDR shall provide the agreed railway freight services in accordance with this Agreement and applicable operational rules:

+
    + {{#each template.providerObligations}} +
  1. {{this}}
  2. + {{/each}} +
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs new file mode 100644 index 000000000..adb1ac797 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs @@ -0,0 +1,9 @@ +
+

Article 6: Contract Documents

+

The following documents form part of this Agreement and shall be read together with the signed contract:

+
    + {{#each template.contractDocuments}} +
  1. {{this}}
  2. + {{/each}} +
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs new file mode 100644 index 000000000..9531ac4ae --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs @@ -0,0 +1,65 @@ +
+

Booking Schedule and Commercial Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Route{{schedule.originLabel}} → {{schedule.destinationLabel}}Trade direction{{schedule.tradeDirection}}
Freight type{{schedule.freightType}}Service type{{schedule.serviceType}}
Scheduled date{{schedule.scheduledDate}}Contract type{{schedule.contractType}}
Cargo{{schedule.cargoDescription}}Total VGM{{schedule.totalWeightVgm}}
Equipment return{{schedule.equipmentReturn}}Hazardous cargo{{schedule.hazardousLabel}}
First mile{{schedule.firstMilePickupAddress}}Last mile{{schedule.lastMileDeliveryAddress}}
+ + {{#if pricing.containerLines.length}} +

Container Details

+ + + + + + + + + + {{#each pricing.containerLines}} + + + + + + {{/each}} + +
Container typeQuantityVGM / unit (tons)
{{label}}{{quantity}}{{vgmPerUnitTons}}
+ {{/if}} +
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs new file mode 100644 index 000000000..d8c1d1287 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs @@ -0,0 +1,12 @@ +
+

Article 4: Force Majeure

+

+ Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control, + including natural disaster, war, civil unrest, government restriction, railway interruption, port closure, + or other force majeure events interpreted under the Ethiopian Civil Code. +

+

+ The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the + effect of the force majeure event on the performance of this Agreement. +

+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs new file mode 100644 index 000000000..05dd450e4 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs @@ -0,0 +1,44 @@ +
+
+

For the Service Provider

+

{{provider.name}}

+ {{#if hasStaffSignature}} + {{#each signatures}} + {{#if (eq role "STAFF")}} +
+ {{#if signatureImageUrl}}Staff signature{{/if}} +
+

Name: {{signerDisplayName}}

+

Role: Authorized EDR representative

+

Date: {{signedAt}}

+ {{/if}} + {{/each}} + {{else}} +
Signature pending
+

Name: Authorized representative

+

Role: EDR representative

+

Date:

+ {{/if}} +
+
+

For the Client

+

{{client.companyName}}

+ {{#if hasCustomerSignature}} + {{#each signatures}} + {{#if (eq role "CUSTOMER")}} +
+ {{#if signatureImageUrl}}Customer signature{{/if}} +
+

Name: {{signerDisplayName}}

+

Role: Authorized client representative

+

Date: {{signedAt}}

+ {{/if}} + {{/each}} + {{else}} +
Signature pending
+

Name: Client representative

+

Role: Authorized client representative

+

Date:

+ {{/if}} +
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs new file mode 100644 index 000000000..43a5495ec --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -0,0 +1,258 @@ + diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs new file mode 100644 index 000000000..75f795bce --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs @@ -0,0 +1,91 @@ + + + + + {{template.title}} — {{reference}} + {{> styles}} + + +
+
+
+
EDR
+
+

Ethio-Djibouti Standard Gauge Railway Share Company

+

Freight Transport Contract

+
+
+ +
+

Contract Agreement

+

{{template.title}}

+

{{template.directionLabel}} • {{template.freightLabel}} • {{template.currency}} • {{template.serviceScope}}

+
+ + + + + + + + + + + + + + +
Contract Ref No.{{reference}}Contract Year{{contractYear}}
Contract Date{{contractDate}}Status{{status}}
+
+ +
+

Parties to the Agreement

+

+ This Contract Agreement is made on {{contractDate}} between the Service Provider and the Client named below. +

+ +
+
+

Service Provider

+

{{provider.name}}

+
+
Address
{{provider.address}}
+
Phone
{{provider.phone}}
+
Email
{{provider.email}}
+
TIN
{{provider.tinNumber}}
+
+
+
+

Client

+

{{client.companyName}}

+
+
Address
{{client.companyAddress}}
+
Location
{{client.companyLocation}}
+
Phone
{{client.phone}}
+
Email
{{client.email}}
+
TIN
{{client.tinNumber}}
+
VAT
{{client.vatNumber}}
+
FAN
{{client.fanNumber}}
+
Business license
{{client.businessLicense}}
+
+
+
+
+ + {{> contract_schedule}} + +
+

Whereas

+

{{template.whereas}}

+

Now therefore, the parties agree as follows:

+
+ + {{> article1}} + {{> articles_obligations}} + {{> force_majeure}} + {{> article5_pricing}} + {{> contract_documents}} + {{> signatures_block}} +
+ + diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts new file mode 100644 index 000000000..a29fb861e --- /dev/null +++ b/apps/edr-freight-api/src/data-source.ts @@ -0,0 +1,21 @@ +// apps/edr-freight-api/src/data-source.ts +import 'dotenv/config'; +import { DataSource } from 'typeorm'; +//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed + +export const AppDataSource = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST ?? 'localhost', + port: Number(process.env.DB_PORT ?? 5432), + username: process.env.DB_USER ?? 'postgres', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_NAME ?? 'edr_freight', + schema: 'freight', // default schema for entities without an explicit schema + entities: [__dirname + '/**/*.entity{.ts,.js}'], + migrations: [__dirname + '/migrations/*{.ts,.js}'], + synchronize: false, + logging: true, +}); + +// Optional: call ensurePostgresSchemas before initializing +// But you can also run it separately. diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 91910e8e5..a76378b0c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -1,4 +1,6 @@ import "reflect-metadata"; +import * as dotenv from "dotenv"; +dotenv.config(); import { NestFactory } from "@nestjs/core"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { @@ -10,7 +12,31 @@ import { import { AppModule } from "./app.module"; async function bootstrap() { - const app = await NestFactory.create(AppModule, { cors: true }); + const app = await NestFactory.create(AppModule); + + // Dev CORS: reflect any localhost origin and allow credentials so the + // freight portal (5173), passenger portal (5174), backoffices (5183/5184) + // and any other dev port can call the API with cookies + Authorization. + // For production, restrict `origin` to known FQDNs. + + app.enableCors({ + origin: true, // reflect request origin + credentials: true, + methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Accept", + "Authorization", + "X-Requested-With", + // IAM context headers required by @tria-plc/api-common's JwtGuard + "organization-unit-id", + "delegator-position-id", + "current-project-id", + "current-position-id", + ], + exposedHeaders: ["Content-Disposition"], + maxAge: 86400, // cache preflight for 24h to cut chatter in dev + }); app.setGlobalPrefix("api"); app.useGlobalPipes(createValidationPipe()); @@ -27,9 +53,12 @@ async function bootstrap() { SwaggerModule.setup("api/docs", app, document); const port = parseInt(process.env.PORT ?? "3001", 10); - await app.listen(port); + // await app.listen(port, "0.0.0.0"); + await app.listen( + + port) // eslint-disable-next-line no-console - console.log(`[freight-api] listening on http://localhost:${port}`); + console.log(`[freight-api] listening on port ${port}`); } bootstrap(); diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts new file mode 100644 index 000000000..65052bad4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -0,0 +1,228 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from "typeorm"; + +export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInterface { + name = "AddServiceTypesAndCargoTypes1748427600000"; + + public async up(queryRunner: QueryRunner): Promise { + // Create service_types table + if (!(await queryRunner.hasTable("freight.service_types"))) await queryRunner.createTable( + new Table({ + name: "service_types", + schema: "freight", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "uuid_generate_v4()", + }, + { + name: "service_name", + type: "varchar", + length: "255", + isNullable: false, + }, + { + name: "description", + type: "text", + isNullable: true, + }, + { + name: "can_be_booked_alone", + type: "boolean", + default: true, + isNullable: false, + }, + { + name: "includes_first_mile", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "includes_last_mile", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "includes_customs", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "priority_bonus_points", + type: "int", + default: 0, + isNullable: false, + }, + { + name: "is_active", + type: "boolean", + default: true, + isNullable: false, + }, + { + name: "display_order", + type: "int", + default: 1, + isNullable: false, + }, + { + name: "created_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "updated_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "deleted_at", + type: "timestamptz", + isNullable: true, + }, + ], + }), + true, + ); + + // Create indexes for service_types + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + + // Create cargo_types table + if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable( + new Table({ + name: "cargo_types", + schema: "freight", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "uuid_generate_v4()", + }, + { + name: "cargo_type_name", + type: "varchar", + length: "255", + isNullable: false, + }, + { + name: "parent_group_id", + type: "uuid", + isNullable: true, + }, + { + name: "show_free_text_box", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "requires_director_approval", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "is_active", + type: "boolean", + default: true, + isNullable: false, + }, + { + name: "display_order", + type: "int", + default: 1, + isNullable: false, + }, + { + name: "created_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "updated_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "deleted_at", + type: "timestamptz", + isNullable: true, + }, + ], + }), + true, + ); + + // Create indexes for cargo_types + await queryRunner.createIndex( + "freight.cargo_types", + new TableIndex({ + name: "IDX_CARGO_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + await queryRunner.createIndex( + "freight.cargo_types", + new TableIndex({ + name: "IDX_CARGO_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + await queryRunner.createIndex( + "freight.cargo_types", + new TableIndex({ + name: "IDX_CARGO_TYPES_PARENT_GROUP_ID", + columnNames: ["parent_group_id"], + }), + ); + + // Create self-referencing foreign key for cargo_types + await queryRunner.createForeignKey( + "freight.cargo_types", + new TableForeignKey({ + name: "FK_CARGO_TYPES_PARENT_GROUP", + columnNames: ["parent_group_id"], + referencedSchema: "freight", + referencedTableName: "cargo_types", + referencedColumnNames: ["id"], + onDelete: "SET NULL", + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop foreign key first + await queryRunner.dropForeignKey("freight.cargo_types", "FK_CARGO_TYPES_PARENT_GROUP"); + + // Drop cargo_types table + await queryRunner.dropTable("freight.cargo_types", true); + + // Drop service_types table + await queryRunner.dropTable("freight.service_types", true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts b/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts new file mode 100644 index 000000000..e1a9f6aeb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts @@ -0,0 +1,293 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, + TableColumn, +} from 'typeorm'; + +export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterface { + name = 'AddRuleEngineTablesAndCodes1748514000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. Add `code` column to existing tables ─────────────────────────── + + if (!(await queryRunner.hasColumn('freight.service_types', 'code'))) { + await queryRunner.addColumn( + 'freight.service_types', + new TableColumn({ + name: 'code', + type: 'varchar', + length: '50', + isNullable: true, + }), + ); + } + await queryRunner.query( + `UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL OR code = ''`, + ); + await queryRunner.query( + `UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`, + ); + const serviceTypesCodeIdx = await queryRunner.query( + `SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_service_types_code' LIMIT 1`, + ); + if (serviceTypesCodeIdx.length === 0) { + await queryRunner.createIndex( + 'freight.service_types', + new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }), + ); + } + + if (!(await queryRunner.hasColumn('freight.cargo_types', 'code'))) { + await queryRunner.addColumn( + 'freight.cargo_types', + new TableColumn({ + name: 'code', + type: 'varchar', + length: '50', + isNullable: true, + }), + ); + } + await queryRunner.query( + `UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL OR code = ''`, + ); + await queryRunner.query( + `UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`, + ); + const cargoTypesCodeIdx = await queryRunner.query( + `SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_cargo_types_code' LIMIT 1`, + ); + if (cargoTypesCodeIdx.length === 0) { + await queryRunner.createIndex( + 'freight.cargo_types', + new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }), + ); + } + + // ── 2. surcharge_types ──────────────────────────────────────────────── + + if (!(await queryRunner.hasTable('freight.surcharge_types'))) await queryRunner.createTable( + new Table({ + name: 'surcharge_types', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'code', type: 'varchar', length: '50', isNullable: false }, + { name: 'name', type: 'varchar', length: '100', isNullable: false }, + { name: 'description', type: 'text', isNullable: true }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'freight.surcharge_types', + new TableIndex({ name: 'IDX_surcharge_types_code', columnNames: ['code'], isUnique: true }), + ); + await queryRunner.createIndex( + 'freight.surcharge_types', + new TableIndex({ name: 'IDX_surcharge_types_is_active', columnNames: ['is_active'] }), + ); + + // ── 3. surcharges ───────────────────────────────────────────────────── + + if (!(await queryRunner.hasTable('freight.surcharges'))) await queryRunner.createTable( + new Table({ + name: 'surcharges', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'surcharge_type_id', type: 'uuid', isNullable: false }, + { name: 'fee_name', type: 'varchar', length: '255', isNullable: false }, + { name: 'trigger_description', type: 'text', isNullable: true }, + { + name: 'calculation_method', + type: 'enum', + enum: ['PER_TON', 'FLAT_FEE', 'PERCENTAGE'], + default: `'PER_TON'`, + }, + { name: 'rate', type: 'numeric', precision: 10, scale: 2, isNullable: false }, + { name: 'currency', type: 'char', length: '3', default: `'USD'` }, + { name: 'apply_to_rail', type: 'boolean', default: false }, + { name: 'apply_to_first_mile', type: 'boolean', default: false }, + { name: 'apply_to_last_mile', type: 'boolean', default: false }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'freight.surcharges', + new TableForeignKey({ + name: 'FK_surcharges_surcharge_type', + columnNames: ['surcharge_type_id'], + referencedTableName: 'freight.surcharge_types', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + await queryRunner.createIndex( + 'freight.surcharges', + new TableIndex({ name: 'IDX_surcharges_surcharge_type_id', columnNames: ['surcharge_type_id'] }), + ); + await queryRunner.createIndex( + 'freight.surcharges', + new TableIndex({ name: 'IDX_surcharges_is_active', columnNames: ['is_active'] }), + ); + + // ── 4. container_types ──────────────────────────────────────────────── + + if (!(await queryRunner.hasTable('freight.container_types'))) await queryRunner.createTable( + new Table({ + name: 'container_types', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'size_code', type: 'varchar', length: '20', isNullable: false }, + { name: 'description', type: 'varchar', length: '100', isNullable: true }, + { name: 'containers_per_wagon', type: 'int', isNullable: false }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'freight.container_types', + new TableIndex({ name: 'IDX_container_types_size_code', columnNames: ['size_code'], isUnique: true }), + ); + await queryRunner.createIndex( + 'freight.container_types', + new TableIndex({ name: 'IDX_container_types_is_active', columnNames: ['is_active'] }), + ); + + // ── 5. weight_limit_rules ───────────────────────────────────────────── + + if (!(await queryRunner.hasTable('freight.weight_limit_rules'))) await queryRunner.createTable( + new Table({ + name: 'weight_limit_rules', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'container_type_id', type: 'uuid', isNullable: false }, + { + name: 'trade_direction', + type: 'enum', + enum: ['IMPORT', 'EXPORT', 'BOTH'], + isNullable: false, + }, + { name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false }, + { name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false }, + { + name: 'exceeded_action', + type: 'enum', + enum: ['WARNING_ONLY', 'HARD_BLOCK'], + default: `'WARNING_ONLY'`, + }, + { name: 'surcharge_id', type: 'uuid', isNullable: true }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'freight.weight_limit_rules', + new TableForeignKey({ + name: 'FK_weight_limit_rules_container_type', + columnNames: ['container_type_id'], + referencedTableName: 'freight.container_types', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + await queryRunner.createForeignKey( + 'freight.weight_limit_rules', + new TableForeignKey({ + name: 'FK_weight_limit_rules_surcharge', + columnNames: ['surcharge_id'], + referencedTableName: 'freight.surcharges', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + await queryRunner.createIndex( + 'freight.weight_limit_rules', + new TableIndex({ name: 'IDX_weight_limit_rules_container_type_id', columnNames: ['container_type_id'] }), + ); + await queryRunner.createIndex( + 'freight.weight_limit_rules', + new TableIndex({ name: 'IDX_weight_limit_rules_surcharge_id', columnNames: ['surcharge_id'] }), + ); + await queryRunner.createIndex( + 'freight.weight_limit_rules', + new TableIndex({ name: 'IDX_weight_limit_rules_is_active', columnNames: ['is_active'] }), + ); + + // ── 6. priority_rules ───────────────────────────────────────────────── + + if (!(await queryRunner.hasTable('freight.priority_rules'))) await queryRunner.createTable( + new Table({ + name: 'priority_rules', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { + name: 'priority_type', + type: 'enum', + enum: ['USD_PAYER', 'RAIL_AND_FORWARDING', 'GOVERNMENT_ACCOUNT', 'HIGH_VOLUME_SHIPMENT'], + isNullable: false, + }, + { name: 'rule_name', type: 'varchar', length: '255', isNullable: false }, + { name: 'description', type: 'text', isNullable: true }, + { name: 'activation_condition', type: 'text', isNullable: true }, + { name: 'bonus_points', type: 'int', default: 0 }, + { name: 'is_active', type: 'boolean', default: false }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'freight.priority_rules', + new TableIndex({ name: 'IDX_priority_rules_priority_type', columnNames: ['priority_type'], isUnique: true }), + ); + await queryRunner.createIndex( + 'freight.priority_rules', + new TableIndex({ name: 'IDX_priority_rules_is_active', columnNames: ['is_active'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.priority_rules', true); + await queryRunner.dropTable('freight.weight_limit_rules', true); + await queryRunner.dropTable('freight.container_types', true); + await queryRunner.dropTable('freight.surcharges', true); + await queryRunner.dropTable('freight.surcharge_types', true); + await queryRunner.dropIndex('freight.cargo_types', 'IDX_cargo_types_code'); + await queryRunner.dropColumn('freight.cargo_types', 'code'); + await queryRunner.dropIndex('freight.service_types', 'IDX_service_types_code'); + await queryRunner.dropColumn('freight.service_types', 'code'); + } +} 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..36e848e64 --- /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/migrations/1749200000000-BookingFlowRefactor.ts b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts new file mode 100644 index 000000000..162672727 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BookingFlowRefactor1749200000000 implements MigrationInterface { + name = 'BookingFlowRefactor1749200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_review_note ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + author_id UUID, + note TEXT NOT NULL, + type VARCHAR(30) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id + ON freight.booking_review_note(booking_id); + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID, + ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS contract_summary TEXT, + ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ; + `); + + await queryRunner.query(` + UPDATE freight.bookings SET status = 'SUBMITTED' + WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED'); + UPDATE freight.bookings SET status = 'REJECTED' + WHERE status = 'QUOTATION_REJECTED'; + UPDATE freight.bookings SET status = 'CANCELLED' + WHERE status = 'CANCELLED'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS locked_at, + DROP COLUMN IF EXISTS contract_summary, + DROP COLUMN IF EXISTS marketing_approved_at, + DROP COLUMN IF EXISTS marketing_approved_by_id; + `); + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts b/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts new file mode 100644 index 000000000..c02c9fb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts @@ -0,0 +1,115 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm'; + +export class CreateCompaniesModule1749200000000 implements MigrationInterface { + name = 'CreateCompaniesModule1749200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'companies', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'name', type: 'varchar', length: '200' }, + { name: 'type', type: 'varchar', length: '32' }, + { name: 'status', type: 'varchar', length: '32', default: "'pending'" }, + { name: 'tin', type: 'varchar', length: '10', isUnique: true }, + { name: 'vat_number', type: 'varchar', length: '50', isNullable: true }, + { name: 'business_license', type: 'varchar', length: '100', isNullable: true }, + { name: 'fan_number', type: 'varchar', length: '16', isNullable: true }, + { name: 'country', type: 'varchar', length: '32', default: "'Ethiopia'" }, + { name: 'address', type: 'text', isNullable: true }, + { name: 'phone', type: 'varchar', length: '20', isNullable: true }, + { name: 'email', type: 'varchar', length: '150', isNullable: true }, + { name: 'website', type: 'varchar', length: '200', isNullable: true }, + { name: 'attributes', type: 'jsonb', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'external_profiles', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'user_id', type: 'uuid' }, + { name: 'company_id', type: 'uuid' }, + { name: 'first_name', type: 'varchar', length: '100' }, + { name: 'last_name', type: 'varchar', length: '100' }, + { name: 'email', type: 'varchar', length: '150', isUnique: true }, + { name: 'phone', type: 'varchar', length: '20', isNullable: true }, + { name: 'national_id', type: 'varchar', length: '50', isNullable: true }, + { name: 'job_title', type: 'varchar', length: '100', isNullable: true }, + { name: 'is_primary_contact', type: 'boolean', default: false }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['company_id'], + referencedTableName: 'companies', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'ff_clients', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'forwarder_company_id', type: 'uuid' }, + { name: 'client_company_id', type: 'uuid' }, + { name: 'relationship_type', type: 'varchar', length: '32', default: "'managed_account'" }, + { name: 'can_book_on_behalf', type: 'boolean', default: true }, + { name: 'can_view_documents', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['forwarder_company_id'], + referencedTableName: 'companies', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }, + { + columnNames: ['client_company_id'], + referencedTableName: 'companies', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }, + ], + }), + true, + ); + + await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['tin'] })); + await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['type'] })); + await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['user_id'] })); + await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['company_id'] })); + await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['forwarder_company_id'] })); + await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['client_company_id'] })); + + await queryRunner.createUniqueConstraint('freight.ff_clients', new TableUnique({ + columnNames: ['forwarder_company_id', 'client_company_id'], + })); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.ff_clients'); + await queryRunner.dropTable('freight.external_profiles'); + await queryRunner.dropTable('freight.companies'); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts new file mode 100644 index 000000000..795d93fc3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingFreightType1749300000000 implements MigrationInterface { + name = 'AddBookingFreightType1749300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20); + `); + + await queryRunner.query(` + UPDATE freight.bookings b + SET freight_type = 'CONTAINER' + WHERE EXISTS ( + SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id + ); + `); + + await queryRunner.query(` + UPDATE freight.bookings b + SET freight_type = 'BULK' + WHERE freight_type IS NULL + AND b.cargo_type_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM freight.cargo_types ct + WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true + ); + `); + + await queryRunner.query(` + UPDATE freight.bookings + SET freight_type = 'CONTAINER' + WHERE freight_type IS NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN cargo_type_id DROP NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN freight_type SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD CONSTRAINT chk_bookings_freight_type + CHECK (freight_type IN ('CONTAINER', 'BULK')); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type; + `); + await queryRunner.query(` + UPDATE freight.bookings SET cargo_type_id = ( + SELECT id FROM freight.cargo_types LIMIT 1 + ) WHERE cargo_type_id IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN cargo_type_id SET NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts new file mode 100644 index 000000000..4df7ea4ce --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFanNumberToCompanies1749300000000 implements MigrationInterface { + name = 'AddFanNumberToCompanies1749300000000'; + + public async up(queryRunner: QueryRunner): Promise { + // fan_number may already exist when CreateCompaniesModule ran with the full schema + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS fan_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts new file mode 100644 index 000000000..8126b91ca --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddContractSignatures1749400000000 implements MigrationInterface { + name = 'AddContractSignatures1749400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80), + ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + signer_role VARCHAR(20) NOT NULL, + signer_user_id UUID, + signer_display_name VARCHAR(200) NOT NULL, + signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL, + consent_text TEXT, + ip_address VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_booking_contract_signatures_role + UNIQUE (booking_id, signer_role) + ); + CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id + ON freight.booking_contract_signatures(booking_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS pricing_breakdown, + DROP COLUMN IF EXISTS contract_generated_at, + DROP COLUMN IF EXISTS contract_template_key; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts new file mode 100644 index 000000000..5e61797da --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts @@ -0,0 +1,153 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTrainScheduling1749400000000 implements MigrationInterface { + name = 'AddTrainScheduling1749400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + capacity_tons NUMERIC(10,3) NOT NULL, + length_meters NUMERIC(10,3) NOT NULL, + max_wagons_per_train INT NULL, + supported_load_types TEXT[] NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.locomotives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(100) NULL, + max_pull_weight_tons NUMERIC(10,3) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', + available_from TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + locomotive_id UUID NOT NULL, + total_weight_tons NUMERIC(10,3) NOT NULL, + total_length_meters NUMERIC(10,3) NOT NULL, + wagon_count INT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id) + REFERENCES freight.locomotives(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_set_wagons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_id UUID NOT NULL, + wagon_type_id UUID NOT NULL, + sequence_no INT NOT NULL, + capacity_tons NUMERIC(10,3) NOT NULL, + length_meters NUMERIC(10,3) NOT NULL, + assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no), + CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id) + REFERENCES freight.train_sets(id) ON DELETE CASCADE, + CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_id UUID NOT NULL UNIQUE, + origin_station_id UUID NOT NULL, + destination_station_id UUID NOT NULL, + scheduled_departure_date TIMESTAMPTZ NOT NULL, + scheduled_arrival_date TIMESTAMPTZ NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id) + REFERENCES freight.train_sets(id), + CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id) + REFERENCES freight.yards(id), + CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id) + REFERENCES freight.yards(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id UUID NOT NULL, + booking_id UUID NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id), + CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id) + REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_wagon_id UUID NOT NULL, + booking_id UUID NOT NULL, + allocated_weight_tons NUMERIC(10,3) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id) + REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE, + CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_locomotives_status + ON freight.locomotives(status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_sets_status + ON freight.train_sets(status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status + ON freight.train_schedules(scheduled_departure_date, status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking + ON freight.wagon_booking_allocations(booking_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts b/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts new file mode 100644 index 000000000..25fbe1806 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompanyIdToBookings1749500000000 implements MigrationInterface { + name = 'AddCompanyIdToBookings1749500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN customer_id DROP NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS company_id UUID; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_company_id + ON freight.bookings(company_id); + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_id' + ) THEN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_company_id" + FOREIGN KEY (company_id) + REFERENCES freight.companies(id); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_company_id"; + `); + + await queryRunner.query(` + UPDATE freight.bookings SET customer_id = company_id WHERE customer_id IS NULL AND company_id IS NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN customer_id SET NOT NULL; + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_bookings_company_id; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS company_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts b/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts new file mode 100644 index 000000000..a1830c370 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlocksRoleToApprovalStep1749600000000 implements MigrationInterface { + name = 'AddBlocksRoleToApprovalStep1749600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + ADD COLUMN IF NOT EXISTS blocks_role VARCHAR(30) NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + DROP COLUMN IF EXISTS blocks_role; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts b/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts new file mode 100644 index 000000000..f972f50c2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seed ITMLS US-06 approval chains if missing (standard + bulk). + */ +export class SeedDefaultApprovalRules1749700000000 implements MigrationInterface { + name = 'SeedDefaultApprovalRules1749700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), false, 1, 'LINE_STAFF', 'Review & Approve', NULL, now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = false AND step_order = 1 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), false, 2, 'DIRECTOR', 'Final Signature', 'LINE_STAFF', now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = false AND step_order = 2 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), true, 1, 'DIRECTOR', 'Review & Approve', 'LINE_STAFF', now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = true AND step_order = 1 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), true, 2, 'CEO', 'Final Signature', NULL, now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = true AND step_order = 2 AND deleted_at IS NULL + ); + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Keep seeded rules on rollback to avoid breaking in-flight bookings. + } +} diff --git a/apps/edr-freight-api/src/migrations/1749800000000-AddShippingLinesCodeUniqueIndex.ts b/apps/edr-freight-api/src/migrations/1749800000000-AddShippingLinesCodeUniqueIndex.ts new file mode 100644 index 000000000..374fea724 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749800000000-AddShippingLinesCodeUniqueIndex.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm'; + +/** + * shipping_lines was created without a unique index on code; seeder upserts require it. + */ +export class AddShippingLinesCodeUniqueIndex1749800000000 implements MigrationInterface { + name = 'AddShippingLinesCodeUniqueIndex1749800000000'; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND tablename = 'shipping_lines' AND indexdef ILIKE '%UNIQUE%code%' LIMIT 1`, + ); + if (existing.length === 0) { + await queryRunner.createIndex( + 'freight.shipping_lines', + new TableIndex({ + name: 'UQ_shipping_lines_code', + columnNames: ['code'], + isUnique: true, + }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('freight.shipping_lines', 'UQ_shipping_lines_code'); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749900000000-CreateFileUploadSettingsTables.ts b/apps/edr-freight-api/src/migrations/1749900000000-CreateFileUploadSettingsTables.ts new file mode 100644 index 000000000..cbbf914d4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749900000000-CreateFileUploadSettingsTables.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * file_upload_settings / file_upload_fields entities had no migration; seeder requires both tables. + */ +export class CreateFileUploadSettingsTables1749900000000 implements MigrationInterface { + name = 'CreateFileUploadSettingsTables1749900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.file_upload_settings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + code VARCHAR(128) NOT NULL, + label VARCHAR(256) NOT NULL, + description TEXT, + entity VARCHAR(32) NOT NULL DEFAULT 'other', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_settings_code" + ON freight.file_upload_settings (code); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.file_upload_fields ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + setting_id UUID NOT NULL, + file_key VARCHAR(128) NOT NULL, + file_label VARCHAR(256) NOT NULL, + help_text TEXT, + is_required BOOLEAN NOT NULL DEFAULT false, + is_multiple BOOLEAN NOT NULL DEFAULT false, + max_files INTEGER NOT NULL DEFAULT 1, + allowed_extensions TEXT[] NOT NULL DEFAULT '{}'::text[], + max_size_mb INTEGER NOT NULL DEFAULT 10, + display_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + CONSTRAINT "CHK_file_upload_fields_max_files" CHECK (max_files > 0), + CONSTRAINT "CHK_file_upload_fields_max_size_mb" CHECK (max_size_mb > 0), + CONSTRAINT "FK_file_upload_fields_setting" + FOREIGN KEY (setting_id) + REFERENCES freight.file_upload_settings(id) + ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_fields_setting_file_key" + ON freight.file_upload_fields (setting_id, file_key); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_fields CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_settings CASCADE`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000000-AddCompanyContactColumns.ts b/apps/edr-freight-api/src/migrations/1750000000000-AddCompanyContactColumns.ts new file mode 100644 index 000000000..56d41edf9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000000-AddCompanyContactColumns.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompanyContactColumns1750000000000 implements MigrationInterface { + name = 'AddCompanyContactColumns1750000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_name VARCHAR(100);`); + await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_phone VARCHAR(20);`); + await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_name VARCHAR(100);`); + await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_email VARCHAR(150);`); + await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_phone VARCHAR(20);`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_phone;`); + await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_email;`); + await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_name;`); + await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_phone;`); + await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_name;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000000-AddTrainExtendedColumns.ts b/apps/edr-freight-api/src/migrations/1750000000000-AddTrainExtendedColumns.ts new file mode 100644 index 000000000..b249bd198 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000000-AddTrainExtendedColumns.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Train entity gained extended fields; baseline trains table only had code/capacity/status/notes. + */ +export class AddTrainExtendedColumns1750000000000 implements MigrationInterface { + name = 'AddTrainExtendedColumns1750000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.trains + ADD COLUMN IF NOT EXISTS train_number VARCHAR(20), + ADD COLUMN IF NOT EXISTS train_name VARCHAR(100), + ADD COLUMN IF NOT EXISTS route_id UUID, + ADD COLUMN IF NOT EXISTS origin_station_id UUID, + ADD COLUMN IF NOT EXISTS destination_station_id UUID, + ADD COLUMN IF NOT EXISTS departure_time TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS arrival_time TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS locomotive_number VARCHAR(50), + ADD COLUMN IF NOT EXISTS remarks TEXT; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_train_number" + ON freight.trains (train_number) + WHERE train_number IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_train_number"`); + await queryRunner.query(` + ALTER TABLE freight.trains + DROP COLUMN IF EXISTS remarks, + DROP COLUMN IF EXISTS locomotive_number, + DROP COLUMN IF EXISTS arrival_time, + DROP COLUMN IF EXISTS departure_time, + DROP COLUMN IF EXISTS destination_station_id, + DROP COLUMN IF EXISTS origin_station_id, + DROP COLUMN IF EXISTS route_id, + DROP COLUMN IF EXISTS train_name, + DROP COLUMN IF EXISTS train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts b/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts new file mode 100644 index 000000000..421f66ef9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface { + name = 'AddRoutesAndExtendLocomotives1750100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL', + ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760, + ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL; + `); + + await queryRunner.query(` + UPDATE freight.locomotives + SET status = 'OUT_OF_SERVICE' + WHERE status = 'INACTIVE'; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.routes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(120) NOT NULL UNIQUE, + origin_yard_id UUID NOT NULL REFERENCES freight.yards(id), + destination_yard_id UUID NOT NULL REFERENCES freight.yards(id), + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.route_milestones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE, + yard_id UUID NOT NULL REFERENCES freight.yards(id), + sequence_no INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no) + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id + ON freight.routes(origin_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id + ON freight.routes(destination_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_is_active + ON freight.routes(is_active); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id + ON freight.route_milestones(route_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id + ON freight.route_milestones(yard_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`); + await queryRunner.query(` + ALTER TABLE freight.locomotives + DROP COLUMN IF EXISTS max_speed_kmh, + DROP COLUMN IF EXISTS traction_force_kn, + DROP COLUMN IF EXISTS power_kw, + DROP COLUMN IF EXISTS max_train_length_meters, + DROP COLUMN IF EXISTS locomotive_type; + `); + await queryRunner.query(` + UPDATE freight.locomotives + SET status = 'INACTIVE' + WHERE status = 'OUT_OF_SERVICE'; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750100000000-CreateFleetCrudTables.ts b/apps/edr-freight-api/src/migrations/1750100000000-CreateFleetCrudTables.ts new file mode 100644 index 000000000..1763a9db0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750100000000-CreateFleetCrudTables.ts @@ -0,0 +1,127 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateFleetCrudTables1750100000000 implements MigrationInterface { + name = 'CreateFleetCrudTables1750100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagons ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + wagon_number VARCHAR NOT NULL UNIQUE, + wagon_type_id UUID NOT NULL, + train_id UUID, + sequence_number INT, + tare_weight NUMERIC(10, 2) NOT NULL, + max_payload_weight NUMERIC(10, 2) NOT NULL, + status VARCHAR NOT NULL DEFAULT 'AVAILABLE', + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.containers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + container_number VARCHAR NOT NULL UNIQUE, + container_type_id UUID NOT NULL, + wagon_id UUID, + position INT, + tare_weight NUMERIC(10, 2) NOT NULL, + max_gross_weight NUMERIC(10, 2) NOT NULL, + seal_number VARCHAR, + status VARCHAR NOT NULL DEFAULT 'AVAILABLE', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.cargoes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + cargo_reference VARCHAR NOT NULL UNIQUE, + shipment_id UUID NOT NULL, + container_id UUID NOT NULL, + cargo_type_id UUID, + description TEXT, + quantity NUMERIC(12, 3) NOT NULL, + weight NUMERIC(10, 2) NOT NULL, + volume NUMERIC(10, 2), + status VARCHAR NOT NULL DEFAULT 'PENDING', + loaded_at TIMESTAMP, + unloaded_at TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_wagons_train_id" ON freight.wagons (train_id)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_containers_wagon_id" ON freight.containers (wagon_id)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_cargoes_container_id" ON freight.cargoes (container_id)`); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.wagons + ADD CONSTRAINT "FK_wagons_train_id" + FOREIGN KEY (train_id) REFERENCES freight.trains(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.wagons + ADD CONSTRAINT "FK_wagons_wagon_type_id" + FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types(id); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT "FK_containers_wagon_id" + FOREIGN KEY (wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT "FK_containers_container_type_id" + FOREIGN KEY (container_type_id) REFERENCES freight.container_types(id); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.cargoes + ADD CONSTRAINT "FK_cargoes_container_id" + FOREIGN KEY (container_id) REFERENCES freight.containers(id) ON DELETE RESTRICT; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.cargoes + ADD CONSTRAINT "FK_cargoes_cargo_type_id" + FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.cargoes CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.containers CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagons CASCADE`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750200000000-SeedDefaultWagonTypes.ts b/apps/edr-freight-api/src/migrations/1750200000000-SeedDefaultWagonTypes.ts new file mode 100644 index 000000000..06dc0b917 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750200000000-SeedDefaultWagonTypes.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedDefaultWagonTypes1750200000000 implements MigrationInterface { + name = 'SeedDefaultWagonTypes1750200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO freight.wagon_types ( + code, + name, + capacity_tons, + length_meters, + max_wagons_per_train, + supported_load_types, + is_active + ) + VALUES + ('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true), + ('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true), + ('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true), + ('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true), + ('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true), + ('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true), + ('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true), + ('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true), + ('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true), + ('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + max_wagons_per_train = EXCLUDED.max_wagons_per_train, + supported_load_types = EXCLUDED.supported_load_types, + is_active = true, + deleted_at = NULL, + updated_at = now(); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.wagon_types + WHERE code IN ('NW7', 'NW5', 'PW2', 'GW2', 'CW4', 'CW3', 'KW2', 'KW3', 'NW6', 'BW1'); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts b/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts new file mode 100644 index 000000000..027ebfe98 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface { + name = 'AddRouteToTrainSchedules1750300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS route_id UUID NULL; + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_train_schedules_route' + ) THEN + ALTER TABLE freight.train_schedules + ADD CONSTRAINT fk_train_schedules_route + FOREIGN KEY (route_id) REFERENCES freight.routes(id); + END IF; + END $$; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id + ON freight.train_schedules(route_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP CONSTRAINT IF EXISTS fk_train_schedules_route; + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS route_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1780639311366-CreatePaymentTable.ts b/apps/edr-freight-api/src/migrations/1780639311366-CreatePaymentTable.ts new file mode 100644 index 000000000..0382c834b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1780639311366-CreatePaymentTable.ts @@ -0,0 +1,98 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreatePaymentTable1780639311366 implements MigrationInterface { + name = "CreatePaymentTable1780639311366"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE freight.payments_type_enum AS ENUM ('booking'); + `); + + await queryRunner.query(` + CREATE TYPE freight.payments_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr'); + `); + + await queryRunner.query(` + CREATE TYPE freight.payments_currency_enum AS ENUM ('ETB', 'USD'); + `); + + await queryRunner.query(` + CREATE TYPE freight.payments_status_enum AS ENUM ( + 'action-required', + 'processing', + 'success', + 'failed', + 'canceled', + 'refunded' + ); + `); + + await queryRunner.query(` + CREATE TABLE freight.payments ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + + ref_id varchar(255) NOT NULL, + + type freight.payments_type_enum NOT NULL, + + method freight.payments_method_enum NOT NULL, + + currency freight.payments_currency_enum NOT NULL, + + amount numeric NOT NULL, + + raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb, + + client_action json, + + merchant_order_id varchar(255) NOT NULL, + + transaction_id varchar(255), + + status freight.payments_status_enum NOT NULL DEFAULT 'action-required', + + paid_at date, + + refunded_at date, + + expires_at date, + + failer_code varchar(30), + + failer_message varchar(255), + + reason varchar(255), + + created_at TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT PK_payments PRIMARY KEY (id), + + CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id), + + CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_id) + ); +`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TABLE IF EXISTS freight.payments; + `); + + await queryRunner.query(` + DROP TYPE IF EXISTS freight.payments_status_enum; + `); + + await queryRunner.query(` + DROP TYPE IF EXISTS freight.payments_currency_enum; + `); + + await queryRunner.query(` + DROP TYPE IF EXISTS freight.payments_method_enum; + `); + + await queryRunner.query(` + DROP TYPE IF EXISTS freight.payments_type_enum; + `); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/migrations/1780639978834-AlterClientActionToJsonb.ts b/apps/edr-freight-api/src/migrations/1780639978834-AlterClientActionToJsonb.ts new file mode 100644 index 000000000..723331ab3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1780639978834-AlterClientActionToJsonb.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AlterClientActionToJsonb1780639978834 implements MigrationInterface { + name = "AlterClientActionToJsonb1780639978834"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN client_action TYPE jsonb + USING client_action::jsonb; + `); + + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN client_action DROP DEFAULT; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN client_action TYPE json + USING client_action::json; + `); + } + + +} diff --git a/apps/edr-freight-api/src/migrations/1780644945086-UpdatePaymentTimestamp.ts b/apps/edr-freight-api/src/migrations/1780644945086-UpdatePaymentTimestamp.ts new file mode 100644 index 000000000..6cfc7fc8f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1780644945086-UpdatePaymentTimestamp.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdatePaymentTimestamp1780644945086 implements MigrationInterface { + name = "UpdatePaymentTimestamp1780644945086"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN refunded_at TYPE timestamp + USING refunded_at::timestamp; + `); + + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN expires_at TYPE timestamp + USING expires_at::timestamp; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN refunded_at TYPE timestamptz + USING refunded_at::timestamptz; + `); + + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN expires_at TYPE timestamptz + USING expires_at::timestamptz; + `); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts new file mode 100644 index 000000000..a689ba24e --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { FreightMeController } from './freight-me.controller'; +import { FreightMeService } from './freight-me.service'; + +@Module({ + controllers: [FreightMeController], + providers: [FreightMeService], +}) +export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts new file mode 100644 index 000000000..b85ecea84 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { FreightMeService } from './freight-me.service'; + +@ApiTags('auth') +@Controller('me') +@ApiBearerAuth() +export class FreightMeController { + constructor(private readonly freightMeService: FreightMeService) {} + + @Get() + @UseGuards(JwtGuard) + @ApiOperation({ + summary: 'Current user with flat permissionKeys for backoffice gating', + }) + getMe(@CurrentUser() user: TCurrentUser) { + return this.freightMeService.getEnrichedProfile(user); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts new file mode 100644 index 000000000..50c90213b --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { + collectPermissionKeys, + isSuperAdmin, +} from '../../common/freight-permission.util'; +import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry'; + +@Injectable() +export class FreightMeService { + getEnrichedProfile(user: TCurrentUser) { + const employee = user.employee + ? [ + { + id: user.employee.id, + organizationId: user.employee.organizationId, + unitId: user.employee.unitId, + name: user.employee.name, + positions: user.employee.position + ? [ + { + id: user.employee.position.id, + key: user.employee.position.key, + employeePositionId: user.employee.position.employeePositionId, + name: user.employee.position.name, + isDelegate: user.employee.position.isDelegate, + parentPositionId: user.employee.position.parentPositionId, + permissions: user.employee.position.permissions ?? [], + }, + ] + : [], + }, + ] + : []; + + const permissionKeys = collectPermissionKeys(user); + + return { + id: user.id, + email: user.email, + name: user.name, + username: user.username, + phoneNumber: user.phoneNumber, + userType: user.userType, + status: user.status, + hasFinishedRegistration: user.hasFinishedRegistration, + hasFinishedDMSOnboarding: user.hasFinishedDMSOnboarding, + roles: user.roles, + permissions: user.permissions, + employee, + permissionKeys, + isSuperAdmin: isSuperAdmin(user), + permissionsCatalog: PERMISSIONS_CATALOG, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts new file mode 100644 index 000000000..395ff0386 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -0,0 +1,66 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + Put, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BackofficeService } from "./backoffice.service"; +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; +import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; + +@ApiTags("backoffice") +@Controller("backoffice") +export class BackofficeController { + constructor(private readonly backofficeService: BackofficeService) {} + + @Post("organizations/:orgId/users") + @ApiOperation({ summary: "Create an organization user without assigning positions" }) + createOrganizationUser( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Body() dto: CreateOrganizationUserDto, + ) { + return this.backofficeService.createOrganizationUser(organizationId, dto); + } + + @Get("organizations/:orgId/employees") + @ApiOperation({ summary: "Get deduplicated organization employees for backoffice" }) + getOrganizationEmployees( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Query("skip") skip?: string, + @Query("take") take?: string, + ) { + return this.backofficeService.getOrganizationEmployees(organizationId, { + skip, + take, + }); + } + + @Get("organizations/:orgId/employee-users/:userId/roles") + @ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" }) + getEmployeeUserRoles( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Param("userId", ParseUUIDPipe) userId: string, + ) { + return this.backofficeService.getEmployeeUserRoles(organizationId, userId); + } + + @Put("organizations/:orgId/employee-users/:userId/roles") + @ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" }) + replaceEmployeeUserRoles( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Param("userId", ParseUUIDPipe) userId: string, + @Body() dto: UpdateEmployeeUserRolesDto, + ) { + return this.backofficeService.replaceEmployeeUserRoles( + organizationId, + userId, + dto.roleIds, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts new file mode 100644 index 000000000..90c1a7c79 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts @@ -0,0 +1,31 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { + Employee, + Organization, + UserCredential, +} from "@tria-plc/iamapi-common"; + +import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; +import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { BackofficeController } from "./backoffice.controller"; +import { BackofficeService } from "./backoffice.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, + ]), + ], + controllers: [BackofficeController], + providers: [BackofficeService], + exports: [BackofficeService], +}) +export class BackofficeModule {} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts new file mode 100644 index 000000000..7c7805b28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -0,0 +1,425 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; + +import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; +import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; +import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; + +const RESERVED_ROLE_KEYS = new Set([ + "super_admin", + "organization_admin", + "unit_admin", +]); +const DEFAULT_USER_PASSWORD = "12345678"; +const ORGANIZATION_ADMIN_ROLE_KEY = "organization_admin"; +const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager"; + +@Injectable() +export class BackofficeService { + constructor( + @InjectRepository(Employee) + private readonly employeeRepository: Repository, + @InjectRepository(Organization) + private readonly organizationRepository: Repository, + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(UserRole) + private readonly userRoleRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly dataSource: DataSource, + ) {} + + async createOrganizationUser( + organizationId: string, + dto: CreateOrganizationUserDto, + ) { + const organizationExists = await this.organizationRepository.exists({ + where: { id: organizationId }, + }); + + if (!organizationExists) { + throw new NotFoundException("organization_not_found"); + } + + const email = dto.email.trim().toLowerCase(); + const username = dto.username.trim().toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + const assignOrganizationAdmin = dto.assignOrganizationAdmin === true; + const name = { + en: dto.name.en.trim(), + ...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}), + }; + + const existingUsers = await this.userRepository.find({ + where: [{ email }, { username }], + select: { id: true, email: true, username: true }, + }); + + const emailUser = existingUsers.find((user) => user.email === email); + const usernameUser = existingUsers.find((user) => user.username === username); + + if (emailUser && usernameUser && emailUser.id !== usernameUser.id) { + throw new BadRequestException("email_or_username_already_in_use"); + } + + const existingUser = emailUser ?? usernameUser; + const hashedPassword = await hashPassword(DEFAULT_USER_PASSWORD); + + return this.dataSource.transaction(async (manager) => { + let user = existingUser; + + if (!user) { + user = await manager.getRepository(User).save( + manager.getRepository(User).create({ + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } else { + await manager.getRepository(User).update( + { id: user.id }, + { + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }, + ); + } + + const activeCredentialExists = await manager.getRepository(UserCredential).exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await manager.getRepository(UserCredential).insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + let employee = await manager.getRepository(Employee).findOne({ + where: { + userId: user.id, + organizationId, + isCurrent: true, + }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + + if (!employee) { + const insertResult = await manager.getRepository(Employee).insert({ + userId: user.id, + organizationId, + isCurrent: true, + name, + }); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: insertResult.identifiers[0]?.id as string }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } else { + await manager.getRepository(Employee).update( + { id: employee.id }, + { name }, + ); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: employee.id }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } + + if (!employee) { + throw new NotFoundException("employee_create_failed"); + } + + const userId = user.id; + + if (!userId) { + throw new NotFoundException("user_create_failed"); + } + + if (assignOrganizationAdmin) { + await this.ensureOrganizationAdminAccess(manager, organizationId, userId); + } + + return employee; + }); + } + + async getEmployeeUserRoles(organizationId: string, userId: string) { + await this.assertUserBelongsToOrganization(organizationId, userId); + + const userRoles = await this.userRoleRepository.find({ + where: { + userId, + organizationId, + unitId: IsNull(), + }, + relations: { + role: true, + }, + order: { + role: { + key: "ASC", + }, + }, + }); + + return userRoles + .map((userRole) => userRole.role) + .filter((role): role is Role => Boolean(role)) + .map((role) => ({ + id: role.id, + key: role.key, + name: role.name, + })); + } + + async getOrganizationEmployees( + organizationId: string, + query: { skip?: string; take?: string }, + ) { + const organizationExists = await this.organizationRepository.exists({ + where: { id: organizationId }, + }); + + if (!organizationExists) { + throw new NotFoundException("organization_not_found"); + } + + const take = Number.parseInt(query.take ?? "1000", 10); + const skip = Number.parseInt(query.skip ?? "0", 10); + + const employees = await this.employeeRepository.find({ + where: { + organizationId, + isCurrent: true, + }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + order: { + createdAt: "DESC", + }, + }); + + const deduplicated = this.mergeEmployeesByUser(employees); + + return { + count: deduplicated.length, + items: deduplicated.slice(skip, skip + take), + }; + } + + async replaceEmployeeUserRoles( + organizationId: string, + userId: string, + roleIds: string[], + ) { + await this.assertUserBelongsToOrganization(organizationId, userId); + + const uniqueRoleIds = [...new Set(roleIds)]; + const roles = uniqueRoleIds.length + ? await this.roleRepository.find({ + where: { + id: In(uniqueRoleIds), + }, + }) + : []; + + if (roles.length !== uniqueRoleIds.length) { + throw new NotFoundException("one_or_more_roles_not_found"); + } + + const reservedRoles = roles.filter((role) => RESERVED_ROLE_KEYS.has(role.key)); + if (reservedRoles.length) { + throw new BadRequestException("reserved_roles_must_use_admin_actions"); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(UserRole).delete({ + userId, + organizationId, + unitId: IsNull(), + }); + + if (!roles.length) { + return; + } + + await manager.getRepository(UserRole).insert( + roles.map((role) => ({ + userId, + roleId: role.id, + organizationId, + })), + ); + }); + + return this.getEmployeeUserRoles(organizationId, userId); + } + + private async assertUserBelongsToOrganization( + organizationId: string, + userId: string, + ) { + const exists = await this.userRepository + .createQueryBuilder("user") + .innerJoin( + "user.employee", + "employee", + "employee.organizationId = :organizationId AND employee.isCurrent = true", + { organizationId }, + ) + .where("user.id = :userId", { userId }) + .getExists(); + + if (!exists) { + throw new NotFoundException("user_not_found_in_organization"); + } + } + + private mergeEmployeesByUser(employees: Employee[]) { + const employeesByUserId = new Map(); + + 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/backoffice/dto/update-employee-user-roles.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/update-employee-user-roles.dto.ts new file mode 100644 index 000000000..f216939b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/dto/update-employee-user-roles.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsUUID } from "class-validator"; + +export class UpdateEmployeeUserRolesDto { + @ApiProperty({ type: [String] }) + @IsArray() + @IsUUID("4", { each: true }) + roleIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 05ea8af67..0091b5341 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -4,7 +4,6 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { BillingService } from "./billing.service"; @ApiTags("billing") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth @Controller("billing") export class BillingController { constructor(private readonly billingService: BillingService) {} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 0031834f5..e2a6f7cc2 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "invoices" }) +@Entity({schema:"freight", name: "invoices" }) export class Invoice extends BaseEntity { @Column({ name: "booking_id", type: "uuid" }) bookingId!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts new file mode 100644 index 000000000..ab8a8dfa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -0,0 +1,284 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Readable } from 'stream'; + +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { ContractRendererService } from '../../contracts/contract-renderer.service'; +import { getTemplateMeta } from '../../contracts/contract-template.registry'; +import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; +import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; +import { MinioService } from '../minio/minio.service'; +import { FilesService } from '../files/files.service'; +import { FileRecord } from '../files/entities/file.entity'; +import { BookingsRepository } from './bookings.repository'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; +import { ContractViewDto } from './dto/contract-view.dto'; +import { SignContractDto } from './dto/sign-contract.dto'; +import { ContractSignerRole } from './entities/booking-contract-signature.entity'; + +@Injectable() +export class BookingContractService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + private readonly templateResolver: ContractTemplateResolver, + private readonly viewModelBuilder: ContractViewModelBuilder, + private readonly renderer: ContractRendererService, + private readonly pdfService: ContractPdfService, + ) {} + + buildContractSummary(booking: Booking): string { + const direction = + booking.tradeDirection === 'IMPORT' + ? 'Import' + : booking.tradeDirection === 'EXPORT' + ? 'Export' + : booking.tradeDirection; + + const cargo = booking.cargoType; + const isBulk = booking.freightType === 'BULK'; + + let cargoLabel: string; + if (isBulk) { + cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`; + } else { + const lines = + booking.bookingContainers?.map((bc) => { + const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container'; + return `${bc.quantity}× ${label}`; + }) ?? []; + cargoLabel = + lines.length > 0 + ? `Container (${lines.join(', ')})` + : 'Container (Standard)'; + } + + return `Operation: ${direction} | Cargo Type: ${cargoLabel}`; + } + + async getSummary(bookingId: string): Promise<{ summary: string }> { + const booking = await this.requireBooking(bookingId); + const summary = booking.contractSummary ?? this.buildContractSummary(booking); + return { summary }; + } + + async getContractView(bookingId: string): Promise { + const { view } = await this.viewModelBuilder.build(bookingId); + await this.inlineSignatureImages(view.signatures); + const html = this.renderer.render(view); + return { + bookingId: view.bookingId, + reference: view.reference, + status: view.status, + templateKey: view.templateKey, + title: view.template.title, + html, + canSignCustomer: view.canSignCustomer, + canSignStaff: view.canSignStaff, + hasContractDocument: view.hasContractDocument, + signatures: view.signatures, + pricingSchedule: view.pricing as unknown as Record, + }; + } + + async generateContract(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['APPROVED']); + + const templateKey = this.templateResolver.resolve(booking); + const summary = this.buildContractSummary(booking); + await this.upsertContractPdf(bookingId, booking.reference, templateKey); + + const now = new Date(); + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CONTRACT_READY', + contractSummary: summary, + contractTemplateKey: templateKey, + contractGeneratedAt: now, + } as never); + return updated!; + } + + async streamContract(bookingId: string) { + const booking = await this.requireBooking(bookingId); + const templateKey = + booking.contractTemplateKey ?? this.templateResolver.resolve(booking); + const record = await this.upsertContractPdf( + bookingId, + booking.reference, + templateKey, + ); + return this.filesService.streamById(record.id); + } + + async signContract( + bookingId: string, + dto: SignContractDto, + options: { signerUserId?: string; ipAddress?: string }, + ): Promise { + const booking = await this.requireBooking(bookingId); + const role = dto.role as ContractSignerRole; + + if (role === 'CUSTOMER') { + assertBookingStatus(booking, ['CONTRACT_READY']); + const existing = await this.bookingsRepository.findContractSignature( + bookingId, + 'CUSTOMER', + ); + if (existing) { + throw new BadRequestException('Customer has already signed this contract'); + } + } else { + assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + const existing = await this.bookingsRepository.findContractSignature( + bookingId, + 'STAFF', + ); + if (existing) { + throw new BadRequestException('Staff has already signed this contract'); + } + } + + const buffer = this.decodeSignatureImage(dto.signatureImageBase64); + const sigFile: Express.Multer.File = { + fieldname: `signature_${role.toLowerCase()}`, + originalname: `signature-${role.toLowerCase()}-${booking.reference}.png`, + encoding: '7bit', + mimetype: 'image/png', + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: '', + filename: '', + path: '', + }; + + const fileRecord = await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff', + file: sigFile, + }); + + const now = new Date(); + await this.bookingsRepository.saveContractSignature({ + bookingId, + signerRole: role, + signerUserId: options.signerUserId ?? null, + signerDisplayName: dto.signerDisplayName, + signedAt: now, + signatureFileId: fileRecord.id, + consentText: dto.consentText ?? null, + ipAddress: options.ipAddress ?? null, + }); + + const updates: Record = {}; + + if (role === 'CUSTOMER') { + updates.status = 'SIGNED_CUSTOMER'; + updates.customerSignedAt = now; + } else { + updates.status = 'FULLY_EXECUTED'; + updates.fullyExecutedAt = now; + updates.marketingApprovedAt = now; + updates.marketingApprovedById = options.signerUserId ?? null; + updates.lockedAt = now; + } + + const updated = await this.bookingsRepository.update(bookingId, updates as never); + await this.upsertContractPdf( + bookingId, + booking.reference, + booking.contractTemplateKey ?? this.templateResolver.resolve(booking), + ); + return updated!; + } + + async getSignatures(bookingId: string) { + const rows = await this.bookingsRepository.findContractSignatures(bookingId); + const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r)); + await this.inlineSignatureImages(views); + return { signatures: views }; + } + + private async upsertContractPdf( + bookingId: string, + reference: string, + templateKey: string, + ): Promise { + const { view } = await this.viewModelBuilder.build(bookingId); + view.templateKey = templateKey; + view.template = getTemplateMeta(templateKey); + await this.inlineSignatureImages(view.signatures); + + const html = this.renderer.render(view); + const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); + const file: Express.Multer.File = { + fieldname: 'contract', + originalname: `contract-${reference}.pdf`, + encoding: '7bit', + mimetype: 'application/pdf', + size: pdfBuffer.length, + buffer: pdfBuffer, + stream: Readable.from(pdfBuffer), + destination: '', + filename: '', + path: '', + }; + + return this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'contract', + file, + }); + } + + private async inlineSignatureImages( + signatures: Array<{ signatureImageUrl?: string | null }>, + ): Promise { + for (const sig of signatures) { + if (!sig.signatureImageUrl) continue; + try { + if (sig.signatureImageUrl.startsWith('data:')) continue; + const objectName = this.minioService.getObjectNameFromUrl( + sig.signatureImageUrl, + ); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + sig.signatureImageUrl = `data:image/png;base64,${buffer.toString( + 'base64', + )}`; + } catch { + /* keep original url */ + } + } + } + + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); + } + + private decodeSignatureImage(base64: string): Buffer { + const raw = base64.includes(',') ? base64.split(',')[1]! : base64; + return Buffer.from(raw, 'base64'); + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts new file mode 100644 index 000000000..e3e97f301 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts @@ -0,0 +1,44 @@ +import { BadRequestException } from '@nestjs/common'; + +import { FREIGHT_TYPES, FreightType } from './entities/booking.entity'; +import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator'; + +/** Normalize and validate booking freight shape (used on create and after update merge). */ +export function assertFreightShape(input: BookingFreightShapeInput): void { + if (!input.freightType || !FREIGHT_TYPES.includes(input.freightType as FreightType)) { + throw new BadRequestException( + `freightType is required and must be one of: ${FREIGHT_TYPES.join(', ')}`, + ); + } + // + + const containers = input.containers ?? []; + const hasContainers = containers.length > 0; + const hasCargoType = Boolean(input.cargoTypeId); + + if (input.freightType === 'BULK') { + if (hasContainers) { + throw new BadRequestException( + 'BULK freight cannot include container lines; use cargoTypeId only', + ); + } + if (!hasCargoType) { + throw new BadRequestException('cargoTypeId is required for BULK freight'); + } + return; + } + + if (hasCargoType) { + throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight'); + } + if (!hasContainers) { + throw new BadRequestException( + 'CONTAINER freight requires at least one container line with containerTypeId', + ); + } + for (const line of containers) { + if (!line.containerTypeId) { + throw new BadRequestException('Each container line must include containerTypeId'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts new file mode 100644 index 000000000..332916727 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts @@ -0,0 +1,54 @@ +export const BOOKING_LIST_TAB_KEYS = [ + 'all', + 'intake', + 'in_approval', + 'approved_contract', + 'payment', + 'operations', + 'completed', + 'closed', +] as const; + +export type BookingListTabKey = (typeof BOOKING_LIST_TAB_KEYS)[number]; + +export const BOOKING_LIST_TABS: ReadonlyArray<{ + key: BookingListTabKey; + statuses: readonly string[] | null; +}> = [ + { key: 'all', statuses: null }, + { key: 'intake', statuses: ['SUBMITTED'] }, + { + key: 'in_approval', + statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], + }, + { + key: 'approved_contract', + statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], + }, + { key: 'payment', statuses: ['FULLY_EXECUTED'] }, + { + key: 'operations', + statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'], + }, + { key: 'completed', statuses: ['COMPLETED'] }, + { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, +]; + +export function mapStatusCountsToTabs( + statusCounts: Record, +): Record { + const result = {} as Record; + + for (const tab of BOOKING_LIST_TABS) { + if (!tab.statuses?.length) { + result[tab.key] = Object.values(statusCounts).reduce((sum, n) => sum + n, 0); + continue; + } + result[tab.key] = tab.statuses.reduce( + (sum, status) => sum + (statusCounts[status] ?? 0), + 0, + ); + } + + return result; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts new file mode 100644 index 000000000..c591a1db4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -0,0 +1,68 @@ +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { Booking } from './entities/booking.entity'; + +export interface BookingNextStep { + action: string; + description: string; + requiredRole?: string; +} + +export function computeNextStep( + booking: Pick, + nextPendingStep?: Pick | null, +): BookingNextStep | null { + const { status } = booking; + + switch (status) { + case 'SUBMITTED': + return { + action: 'ACCEPT_INTAKE', + description: 'Line Staff must accept the submission to begin approval', + }; + case 'PENDING_APPROVAL': + case 'APPROVED_PENDING_SIGNATURE': + if (nextPendingStep) { + return { + action: 'APPROVE_STEP', + requiredRole: nextPendingStep.requiredRole, + description: `${nextPendingStep.requiredRole} must approve step ${nextPendingStep.stepOrder}`, + }; + } + return { + action: 'APPROVE_STEP', + description: 'Complete the pending approval step in sequence', + }; + case 'APPROVED': + return { + action: 'CUSTOMER_SIGN', + description: 'Contract generated; customer must sign', + }; + case 'CONTRACT_READY': + return { + action: 'CUSTOMER_SIGN', + description: 'Customer must sign the contract', + }; + case 'SIGNED_CUSTOMER': + return { + action: 'STAFF_SIGN', + description: 'Internal staff must counter-sign the contract', + }; + case 'FULLY_EXECUTED': + return { + action: 'AWAIT_PAYMENT', + description: 'Awaiting customer payment', + }; + case 'PAID': + return { + action: 'START_TRANSIT', + description: 'Mark shipment as in transit', + }; + case 'IN_TRANSIT': + return { + action: 'COMPLETE', + description: 'Mark shipment complete', + }; + default: + return null; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts new file mode 100644 index 000000000..07ef6a183 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -0,0 +1,50 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { BookingsRepository } from './bookings.repository'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; +import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; +import { PaymentService } from '../payment/payment.service'; +import { PaymentStatus } from '../payment/entities/payment.entity'; +export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } + +const NON_TERMINAL_STATUSES: PaymentStatus[] = [ + "action-required", + "processing", + "success", +]; + +@Injectable() +export class BookingPaymentService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly paymentService: PaymentService, + ) { } + + async pay(bookingId: string): Promise<{ redirectUrl: string }> { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['FULLY_EXECUTED', '']); + + const existing = await this.paymentService.findBookingById(bookingId); + if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { + if (existing.clientAction) { + const action = existing.clientAction as { type?: string; url?: string }; + if (action.type === "REDIRECT" && action.url) { + return { redirectUrl: action.url }; + } + } + } + + const resp = await this.paymentService.initBookingTelebirr(bookingId, "web"); + + return { + redirectUrl: + resp.redirectUrl ?? "", + }; + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findById(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts new file mode 100644 index 000000000..4e47456e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -0,0 +1,311 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { RatesService } from '../rule-engine/services/rates.service'; +import { ServiceTypesService } from '../rule-engine/services/service-types.service'; +import { Rate } from '../rule-engine/entities/rate.entity'; +import { + AppliedCargoModifier, + BookingEvaluationInput, + RuleEngineService, +} from '../rule-engine/rule-engine.service'; +import { BookingsRepository } from './bookings.repository'; +import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; + +@Injectable() +export class BookingPricingService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly ruleEngineService: RuleEngineService, + private readonly containerTypesService: ContainerTypesService, + private readonly ratesService: RatesService, + private readonly serviceTypesService: ServiceTypesService, + ) {} + + async generatePrice(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['DRAFT']); + + const evalInput = await this.buildEvalInputForBooking(booking); + console.log('evalInput----', evalInput); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + this.ruleEngineService.assertNoHardBlocks(ruleResult); + + const lineItems: PriceLineItemDto[] = []; + let total = 0; + + const baseLines = await this.computeBaseRailLines(booking, evalInput); + for (const line of baseLines) { + lineItems.push(line); + total += line.amount; + } + + for (const mod of ruleResult.appliedModifiers) { + const item: PriceLineItemDto = { + code: mod.surchargeTypeCode, + description: `Surcharge: ${mod.surchargeTypeCode}`, + amount: mod.calculatedAmount, + currency: mod.currency, + }; + lineItems.push(item); + total += mod.calculatedAmount; + } + + await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total); + + await this.bookingsRepository.update(bookingId, { + totalAmount: total, + priorityScore: ruleResult.priorityScore, + pricingBreakdown: { + lineItems, + totalAmount: total, + currency: booking.paymentCurrency, + generatedAt: new Date().toISOString(), + }, + } as never); + + return { + bookingId, + totalAmount: total, + currency: booking.paymentCurrency, + lineItems, + warnings: ruleResult.warnings, + }; + } + + async buildEvalInputForBooking(booking: Booking): Promise { + const containers = await Promise.all( + (booking.bookingContainers ?? []).map(async (bc) => { + const ct = await this.containerTypesService.findById(bc.containerTypeId); + const vgm = Number(bc.vgmPerUnitTons); + const qty = bc.quantity; + return { + containerTypeId: bc.containerTypeId, + quantity: qty, + vgmPerUnitTons: vgm, + totalVgmTons: qty * vgm, + isReefer: ct.isReefer, + }; + }), + ); + return { + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId ?? null, + serviceTypeId: booking.serviceTypeId, + paymentCurrency: booking.paymentCurrency, + tradeDirection: booking.tradeDirection, + isHazardous: booking.isHazardous, + allowConsolidation: booking.allowConsolidation, + shippingLineId: booking.shippingLineId, + containers, + }; + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } + + /** Line items for contract schedule (uses stored breakdown or recomputes). */ + async computeContractLineItems(booking: Booking): Promise<{ + lineItems: PriceLineItemDto[]; + totalAmount: number; + currency: string; + }> { + const stored = booking.pricingBreakdown as { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + currency?: string; + } | null; + + if (stored?.lineItems?.length) { + return { + lineItems: stored.lineItems, + totalAmount: Number(stored.totalAmount ?? booking.totalAmount), + currency: stored.currency ?? booking.paymentCurrency, + }; + } + + const evalInput = await this.buildEvalInputForBooking(booking); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + const lineItems: PriceLineItemDto[] = []; + let total = 0; + + const baseLines = await this.computeBaseRailLines(booking, evalInput); + for (const line of baseLines) { + lineItems.push(line); + total += line.amount; + } + + for (const mod of ruleResult.appliedModifiers) { + lineItems.push({ + code: mod.surchargeTypeCode, + description: `Surcharge: ${mod.surchargeTypeCode}`, + amount: mod.calculatedAmount, + currency: mod.currency, + }); + total += mod.calculatedAmount; + } + + if (lineItems.length === 0) { + total = Number(booking.totalAmount); + lineItems.push({ + code: 'TOTAL', + description: 'Contract total', + amount: total, + currency: booking.paymentCurrency, + }); + } + + return { + lineItems, + totalAmount: total || Number(booking.totalAmount), + currency: booking.paymentCurrency, + }; + } + + /** Recompute priority on submit (USD + service tier). */ + async computeSubmitPriorityScore(booking: Booking): Promise { + const evalInput = await this.buildEvalInputForBooking(booking); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + let score = ruleResult.priorityScore; + + const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId); + if (booking.paymentCurrency === 'USD' && serviceType) { + const code = (serviceType.code ?? '').toUpperCase(); + const hasForwarding = + serviceType.includesFirstMile || + serviceType.includesLastMile || + code.includes('FORWARD') || + code.includes('Y'); + const railOnly = code.includes('RAIL') && !hasForwarding; + + if (hasForwarding) score += 1000; + else if (railOnly || code.includes('X')) score += 500; + } + + return score; + } + + private async computeBaseRailLines( + booking: Booking, + evalInput: BookingEvaluationInput, + ): Promise { + const liveRates = await this.ratesService.findLiveRates(); + const currency = booking.paymentCurrency; + const isBulk = booking.freightType === 'BULK'; +console.log('liveRates----', liveRates); + const rateType = + booking.tradeDirection === 'IMPORT' + ? isBulk + ? 'BULK_IMPORT' + : 'CONTAINER_IMPORT' + : booking.tradeDirection === 'EXPORT' + ? isBulk + ? 'BULK_EXPORT' + : 'CONTAINER_EXPORT' + : 'INTERCITY_CONTAINER'; + + + console.log('rateType----', rateType); + + const lines: PriceLineItemDto[] = []; + const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + + for (const container of evalInput.containers) { + console.log('container----', container); + const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency); + console.log('rate----', rate); + if (!rate) continue; + + const amount = this.amountForRate(rate, container.quantity, wagonCount); + lines.push({ + code: rateType, + description: `Base rail (${rateType})`, + amount, + currency: rate.currency, + }); + } + + if (lines.length === 0) { + const fallback = liveRates.find( + (r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE', + ); + if (fallback) { + const amount = this.amountForRate(fallback, 1, wagonCount); + lines.push({ + code: rateType, + description: `Base rail (${rateType})`, + amount, + currency: fallback.currency, + }); + } + } + + return lines; + } + + private pickRate( + rates: Rate[], + rateType: string, + containerTypeId: string, + currency: string, + ): Rate | undefined { + return ( + rates.find( + (r) => + r.rateType === rateType && + r.currency === currency && + r.containerTypeId === containerTypeId, + ) ?? + rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId) + ); + } + + private amountForRate(rate: Rate, quantity: number, wagonCount: number): number { + const value = Number(rate.rateValue); + switch (rate.rateUnit) { + case 'PER_CONTAINER': + return value * quantity; + case 'PER_WAGON': + return value * wagonCount; + case 'PER_TON': + return value * quantity; + case 'FLAT': + return value; + default: + return value * quantity; + } + } + + private async persistPriceRun( + bookingId: string, + modifiers: AppliedCargoModifier[], + _total: number, + ): Promise { + await this.bookingsRepository.clearPricingArtifacts(bookingId); + const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId); + + const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id])); + const rows = modifiers + .map((m) => { + const snapshotId = snapshotByRateId.get(m.rateId); + if (!snapshotId) return null; + return { + bookingId, + surchargeTypeId: m.surchargeTypeId, + triggerValue: m.triggerValue, + calculatedAmount: m.calculatedAmount, + rateSnapshotId: snapshotId, + }; + }) + .filter((r): r is NonNullable => r !== null); + + if (rows.length > 0) { + await this.bookingsRepository.createCargoModifiers(rows); + } + } +} 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/booking-status.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts new file mode 100644 index 000000000..fe9152149 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts @@ -0,0 +1,10 @@ +import { ConflictException } from '@nestjs/common'; +import { Booking } from './entities/booking.entity'; + +export function assertBookingStatus(booking: Booking, allowed: string[]): void { + if (!allowed.includes(booking.status)) { + throw new ConflictException( + `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts new file mode 100644 index 000000000..0fdfc5084 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -0,0 +1,324 @@ +import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +import { RuleEngineService } from '../rule-engine/rule-engine.service'; +import { BookingContractService } from './booking-contract.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingsRepository } from './bookings.repository'; +import { assertBookingStatus } from './booking-status.util'; +import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; +import { Booking } from './entities/booking.entity'; +import { BookingsService } from './bookings.service'; + +@Injectable() +export class BookingTransitionService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly ruleEngineService: RuleEngineService, + private readonly pricingService: BookingPricingService, + private readonly contractService: BookingContractService, + @Inject(forwardRef(() => BookingsService)) + private readonly bookingsService: BookingsService, + ) {} + + async submit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + + if (Number(booking.totalAmount) <= 0) { + throw new BadRequestException( + 'Generate a price before submitting (POST /bookings/:id/generate-price)', + ); + } + + const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + await this.ruleEngineService.snapshotLiveRates(bookingId); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SUBMITTED', + priorityScore, + } as never); + return this.bookingsService.findById(updated!.id); + } + + async requestChanges( + bookingId: string, + note: string, + actorId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED']); + + await this.bookingsRepository.createReviewNote( + bookingId, + note, + 'CHANGES_REQUESTED', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CHANGES_REQUESTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + /** Auto-create booking approval steps from system rules when none exist yet. */ + private async ensureBookingApprovalSteps(booking: Booking): Promise { + if ((booking.approvalSteps?.length ?? 0) > 0) return; + + await this.ruleEngineService.instantiateApprovalSteps(booking.id, { + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId, + }); + } + + async acceptIntake(bookingId: string, actorId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED']); + + await this.ruleEngineService.instantiateApprovalSteps(bookingId, { + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId, + }); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PENDING_APPROVAL', + approvedByStaffId: actorId, + approvedByStaffAt: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async staffReject( + bookingId: string, + reason: string, + actorId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'REJECTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async approveStep( + bookingId: string, + stepId: string, + actorId: string, + requiredRole: string, + authUser?: TCurrentUser, + ): Promise { + if (authUser) { + assertCanApproveBookingStep(authUser, requiredRole); + } + + let booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', + ]); + + if ((booking.approvalSteps?.length ?? 0) === 0) { + await this.ensureBookingApprovalSteps(booking); + booking = await this.bookingsService.findById(bookingId); + } + + const step = await this.bookingsRepository.findApprovalStepById( + bookingId, + stepId, + ); + if (!step || step.status !== 'PENDING') { + throw new BadRequestException('Approval step not found or already actioned'); + } + + const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); + if (!next || next.id !== step.id) { + throw new BadRequestException( + 'Approval steps must be completed in order', + ); + } + + if (step.requiredRole !== requiredRole) { + throw new BadRequestException( + `Step requires role ${step.requiredRole}, not ${requiredRole}`, + ); + } + + const blocksRole = step.blocksRole; + if (blocksRole && blocksRole === requiredRole) { + throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + } + + await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + + const updates: Record = {}; + const now = new Date(); + + if (requiredRole === 'LINE_STAFF') { + updates.status = 'APPROVED_PENDING_SIGNATURE'; + updates.approvedByStaffId = actorId; + updates.approvedByStaffAt = now; + } else if (requiredRole === 'DIRECTOR') { + updates.signedByDirectorId = actorId; + updates.signedByDirectorAt = now; + } else if (requiredRole === 'CEO') { + updates.signedByCeoId = actorId; + updates.signedByCeoAt = now; + } + + const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); + if (allDone) { + updates.status = 'APPROVED'; + } + + if (Object.keys(updates).length > 0) { + await this.bookingsRepository.update(bookingId, updates as never); + } + + if (allDone) { + const generated = await this.contractService.generateContract(bookingId); + return this.bookingsService.findById(generated.id); + } + + return this.bookingsService.findById(bookingId); + } + + async rejectStep( + bookingId: string, + stepId: string, + actorId: string, + reason: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + + const step = await this.bookingsRepository.findApprovalStepById( + bookingId, + stepId, + ); + if (!step) throw new BadRequestException('Approval step not found'); + + await this.bookingsRepository.completeApprovalStep( + step.id, + actorId, + 'REJECTED', + reason, + ); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'REJECTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async customerSign(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['CONTRACT_READY']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SIGNED_CUSTOMER', + customerSignedAt: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async marketingApprove(bookingId: string, actorId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'FULLY_EXECUTED', + fullyExecutedAt: new Date(), + marketingApprovedById: actorId, + marketingApprovedAt: new Date(), + lockedAt: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async startTransit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PAID']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'IN_TRANSIT', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async complete(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['IN_TRANSIT']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'COMPLETED', + endDate: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async cancel(bookingId: string, reason: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'DRAFT', + 'SUBMITTED', + 'CHANGES_REQUESTED', + 'PENDING_APPROVAL', + 'CONTRACT_READY', + ]); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CANCELLED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async enrichBookingResponse(booking: Booking): Promise { + const note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + 'CHANGES_REQUESTED', + ); + const summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + const nextPending = + booking.status === 'PENDING_APPROVAL' || + booking.status === 'APPROVED_PENDING_SIGNATURE' + ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) + : null; + const nextStep = computeNextStep(booking, nextPending); + return { + ...booking, + latestChangeRequestNote: note?.note ?? null, + contractSummary: summary, + nextStep, + }; + } +} 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 4ed037cfc..ccd598577 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -6,43 +6,412 @@ import { HttpCode, Param, ParseUUIDPipe, + Patch, Post, Query, -} from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; + Request, + Res, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import type { Response } from 'express'; -import { BookingsService } from "./bookings.service"; -import { CreateBookingDto } from "./dto/create-booking.dto"; -import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { BookingContractService } from './booking-contract.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingTransitionService } from './booking-transition.service'; +import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingsService } from './bookings.service'; +import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; +import { CreateBookingDto } from './dto/create-booking.dto'; +import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; +import { FilterBookingDto } from './dto/filter-booking.dto'; +import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; +import { + ApproveStepDto, + CancelBookingDto, + RejectStepDto, + RequestChangesDto, + StaffRejectDto, +} from './dto/request-changes.dto'; +import { ContractViewDto } from './dto/contract-view.dto'; +import { SignContractDto } from './dto/sign-contract.dto'; +import { UpdateBookingDto } from './dto/update-booking.dto'; +import { + type AuthUserPayload, + resolveAuthUserId, +} from '../../common/resolve-auth-user-id'; -@ApiTags("bookings") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth -@Controller("bookings") +@ApiTags('bookings') +@Controller('bookings') +@ApiBearerAuth() export class BookingsController { - constructor(private readonly bookingsService: BookingsService) {} + constructor( + private readonly bookingsService: BookingsService, + private readonly bookingReferenceDataService: BookingReferenceDataService, + private readonly pricingService: BookingPricingService, + private readonly transitionService: BookingTransitionService, + private readonly contractService: BookingContractService, + ) {} @Post() - @ApiOperation({ summary: "Create a new freight booking" }) - create(@Body() dto: CreateBookingDto) { - return this.bookingsService.create(dto); + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) + @ApiBody({ type: CreateBookingDto }) + create( + @Body() dto: CreateBookingDto, + @UploadedFiles() files: Express.Multer.File[], + @Request() req: { user?: { id?: string; sub?: string } }, + ) { + const userId = req.user?.id ?? req.user?.sub; + return this.bookingsService.create(dto, files ?? [], userId); + } + + @Patch(':id') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'Update booking', + description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', + }) + @ApiBody({ type: UpdateBookingDto }) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateBookingDto, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.bookingsService.update(id, dto, files ?? []); } @Get() - @ApiOperation({ summary: "List freight bookings (paginated)" }) + @ApiOperation({ summary: 'List freight bookings (paginated)' }) findAll(@Query() filter: FilterBookingDto) { return this.bookingsService.findAll(filter); } - @Get(":id") - @ApiOperation({ summary: "Get a freight booking by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.bookingsService.findById(id); + @Get('list-summary') + @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) + @ApiOkResponse({ type: BookingListSummaryDto }) + findListSummary(@Query() filter: FilterBookingDto) { + return this.bookingsService.getListSummary(filter); } - @Delete(":id") + @Get('queues/:queue') + @ApiOperation({ + summary: 'List bookings for a dashboard queue', + description: 'Queues: intake, approval, signatures, marketing, finance', + }) + findQueue( + @Param('queue') queue: string, + @Query() filter: FilterBookingDto, + @Query('excludeBulk') excludeBulk?: string, + ) { + return this.bookingsService.findQueue(queue, filter, { + excludeBulk: excludeBulk === 'true', + }); + } + + @Get('reference-data') + @ApiOperation({ summary: 'Booking form catalog' }) + @ApiOkResponse({ type: BookingReferenceDataDto }) + getReferenceData(): Promise { + return this.bookingReferenceDataService.getReferenceData(); + } + + @Get('by-reference/:reference') + @ApiOperation({ summary: 'Get booking by reference' }) + async findByReference(@Param('reference') reference: string) { + const booking = await this.bookingsService.findByReference(reference); + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id') + @ApiOperation({ summary: 'Get booking by ID' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.bookingsService.findById(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Delete(':id') @HttpCode(204) - @ApiOperation({ summary: "Soft-delete a freight booking" }) - remove(@Param("id", ParseUUIDPipe) id: string) { + @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) + remove(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } + + @Post(':id/documents') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + async uploadDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + const booking = await this.bookingsService.uploadDocuments(id, files ?? []); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/generate-price') + @ApiOperation({ summary: 'Generate price preview (DRAFT only)' }) + @ApiOkResponse({ type: GeneratePriceResponseDto }) + generatePrice(@Param('id', ParseUUIDPipe) id: string) { + return this.pricingService.generatePrice(id); + } + + @Post(':id/submit') + @ApiOperation({ summary: 'Customer submit booking' }) + async submit(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.submit(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/request-changes') + @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) + @ApiOperation({ summary: 'Staff return booking for customer updates' }) + async requestChanges( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestChangesDto, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.requestChanges( + id, + dto.note, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/accept') + @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) + @ApiOperation({ summary: 'Staff accept intake → start approval chain' }) + async acceptIntake( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.acceptIntake( + id, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/reject') + @BookingStaff(FREIGHT_PERMS.bookings.reject) + @ApiOperation({ summary: 'Staff final reject' }) + async staffReject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: StaffRejectDto, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.staffReject( + id, + dto.reason, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/approval-steps/:stepId/approve') + @BookingStaff([ + FREIGHT_PERMS.bookings.approveLineStaff, + FREIGHT_PERMS.bookings.approveDirector, + FREIGHT_PERMS.bookings.approveCeo, + ]) + @ApiOperation({ summary: 'Approve one approval step in sequence' }) + async approveStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: ApproveStepDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.transitionService.approveStep( + id, + stepId, + resolveAuthUserId(user), + dto.requiredRole, + user, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/approval-steps/:stepId/reject') + @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) + @ApiOperation({ summary: 'Reject at approval step' }) + async rejectStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: RejectStepDto, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.rejectStep( + id, + stepId, + resolveAuthUserId(user), + dto.reason, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/contract/generate') + @BookingStaff(FREIGHT_PERMS.bookings.generateContract) + @ApiOperation({ summary: 'Generate contract PDF from template' }) + async generateContract(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.contractService.generateContract(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id/contract/view') + @ApiOkResponse({ type: ContractViewDto }) + @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) + getContractView(@Param('id', ParseUUIDPipe) id: string) { + return this.contractService.getContractView(id); + } + + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download contract PDF' }) + async downloadContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @Res() res: Response, + ): Promise { + const { stream, record } = await this.contractService.streamContract(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); + } + + @Get(':id/contract') + @ApiOperation({ summary: 'Download contract file (alias)' }) + async downloadContract( + @Param('id', ParseUUIDPipe) id: string, + @Res() res: Response, + ): Promise { + return this.downloadContractDocument(id, res); + } + + @Post(':id/contract/sign') + @ApiOperation({ summary: 'Apply digital signature (customer or staff)' }) + async signContract( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignContractDto, + @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, + ) { + const userId = req.user?.id ?? req.user?.sub; + const booking = await this.contractService.signContract(id, dto, { + signerUserId: userId, + ipAddress: req.ip, + }); + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id/contract/signatures') + @ApiOperation({ summary: 'List contract signatures' }) + getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { + return this.contractService.getSignatures(id); + } + + @Get(':id/summary') + @ApiOperation({ summary: 'Contract summary string for dashboard' }) + getSummary(@Param('id', ParseUUIDPipe) id: string) { + return this.contractService.getSummary(id); + } + + @Post(':id/customer/sign') + @ApiOperation({ + summary: 'Customer digital signature (deprecated — use POST contract/sign)', + }) + async customerSign( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignContractDto, + @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, + ) { + const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; + const booking = await this.contractService.signContract(id, payload, { + signerUserId: req.user?.id ?? req.user?.sub, + ipAddress: req.ip, + }); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/marketing/approve') + @BookingStaff(FREIGHT_PERMS.bookings.signStaff) + @ApiOperation({ + summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', + }) + async marketingApprove( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignContractDto, + @CurrentUser() user: AuthUserPayload, + @Request() req: { ip?: string }, + ) { + const payload: SignContractDto = { + ...dto, + role: 'STAFF', + }; + const booking = await this.contractService.signContract(id, payload, { + signerUserId: resolveAuthUserId(user), + ipAddress: req.ip, + }); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/operations/start-transit') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'Mark in transit' }) + async startTransit(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.startTransit(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/operations/complete') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'Mark completed' }) + async complete(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.complete(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/cancel') + @BookingStaff(FREIGHT_PERMS.bookings.cancel) + @ApiOperation({ summary: 'Cancel booking' }) + async cancel( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CancelBookingDto, + ) { + const booking = await this.transitionService.cancel(id, dto.reason); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/consolidation') + @ApiOperation({ summary: 'Request freight consolidation' }) + requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingsService.requestConsolidation(id); + } + + @Delete(':id/consolidation') + @ApiOperation({ summary: 'Remove consolidation pairing' }) + removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingsService.removeConsolidation(id); + } + + @Get(':id/consolidation') + @ApiOperation({ summary: 'Get consolidation details' }) + getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingsService.getConsolidationDetails(id); + } } 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 98bf64196..c230a187f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,15 +1,69 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; -import { BookingsController } from "./bookings.controller"; -import { BookingsRepository } from "./bookings.repository"; -import { BookingsService } from "./bookings.service"; -import { Booking } from "./entities/booking.entity"; +// import { CustomersModule } from '../customers/customers.module'; +import { CompaniesModule } from '../companies/companies.module'; +import { FilesModule } from '../files/files.module'; +import { MinioModule } from '../minio/minio.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { BookingContractService } from './booking-contract.service'; +import { BookingPaymentService } from './booking-payment.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingTransitionService } from './booking-transition.service'; +import { BookingsController } from './bookings.controller'; +import { PayController } from './pay.controller'; +import { BookingsRepository } from './bookings.repository'; +import { ConsolidationService } from './consolidation.service'; +import { BookingsService } from './bookings.service'; +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; +import { BookingContainer } from './entities/booking-container.entity'; +import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { BookingContractSignature } from './entities/booking-contract-signature.entity'; +import { BookingReviewNote } from './entities/booking-review-note.entity'; +import { Booking } from './entities/booking.entity'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; +import { ContractRendererService } from '../../contracts/contract-renderer.service'; +import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; +import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; +import { PaymentModule } from '../payment/payment.module'; @Module({ - imports: [TypeOrmModule.forFeature([Booking])], - controllers: [BookingsController], - providers: [BookingsService, BookingsRepository], - exports: [BookingsService], + imports: [ + TypeOrmModule.forFeature([ + Booking, + BookingContainer, + BookingCargoModifier, + BookingApprovalStep, + BookingRateSnapshot, + BookingReviewNote, + BookingContractSignature, + ]), + PaymentModule, + FilesModule, + MinioModule, + CompaniesModule, + // CustomersModule, + RuleEngineModule, + ], + controllers: [BookingsController, PayController], + providers: [ + BookingsService, + BookingsRepository, + ConsolidationService, + BookingReferenceDataService, + BookingPricingService, + BookingTransitionService, + BookingContractService, + BookingPaymentService, + ContractTemplateResolver, + ContractViewModelBuilder, + ContractPricingScheduleBuilder, + ContractRendererService, + ContractPdfService, + ], + exports: [BookingsService, BookingsRepository], }) export class BookingsModule {} 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 5a3838c02..9c17eff3e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,15 +1,42 @@ -import { BaseRepository } from "@edr/api-common"; -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm'; -import { Booking } from "./entities/booking.entity"; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; +import { BookingContainer } from './entities/booking-container.entity'; +import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; +import { Booking } from './entities/booking.entity'; +import { + BookingContractSignature, + ContractSignerRole, +} from './entities/booking-contract-signature.entity'; +import { FileRecord } from '../files/entities/file.entity'; +import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; + +export interface BookingListFilterOptions { + statuses?: string[]; + status?: string; + companyId?: string; + contractType?: string; + serviceTypeId?: string; + cargoTypeId?: string; + freightType?: string; + tradeDirection?: string; + paymentCurrency?: string; + allowConsolidation?: boolean; + consolidationPaired?: string; +} @Injectable() export class BookingsRepository extends BaseRepository { constructor( @InjectRepository(Booking) repository: Repository, + private readonly dataSource: DataSource, ) { super(repository); } @@ -18,4 +45,544 @@ export class BookingsRepository extends BaseRepository { findByReference(reference: string): Promise { return this.repository.findOne({ where: { reference } }); } + + /** Count bookings created in a specific year. */ + 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 }) + .getCount(); + } + + /** Find a booking by reference with files and relations. */ + async findByReferenceWithFiles(reference: string): Promise { + return this.findByIdWithFiles( + ( + await this.repository.findOne({ where: { reference }, select: ['id'] }) + )?.id ?? '', + ); + } + + /** Find a booking by ID with files, containers, and config relations. */ + async findByIdWithFiles(id: string): Promise { + if (!id) return null; + + const booking = await this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.bookingContainers', 'bc') + .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('booking.company', 'company') + // .leftJoinAndSelect('booking.customer', 'customer') + .leftJoinAndSelect('booking.train', 'train') + .leftJoinAndSelect('booking.serviceType', 'st') + .leftJoinAndSelect('booking.cargoType', 'cargo') + .leftJoinAndSelect('booking.originYard', 'oy') + .leftJoinAndSelect('booking.destinationYard', 'dy') + .leftJoinAndSelect('booking.shippingLine', 'sl') + .leftJoinAndSelect('booking.approvalSteps', 'steps') + .leftJoinAndSelect('booking.rateSnapshots', 'snapshots') + .leftJoinAndSelect('booking.cargoModifiers', 'modifiers') + .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') + .where('booking.id = :id', { id }) + .leftJoinAndMapMany( + 'booking.files', + FileRecord, + 'file', + "file.resource_id = booking.id AND file.resource = 'bookings'", + ) + .getOne(); + + return booking ?? null; + } + + /** Persist booking container rows with weight rule results. */ + async createContainers( + bookingId: string, + containers: Array<{ + containerTypeId: string; + quantity: number; + vgmPerUnitTons: number; + weightResult: ContainerWeightResult; + }>, + ): Promise { + 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, + }) + .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', + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: bookingId, + status: 'CONSOLIDATED', + } as never); + } + + /** Un-pair a consolidation. */ + async unpairConsolidation(bookingId: string, partnerId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: null, + status: 'PENDING_CONSOLIDATION', + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: null, + status: 'PENDING_CONSOLIDATION', + } as never); + } + + /** Delete all containers for a booking (used on draft update). */ + async deleteContainers(bookingId: string): Promise { + await this.dataSource.getRepository(BookingContainer).delete({ bookingId }); + } + + /** Lowest-order pending approval step (sequential enforcement). */ + async findNextPendingApprovalStep( + bookingId: string, + ): Promise { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, status: 'PENDING' }, + order: { stepOrder: 'ASC' }, + }); + } + + async findApprovalStepById( + bookingId: string, + stepId: string, + ): Promise { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, id: stepId }, + }); + } + + /** Get pending approval step for a role (must match next in sequence). */ + async findPendingApprovalStep( + bookingId: string, + requiredRole: string, + ): Promise { + const next = await this.findNextPendingApprovalStep(bookingId); + if (!next || next.requiredRole !== requiredRole) return null; + return next; + } + + /** Mark an approval step complete. */ + async completeApprovalStep( + stepId: string, + actorId: string, + status: 'APPROVED' | 'REJECTED', + remarks?: string, + ): Promise { + 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 }, + }); + } + + async createReviewNote( + bookingId: string, + note: string, + type: ReviewNoteType, + authorId?: string, + ): Promise { + const repo = this.dataSource.getRepository(BookingReviewNote); + return repo.save( + repo.create({ bookingId, note, type, authorId: authorId ?? null }), + ); + } + + async findLatestReviewNote( + bookingId: string, + type?: ReviewNoteType, + ): Promise { + const repo = this.dataSource.getRepository(BookingReviewNote); + return repo.findOne({ + where: type ? { bookingId, type } : { bookingId }, + order: { createdAt: 'DESC' }, + }); + } + + async clearPricingArtifacts(bookingId: string): Promise { + await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId }); + await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId }); + } + + /** Queue listing with optional bulk exclusion for LINE_STAFF. */ + async findQueue(options: { + status: string | string[]; + page?: number; + pageSize?: number; + excludeBulk?: boolean; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ items: Booking[]; total: number }> { + const page = options.page ?? 1; + const pageSize = options.pageSize ?? 20; + const statuses = Array.isArray(options.status) ? options.status : [options.status]; + + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.cargoType', 'cargo') + .leftJoinAndSelect('booking.serviceType', 'serviceType') + .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') + .where('booking.status IN (:...statuses)', { statuses }); + + if (options.excludeBulk) { + qb.andWhere("booking.freight_type = 'CONTAINER'"); + } + + const sortField = + options.sortBy === 'priorityScore' + ? 'booking.priorityScore' + : 'booking.createdAt'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + + const [items, total] = await qb + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + /** Paginated list with optional multi-status filter (API tab queues). */ + async findAllPaginated(options: BookingListFilterOptions & { + page: number; + pageSize: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ items: Booking[]; total: number }> { + const page = options.page; + const pageSize = options.pageSize; + + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.serviceType', 'serviceType') + .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') + .where('booking.deleted_at IS NULL'); + + this.applyListFilters(qb, options); + + const sortField = + options.sortBy === 'priorityScore' + ? 'booking.priorityScore' + : 'booking.createdAt'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + + const [items, total] = await qb + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + async getStatusCounts(): Promise> { + const rows = await this.repository + .createQueryBuilder('booking') + .select('booking.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .groupBy('booking.status') + .getRawMany<{ status: string; count: string }>(); + + return Object.fromEntries( + rows.map((row) => [row.status, Number(row.count)]), + ); + } + + async getListSummaryMetrics( + options: BookingListFilterOptions & { + page: number; + pageSize: number; + needsActionStatuses: readonly string[]; + urgentPriorityThreshold: number; + }, + ): Promise<{ + inQueue: number; + onThisPage: number; + needsAction: number; + urgent: number; + }> { + const baseQb = () => { + const qb = this.repository + .createQueryBuilder('booking') + .where('booking.deleted_at IS NULL'); + this.applyListFilters(qb, options); + return qb; + }; + + const inQueue = await baseQb().getCount(); + + const needsAction = await baseQb() + .andWhere('booking.status IN (:...needsActionStatuses)', { + needsActionStatuses: [...options.needsActionStatuses], + }) + .getCount(); + + const urgent = await baseQb() + .andWhere('booking.priority_score >= :urgentPriorityThreshold', { + urgentPriorityThreshold: options.urgentPriorityThreshold, + }) + .getCount(); + + const offset = (options.page - 1) * options.pageSize; + const onThisPage = Math.min( + options.pageSize, + Math.max(0, inQueue - offset), + ); + + return { inQueue, onThisPage, needsAction, urgent }; + } + + private applyListFilters( + qb: SelectQueryBuilder, + options: BookingListFilterOptions, + ): void { + if (options.statuses?.length) { + qb.andWhere('booking.status IN (:...statuses)', { + statuses: options.statuses, + }); + } else if (options.status) { + qb.andWhere('booking.status = :status', { status: options.status }); + } + + if (options.companyId) { + qb.andWhere('booking.company_id = :companyId', { + companyId: options.companyId, + }); + } + if (options.contractType) { + qb.andWhere('booking.contract_type = :contractType', { + contractType: options.contractType, + }); + } + if (options.serviceTypeId) { + qb.andWhere('booking.service_type_id = :serviceTypeId', { + serviceTypeId: options.serviceTypeId, + }); + } + if (options.cargoTypeId) { + qb.andWhere('booking.cargo_type_id = :cargoTypeId', { + cargoTypeId: options.cargoTypeId, + }); + } + if (options.freightType) { + qb.andWhere('booking.freight_type = :freightType', { + freightType: options.freightType, + }); + } + if (options.tradeDirection) { + qb.andWhere('booking.trade_direction = :tradeDirection', { + tradeDirection: options.tradeDirection, + }); + } + if (options.paymentCurrency) { + qb.andWhere('booking.payment_currency = :paymentCurrency', { + paymentCurrency: options.paymentCurrency, + }); + } + if (options.allowConsolidation !== undefined) { + qb.andWhere('booking.allow_consolidation = :allowConsolidation', { + allowConsolidation: options.allowConsolidation, + }); + } + if (options.consolidationPaired === 'true') { + qb.andWhere('booking.consolidation_partner_id IS NOT NULL'); + } else if (options.consolidationPaired === 'false') { + qb.andWhere('booking.consolidation_partner_id IS NULL'); + } + } + + async findAndCountFiltered(where: FindOptionsWhere, options: { + skip: number; + take: number; + order: Record; + }): Promise<[Booking[], number]> { + return this.repository.findAndCount({ + where, + skip: options.skip, + take: options.take, + order: options.order, + }); + } + + findContractSignatures(bookingId: string): Promise { + return this.dataSource.getRepository(BookingContractSignature).find({ + where: { bookingId }, + relations: ['signatureFile'], + order: { signedAt: 'ASC' }, + }); + } + + findContractSignature( + bookingId: string, + role: ContractSignerRole, + ): Promise { + return this.dataSource.getRepository(BookingContractSignature).findOne({ + where: { bookingId, signerRole: role }, + relations: ['signatureFile'], + }); + } + + async saveContractSignature( + data: Partial, + ): Promise { + const repo = this.dataSource.getRepository(BookingContractSignature); + const existing = await repo.findOne({ + where: { + bookingId: data.bookingId!, + signerRole: data.signerRole!, + }, + }); + if (existing) { + Object.assign(existing, data); + return repo.save(existing); + } + return repo.save(repo.create(data)); + } } 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 d535ed396..87e9298ff 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1,20 +1,416 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +// import { CustomersService } from '../customers/customers.service'; +import { CompaniesService } from '../companies/companies.service'; +import { FilesService } from '../files/files.service'; +import { MinioService } from '../minio/minio.service'; +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { + BookingEvaluationInput, + RuleEngineService, +} from '../rule-engine/rule-engine.service'; +import { BookingsRepository } from './bookings.repository'; +import { ConsolidationService } from './consolidation.service'; +import { assertFreightShape } from './booking-freight.util'; +import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; +import { mapStatusCountsToTabs } from './booking-list-tabs.config'; +import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; +import { FilterBookingDto } from './dto/filter-booking.dto'; +import { UpdateBookingDto } from './dto/update-booking.dto'; +import { + BOOKING_STATUSES, + CUSTOMER_EDITABLE_STATUSES, + FreightType, +} from './entities/booking.entity'; +import { Booking } from './entities/booking.entity'; +import { FileRecord } from '../files/entities/file.entity'; -import { BookingsRepository } from "./bookings.repository"; -import { CreateBookingDto } from "./dto/create-booking.dto"; -import { FilterBookingDto } from "./dto/filter-booking.dto"; -import { Booking } from "./entities/booking.entity"; +const URGENT_PRIORITY_THRESHOLD = 1000; +const NEEDS_ACTION_STATUSES = [ + 'SUBMITTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', +] as const; @Injectable() export class BookingsService { - constructor(private readonly bookingsRepository: BookingsRepository) {} + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + // private readonly customersService: CustomersService, + private readonly companiesService: CompaniesService, + private readonly ruleEngineService: RuleEngineService, + private readonly containerTypesService: ContainerTypesService, + private readonly consolidationService: ConsolidationService, + ) {} + + /** Generate a unique booking reference number. */ + private async generateReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.bookingsRepository.countByYear(year); + return `BK-${year}-${String(count + 1).padStart(6, '0')}`; + } + + /** Build evaluation input from booking freight shape. */ + private async buildEvalInput(dto: { + freightType: FreightType; + cargoTypeId?: string | null; + serviceTypeId: string; + paymentCurrency: string; + tradeDirection: string; + isHazardous?: boolean; + allowConsolidation?: boolean; + shippingLineId?: string | null; + containers: CreateBookingContainerDto[]; + }): Promise { + const containerLines = + dto.freightType === 'CONTAINER' ? dto.containers : []; + + const containers = await Promise.all( + containerLines.map(async (c) => { + const ct = await this.containerTypesService.findById(c.containerTypeId); + const totalVgmTons = c.quantity * c.vgmPerUnitTons; + return { + containerTypeId: c.containerTypeId, + quantity: c.quantity, + vgmPerUnitTons: c.vgmPerUnitTons, + totalVgmTons, + isReefer: ct.isReefer, + }; + }), + ); + + return { + freightType: dto.freightType, + cargoTypeId: dto.cargoTypeId ?? null, + serviceTypeId: dto.serviceTypeId, + paymentCurrency: dto.paymentCurrency, + tradeDirection: dto.tradeDirection, + isHazardous: dto.isHazardous ?? false, + allowConsolidation: + dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, + shippingLineId: dto.shippingLineId, + containers, + }; + } + + /** + * Enable consolidation when any container line leaves a wagon partially filled + * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out. + */ + private async resolveConsolidation( + containers: CreateBookingContainerDto[], + explicit?: boolean, + ): Promise { + if (explicit === false) return false; + const needs = await this.consolidationService.needsConsolidation( + containers.map((c) => ({ + containerTypeId: c.containerTypeId, + quantity: c.quantity, + })), + ); + if (needs) return true; + return explicit ?? false; + } + + /** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */ + private async tryAutoConsolidate(booking: Booking): Promise<{ + booking: Booking; + messages: string[]; + }> { + const messages: string[] = []; + + if (!booking.allowConsolidation || booking.consolidationPartnerId) { + return { booking, messages }; + } + + const slots = await this.consolidationService.slotsFromBooking(booking); + if (slots.length === 0) { + return { booking, messages }; + } + + const partner = await this.bookingsRepository.findConsolidationPartner( + booking, + slots, + ); + + if (partner) { + await this.bookingsRepository.pairConsolidation(booking.id, partner.id); + const paired = await this.findById(booking.id); + messages.push( + this.consolidationService.describePaired(partner.reference, slots), + ); + return { booking: paired, messages }; + } + + if (booking.status === 'DRAFT') { + await this.bookingsRepository.update(booking.id, { + status: 'PENDING_CONSOLIDATION', + } as never); + } + + const pending = await this.findById(booking.id); + messages.push(this.consolidationService.describePending(pending, slots)); + return { booking: pending, messages }; + } /** Create a new freight booking. */ - async create(dto: CreateBookingDto): Promise { - return this.bookingsRepository.create({ - ...dto, - scheduledDate: new Date(dto.scheduledDate), + async create( + dto: CreateBookingDto, + files: Express.Multer.File[], + userId?: string, + ): Promise<{ booking: Booking; warnings: string[] }> { + const warnings: string[] = []; + + // let customerId = dto.customerId; + // if (!customerId) { + // if (!userId) { + // throw new BadRequestException( + // 'customerId is required or must be resolvable from auth token', + // ); + // } + // const customer = await this.customersService.findByUserId(userId); + // customerId = customer.id; + // } + + let companyId = dto.companyId; + if (!companyId) { + if (!userId) { + throw new BadRequestException( + 'companyId is required or must be resolvable from auth token', + ); + } + const { company } = await this.companiesService.getCompanyInfoByUserId(userId); + companyId = company.id; + } + + const reference = dto.reference || (await this.generateReference()); + const containers = dto.containers ?? []; + assertFreightShape({ + freightType: dto.freightType, + cargoTypeId: dto.cargoTypeId, + containers, }); + + const allowConsolidation = + dto.freightType === 'CONTAINER' + ? await this.resolveConsolidation(containers, dto.allowConsolidation) + : false; + + const evalInput = await this.buildEvalInput({ + freightType: dto.freightType as FreightType, + cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null, + serviceTypeId: dto.serviceTypeId, + paymentCurrency: dto.paymentCurrency, + tradeDirection: dto.tradeDirection, + isHazardous: dto.isHazardous, + allowConsolidation, + shippingLineId: dto.shippingLineId, + containers, + }); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + this.ruleEngineService.assertNoHardBlocks(ruleResult); + + warnings.push(...ruleResult.warnings); + + const booking = await this.bookingsRepository.create({ + reference, + companyId, + trainId: dto.trainId, + contractType: dto.contractType, + previousContractId: dto.previousContractId, + serviceTypeId: dto.serviceTypeId, + firstMilePickupAddress: dto.firstMilePickupAddress, + lastMileDeliveryAddress: dto.lastMileDeliveryAddress, + equipmentReturn: dto.equipmentReturn, + originYardId: dto.originYardId, + destinationYardId: dto.destinationYardId, + tradeDirection: dto.tradeDirection, + freightType: dto.freightType, + cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null, + cargoFreeText: dto.cargoFreeText, + shippingLineId: dto.shippingLineId, + cargoTotalWeightVgm: dto.cargoTotalWeightVgm, + isHazardous: dto.isHazardous ?? false, + paymentCurrency: dto.paymentCurrency, + pnrCode: dto.pnrCode, + financialTerms: dto.financialTerms, + scheduledDate: new Date(dto.scheduledDate), + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + status: 'DRAFT', + allowConsolidation, + priorityScore: ruleResult.priorityScore, + totalAmount: 0, + paymentStatus: 'PENDING', + }); + + if (dto.freightType === 'CONTAINER') { + await this.bookingsRepository.createContainers( + booking.id, + containers.map((c, i) => ({ + containerTypeId: c.containerTypeId, + quantity: c.quantity, + vgmPerUnitTons: c.vgmPerUnitTons, + weightResult: ruleResult.containerWeightResults[i], + })), + ); + const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + warnings.push(`Estimated wagons required: ${wagonCount}`); + } + + if (files.length > 0) { + try { + await this.filesService.uploadMany(booking.id, 'bookings', files); + } catch { + warnings.push('File upload failed — booking was created without attached files.'); + } + } + + let full = await this.findById(booking.id); + + if (allowConsolidation) { + const consolidation = await this.tryAutoConsolidate(full); + full = consolidation.booking; + warnings.push(...consolidation.messages); + } + + return { booking: full, warnings }; + } + + /** Update a draft booking. */ + async update( + id: string, + dto: UpdateBookingDto, + files: Express.Multer.File[], + ): Promise<{ booking: Booking; warnings: string[] }> { + const existing = await this.findById(id); + if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { + throw new BadRequestException( + 'Only DRAFT or CHANGES_REQUESTED bookings can be updated', + ); + } + + const warnings: string[] = []; + const freightType = (dto.freightType ?? existing.freightType) as FreightType; + let containers = + dto.containers ?? + existing.bookingContainers?.map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + vgmPerUnitTons: Number(bc.vgmPerUnitTons), + })) ?? + []; + + let cargoTypeId = + dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; + + if (freightType === 'BULK') { + containers = []; + if (dto.containers !== undefined) { + await this.bookingsRepository.deleteContainers(id); + } + } else { + cargoTypeId = null; + if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) { + throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight'); + } + } + + assertFreightShape({ freightType, cargoTypeId, containers }); + + const allowConsolidation = + freightType === 'CONTAINER' + ? await this.resolveConsolidation( + containers, + dto.allowConsolidation ?? existing.allowConsolidation, + ) + : false; + + const evalInput = await this.buildEvalInput({ + freightType, + cargoTypeId, + serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, + paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, + tradeDirection: dto.tradeDirection ?? existing.tradeDirection, + isHazardous: dto.isHazardous ?? existing.isHazardous, + allowConsolidation, + shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, + containers, + }); + + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + this.ruleEngineService.assertNoHardBlocks(ruleResult); + warnings.push(...ruleResult.warnings); + + const updates: Record = { + ...dto, + freightType, + cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, + allowConsolidation, + priorityScore: ruleResult.priorityScore, + }; + if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); + if (dto.startDate) updates.startDate = new Date(dto.startDate); + if (dto.endDate) updates.endDate = new Date(dto.endDate); + delete updates.containers; + + await this.bookingsRepository.update(id, updates); + + if (freightType === 'CONTAINER' && dto.containers) { + await this.bookingsRepository.deleteContainers(id); + await this.bookingsRepository.createContainers( + id, + dto.containers.map((c, i) => ({ + containerTypeId: c.containerTypeId, + quantity: c.quantity, + vgmPerUnitTons: c.vgmPerUnitTons, + weightResult: ruleResult.containerWeightResults[i], + })), + ); + } + + if (files.length > 0) { + await this.filesService.uploadMany(id, 'bookings', files); + } + + let booking = await this.findById(id); + + if (allowConsolidation && !booking.consolidationPartnerId) { + const consolidation = await this.tryAutoConsolidate(booking); + booking = consolidation.booking; + warnings.push(...consolidation.messages); + } + + return { booking, warnings }; + } + + /** Parse comma-separated or repeated status query values. */ + private parseStatusFilter(filter: FilterBookingDto): { + statuses?: string[]; + status?: string; + } { + const allowed = new Set(BOOKING_STATUSES); + const raw = filter.statuses; + const statusList = raw + ? raw + .split(',') + .map((s) => s.trim()) + .filter((s) => allowed.has(s)) + : []; + + if (statusList.length > 0) { + return { statuses: statusList }; + } + if (filter.status && allowed.has(filter.status)) { + return { status: filter.status }; + } + return {}; } /** Return a paginated list of bookings matching the filter. */ @@ -23,30 +419,233 @@ export class BookingsService { ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; - const [items, total] = await this.bookingsRepository.findAndCount({ - where: { - ...(filter.status ? { status: filter.status } : {}), - ...(filter.customerId ? { customerId: filter.customerId } : {}), - }, - skip: (page - 1) * pageSize, - take: pageSize, - order: { createdAt: "DESC" }, + const statusFilter = this.parseStatusFilter(filter); + + return this.bookingsRepository.findAllPaginated({ + page, + pageSize, + ...statusFilter, + companyId: filter.companyId, + contractType: filter.contractType, + serviceTypeId: filter.serviceTypeId, + cargoTypeId: filter.cargoTypeId, + freightType: filter.freightType, + tradeDirection: filter.tradeDirection, + paymentCurrency: filter.paymentCurrency, + allowConsolidation: filter.allowConsolidation, + consolidationPaired: filter.consolidationPaired, + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, }); - return { items, total }; } - /** Get a single booking by ID, throwing if not found. */ + /** Aggregate metrics and tab counts for the backoffice booking list. */ + async getListSummary(filter: FilterBookingDto): Promise { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const statusFilter = this.parseStatusFilter(filter); + const listFilter = { + ...statusFilter, + companyId: filter.companyId, + contractType: filter.contractType, + serviceTypeId: filter.serviceTypeId, + cargoTypeId: filter.cargoTypeId, + freightType: filter.freightType, + tradeDirection: filter.tradeDirection, + paymentCurrency: filter.paymentCurrency, + allowConsolidation: filter.allowConsolidation, + consolidationPaired: filter.consolidationPaired, + }; + + const [statusCounts, metrics] = await Promise.all([ + this.bookingsRepository.getStatusCounts(), + this.bookingsRepository.getListSummaryMetrics({ + ...listFilter, + page, + pageSize, + needsActionStatuses: NEEDS_ACTION_STATUSES, + urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD, + }), + ]); + + return { + metrics, + tabs: mapStatusCountsToTabs(statusCounts), + }; + } + + /** Get a single booking by ID with files. */ async findById(id: string): Promise { - const booking = await this.bookingsRepository.findById(id); + const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { throw new NotFoundException(`Booking ${id} not found`); } + + if (booking.files && booking.files.length > 0) { + booking.files = await Promise.all( + booking.files.map(async (file: FileRecord) => { + const objectName = this.minioService.getObjectNameFromUrl(file.url); + const signedUrl = await this.minioService.getSignedUrl(objectName, 300); + return { ...file, signedUrl }; + }), + ); + } + return booking; } - /** Soft-delete a booking. */ + async findByReference(reference: string): Promise { + const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); + if (!booking) { + throw new NotFoundException(`Booking with reference "${reference}" not found`); + } + return this.findById(booking.id); + } + + /** Upload documents for a DRAFT booking. */ + async uploadDocuments( + id: string, + files: Express.Multer.File[], + ): Promise { + const booking = await this.findById(id); + if (booking.status !== 'DRAFT') { + throw new BadRequestException( + 'Documents can only be uploaded for DRAFT bookings', + ); + } + await this.filesService.uploadMany(id, 'bookings', files); + return this.findById(id); + } + async remove(id: string): Promise { - await this.findById(id); + const booking = await this.findById(id); + if (booking.status !== 'DRAFT') { + throw new BadRequestException('Only DRAFT bookings can be deleted'); + } await this.bookingsRepository.softDelete(id); } + + async findQueue( + queue: string, + filter: FilterBookingDto, + options?: { excludeBulk?: boolean }, + ): Promise<{ items: Booking[]; total: number }> { + const statusMap: Record = { + intake: 'SUBMITTED', + approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], + signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'], + contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], + marketing: 'SIGNED_CUSTOMER', + finance: 'FULLY_EXECUTED', + }; + + const status = statusMap[queue]; + if (!status) { + throw new BadRequestException(`Unknown queue: ${queue}`); + } + + return this.bookingsRepository.findQueue({ + status, + page: filter.page, + pageSize: filter.pageSize, + excludeBulk: options?.excludeBulk ?? queue === 'approval', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + } + + async requestConsolidation(id: string): Promise<{ + booking: Booking; + partner: Booking | null; + paired: boolean; + message: string; + }> { + const booking = await this.findById(id); + + if (!booking.allowConsolidation) { + throw new BadRequestException('Booking is not eligible for consolidation'); + } + + const needs = await this.consolidationService.needsConsolidationFromBooking( + booking, + ); + if (!needs) { + throw new BadRequestException( + 'Booking already fills whole wagon(s) for all container lines; consolidation is not required', + ); + } + + if (booking.consolidationPartnerId) { + throw new ConflictException('Booking is already paired for consolidation'); + } + + const result = await this.tryAutoConsolidate(booking); + const partner = result.booking.consolidationPartnerId + ? await this.findById(result.booking.consolidationPartnerId) + : null; + + return { + booking: result.booking, + partner, + paired: partner !== null, + message: result.messages[0] ?? '', + }; + } + + async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> { + const booking = await this.findById(id); + if (!booking.consolidationPartnerId) { + throw new BadRequestException('Booking has no consolidation partner'); + } + + const partnerId = booking.consolidationPartnerId; + await this.bookingsRepository.unpairConsolidation(id, partnerId); + + return { + booking: await this.findById(id), + partner: await this.findById(partnerId), + }; + } + + async getConsolidationDetails(id: string): Promise<{ + booking: Booking; + partner: Booking | null; + splitBilling: { bookingShare: number; partnerShare: number } | null; + wagonSlots: Awaited>; + statusMessage: string; + }> { + const booking = await this.findById(id); + const wagonSlots = await this.consolidationService.slotsFromBooking(booking); + + if (!booking.consolidationPartnerId) { + const statusMessage = + booking.status === 'PENDING_CONSOLIDATION' + ? this.consolidationService.describePending(booking, wagonSlots) + : wagonSlots.length > 0 + ? 'Consolidation may be required; no partner paired yet.' + : 'No wagon consolidation needed.'; + return { + booking, + partner: null, + splitBilling: null, + wagonSlots, + statusMessage, + }; + } + + const partner = await this.findById(booking.consolidationPartnerId); + return { + booking, + partner, + splitBilling: { + bookingShare: Number(booking.totalAmount), + partnerShare: Number(partner.totalAmount), + }, + wagonSlots, + statusMessage: this.consolidationService.describePaired( + partner.reference, + wagonSlots, + ), + }; + } } 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-list-summary.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts new file mode 100644 index 000000000..30ca4bdb7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BookingListSummaryMetricsDto { + @ApiProperty({ example: 42 }) + inQueue!: number; + + @ApiProperty({ example: 10 }) + onThisPage!: number; + + @ApiProperty({ example: 8 }) + needsAction!: number; + + @ApiProperty({ example: 3 }) + urgent!: number; +} + +export class BookingListSummaryTabsDto { + @ApiProperty() all!: number; + @ApiProperty() intake!: number; + @ApiProperty() in_approval!: number; + @ApiProperty() approved_contract!: number; + @ApiProperty() payment!: number; + @ApiProperty() operations!: number; + @ApiProperty() completed!: number; + @ApiProperty() closed!: number; +} + +export class BookingListSummaryDto { + @ApiProperty({ type: BookingListSummaryMetricsDto }) + metrics!: BookingListSummaryMetricsDto; + + @ApiProperty({ type: BookingListSummaryTabsDto }) + tabs!: BookingListSummaryTabsDto; +} 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/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts new file mode 100644 index 000000000..4af9e535d --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ContractSignatureDto { + @ApiProperty({ enum: ['CUSTOMER', 'STAFF'] }) + role!: string; + + @ApiProperty() + signerDisplayName!: string; + + @ApiProperty() + signedAt!: string; + + @ApiPropertyOptional() + signatureImageUrl?: string | null; +} + +export class ContractViewDto { + @ApiProperty() + bookingId!: string; + + @ApiProperty() + reference!: string; + + @ApiProperty() + status!: string; + + @ApiProperty() + templateKey!: string; + + @ApiProperty() + title!: string; + + @ApiProperty({ description: 'Full HTML document for in-browser display' }) + html!: string; + + @ApiProperty() + canSignCustomer!: boolean; + + @ApiProperty() + canSignStaff!: boolean; + + @ApiProperty() + hasContractDocument!: boolean; + + @ApiProperty({ type: [ContractSignatureDto] }) + signatures!: ContractSignatureDto[]; + + @ApiPropertyOptional() + pricingSchedule?: Record; +} 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 c96e4fd62..8089d0307 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,33 +1,197 @@ -import { Freight } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; import { + ArrayMinSize, + IsArray, + IsBoolean, IsDateString, - IsEnum, + IsIn, + IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, -} from "class-validator"; + Validate, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity'; +import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; + +const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const; +const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const; +const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; +const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; + +export { + BOOKING_STATUSES, + CONTRACT_TYPES, + EQUIPMENT_RETURNS, + FREIGHT_TYPES, + TRADE_DIRECTIONS, + PAYMENT_CURRENCIES, +}; + +export class CreateBookingContainerDto { + @ApiProperty({ format: 'uuid', description: 'FK to container_types.id' }) + @IsUUID() + containerTypeId!: string; + + @ApiProperty({ description: 'Quantity of containers', minimum: 1 }) + @IsInt() + @Min(1) + @Transform(({ value }) => Number(value)) + quantity!: number; + + @ApiProperty({ description: 'VGM per container in tons', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + vgmPerUnitTons!: number; +} export class CreateBookingDto { + /** Class-level freight shape check (not a request field). */ + @Validate(BookingFreightShapeConstraint) + freightShapeValidation?: boolean; + @ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' }) + @IsOptional() @IsString() - reference!: string; + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + reference?: string; + // @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer (legacy)' }) + // @IsOptional() + // @IsUUID() + // customerId?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' }) + @IsOptional() @IsUUID() - customerId!: string; + companyId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() trainId?: string; + @ApiProperty({ example: '2026-06-15T00:00:00.000Z' }) @IsDateString() scheduledDate!: string; + @ApiProperty({ enum: CONTRACT_TYPES }) + @IsIn([...CONTRACT_TYPES]) + contractType!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + @Transform(({ value }) => (value === '' || value == null ? undefined : value)) + previousContractId?: string; + + @ApiProperty({ format: 'uuid', description: 'FK to service_types.id' }) + @IsUUID() + serviceTypeId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + firstMilePickupAddress?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + lastMileDeliveryAddress?: string; + + @ApiProperty({ enum: EQUIPMENT_RETURNS }) + @IsIn([...EQUIPMENT_RETURNS]) + equipmentReturn!: string; + + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' }) + @IsUUID() + originYardId!: string; + + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' }) + @IsUUID() + destinationYardId!: string; + + @ApiProperty({ enum: TRADE_DIRECTIONS }) + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection!: string; + + @ApiProperty({ enum: FREIGHT_TYPES, description: 'CONTAINER or BULK (mutually exclusive cargo shape)' }) + @IsIn([...FREIGHT_TYPES]) + freightType!: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Required for BULK; must be omitted for CONTAINER', + }) + @ValidateIf((o) => o.freightType === 'BULK') + @IsUUID() + cargoTypeId?: string; + + @ApiPropertyOptional({ maxLength: 200 }) + @IsOptional() + @IsString() + cargoFreeText?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'FK to shipping_lines.id' }) + @IsOptional() + @IsUUID() + shippingLineId?: string; + + @ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 }) @IsNumber() @Min(0) - totalAmount!: number; + @Transform(({ value }) => Number(value)) + cargoTotalWeightVgm!: number; + @ApiPropertyOptional({ default: false }) @IsOptional() - @IsEnum(Freight.BookingStatus) - status?: Freight.BookingStatus; + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isHazardous?: boolean; + + @ApiProperty({ enum: PAYMENT_CURRENCIES }) + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + pnrCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + financialTerms?: string; + + @ApiPropertyOptional({ + type: [CreateBookingContainerDto], + description: 'Required for CONTAINER (min 1 line); must be empty for BULK', + }) + @ValidateIf((o) => o.freightType === 'CONTAINER') + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateBookingContainerDto) + containers?: CreateBookingContainerDto[]; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + allowConsolidation?: boolean; } 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 e4064b1e4..03fe73683 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,25 +1,95 @@ -import { Freight } from "@edr/types"; -import { Type } from "class-transformer"; -import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator"; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; +import { + BOOKING_STATUSES, + FREIGHT_TYPES, + PAYMENT_CURRENCIES, + TRADE_DIRECTIONS, +} from './create-booking.dto'; export class FilterBookingDto { + @ApiPropertyOptional({ enum: BOOKING_STATUSES }) @IsOptional() - @IsEnum(Freight.BookingStatus) - status?: Freight.BookingStatus; + @IsIn([...BOOKING_STATUSES]) + status?: string; + @ApiPropertyOptional({ + description: + 'Filter by statuses: comma-separated (PENDING_APPROVAL,APPROVED) or repeated query params. Overrides status when set.', + }) + @IsOptional() + @Transform(({ value }) => { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value)) return value.map(String).join(','); + return String(value); + }) + statuses?: string; + + // @ApiPropertyOptional({ format: 'uuid' }) + // @IsOptional() + // @IsUUID() + // customerId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() - customerId?: string; + companyId?: string; + @ApiPropertyOptional() @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - page?: number = 1; + contractType?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - pageSize?: number = 20; + @IsUUID() + serviceTypeId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string; + + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) + @IsOptional() + @IsIn([...FREIGHT_TYPES]) + freightType?: string; + + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) + @IsOptional() + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection?: string; + + @ApiPropertyOptional({ enum: PAYMENT_CURRENCIES }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency?: string; + + @ApiPropertyOptional() + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + allowConsolidation?: boolean; + + @ApiPropertyOptional({ description: 'true | false — filter paired consolidation' }) + @IsOptional() + consolidationPaired?: string; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Transform(({ value }) => (value ? parseInt(value, 10) : 1)) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Transform(({ value }) => (value ? parseInt(value, 10) : 20)) + pageSize?: number; + + @ApiPropertyOptional({ default: 'createdAt' }) + @IsOptional() + sortBy?: string; + + @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) + @IsOptional() + @IsIn(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts new file mode 100644 index 000000000..3474bec74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PriceLineItemDto { + @ApiProperty() + code!: string; + + @ApiProperty() + description!: string; + + @ApiProperty() + amount!: number; + + @ApiProperty() + currency!: string; +} + +export class GeneratePriceResponseDto { + @ApiProperty() + bookingId!: string; + + @ApiProperty() + totalAmount!: number; + + @ApiProperty() + currency!: string; + + @ApiProperty({ type: [PriceLineItemDto] }) + lineItems!: PriceLineItemDto[]; + + @ApiProperty({ type: [String] }) + warnings!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts new file mode 100644 index 000000000..50db3864b --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InAppPaymentReceiptDto { + @ApiProperty({ example: true }) + success!: boolean; + + @ApiProperty({ example: 'TELEBIRR' }) + provider!: string; + + @ApiProperty({ example: 'TB-BK-2026-000123-1717584000000' }) + providerRef!: string; + + @ApiProperty({ example: 15000 }) + amount!: number; + + @ApiProperty({ example: 'ETB' }) + currency!: string; + + @ApiProperty({ example: '2026-06-05T12:00:00.000Z' }) + paidAt!: string; +} + +export class PayBookingResponseDto { + @ApiProperty({ type: InAppPaymentReceiptDto }) + paymentReceipt!: InAppPaymentReceiptDto; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts new file mode 100644 index 000000000..99855d49f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength } from 'class-validator'; + +export class RequestChangesDto { + @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) + @IsString() + @MinLength(1) + note!: string; +} + +export class StaffRejectDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} + +export class ApproveStepDto { + @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) + @IsString() + requiredRole!: string; +} + +export class RejectStepDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} + +export class CancelBookingDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts new file mode 100644 index 000000000..0b176ebd5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; + +export class SignContractDto { + @ApiProperty({ enum: ['CUSTOMER', 'STAFF'] }) + @IsIn(['CUSTOMER', 'STAFF']) + role!: 'CUSTOMER' | 'STAFF'; + + @ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' }) + @IsString() + @MinLength(20) + signatureImageBase64!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + signerDisplayName!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + consentText?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts new file mode 100644 index 000000000..328e71180 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts @@ -0,0 +1,10 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { Validate } from 'class-validator'; + +import { CreateBookingDto } from './create-booking.dto'; +import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; + +export class UpdateBookingDto extends PartialType(CreateBookingDto) { + @Validate(BookingFreightShapeConstraint) + freightShapeValidation?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts new file mode 100644 index 000000000..1365158b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts @@ -0,0 +1,60 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity'; + +export interface BookingFreightShapeInput { + freightType?: string; + cargoTypeId?: string | null; + containers?: Array<{ containerTypeId?: string }> | null; +} + +@ValidatorConstraint({ name: 'BookingFreightShape', async: false }) +export class BookingFreightShapeConstraint implements ValidatorConstraintInterface { + validate(_value: unknown, args: ValidationArguments): boolean { + const dto = args.object as BookingFreightShapeInput; + if (!dto.freightType || !FREIGHT_TYPES.includes(dto.freightType as FreightType)) { + return true; + } + + const containers = dto.containers ?? []; + const hasContainers = containers.length > 0; + const hasCargoType = + dto.cargoTypeId !== undefined && + dto.cargoTypeId !== null && + String(dto.cargoTypeId).trim() !== ''; + + if (dto.freightType === 'BULK') { + if (hasContainers) return false; + if (!hasCargoType) return false; + return true; + } + + if (dto.freightType === 'CONTAINER') { + if (hasCargoType) return false; + if (!hasContainers) return false; + return containers.every( + (c) => + c.containerTypeId !== undefined && + c.containerTypeId !== null && + String(c.containerTypeId).trim() !== '', + ); + } + + return true; + } + + defaultMessage(args: ValidationArguments): string { + const dto = args.object as BookingFreightShapeInput; + if (dto.freightType === 'BULK') { + return 'BULK freight requires cargoTypeId and must not include container lines'; + } + if (dto.freightType === 'CONTAINER') { + return 'CONTAINER freight requires at least one container line with containerTypeId and must not include cargoTypeId'; + } + return 'Invalid freight type shape'; + } +} 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..68018e883 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts @@ -0,0 +1,48 @@ +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: 'blocks_role', type: 'varchar', length: 30, nullable: true }) + blocksRole?: string | null; + + @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-contract-signature.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts new file mode 100644 index 000000000..6370c97c2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts @@ -0,0 +1,44 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; +import { FileRecord } from '../../files/entities/file.entity'; +import { Booking } from './booking.entity'; + +export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF'] as const; +export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number]; + +@Entity({ schema: 'freight', name: 'booking_contract_signatures' }) +@Unique(['bookingId', 'signerRole']) +@Index(['bookingId']) +export class BookingContractSignature extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'signer_role', type: 'varchar', length: 20 }) + signerRole!: ContractSignerRole; + + @Column({ name: 'signer_user_id', type: 'uuid', nullable: true }) + signerUserId?: string | null; + + @Column({ name: 'signer_display_name', type: 'varchar', length: 200 }) + signerDisplayName!: string; + + @Column({ name: 'signed_at', type: 'timestamptz' }) + signedAt!: Date; + + @Column({ name: 'signature_file_id', type: 'uuid', nullable: true }) + signatureFileId?: string | null; + + @ManyToOne(() => FileRecord, { nullable: true }) + @JoinColumn({ name: 'signature_file_id' }) + signatureFile?: FileRecord | null; + + @Column({ name: 'consent_text', type: 'text', nullable: true }) + consentText?: string | null; + + @Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true }) + ipAddress?: string | 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-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts new file mode 100644 index 000000000..af39a469c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; + +export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const; +export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'booking_review_note' }) +@Index(['bookingId']) +export class BookingReviewNote extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.reviewNotes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'author_id', type: 'uuid', nullable: true }) + authorId?: string | null; + + @Column({ name: 'note', type: 'text' }) + note!: string; + + @Column({ name: 'type', type: 'varchar', length: 30 }) + type!: ReviewNoteType; +} 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 0d848c516..fb3679a60 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,43 +1,261 @@ -import { BaseEntity } from "@edr/api-common"; -import { Freight } from "@edr/types"; -import { Column, Entity } from "typeorm"; +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +// import { Customer } from '../../customers/entities/customer.entity'; +import { Company } from '../../companies/entities/company.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'; +import { BookingReviewNote } from './booking-review-note.entity'; -@Entity({ name: "bookings" }) +export const BOOKING_STATUSES = [ + 'DRAFT', + 'SUBMITTED', + 'CHANGES_REQUESTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', + 'APPROVED', + 'CONTRACT_READY', + 'SIGNED_CUSTOMER', + 'FULLY_EXECUTED', + 'PNR_GENERATED', + 'PAYMENT_VERIFICATION_IN_PROGRESS', + 'PAID', + 'IN_TRANSIT', + 'COMPLETED', + 'REJECTED', + 'CANCELLED', + 'PENDING_CONSOLIDATION', + 'CONSOLIDATED', +] as const; + +export type BookingStatus = (typeof BOOKING_STATUSES)[number]; + +export const PAYMENT_STATUSES = [ + 'PENDING', + 'PNR_GENERATED', + 'VERIFICATION_IN_PROGRESS', + 'PAID', + 'FAILED', +] as const; + +export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; + +export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; +export type FreightType = (typeof FREIGHT_TYPES)[number]; + +/** Statuses where the customer may edit booking fields. */ +export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [ + 'DRAFT', + 'CHANGES_REQUESTED', +]; + +@Entity({ schema: 'freight', name: 'bookings' }) export class Booking extends BaseEntity { - @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" }) - customerId!: string; + // Legacy — superseded by companyId (column kept in DB) + // @Column({ name: 'customer_id', type: 'uuid' }) + // customerId!: string; + // @ManyToOne(() => Customer) + // @JoinColumn({ name: 'customer_id' }) + // customer?: Customer; - @Column({ name: "train_id", type: "uuid", nullable: true }) + @Column({ name: 'company_id', type: 'uuid' }) + companyId!: string; + + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company?: Company; + + @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId?: string | null; - @Column({ - name: "status", - type: "enum", - enum: Freight.BookingStatus, - default: Freight.BookingStatus.Draft, - }) - status!: Freight.BookingStatus; + @ManyToOne(() => Train, { nullable: true }) + @JoinColumn({ name: 'train_id' }) + train?: Train | null; - @Column({ name: "scheduled_date", type: "timestamptz" }) + @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) + status!: string; + + @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: "enum", - enum: Freight.PaymentStatus, - default: Freight.PaymentStatus.Pending, + @Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' }) + paymentStatus!: string; + + @Column({ name: 'contract_type', type: 'varchar', length: 20 }) + contractType!: string; + + @Column({ name: 'previous_contract_id', type: 'uuid', nullable: true }) + previousContractId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'previous_contract_id' }) + previousContract?: Booking | null; + + @Column({ name: 'service_type_id', type: 'uuid' }) + serviceTypeId!: string; + + @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_delivery_address', type: 'text', nullable: true }) + lastMileDeliveryAddress?: string | null; + + @Column({ name: 'equipment_return', type: 'varchar', length: 20 }) + equipmentReturn!: string; + + @Column({ name: 'origin_yard_id', type: 'uuid' }) + originYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard; + + @Column({ name: 'destination_yard_id', type: 'uuid' }) + destinationYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard; + + @Column({ name: 'trade_direction', type: 'varchar', length: 10 }) + tradeDirection!: string; + + @Column({ name: 'freight_type', type: 'varchar', length: 20 }) + freightType!: string; + + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId?: string | null; + + @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: '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 }) + endDate?: Date | null; + + @Column({ name: 'financial_terms', type: 'text', nullable: true }) + financialTerms?: string | null; + + @Column({ name: 'version_number', type: 'int', default: 1 }) + versionNumber!: number; + + @Column({ name: 'approved_by_staff_id', type: 'uuid', nullable: true }) + approvedByStaffId?: string | null; + + @Column({ name: 'approved_by_staff_at', type: 'timestamptz', nullable: true }) + approvedByStaffAt?: Date | null; + + @Column({ name: 'signed_by_director_id', type: 'uuid', nullable: true }) + signedByDirectorId?: string | null; + + @Column({ name: 'signed_by_director_at', type: 'timestamptz', nullable: true }) + signedByDirectorAt?: Date | null; + + @Column({ name: 'signed_by_ceo_id', type: 'uuid', nullable: true }) + signedByCeoId?: string | null; + + @Column({ name: 'signed_by_ceo_at', type: 'timestamptz', nullable: true }) + signedByCeoAt?: Date | null; + + @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: 'marketing_approved_by_id', type: 'uuid', nullable: true }) + marketingApprovedById?: string | null; + + @Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true }) + marketingApprovedAt?: Date | null; + + @Column({ name: 'contract_summary', type: 'text', nullable: true }) + contractSummary?: string | null; + + @Column({ name: 'contract_template_key', type: 'varchar', length: 80, nullable: true }) + contractTemplateKey?: string | null; + + @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true }) + contractGeneratedAt?: Date | null; + + @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) + pricingBreakdown?: Record | null; + + @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) + lockedAt?: Date | null; + + @Column({ name: 'priority_score', type: 'int', default: 0 }) + priorityScore!: number; + + @Column({ name: 'allow_consolidation', type: 'boolean', default: false }) + allowConsolidation!: boolean; + + @Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true }) + consolidationPartnerId?: string | null; + + @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(() => BookingReviewNote, (n) => n.booking) + reviewNotes?: BookingReviewNote[]; + + @OneToMany(() => FileRecord, (file) => file.resourceId, { + createForeignKeyConstraints: false, }) - paymentStatus!: Freight.PaymentStatus; + files?: FileRecord[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts new file mode 100644 index 000000000..25f1927d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { BookingPaymentService } from './booking-payment.service'; +// import { BookingTransitionService } from './booking-transition.service'; +// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; +// import { Booking } from './entities/booking.entity'; +// import { BookingNextStep } from './booking-next-step.util'; + +@ApiTags('payments') +@ApiBearerAuth() +@Controller('bookings') +export class PayController { + constructor( + private readonly paymentService: BookingPaymentService, + // private readonly transitionService: BookingTransitionService, + ) { } + + @Post(':id/payment/pay') + @ApiOperation({ summary: 'Complete in-app payment (mock)' }) + @ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' }) + async pay(@Param('id', ParseUUIDPipe) id: string) { + return await this.paymentService.pay(id); + // const abstract = await this.transitionService.enrichBookingResponse(booking); + // return { ...abstract, paymentReceipt: receipt }; + } +} diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts new file mode 100644 index 000000000..b0babb06f --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -0,0 +1,71 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateCargoDto } from './dto/create-cargo.dto'; +import { UpdateCargoDto } from './dto/update-cargo.dto'; +import { LoadCargoDto } from './dto/load-cargo.dto'; +import { DeliverCargoDto } from './dto/deliver-cargo.dto'; +import { CargoesService } from './cargoes.service'; + +@ApiTags('cargoes') +@Controller('cargoes') +export class CargoesController { + constructor(private readonly cargoesService: CargoesService) {} + + @Post() + @ApiOperation({ summary: 'Create a new cargo' }) + create(@Body() dto: CreateCargoDto) { + return this.cargoesService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all cargoes' }) + findAll(@Query() query: Record) { + return this.cargoesService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a cargo by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.cargoesService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a cargo' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) { + return this.cargoesService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a cargo' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.cargoesService.remove(id); + } + + @Post(':id/load') + @ApiOperation({ summary: 'Load cargo into a container' }) + load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) { + return this.cargoesService.loadCargo(id, dto); + } + + @Post(':id/unload') + @ApiOperation({ summary: 'Unload cargo from container' }) + unload(@Param('id', ParseUUIDPipe) id: string) { + return this.cargoesService.unloadCargo(id); + } + + @Post(':id/deliver') + @ApiOperation({ summary: 'Mark cargo as delivered' }) + deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { + return this.cargoesService.deliverCargo(id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts new file mode 100644 index 000000000..d1c03c9d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Cargo } from './entities/cargoes.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { CargoesController } from './cargoes.controller'; +import { CargoesService } from './cargoes.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Cargo, Container, CargoType])], + controllers: [CargoesController], + providers: [CargoesService], + exports: [CargoesService], +}) +export class CargoesModule {} diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts new file mode 100644 index 000000000..cedb217da --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cargo } from './entities/cargoes.entity'; + +@Injectable() +export class CargoesRepository extends BaseRepository { + constructor( + @InjectRepository(Cargo) + repository: Repository, + ) { + super(repository); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts new file mode 100644 index 000000000..f5940d6a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts @@ -0,0 +1,172 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { CreateCargoDto } from './dto/create-cargo.dto'; +import { UpdateCargoDto } from './dto/update-cargo.dto'; +import { LoadCargoDto } from './dto/load-cargo.dto'; +import { DeliverCargoDto } from './dto/deliver-cargo.dto'; +import { Cargo } from './entities/cargoes.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; + +@Injectable() +export class CargoesService { + constructor( + @InjectRepository(Cargo) + private readonly cargoRepo: Repository, + @InjectRepository(Container) + private readonly containerRepo: Repository, + @InjectRepository(CargoType) + private readonly cargoTypeRepo: Repository, + ) {} + + async create(dto: CreateCargoDto): Promise { + const existing = await this.cargoRepo.findOne({ + where: { cargoReference: dto.cargoReference }, + }); + if (existing) { + throw new ConflictException(`Cargo reference "${dto.cargoReference}" already exists`); + } + + const container = await this.containerRepo.findOne({ where: { id: dto.containerId } }); + if (!container) { + throw new NotFoundException(`Container ${dto.containerId} not found`); + } + + if (dto.cargoTypeId) { + const cargoType = await this.cargoTypeRepo.findOne({ + where: { id: dto.cargoTypeId, isActive: true }, + }); + if (!cargoType) throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + + const cargo = this.cargoRepo.create(dto); + return this.cargoRepo.save(cargo); + } + + async findAll(query: Record = {}): Promise { + const where: FindOptionsWhere[] | FindOptionsWhere = []; + const search = query.search?.trim(); + const status = query.status?.trim(); + const containerId = query.containerId?.trim(); + + if (search) { + where.push({ + cargoReference: ILike(`%${search}%`), + ...(status ? { status } : {}), + ...(containerId ? { containerId } : {}), + }); + where.push({ + description: ILike(`%${search}%`), + ...(status ? { status } : {}), + ...(containerId ? { containerId } : {}), + }); + } + + const sortBy = ['cargoReference', 'quantity', 'weight', 'volume', 'status'].includes(query.sortBy ?? '') + ? (query.sortBy as keyof Cargo) + : 'cargoReference'; + const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + return this.cargoRepo.find({ + where: search ? where : { ...(status ? { status } : {}), ...(containerId ? { containerId } : {}) }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, + take: query.limit ? Number(query.limit) : undefined, + }); + } + + async findById(id: string): Promise { + const cargo = await this.cargoRepo.findOne({ where: { id } }); + if (!cargo) throw new NotFoundException(`Cargo ${id} not found`); + return cargo; + } + + async update(id: string, dto: UpdateCargoDto): Promise { + const cargo = await this.findById(id); + if (dto.cargoReference && dto.cargoReference !== cargo.cargoReference) { + const existing = await this.cargoRepo.findOne({ + where: { cargoReference: dto.cargoReference }, + }); + if (existing) { + throw new ConflictException(`Cargo reference "${dto.cargoReference}" already exists`); + } + } + if (dto.containerId) { + const container = await this.containerRepo.findOne({ where: { id: dto.containerId } }); + if (!container) throw new NotFoundException(`Container ${dto.containerId} not found`); + } + if (dto.cargoTypeId) { + const cargoType = await this.cargoTypeRepo.findOne({ + where: { id: dto.cargoTypeId, isActive: true }, + }); + if (!cargoType) throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + Object.assign(cargo, dto); + return this.cargoRepo.save(cargo); + } + + async remove(id: string): Promise { + const cargo = await this.findById(id); + await this.cargoRepo.remove(cargo); + } + + async loadCargo(id: string, dto: LoadCargoDto): Promise { + const cargo = await this.cargoRepo.findOne({ + where: { id }, + relations: { container: true }, // ✅ fixed + }); + if (!cargo) throw new NotFoundException('Cargo not found'); + if (cargo.status !== 'PENDING') { + throw new ConflictException('Cargo already loaded or delivered'); + } + + cargo.status = 'LOADED'; + cargo.loadedAt = new Date(); + cargo.quantity = dto.quantity; + cargo.weight = dto.weight; + cargo.volume = dto.volume ?? null; + if (dto.description) cargo.description = dto.description; + + if (cargo.container) { + cargo.container.status = 'LOADED'; + await this.containerRepo.save(cargo.container); + } + + return this.cargoRepo.save(cargo); + } + + async unloadCargo(id: string): Promise { + const cargo = await this.findById(id); + if (cargo.status !== 'LOADED') { + throw new ConflictException('Cargo is not loaded'); + } + cargo.status = 'UNLOADED'; + cargo.unloadedAt = new Date(); + return this.cargoRepo.save(cargo); + } + + async deliverCargo(id: string, dto?: DeliverCargoDto): Promise { + const cargo = await this.cargoRepo.findOne({ + where: { id }, + relations: { container: true }, // ✅ fixed + }); + if (!cargo) throw new NotFoundException('Cargo not found'); + if (cargo.status !== 'LOADED') { + throw new ConflictException('Only loaded cargo can be delivered'); + } + + cargo.status = 'DELIVERED'; + if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks; + + const remaining = await this.cargoRepo.count({ + where: { containerId: cargo.containerId, status: 'LOADED' }, + }); + if (remaining === 0 && cargo.container) { + cargo.container.status = 'AVAILABLE'; + await this.containerRepo.save(cargo.container); + } + + return this.cargoRepo.save(cargo); + } +} diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts new file mode 100644 index 000000000..8373f5a4b --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts @@ -0,0 +1,45 @@ +import { IsString, IsUUID, IsOptional, IsNumber, Min, IsIn, IsDateString } from 'class-validator'; + +export class CreateCargoDto { + @IsString() + cargoReference!: string; + + @IsUUID() + shipmentId!: string; + + @IsUUID() + containerId!: string; + + @IsOptional() + @IsUUID() + cargoTypeId?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsNumber() + @Min(0.001) + quantity!: number; + + @IsNumber() + @Min(0) + weight!: number; + + @IsOptional() + @IsNumber() + @Min(0) + volume?: number; + + @IsOptional() + @IsIn(['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'UNLOADED']) + status?: string; + + @IsOptional() + @IsDateString() + loadedAt?: string; + + @IsOptional() + @IsDateString() + unloadedAt?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts new file mode 100644 index 000000000..020e4d630 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class DeliverCargoDto { + @IsOptional() + @IsString() + deliveryRemarks?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts new file mode 100644 index 000000000..9e8573751 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts @@ -0,0 +1,20 @@ +import { IsNumber, Min, IsOptional, IsString } from 'class-validator'; + +export class LoadCargoDto { + @IsNumber() + @Min(0.001) + quantity!: number; + + @IsNumber() + @Min(0) + weight!: number; + + @IsOptional() + @IsNumber() + @Min(0) + volume?: number; + + @IsOptional() + @IsString() + description?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/unload-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/unload-cargo.dto.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts new file mode 100644 index 000000000..7596b7dbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCargoDto } from './create-cargo.dto'; + +export class UpdateCargoDto extends PartialType(CreateCargoDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts new file mode 100644 index 000000000..7c2f752e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts @@ -0,0 +1,45 @@ +// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts +import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; +import { Container } from '../../container-management/entities/container.entity'; + +@Entity({ name: 'cargoes', schema: 'freight' }) +export class Cargo extends BaseEntity { + @Column({ unique: true, name: 'cargo_reference' }) + cargoReference!: string; + + @Column({ name: 'shipment_id', type: 'uuid' }) + shipmentId!: string; + + @Column({ name: 'container_id', type: 'uuid' }) + containerId!: string; + + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId!: string | null; // optional link to cargo_types table + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ type: 'decimal', precision: 12, scale: 3 }) + quantity!: number; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + weight!: number; // kg + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + volume!: number | null; // m³ + + @Column({ type: 'varchar', default: 'PENDING' }) + status!: string; // PENDING, LOADED, IN_TRANSIT, DELIVERED, UNLOADED + + @Column({ name: 'loaded_at', type: 'timestamp', nullable: true }) + loadedAt!: Date | null; + + @Column({ name: 'unloaded_at', type: 'timestamp', nullable: true }) + unloadedAt!: Date | null; + + // Relationship to Container + @ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'container_id' }) + container!: Container; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts new file mode 100644 index 000000000..b1481d0d4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -0,0 +1,192 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import { FilesService } from '../files/files.service'; +import { CompaniesService } from './companies.service'; +import { CreateCompanyDto } from './dto/create-company.dto'; +import { UpdateCompanyDto } from './dto/update-company.dto'; +import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; +import { CreateFFClientDto } from './dto/create-ff-client.dto'; +import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; +import { ResponseCompanyDto } from './dto/response-company.dto'; +import { ResponseExternalProfileDto } from './dto/response-external-profile.dto'; +import { ResponseFFClientDto } from './dto/response-ff-client.dto'; +import { CompanyInfoResponseDto } from './dto/company-info-response.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ProfileResponseDto } from './dto/profile-response.dto'; + +interface CurrentIamUser { + id: string; + name?: { en: string; am: string }; + email?: string; + phoneNumber?: string; +} + +@ApiTags('Companies') +@Controller('companies') +export class CompaniesController { + constructor( + private readonly companiesService: CompaniesService, + private readonly filesService: FilesService, + ) {} + + @Get('getInfo') + @ApiOperation({ summary: 'Get company info for the current user' }) + async getInfo(@CurrentUser() user: CurrentIamUser): Promise { + const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + return new CompanyInfoResponseDto(profile, company); + } + + @Get('profile') + @ApiOperation({ summary: 'Get flattened profile for the settings page' }) + async getProfile(@CurrentUser() user: CurrentIamUser): Promise { + const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + return new ProfileResponseDto(profile, company); + } + + @Patch('profile') + @ApiOperation({ summary: 'Update profile (flattened settings page)' }) + async updateProfile( + @CurrentUser() user: CurrentIamUser, + @Body() dto: UpdateProfileDto, + ): Promise { + return this.companiesService.updateProfile(user.id, dto); + } + + @Post('create') + @ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' }) + async createWithProfile( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CreateCompanyWithProfileDto, + ): Promise { + const nameParts = (user.name?.en ?? '').split(' '); + const { profile, company } = await this.companiesService.createCompanyWithProfile( + { + userId: user.id, + firstName: nameParts[0] || '', + lastName: nameParts.slice(-1)[0] || '', + email: user.email ?? '', + phone: user.phoneNumber ?? '', + }, + dto, + ); + return new CompanyInfoResponseDto(profile, company); + } + + @Post() + @ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' }) + async create(@Body() dto: CreateCompanyDto): Promise { + const company = await this.companiesService.createCompany(dto); + return new ResponseCompanyDto(company); + } + + @Get() + @ApiOperation({ summary: 'List all companies' }) + async findAll(): Promise { + const companies = await this.companiesService.findAllCompanies(); + return companies.map((c) => new ResponseCompanyDto(c)); + } + + @Get('type/:type') + @ApiOperation({ summary: 'Find companies by type' }) + async findByType(@Param('type') type: string): Promise { + const companies = await this.companiesService.findAllCompanies(); + return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c)); + } + + @Get('search') + @ApiOperation({ summary: 'Search companies by name' }) + async search(@Query('name') name: string): Promise { + const companies = await this.companiesService.findAllCompanies(); + return companies + .filter((c) => c.name.toLowerCase().includes(name.toLowerCase())) + .map((c) => new ResponseCompanyDto(c)); + } + + @Get(':id') + @ApiOperation({ summary: 'Get company by ID' }) + async findById(@Param('id', ParseUUIDPipe) id: string): Promise { + const company = await this.companiesService.findCompanyById(id); + return new ResponseCompanyDto(company); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a company' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateCompanyDto, + ): Promise { + const company = await this.companiesService.updateCompany(id, dto); + return new ResponseCompanyDto(company); + } + + @Delete(':id') + @ApiOperation({ summary: 'Soft-delete a company' }) + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + await this.companiesService.deleteCompany(id); + } + + @Post(':companyId/documents') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload documents for a company (onboarding)' }) + async uploadDocuments( + @Param('companyId', ParseUUIDPipe) companyId: string, + @UploadedFiles() files: Array, + ) { + return this.filesService.uploadMany(companyId, 'companies', files); + } + + @Post(':companyId/profiles') + @ApiOperation({ summary: 'Add a profile (employee) to a company' }) + async createProfile( + @Param('companyId', ParseUUIDPipe) companyId: string, + @Body() dto: CreateExternalProfileDto, + ): Promise { + const profile = await this.companiesService.createProfile({ ...dto, companyId }); + return new ResponseExternalProfileDto(profile); + } + + @Get(':companyId/profiles') + @ApiOperation({ summary: 'List profiles for a company' }) + async listProfiles( + @Param('companyId', ParseUUIDPipe) companyId: string, + ): Promise { + const profiles = await this.companiesService.findProfilesByCompany(companyId); + return profiles.map((p) => new ResponseExternalProfileDto(p)); + } + + @Get('profile/user/:userId') + @ApiOperation({ summary: 'Get profile by IAM user ID' }) + async findProfileByUser( + @Param('userId', ParseUUIDPipe) userId: string, + ): Promise { + const profile = await this.companiesService.findProfileByUserId(userId); + return new ResponseExternalProfileDto(profile); + } + + @Post('ff-clients') + @ApiOperation({ summary: 'Link a forwarder to a client company' }) + async createFFClient(@Body() dto: CreateFFClientDto): Promise { + const client = await this.companiesService.createFFClient(dto); + return new ResponseFFClientDto(client); + } + + @Get(':forwarderCompanyId/clients') + @ApiOperation({ summary: 'List clients of a forwarder' }) + async listFFClients( + @Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string, + ): Promise { + const clients = await this.companiesService.findForwarderClients(forwarderCompanyId); + return clients.map((c) => new ResponseFFClientDto(c)); + } + + @Delete('ff-clients/:id') + @ApiOperation({ summary: 'Remove a forwarder-client relationship' }) + @HttpCode(HttpStatus.NO_CONTENT) + async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise { + await this.companiesService.deleteFFClient(id); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts new file mode 100644 index 000000000..d18573460 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FilesModule } from '../files/files.module'; +import { CompaniesController } from './companies.controller'; +import { CompaniesService } from './companies.service'; +import { CompaniesRepository } from './companies.repository'; +import { ExternalProfileRepository } from './external-profile.repository'; +import { FFClientRepository } from './ff-client.repository'; +import { Company } from './entities/company.entity'; +import { ExternalProfile } from './entities/external-profile.entity'; +import { FFClient } from './entities/ff-client.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule], + controllers: [CompaniesController], + providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository], + exports: [CompaniesService], +}) +export class CompaniesModule {} diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts new file mode 100644 index 000000000..1156823f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Company } from './entities/company.entity'; + +@Injectable() +export class CompaniesRepository extends BaseRepository { + constructor( + @InjectRepository(Company) + repo: Repository, + ) { + super(repo); + } + + async findByTin(tin: string): Promise { + return this.repository.findOne({ where: { tin } as any }); + } + + async findByType(type: string): Promise { + return this.repository.find({ where: { type } as any, order: { name: 'ASC' } }); + } + + async findByName(name: string): Promise { + return this.repository + .createQueryBuilder('company') + .where('company.name ILIKE :name', { name: `%${name}%` }) + .getMany(); + } + + async existsByTin(tin: string): Promise { + const count = await this.repository.count({ where: { tin } as any }); + return count > 0; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts new file mode 100644 index 000000000..f383b9e55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -0,0 +1,194 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { CompaniesRepository } from './companies.repository'; +import { ExternalProfileRepository } from './external-profile.repository'; +import { FFClientRepository } from './ff-client.repository'; +import { CreateCompanyDto } from './dto/create-company.dto'; +import { UpdateCompanyDto } from './dto/update-company.dto'; +import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; +import { CreateFFClientDto } from './dto/create-ff-client.dto'; +import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ProfileResponseDto } from './dto/profile-response.dto'; +import { Company } from './entities/company.entity'; +import { ExternalProfile } from './entities/external-profile.entity'; +import { FFClient } from './entities/ff-client.entity'; + +export interface UserIdentity { + userId: string; + firstName: string; + lastName: string; + email: string; + phone: string; +} + +@Injectable() +export class CompaniesService { + constructor( + private readonly companiesRepo: CompaniesRepository, + private readonly profilesRepo: ExternalProfileRepository, + private readonly ffClientsRepo: FFClientRepository, + ) {} + + async createCompany(dto: CreateCompanyDto): Promise { + const exists = await this.companiesRepo.existsByTin(dto.tin); + if (exists) { + throw new ConflictException(`Company with TIN ${dto.tin} already exists`); + } + return this.companiesRepo.create(dto); + } + + async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> { + if (dto.tin) { + const exists = await this.companiesRepo.existsByTin(dto.tin); + if (exists) { + throw new ConflictException(`Company with TIN ${dto.tin} already exists`); + } + } + + const existingProfile = await this.profilesRepo.findByEmail(identity.email); + if (existingProfile) { + throw new ConflictException(`Profile with email ${identity.email} already exists`); + } + + const company = await this.companiesRepo.create({ + name: dto.companyName, + type: dto.companyType, + tin: dto.tin ?? '', + vatNumber: dto.vatNumber ?? null, + businessLicense: dto.fanNumber ?? null, + fanNumber: dto.fanNumber ?? null, + country: dto.companyLocation ?? 'Ethiopia', + address: dto.companyAddress ?? null, + phone: dto.companyPhone ?? null, + email: dto.companyEmail ?? null, + attributes: dto.attributes ?? null, + }); + + const profile = await this.profilesRepo.create({ + userId: identity.userId, + companyId: company.id, + firstName: identity.firstName, + lastName: identity.lastName, + email: identity.email, + phone: identity.phone, + jobTitle: dto.jobTitle ?? null, + isPrimaryContact: dto.isPrimaryContact ?? true, + }); + + return { company, profile }; + } + + async findAllCompanies(): Promise { + return this.companiesRepo.findAll({ order: { name: 'ASC' as any } }); + } + + async findCompanyById(id: string): Promise { + const company = await this.companiesRepo.findById(id); + if (!company) throw new NotFoundException(`Company ${id} not found`); + return company; + } + + async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + + const company = profile.company; + if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`); + + return { profile, company }; + } + + async updateCompany(id: string, dto: UpdateCompanyDto): Promise { + await this.findCompanyById(id); + const updated = await this.companiesRepo.update(id, dto); + if (!updated) throw new NotFoundException(`Company ${id} not found`); + return updated; + } + + async updateProfile(userId: string, dto: UpdateProfileDto): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + const companyUpdates: Record = {}; + const attrUpdates: Record = { ...(company.attributes ?? {}) }; + + if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; + if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail; + if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone; + if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation; + if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; + if (dto.tin !== undefined) companyUpdates.tin = dto.tin; + if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; + if (dto.fanNumber !== undefined) { + companyUpdates.businessLicense = dto.fanNumber; + companyUpdates.fanNumber = dto.fanNumber; + } + + if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; + if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone; + if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; + if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail; + if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone; + if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; + if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone; + if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; + if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; + if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; + + companyUpdates.attributes = attrUpdates; + + const updated = await this.companiesRepo.update(company.id, companyUpdates); + if (!updated) throw new NotFoundException(`Company ${company.id} not found`); + return new ProfileResponseDto(profile, updated); + } + + async deleteCompany(id: string): Promise { + await this.findCompanyById(id); + await this.companiesRepo.softDelete(id); + } + + async createProfile(dto: CreateExternalProfileDto): Promise { + await this.findCompanyById(dto.companyId); + + const existing = await this.profilesRepo.findByEmail(dto.email); + if (existing) { + throw new ConflictException(`Profile with email ${dto.email} already exists`); + } + + return this.profilesRepo.create(dto); + } + + async findProfileByUserId(userId: string): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + return profile; + } + + async findProfilesByCompany(companyId: string): Promise { + return this.profilesRepo.findByCompanyId(companyId); + } + + async createFFClient(dto: CreateFFClientDto): Promise { + await this.findCompanyById(dto.forwarderCompanyId); + await this.findCompanyById(dto.clientCompanyId); + + const existing = await this.ffClientsRepo.findRelationship( + dto.forwarderCompanyId, + dto.clientCompanyId, + ); + if (existing) { + throw new ConflictException('This forwarder-client relationship already exists'); + } + + return this.ffClientsRepo.create(dto); + } + + async findForwarderClients(forwarderCompanyId: string): Promise { + return this.ffClientsRepo.findByForwarder(forwarderCompanyId); + } + + async deleteFFClient(id: string): Promise { + const client = await this.ffClientsRepo.findById(id); + if (!client) throw new NotFoundException(`FFClient ${id} not found`); + await this.ffClientsRepo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts new file mode 100644 index 000000000..f6ffb8296 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -0,0 +1,14 @@ +import { Company } from '../entities/company.entity'; +import { ExternalProfile } from '../entities/external-profile.entity'; +import { ResponseCompanyDto } from './response-company.dto'; +import { ResponseExternalProfileDto } from './response-external-profile.dto'; + +export class CompanyInfoResponseDto { + profile: ResponseExternalProfileDto; + company: ResponseCompanyDto; + + constructor(profile: ExternalProfile, company: Company) { + this.profile = new ResponseExternalProfileDto(profile); + this.company = new ResponseCompanyDto(company); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts new file mode 100644 index 000000000..4287676d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -0,0 +1,58 @@ +import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator'; +import { CompanyType } from '../entities/company.entity'; + +export class CreateCompanyWithProfileDto { + @IsEnum(CompanyType) + companyType!: CompanyType; + + @IsString() + @IsNotEmpty() + @MaxLength(200) + companyName!: string; + + @IsOptional() + @IsEmail() + @MaxLength(150) + companyEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + companyPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + companyLocation?: string; + + @IsOptional() + @IsString() + companyAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + tin?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + vatNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(16) + fanNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + jobTitle?: string; + + @IsOptional() + @IsBoolean() + isPrimaryContact?: boolean; + + @IsOptional() + attributes?: Record; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts new file mode 100644 index 000000000..b22334697 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -0,0 +1,59 @@ +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; +import { CompanyType, CompanyStatus } from '../entities/company.entity'; + +export class CreateCompanyDto { + @IsString() + @IsNotEmpty() + @MaxLength(200) + name!: string; + + @IsEnum(CompanyType) + type!: CompanyType; + + @IsOptional() + @IsEnum(CompanyStatus) + status?: CompanyStatus; + + @IsString() + @IsNotEmpty() + @Length(10, 10) + @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + tin!: string; + + @IsOptional() + @IsString() + @MaxLength(50) + vatNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + country?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsEmail() + @MaxLength(150) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + website?: string; + + @IsOptional() + attributes?: Record; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts new file mode 100644 index 000000000..c694a50e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -0,0 +1,44 @@ +import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator'; + +export class CreateExternalProfileDto { + @IsUUID() + @IsNotEmpty() + userId!: string; + + @IsUUID() + @IsNotEmpty() + companyId!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + firstName!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + lastName!: string; + + @IsEmail() + @IsNotEmpty() + email!: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + nationalId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + jobTitle?: string; + + @IsOptional() + @IsBoolean() + isPrimaryContact?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts new file mode 100644 index 000000000..46375d7ea --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts @@ -0,0 +1,24 @@ +import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator'; +import { FFClientRelationship } from '../entities/ff-client.entity'; + +export class CreateFFClientDto { + @IsUUID() + @IsNotEmpty() + forwarderCompanyId!: string; + + @IsUUID() + @IsNotEmpty() + clientCompanyId!: string; + + @IsOptional() + @IsEnum(FFClientRelationship) + relationshipType?: FFClientRelationship; + + @IsOptional() + @IsBoolean() + canBookOnBehalf?: boolean; + + @IsOptional() + @IsBoolean() + canViewDocuments?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts new file mode 100644 index 000000000..ee8ede34f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -0,0 +1,53 @@ +import { Company } from '../entities/company.entity'; +import { ExternalProfile } from '../entities/external-profile.entity'; + +export class ProfileResponseDto { + companyId: string; + companyName: string; + companyEmail: string | null; + companyPhone: string | null; + companyLocation: string; + companyAddress: string | null; + tinNumber: string; + vatNumber: string | null; + fanNumber: string | null; + + contactPersonName: string | null; + contactPersonPhone: string | null; + generalManagerName: string | null; + generalManagerEmail: string | null; + generalManagerPhone: string | null; + + poaName: string | null; + poaPhone: string | null; + poaEmail: string | null; + poaLocation: string | null; + poaAddress: string | null; + + profileId: string; + + constructor(profile: ExternalProfile, company: Company) { + this.companyId = company.id; + this.companyName = company.name; + this.companyEmail = company.email ?? null; + this.companyPhone = company.phone ?? null; + this.companyLocation = company.country; + this.companyAddress = company.address ?? null; + this.tinNumber = company.tin; + this.vatNumber = company.vatNumber ?? null; + this.fanNumber = company.fanNumber ?? null; + this.profileId = profile.id; + + const attrs = company.attributes ?? {}; + this.contactPersonName = attrs.contactPersonName ?? null; + this.contactPersonPhone = attrs.contactPersonPhone ?? null; + this.generalManagerName = attrs.generalManagerName ?? null; + this.generalManagerEmail = attrs.generalManagerEmail ?? null; + this.generalManagerPhone = attrs.generalManagerPhone ?? null; + this.poaName = attrs.poaName ?? null; + this.poaPhone = attrs.poaPhone ?? null; + this.poaEmail = attrs.poaEmail ?? null; + this.poaLocation = attrs.poaLocation ?? null; + this.poaAddress = attrs.poaAddress ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts new file mode 100644 index 000000000..1879e25d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -0,0 +1,42 @@ +import { Company, CompanyType, CompanyStatus } from '../entities/company.entity'; +import { ResponseExternalProfileDto } from './response-external-profile.dto'; + +export class ResponseCompanyDto { + id: string; + name: string; + type: CompanyType; + status: CompanyStatus; + tin: string; + vatNumber?: string | null; + businessLicense?: string | null; + fanNumber?: string | null; + country: string; + address?: string | null; + phone?: string | null; + email?: string | null; + website?: string | null; + attributes?: Record | null; + profiles?: ResponseExternalProfileDto[]; + createdAt: Date; + updatedAt: Date; + + constructor(company: Company) { + this.id = company.id; + this.name = company.name; + this.type = company.type; + this.status = company.status; + this.tin = company.tin; + this.vatNumber = company.vatNumber; + this.businessLicense = company.businessLicense; + this.fanNumber = company.fanNumber; + this.country = company.country; + this.address = company.address; + this.phone = company.phone; + this.email = company.email; + this.website = company.website; + this.attributes = company.attributes; + this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p)); + this.createdAt = company.createdAt; + this.updatedAt = company.updatedAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts new file mode 100644 index 000000000..a33585845 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -0,0 +1,31 @@ +import { ExternalProfile } from '../entities/external-profile.entity'; + +export class ResponseExternalProfileDto { + id: string; + userId: string; + companyId: string; + firstName: string; + lastName: string; + email: string; + phone?: string | null; + nationalId?: string | null; + jobTitle?: string | null; + isPrimaryContact: boolean; + createdAt: Date; + updatedAt: Date; + + constructor(profile: ExternalProfile) { + this.id = profile.id; + this.userId = profile.userId; + this.companyId = profile.companyId; + this.firstName = profile.firstName; + this.lastName = profile.lastName; + this.email = profile.email; + this.phone = profile.phone; + this.nationalId = profile.nationalId; + this.jobTitle = profile.jobTitle; + this.isPrimaryContact = profile.isPrimaryContact; + this.createdAt = profile.createdAt; + this.updatedAt = profile.updatedAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts new file mode 100644 index 000000000..44a48069b --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts @@ -0,0 +1,23 @@ +import { FFClient, FFClientRelationship } from '../entities/ff-client.entity'; + +export class ResponseFFClientDto { + id: string; + forwarderCompanyId: string; + clientCompanyId: string; + relationshipType: FFClientRelationship; + canBookOnBehalf: boolean; + canViewDocuments: boolean; + createdAt: Date; + updatedAt: Date; + + constructor(client: FFClient) { + this.id = client.id; + this.forwarderCompanyId = client.forwarderCompanyId; + this.clientCompanyId = client.clientCompanyId; + this.relationshipType = client.relationshipType; + this.canBookOnBehalf = client.canBookOnBehalf; + this.canViewDocuments = client.canViewDocuments; + this.createdAt = client.createdAt; + this.updatedAt = client.updatedAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts new file mode 100644 index 000000000..71c3c0739 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCompanyDto } from './create-company.dto'; + +export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts new file mode 100644 index 000000000..3546e5c10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateExternalProfileDto } from './create-external-profile.dto'; + +export class UpdateExternalProfileDto extends PartialType(CreateExternalProfileDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts new file mode 100644 index 000000000..a7ace689d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateFFClientDto } from './create-ff-client.dto'; + +export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts new file mode 100644 index 000000000..0acdf60a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -0,0 +1,83 @@ +import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator'; + +export class UpdateProfileDto { + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + + @IsOptional() + @IsEmail() + @MaxLength(150) + companyEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + companyPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + companyLocation?: string; + + @IsOptional() + @IsString() + companyAddress?: string; + + @IsOptional() + @IsString() + @Length(10, 10) + @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + tin?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + vatNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(16) + fanNumber?: string; + + @IsOptional() + @IsString() + contactPersonName?: string; + + @IsOptional() + @IsString() + contactPersonPhone?: string; + + @IsOptional() + @IsString() + generalManagerName?: string; + + @IsOptional() + @IsEmail() + generalManagerEmail?: string; + + @IsOptional() + @IsString() + generalManagerPhone?: string; + + @IsOptional() + @IsString() + poaName?: string; + + @IsOptional() + @IsString() + poaPhone?: string; + + @IsOptional() + @IsEmail() + poaEmail?: string; + + @IsOptional() + @IsString() + poaLocation?: string; + + @IsOptional() + @IsString() + poaAddress?: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts new file mode 100644 index 000000000..070854eb8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -0,0 +1,79 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { ExternalProfile } from './external-profile.entity'; + +export enum CompanyType { + Customer = 'customer', + Forwarder = 'forwarder', + Transporter = 'transporter', + Broker = 'broker', +} + +export enum CompanyStatus { + Active = 'active', + Pending = 'pending', + Suspended = 'suspended', + Blacklisted = 'blacklisted', +} + +@Entity({ schema: 'freight', name: 'companies' }) +@Index(['tin']) +@Index(['type']) +export class Company extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 200 }) + name!: string; + + @Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType }) + type!: CompanyType; + + @Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending }) + status!: CompanyStatus; + + @Column({ name: 'tin', type: 'varchar', length: 10, unique: true }) + tin!: string; + + @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true }) + vatNumber?: string | null; + + @Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true }) + businessLicense?: string | null; + + @Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true }) + fanNumber?: string | null; + + @Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' }) + country!: string; + + @Column({ name: 'address', type: 'text', nullable: true }) + address?: string | null; + + @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true }) + phone?: string | null; + + @Column({ name: 'email', type: 'varchar', length: 150, nullable: true }) + email?: string | null; + + @Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true }) + contactPersonName?: string | null; + + @Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true }) + contactPersonPhone?: string | null; + + @Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true }) + generalManagerName?: string | null; + + @Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true }) + generalManagerEmail?: string | null; + + @Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true }) + generalManagerPhone?: string | null; + + @Column({ name: 'website', type: 'varchar', length: 200, nullable: true }) + website?: string | null; + + @Column({ name: 'attributes', type: 'jsonb', nullable: true }) + attributes?: Record | null; + + @OneToMany(() => ExternalProfile, (profile) => profile.company) + profiles?: ExternalProfile[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts new file mode 100644 index 000000000..91a014f10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Company } from './company.entity'; + +@Entity({ schema: 'freight', name: 'external_profiles' }) +@Index(['userId']) +@Index(['companyId']) +export class ExternalProfile extends BaseEntity { + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @Column({ name: 'company_id', type: 'uuid' }) + companyId!: string; + + @ManyToOne(() => Company, (company) => company.profiles) + @JoinColumn({ name: 'company_id' }) + company!: Company; + + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName!: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100 }) + lastName!: string; + + @Column({ name: 'email', type: 'varchar', length: 150, unique: true }) + email!: string; + + @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true }) + phone?: string | null; + + @Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true }) + nationalId?: string | null; + + @Column({ name: 'job_title', type: 'varchar', length: 100, nullable: true }) + jobTitle?: string | null; + + @Column({ name: 'is_primary_contact', type: 'boolean', default: false }) + isPrimaryContact!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts new file mode 100644 index 000000000..136dea277 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm'; +import { Company } from './company.entity'; + +export enum FFClientRelationship { + ManagedAccount = 'managed_account', + SubAgent = 'sub_agent', +} + +@Entity({ schema: 'freight', name: 'ff_clients' }) +@Unique(['forwarderCompanyId', 'clientCompanyId']) +@Index(['forwarderCompanyId']) +@Index(['clientCompanyId']) +export class FFClient extends BaseEntity { + @Column({ name: 'forwarder_company_id', type: 'uuid' }) + forwarderCompanyId!: string; + + @ManyToOne(() => Company) + @JoinColumn({ name: 'forwarder_company_id' }) + forwarderCompany!: Company; + + @Column({ name: 'client_company_id', type: 'uuid' }) + clientCompanyId!: string; + + @ManyToOne(() => Company) + @JoinColumn({ name: 'client_company_id' }) + clientCompany!: Company; + + @Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount }) + relationshipType!: FFClientRelationship; + + @Column({ name: 'can_book_on_behalf', type: 'boolean', default: true }) + canBookOnBehalf!: boolean; + + @Column({ name: 'can_view_documents', type: 'boolean', default: true }) + canViewDocuments!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts new file mode 100644 index 000000000..581dfd72b --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { ExternalProfile } from './entities/external-profile.entity'; + +@Injectable() +export class ExternalProfileRepository extends BaseRepository { + constructor( + @InjectRepository(ExternalProfile) + repo: Repository, + ) { + super(repo); + } + + async findByUserId(userId: string): Promise { + return this.repository.findOne({ + where: { userId } as any, + relations: ['company'], + }); + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ where: { companyId } as any }); + } + + async findByEmail(email: string): Promise { + return this.repository.findOne({ where: { email } as any }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts b/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts new file mode 100644 index 000000000..b94cedec6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { FFClient } from './entities/ff-client.entity'; + +@Injectable() +export class FFClientRepository extends BaseRepository { + constructor( + @InjectRepository(FFClient) + repo: Repository, + ) { + super(repo); + } + + async findByForwarder(forwarderCompanyId: string): Promise { + return this.repository.find({ where: { forwarderCompanyId } as any }); + } + + async findByClient(clientCompanyId: string): Promise { + return this.repository.find({ where: { clientCompanyId } as any }); + } + + async findRelationship( + forwarderCompanyId: string, + clientCompanyId: string, + ): Promise { + return this.repository.findOne({ + where: { forwarderCompanyId, clientCompanyId } as any, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index 44220f52f..46ef22bf8 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -14,7 +14,6 @@ import { CreateConsignmentDto } from "./dto/create-consignment.dto"; import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth @Controller("consignments") export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} diff --git a/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts b/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts index 6224db4cc..3b7ee222b 100644 --- a/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts +++ b/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "consignments" }) +@Entity({schema:"freight", name: "consignments" }) export class Consignment extends BaseEntity { @Column({ name: "booking_id", type: "uuid" }) bookingId!: string; diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts new file mode 100644 index 000000000..efffb075e --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -0,0 +1,64 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateContainerDto } from './dto/create-container.dto'; +import { UpdateContainerDto } from './dto/update-container.dto'; +import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; +import { ContainersService } from './containers.service'; + +@ApiTags('containers') +@Controller('containers') +export class ContainersController { + constructor(private readonly containersService: ContainersService) {} + + @Post() + @ApiOperation({ summary: 'Create a new container' }) + create(@Body() dto: CreateContainerDto) { + return this.containersService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all containers' }) + findAll(@Query() query: Record) { + return this.containersService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a container by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.containersService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a container' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) { + return this.containersService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a container' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.containersService.remove(id); + } + + @Post(':id/assign-wagon') + @ApiOperation({ summary: 'Assign container to a wagon' }) + assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) { + return this.containersService.assignToWagon(id, dto); + } + + @Post(':id/unassign-wagon') + @ApiOperation({ summary: 'Unassign container from wagon' }) + unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { + return this.containersService.unassignFromWagon(id); + } +} diff --git a/apps/edr-freight-api/src/modules/container-management/containers.module.ts b/apps/edr-freight-api/src/modules/container-management/containers.module.ts new file mode 100644 index 000000000..b048b5af8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.module.ts @@ -0,0 +1,15 @@ +// apps/edr-freight-api/src/modules/container-management/containers.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Container } from './entities/container.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { ContainersController } from './containers.controller'; +import { ContainersService } from './containers.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Container, Wagon, ContainerType])], + controllers: [ContainersController], + providers: [ContainersService], +}) +export class ContainersModule {} diff --git a/apps/edr-freight-api/src/modules/container-management/containers.repository.ts b/apps/edr-freight-api/src/modules/container-management/containers.repository.ts new file mode 100644 index 000000000..c6842cdea --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Container } from './entities/container.entity'; + +@Injectable() +export class ContainersRepository extends BaseRepository { + constructor( + @InjectRepository(Container) + repository: Repository, + ) { + super(repository); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts b/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts new file mode 100644 index 000000000..f6094a497 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts @@ -0,0 +1,86 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateContainerDto } from './dto/create-container.dto'; +import { UpdateContainerDto } from './dto/update-container.dto'; +import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; +import { Container } from './entities/container.entity'; +//import { ContainersRepository } from './containers.repository'; +import { WagonsRepository } from '../wagons/wagons.repository'; + +@Injectable() +export class ContainersService { + constructor( + @InjectRepository(Container) + private readonly containerRepo: Repository, + private readonly wagonsRepository: WagonsRepository, + ) {} + + async create(dto: CreateContainerDto): Promise { + const container = this.containerRepo.create(dto); + // Convert undefined to null for optional fields + if (dto.wagonId === undefined) container.wagonId = null; + if (dto.position === undefined) container.position = null; + return this.containerRepo.save(container); + } + + async findAll(): Promise { + return this.containerRepo.find({ order: { containerNumber: 'ASC' } }); + } + + async findById(id: string): Promise { + const container = await this.containerRepo.findOne({ where: { id } }); + if (!container) throw new NotFoundException(`Container ${id} not found`); + return container; + } + + async update(id: string, dto: UpdateContainerDto): Promise { + const container = await this.findById(id); + Object.assign(container, dto); + // Convert undefined to null for nullable fields + if (dto.wagonId === undefined) container.wagonId = null; + if (dto.position === undefined) container.position = null; + return this.containerRepo.save(container); + } + + async remove(id: string): Promise { + const container = await this.findById(id); + await this.containerRepo.remove(container); + } + + async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot reassign a loaded container'); + } + + const wagon = await this.wagonsRepository.findById(dto.wagonId); + if (!wagon) throw new NotFoundException('Wagon not found'); + + let position: number | null = dto.position ?? null; // convert undefined to null + if (position === null) { + const maxPos = await this.containerRepo + .createQueryBuilder('c') + .select('MAX(c.position)', 'max') + .where('c.wagonId = :wagonId', { wagonId: wagon.id }) + .getRawOne(); + position = (maxPos?.max ?? 0) + 1; + } + + container.wagonId = wagon.id; + container.position = position; // now position is number | null, safe + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } + + async unassignFromWagon(containerId: string): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot unassign a loaded container'); + } + container.wagonId = null; + container.position = null; + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service.ts b/apps/edr-freight-api/src/modules/container-management/containers.service.ts new file mode 100644 index 000000000..bf7c966aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.service.ts @@ -0,0 +1,148 @@ +// apps/edr-freight-api/src/modules/container-management/containers.service.ts +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { CreateContainerDto } from './dto/create-container.dto'; +import { UpdateContainerDto } from './dto/update-container.dto'; +import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; +import { Container } from './entities/container.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; + +@Injectable() +export class ContainersService { + constructor( + @InjectRepository(Container) + private readonly containerRepo: Repository, + @InjectRepository(Wagon) + private readonly wagonRepo: Repository, // ✅ use raw repository + @InjectRepository(ContainerType) + private readonly containerTypeRepo: Repository, + ) {} + + async create(dto: CreateContainerDto): Promise { + const existing = await this.containerRepo.findOne({ + where: { containerNumber: dto.containerNumber }, + }); + if (existing) { + throw new ConflictException(`Container number "${dto.containerNumber}" already exists`); + } + + const containerType = await this.containerTypeRepo.findOne({ + where: { id: dto.containerTypeId, isActive: true }, + }); + if (!containerType) { + throw new NotFoundException(`Container type ${dto.containerTypeId} not found`); + } + + if (dto.wagonId) { + const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); + if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); + } + + const container = this.containerRepo.create(dto); + if (dto.wagonId === undefined) container.wagonId = null; + if (dto.position === undefined) container.position = null; + return this.containerRepo.save(container); + } + + async findAll(query: Record = {}): Promise { + const where: FindOptionsWhere[] | FindOptionsWhere = []; + const search = query.search?.trim(); + const status = query.status?.trim(); + const wagonId = query.wagonId?.trim(); + + if (search) { + where.push({ + containerNumber: ILike(`%${search}%`), + ...(status ? { status } : {}), + ...(wagonId ? { wagonId } : {}), + }); + } + + const sortBy = ['containerNumber', 'tareWeight', 'maxGrossWeight', 'status', 'position'].includes(query.sortBy ?? '') + ? (query.sortBy as keyof Container) + : 'containerNumber'; + const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + return this.containerRepo.find({ + where: search ? where : { ...(status ? { status } : {}), ...(wagonId ? { wagonId } : {}) }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, + take: query.limit ? Number(query.limit) : undefined, + }); + } + + async findById(id: string): Promise { + const container = await this.containerRepo.findOne({ where: { id } }); + if (!container) throw new NotFoundException(`Container ${id} not found`); + return container; + } + + async update(id: string, dto: UpdateContainerDto): Promise { + const container = await this.findById(id); + if (dto.containerNumber && dto.containerNumber !== container.containerNumber) { + const existing = await this.containerRepo.findOne({ + where: { containerNumber: dto.containerNumber }, + }); + if (existing) { + throw new ConflictException(`Container number "${dto.containerNumber}" already exists`); + } + } + if (dto.containerTypeId) { + const containerType = await this.containerTypeRepo.findOne({ + where: { id: dto.containerTypeId, isActive: true }, + }); + if (!containerType) { + throw new NotFoundException(`Container type ${dto.containerTypeId} not found`); + } + } + if (dto.wagonId) { + const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); + if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); + } + Object.assign(container, dto); + return this.containerRepo.save(container); + } + + async remove(id: string): Promise { + const container = await this.findById(id); + await this.containerRepo.remove(container); + } + + async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot reassign a loaded container'); + } + + const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); + if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); + + let position: number | null = dto.position ?? null; + if (position === null) { + const maxPos = await this.containerRepo + .createQueryBuilder('c') + .select('MAX(c.position)', 'max') + .where('c.wagonId = :wagonId', { wagonId: wagon.id }) + .getRawOne(); + position = (maxPos?.max ?? 0) + 1; + } + + container.wagonId = wagon.id; + container.position = position; + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } + + async unassignFromWagon(containerId: string): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot unassign a loaded container'); + } + container.wagonId = null; + container.position = null; + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } +} diff --git a/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts new file mode 100644 index 000000000..3b7be1d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts @@ -0,0 +1,11 @@ +import { IsUUID, IsOptional, IsInt, Min } from 'class-validator'; + +export class AssignContainerToWagonDto { + @IsUUID() + wagonId!: string; + + @IsOptional() + @IsInt() + @Min(1) + position?: number; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts new file mode 100644 index 000000000..1efed1cc9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts @@ -0,0 +1,34 @@ +import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator'; + +export class CreateContainerDto { + @IsString() + containerNumber!: string; + + @IsUUID() + containerTypeId!: string; + + @IsOptional() + @IsUUID() + wagonId?: string; + + @IsOptional() + @IsInt() + @Min(1) + position?: number; + + @IsNumber() + @Min(0) + tareWeight!: number; + + @IsNumber() + @Min(0) + maxGrossWeight!: number; + + @IsOptional() + @IsString() + sealNumber?: string; + + @IsOptional() + @IsIn(['AVAILABLE', 'LOADED', 'IN_TRANSIT', 'MAINTENANCE', 'DAMAGED']) + status?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts new file mode 100644 index 000000000..7391bc642 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateContainerDto } from './create-container.dto'; + +export class UpdateContainerDto extends PartialType(CreateContainerDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts new file mode 100644 index 000000000..a5c7ee9c1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts @@ -0,0 +1,45 @@ +// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts +import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { Cargo } from '../../cargoes/entities/cargoes.entity'; + +@Entity({ name: 'containers', schema: 'freight' }) +export class Container extends BaseEntity { + @Column({ unique: true, name: 'container_number' }) + containerNumber!: string; + + @Column({ name: 'container_type_id', type: 'uuid' }) + containerTypeId!: string; + + @Column({ name: 'wagon_id', type: 'uuid', nullable: true }) + wagonId!: string | null; + + @Column({ type: 'int', nullable: true }) + position!: number | null; // position on the wagon (1..N) + + @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) + tareWeight!: number; + + @Column({ name: 'max_gross_weight', type: 'decimal', precision: 10, scale: 2 }) + maxGrossWeight!: number; + + @Column({ + name: 'seal_number', + type: 'varchar', + nullable: true, +}) +sealNumber!: string | null; + + @Column({ type: 'varchar', default: 'AVAILABLE' }) + status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED + + // Relationship to Wagon + @ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'wagon_id' }) + wagon!: Wagon | null; + + // Relationship to Cargo + @OneToMany(() => Cargo, (cargo) => cargo.container) + cargoes!: Cargo[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts index 65c4f42de..404e4d27b 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.controller.ts @@ -1,37 +1,84 @@ +// src/modules/customers/customers.controller.ts + import { - Body, Controller, + Delete, Get, + HttpCode, + HttpStatus, Param, ParseUUIDPipe, + Patch, Post, + Body, + Query, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { ApiOperation } from "@nestjs/swagger"; import { CustomersService } from "./customers.service"; import { CreateCustomerDto } from "./dto/create-customer.dto"; +import { UpdateCustomerDto } from "./dto/update-customer.dto"; +import { Customer } from "./entities/customer.entity"; -@ApiTags("customers") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth @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); + create(@Body() createCustomerDto: CreateCustomerDto): Promise { + return this.customersService.create(createCustomerDto); } @Get() - @ApiOperation({ summary: "List all customers" }) - findAll() { + findAll(): Promise { return this.customersService.findAll(); } + @Get("stats") + @ApiOperation({ summary: "Get customer statistics" }) + getStats(): Promise<{ total: number; withVatNumber: number }> { + return this.customersService.getStats(); + } + + @Get("search") + searchByName(@Query("name") name: string): Promise { + return this.customersService.searchByName(name); + } + + @Get("email/:email") + findByEmail(@Param("email") email: string): Promise { + return this.customersService.findByEmail(email); + } + + @Get("vat/:vatNumber") + findByVatNumber(@Param("vatNumber") vatNumber: string): Promise { + return this.customersService.findByVatNumber(vatNumber); + } + @Get(":id") - @ApiOperation({ summary: "Get a customer by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { + findById(@Param("id", ParseUUIDPipe) id: string): Promise { return this.customersService.findById(id); } -} + + // @Get("user/:userId") + // findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise { + // return this.customersService.findByUserId(userId); + // } + + @Patch(":id") + @ApiOperation({ summary: "Update a customer" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateCustomerDto, + ): Promise { + return this.customersService.update(id, dto); + } + + @Delete(":id") + @ApiOperation({ summary: "Soft-delete a customer" }) + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param("id", ParseUUIDPipe) id: string): Promise { + return this.customersService.delete(id); + } +} \ No newline at end of file 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 c6cb72fcf..5933cef61 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.repository.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.repository.ts @@ -1,21 +1,117 @@ -import { BaseRepository } from "@edr/api-common"; +// import { BaseRepository } from "@edr/api-common"; +// import { EntityRepository } from "typeorm"; + +// src/modules/customers/customers.repository.ts import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - +import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm"; import { Customer } from "./entities/customer.entity"; +import { CreateCustomerDto } from "./dto/create-customer.dto"; +// import { UpdateCustomerDto } from "./dto/update-customer.dto"; @Injectable() -export class CustomersRepository extends BaseRepository { +export class CustomersRepository { constructor( @InjectRepository(Customer) - repository: Repository, - ) { - super(repository); + private readonly repository: Repository, + ) { } + + async create(dto: CreateCustomerDto): Promise { + const customer = this.repository.create(dto); + return await this.repository.save(customer); } - /** Find a customer by their unique email. */ - findByEmail(email: string): Promise { - return this.repository.findOne({ where: { email } }); + async findAll(options?: FindManyOptions): Promise { + return await this.repository.find(options); } -} + + async findById(id: string): Promise { + return await this.repository.findOne({ where: { id } as FindOptionsWhere }); + } + + async findByUserId(userId: string): Promise { + return await this.repository.findOne({ where: { userId } as FindOptionsWhere }); + } + + async findByEmail(email: string): Promise { + return await this.repository.findOne({ where: { email } as FindOptionsWhere }); + } + + async findByVatNumber(vatNumber: string): Promise { + return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere }); + } + + async findByName(name: string): Promise { + return await this.repository + .createQueryBuilder("customer") + .where("customer.companyName ILIKE :name", { name: `%${name}%` }) + .getMany(); + } + + async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise { + if (!email && !vatNumber) return null; + + const queryBuilder = this.repository.createQueryBuilder('customer'); + + if (email && vatNumber) { + queryBuilder.where('customer.email = :email', { email }) + .orWhere('customer.vatNumber = :vatNumber', { vatNumber }); + } else if (email) { + queryBuilder.where('customer.email = :email', { email }); + } else if (vatNumber) { + queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber }); + } + + return await queryBuilder.getOne(); + } + + async update(id: string, updates: Partial): Promise { + await this.repository.update(id, updates); + return this.findById(id); + } + + async delete(id: string): Promise { + const result = await this.repository.delete(id); + return (result.affected ?? 0) > 0; + } + + async count(where?: any): Promise { + if (where?.createdAt) { + const result = await this.repository + .createQueryBuilder('customer') + .where('customer.createdAt >= :date', { date: where.createdAt }) + .getCount(); + return result; + } + return await this.repository.count(); + } + + async existsByUniqueFields(email: string, vatNumber?: string): Promise { + const queryBuilder = this.repository.createQueryBuilder('customer') + .where('customer.email = :email', { email }); + + if (vatNumber) { + queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber }); + } + + const count = await queryBuilder.getCount(); + return count > 0; + } + + async countWithVatNumber(): Promise { + const count = await this.repository + .createQueryBuilder('customer') + .where('customer.vatNumber IS NOT NULL') + .andWhere("customer.vatNumber != ''") + .getCount(); + + return count; + } + + getRepository(): Repository { + return this.repository; + } + softDelete(id: string): any { + return id; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.service.ts b/apps/edr-freight-api/src/modules/customers/customers.service.ts index d3be75800..e3d1f3a82 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.service.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.service.ts @@ -1,29 +1,140 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} 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) {} - /** Create a new freight customer. */ - create(dto: CreateCustomerDto): Promise { + /** Create a new customer */ + async create(dto: CreateCustomerDto): Promise { + const exists = await this.customersRepository.existsByUniqueFields( + dto.email, + dto.vatNumber, + ); + + if (exists) { + throw new ConflictException( + "Customer with same email or VAT number already exists", + ); + } + + if (dto.vatNumber && dto.vatNumber.length !== 10) { + throw new BadRequestException("VAT number must be exactly 10 digits"); + } + return this.customersRepository.create(dto); } - /** List every customer (alphabetical). */ + /** Get all customers */ findAll(): Promise { - return this.customersRepository.findAll({ order: { name: "ASC" } }); + return this.customersRepository.findAll({ + order: { companyName: "ASC" }, + }); } - /** Get a single customer by ID. */ + /** Get customer by ID */ async findById(id: string): Promise { const customer = await this.customersRepository.findById(id); + if (!customer) { - throw new NotFoundException(`Customer ${id} not found`); + throw new NotFoundException(`Customer with ID ${id} not found`); } + return customer; } -} + + // async findByUserId(userId: string): Promise { + // const customer = await this.customersRepository.findByUserId(userId); + + // if (!customer) { + // throw new NotFoundException(`Customer with ID ${userId} not found`); + // } + + // return customer; + //} + + /** Get customer by email */ + async findByEmail(email: string): Promise { + const customer = await this.customersRepository.findByEmail(email); + + if (!customer) { + throw new NotFoundException(`Customer with email ${email} not found`); + } + + return customer; + } + + /** Get customer by VAT number */ + async findByVatNumber(vatNumber: string): Promise { + const customer = await this.customersRepository.findByVatNumber(vatNumber); + + if (!customer) { + throw new NotFoundException( + `Customer with VAT number ${vatNumber} not found`, + ); + } + + return customer; + } + + /** Search customers by name */ + searchByName(name: string): Promise { + return this.customersRepository.findByName(name); + } + + /** Update customer */ + async update(id: string, dto: UpdateCustomerDto): Promise { + await this.findById(id); + + // Validate VAT number if provided + if (dto.vatNumber && dto.vatNumber.length !== 10) { + throw new BadRequestException("VAT number must be exactly 10 digits"); + } + + // // Check email conflict + // if (dto.email) { + // const existing = await this.customersRepository.findByEmail(dto.email); + + // // if (existing && existing.userId !== 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; + } + + /** Delete customer (soft delete) */ + async remove(id: string): Promise { + await this.findById(id); + await this.customersRepository.softDelete(id); + } + + /** Get customer statistics */ + async getStats(): Promise<{ total: number; withVatNumber: number }> { + const total = await this.customersRepository.count(); + const withVatNumber = await this.customersRepository.countWithVatNumber(); + + return { total, withVatNumber }; + } + + delete(id: string): any { + return id; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts index bcaa13e78..39fc16414 100644 --- a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts +++ b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts @@ -1,20 +1,156 @@ -import { IsEmail, IsOptional, IsString } from "class-validator"; +import { + IsEmail, + IsEnum, + IsOptional, + IsString, + MaxLength, + IsNotEmpty, + Length, + Matches, +} from "class-validator"; +// Enums +export enum CustomerStatusDto { + Active = "Active", + Pending = "Pending", + Inactive = "Inactive", +} + +export enum CustomerTypeDto { + Importer = "Importer", + Exporter = "Exporter", + Supplier = "Supplier", +} + +// DTO export class CreateCustomerDto { + // Basic identity @IsString() - name!: string; + @IsNotEmpty() + userId!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + firstName!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + lastName!: string; @IsEmail() + @IsNotEmpty() email!: string; @IsString() + @IsNotEmpty() + @MaxLength(20) phone!: string; + // Company info + @IsString() + @IsNotEmpty() + @MaxLength(200) + companyName!: string; + + @IsEmail() + @IsNotEmpty() + companyEmail!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(20) + companyPhone!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + companyLocation!: string; + + @IsString() + @IsNotEmpty() + companyAddress!: string; + + // Classification + @IsOptional() + @IsEnum(CustomerTypeDto) + customerType?: CustomerTypeDto; + + @IsOptional() + @IsEnum(CustomerStatusDto) + status?: CustomerStatusDto; + + // Legal identifiers + @IsString() + @IsNotEmpty() + @Length(10, 10) + @Matches(/^\d+$/, { message: "TIN must contain only digits" }) + tinNumber!: string; + + @IsString() + @IsNotEmpty() + @Length(16, 16) + @Matches(/^\d+$/, { message: "FAN must contain only digits" }) + fanNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(50) + vatNumber!: string; + + // Contact person + @IsString() + @IsNotEmpty() + @MaxLength(100) + contactPersonName!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(20) + contactPersonPhone!: string; + + // Management + @IsString() + @IsNotEmpty() + @MaxLength(100) + generalManagerName!: string; + + @IsEmail() + @IsNotEmpty() + generalManagerEmail!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(20) + generalManagerPhone!: string; + + // POA (Power of Attorney) @IsOptional() @IsString() - address?: string; + @MaxLength(100) + poaName?: string; @IsOptional() @IsString() - taxId?: string; -} + @MaxLength(20) + poaPhone?: string; + + @IsOptional() + @IsString() + poaAddress?: string; + + @IsOptional() + @IsEmail() + poaEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + poaLocation?: string; + + // Extra + @IsOptional() + @IsString() + notes?: string; +} \ No newline at end of file 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 new file mode 100644 index 000000000..3d2a086d4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts @@ -0,0 +1,60 @@ +// src/modules/customers/dto/response-customer.dto.ts +import { Customer } from '../entities/customer.entity'; + +export class ResponseCustomerDto { + //UserId: string; + firstName: string; + lastName: string; + email: string; + phone: string; + companyName: string; + companyEmail: string; + companyPhone: string; + companyLocation: string; + companyAddress: string; + contactPersonName: string; + contactPersonPhone: string; + tinNumber: string; + vatNumber?: string; + fanNumber: string; + generalManagerName: string; + generalManagerEmail: string; + generalManagerPhone: string; + poaName?: string; + poaPhone?: string; + poaAddress?: string; + poaEmail?: string; + poaLocation?: string; + notes?: string; + createdAt: Date; + updatedAt: Date; + + constructor(customer: Customer) { + //this.UserId = customer.userId; + this.firstName = customer.firstName; + this.lastName = customer.lastName; + this.email = customer.email; + this.phone = customer.phone; + this.companyName = customer.companyName; + this.companyEmail = customer.companyEmail; + this.companyPhone = customer.companyPhone; + this.companyLocation = customer.companyLocation; + this.companyAddress = customer.companyAddress; + this.contactPersonName = customer.contactPersonName; + this.contactPersonPhone = customer.contactPersonPhone; + this.tinNumber = customer.tinNumber; + this.vatNumber = customer.vatNumber ?? undefined; + this.fanNumber = customer.fanNumber; + this.generalManagerName = customer.generalManagerName; + this.generalManagerEmail = customer.generalManagerEmail; + this.generalManagerPhone = customer.generalManagerPhone; + this.poaName = customer.poaName ?? ''; + this.poaPhone = customer.poaPhone ?? ''; + this.poaAddress = customer.poaAddress ?? ''; + this.poaEmail = customer.poaEmail ?? ''; + this.poaLocation = customer.poaLocation ?? ''; + this.notes = customer.notes ?? ''; + this.createdAt = customer.createdAt; + this.updatedAt = customer.updatedAt; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts new file mode 100644 index 000000000..f8cefe046 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts @@ -0,0 +1,9 @@ +// src/modules/customers/dto/update-customer.dto.ts +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCustomerDto } from './create-customer.dto'; + +export class UpdateCustomerDto extends PartialType(CreateCustomerDto) { + email?: string; + vatNumber?: string; + // Add any other properties you need to access directly +} 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 28f79f4c8..abccbaef8 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,20 +1,87 @@ -import { BaseEntity } from "@edr/api-common"; -import { Column, Entity } from "typeorm"; +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; -@Entity({ name: "customers" }) +@Entity({ schema: 'freight', name: 'customers' }) +@Index(['email']) +//@Index(['userId']) +@Index(['tinNumber']) +@Index(['fanNumber']) export class Customer extends BaseEntity { - @Column({ name: "name", type: "varchar", length: 256 }) - name!: string; + //@Column({ name: 'user_id', type: 'uuid' }) + //userId!: string; - @Column({ name: "email", type: "varchar", length: 256, unique: true }) + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName!: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100 }) + lastName!: string; + + @Column({ name: 'email', type: 'varchar', length: 150, unique: true }) email!: string; - @Column({ name: "phone", type: "varchar", length: 32 }) + @Column({ name: 'phone', type: 'varchar', length: 20 }) phone!: string; - @Column({ name: "address", type: "text", nullable: true }) - address?: string | null; + @Column({ name: 'company_name', type: 'varchar', length: 200 }) + companyName!: string; - @Column({ name: "tax_id", type: "varchar", length: 64, nullable: true }) - taxId?: string | null; + @Column({ name: 'company_email', type: 'varchar', length: 150 }) + companyEmail!: string; + + @Column({ name: 'company_phone', type: 'varchar', length: 20 }) + companyPhone!: string; + + @Column({ name: 'company_location', type: 'varchar', length: 100 }) + companyLocation!: string; + + @Column({ name: 'company_address', type: 'text' }) + companyAddress!: string; + + @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({ name: 'contact_person_phone', type: 'varchar', length: 20 }) + contactPersonPhone!: string; + + @Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true }) + tinNumber!: string; + + @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true }) + vatNumber?: string | null; + + @Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true }) + fanNumber!: string; + + @Column({ name: 'general_manager_name', type: 'varchar', length: 100 }) + generalManagerName!: string; + + @Column({ name: 'general_manager_email', type: 'varchar', length: 150 }) + generalManagerEmail!: string; + + @Column({ name: 'general_manager_phone', type: 'varchar', length: 20 }) + generalManagerPhone!: string; + + @Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true }) + poaName?: string | null; + + @Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true }) + poaPhone?: string | null; + + @Column({ name: 'poa_address', type: 'text', nullable: true }) + poaAddress?: string | null; + + @Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true }) + poaEmail?: string | null; + + @Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true }) + poaLocation?: string | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; } diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts new file mode 100644 index 000000000..6f425e1bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard"; + +@ApiTags("demo-permissions") +@Controller() +export class DemoPermissionsController { + @Get("test_user1") + @ApiOperation({ summary: "Permission demo (can:demo:user1)" }) + @UseGuards(PermissionGuard(["can:demo:user1"])) + testUser1() { + return { ok: true, permission: "can:demo:user1" }; + } + + @Get("test_user2") + @ApiOperation({ summary: "Permission demo (can:demo:user2)" }) + @UseGuards(PermissionGuard(["can:demo:user2"])) + testUser2() { + return { ok: true, permission: "can:demo:user2" }; + } +} diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts new file mode 100644 index 000000000..db73ed728 --- /dev/null +++ b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; + +import { DemoPermissionsController } from "./demo-permissions.controller"; + +@Module({ + controllers: [DemoPermissionsController], +}) +export class DemoPermissionsModule {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts new file mode 100644 index 000000000..e8c3fbba0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts @@ -0,0 +1,102 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; +import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; +import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto"; +import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto"; +import { DropdownSettingsService } from "./dropdown-settings.service"; + +@ApiTags("dropdown-settings") +@Controller("dropdown-settings") +export class DropdownSettingsController { + constructor(private readonly service: DropdownSettingsService) {} + + @Get() + @ApiOperation({ summary: "List all dropdown settings" }) + list() { + return this.service.list(); + } + + @Get(":id") + @ApiOperation({ summary: "Get a dropdown setting by ID" }) + getById(@Param("id", ParseUUIDPipe) id: string) { + return this.service.getById(id); + } + + @Get("by-code/:code") + @ApiOperation({ summary: "Get a dropdown setting by its stable code" }) + getByCode(@Param("code") code: string) { + return this.service.getByCode(code); + } + + @Post() + @ApiOperation({ summary: "Create a new dropdown setting" }) + create(@Body() dto: CreateDropdownSettingDto) { + return this.service.create(dto); + } + + @Patch(":id") + @ApiOperation({ summary: "Update a dropdown setting's metadata" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateDropdownSettingDto, + ) { + return this.service.update(id, dto); + } + + @Delete(":id") + @ApiOperation({ summary: "Soft-delete a dropdown setting" }) + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.service.remove(id); + } + + /* ------------------------- option routes ------------------------- */ + + @Put(":id/options") + @ApiOperation({ summary: "Replace the full option list for a setting" }) + replaceOptions( + @Param("id", ParseUUIDPipe) id: string, + @Body() options: CreateDropdownOptionDto[], + ) { + return this.service.replaceOptions(id, options); + } + + @Post(":id/options") + @ApiOperation({ summary: "Append a single option to a setting" }) + addOption( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CreateDropdownOptionDto, + ) { + return this.service.addOption(id, dto); + } + + @Patch("options/:optionId") + @ApiOperation({ summary: "Update a single option" }) + updateOption( + @Param("optionId", ParseUUIDPipe) optionId: string, + @Body() dto: UpdateDropdownOptionDto, + ) { + return this.service.updateOption(optionId, dto); + } + + @Delete("options/:optionId") + @ApiOperation({ summary: "Soft-delete a single option" }) + @HttpCode(HttpStatus.NO_CONTENT) + removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) { + return this.service.removeOption(optionId); + } +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.module.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.module.ts new file mode 100644 index 000000000..59fc3ebbb --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.module.ts @@ -0,0 +1,24 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { DropdownOption } from "./entities/dropdown-option.entity"; +import { DropdownSetting } from "./entities/dropdown-setting.entity"; +import { DropdownSettingsController } from "./dropdown-settings.controller"; +import { DropdownSettingsRepository } from "./dropdown-settings.repository"; +import { DropdownSettingsService } from "./dropdown-settings.service"; +import { DROPDOWN_SETTINGS_REPOSITORY } from "./interfaces/dropdown-settings.repository.interface"; + +@Module({ + imports: [TypeOrmModule.forFeature([DropdownSetting, DropdownOption])], + controllers: [DropdownSettingsController], + providers: [ + DropdownSettingsRepository, + { + provide: DROPDOWN_SETTINGS_REPOSITORY, + useExisting: DropdownSettingsRepository, + }, + DropdownSettingsService, + ], + exports: [DropdownSettingsService], +}) +export class DropdownSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts new file mode 100644 index 000000000..a4f851e53 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts @@ -0,0 +1,82 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { DropdownOption } from "./entities/dropdown-option.entity"; +import { DropdownSetting } from "./entities/dropdown-setting.entity"; +import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface"; + +@Injectable() +export class DropdownSettingsRepository + extends BaseRepository + implements IDropdownSettingsRepository +{ + constructor( + @InjectRepository(DropdownSetting) + repository: Repository, + @InjectRepository(DropdownOption) + private readonly optionsRepository: Repository, + ) { + super(repository); + } + + findByCode(code: string): Promise { + return this.repository.findOne({ + where: { code }, + relations: { children: true }, + order: { children: { order: "ASC" } }, + }); + } + + override findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { children: true }, + order: { children: { order: "ASC" } }, + }); + } + + override findAll(): Promise { + return this.repository.find({ + order: { label: "ASC", children: { order: "ASC" } }, + relations: { children: true }, + }); + } + + async replaceOptions( + settingId: string, + options: Array>, + ): Promise { + await this.optionsRepository.delete({ settingId }); + if (options.length === 0) return []; + const entities = options.map((o, idx) => + this.optionsRepository.create({ + ...o, + settingId, + order: o.order ?? idx + 1, + }), + ); + return this.optionsRepository.save(entities); + } + + async addOption( + settingId: string, + option: Partial, + ): Promise { + const entity = this.optionsRepository.create({ ...option, settingId }); + return this.optionsRepository.save(entity); + } + + async updateOption( + optionId: string, + data: Partial, + ): Promise { + await this.optionsRepository.update(optionId, data as never); + return this.optionsRepository.findOne({ where: { id: optionId } }); + } + + async removeOption(optionId: string): Promise { + await this.optionsRepository.softDelete(optionId); + } +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts new file mode 100644 index 000000000..530cfded4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts @@ -0,0 +1,203 @@ +import { + ConflictException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; +import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; +import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto"; +import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto"; +import { DropdownOption } from "./entities/dropdown-option.entity"; +import { DropdownSetting } from "./entities/dropdown-setting.entity"; +import { + DROPDOWN_SETTINGS_REPOSITORY, + IDropdownSettingsRepository, +} from "./interfaces/dropdown-settings.repository.interface"; + +const STATIONS_TER_CODE = "stations_ter"; + +const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [ + { + value: "inside_addis_ababa", + label: "Addis Ababa", + note: "Inside country", + order: 1, + }, + { + value: "inside_adama", + label: "Adama", + note: "Inside country", + order: 2, + }, + { + value: "inside_mojo", + label: "Mojo", + note: "Inside country", + order: 3, + }, + { + value: "inside_awash", + label: "Awash", + note: "Inside country", + order: 4, + }, + { + value: "inside_mieso", + label: "Mieso", + note: "Inside country", + order: 5, + }, + { + value: "inside_dire_dawa", + label: "Dire Dawa", + note: "Inside country", + order: 6, + }, + { + value: "outside_ali_sabieh", + label: "Ali Sabieh", + note: "Outside country", + order: 7, + }, + { + value: "outside_holhol", + label: "Holhol", + note: "Outside country", + order: 8, + }, + { + value: "outside_djibouti_city", + label: "Djibouti City", + note: "Outside country", + order: 9, + }, + { + value: "outside_doraleh_terminal", + label: "Doraleh Terminal", + note: "Outside country", + order: 10, + }, +]; + +@Injectable() +export class DropdownSettingsService { + constructor( + @Inject(DROPDOWN_SETTINGS_REPOSITORY) + private readonly repository: IDropdownSettingsRepository, + ) {} + + list(): Promise { + return this.repository.findAll(); + } + + async getById(id: string): Promise { + const setting = await this.repository.findById(id); + if (!setting) throw new NotFoundException(`Setting ${id} not found`); + return setting; + } + + async getByCode(code: string): Promise { + const setting = await this.repository.findByCode(code); + if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return setting; + } + + async create(dto: CreateDropdownSettingDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) { + throw new ConflictException( + `Dropdown setting with code "${dto.code}" already exists`, + ); + } + + const setting = await this.repository.create({ + code: dto.code, + label: dto.label, + description: dto.description ?? null, + multiple: dto.multiple ?? false, + meta: dto.meta ?? null, + }); + + if (dto.children && dto.children.length > 0) { + await this.repository.replaceOptions(setting.id, dto.children); + } + + return this.getById(setting.id); + } + + async seedDefaultStations(): Promise { + const existing = await this.repository.findByCode(STATIONS_TER_CODE); + + if (!existing) { + await this.create({ + code: STATIONS_TER_CODE, + label: "Stations TER", + description: + "Temporary freight station list used by booking origin and destination yards.", + multiple: false, + meta: { + searchable: true, + clearable: true, + version: "temporary", + }, + children: DEFAULT_STATION_OPTIONS, + }); + return; + } + + if ((existing.children?.length ?? 0) === 0) { + await this.repository.replaceOptions( + existing.id, + DEFAULT_STATION_OPTIONS, + ); + } + } + + async update( + id: string, + dto: UpdateDropdownSettingDto, + ): Promise { + await this.getById(id); + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Setting ${id} not found`); + return this.getById(id); + } + + async remove(id: string): Promise { + await this.getById(id); + await this.repository.softDelete(id); + } + + /* ------------------------ option operations ------------------------ */ + + async replaceOptions( + settingId: string, + options: CreateDropdownOptionDto[], + ): Promise { + await this.getById(settingId); + return this.repository.replaceOptions(settingId, options); + } + + async addOption( + settingId: string, + dto: CreateDropdownOptionDto, + ): Promise { + await this.getById(settingId); + return this.repository.addOption(settingId, dto); + } + + async updateOption( + optionId: string, + dto: UpdateDropdownOptionDto, + ): Promise { + const updated = await this.repository.updateOption(optionId, dto); + if (!updated) throw new NotFoundException(`Option ${optionId} not found`); + return updated; + } + + async removeOption(optionId: string): Promise { + await this.repository.removeOption(optionId); + } +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-option.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-option.dto.ts new file mode 100644 index 000000000..675c0794f --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-option.dto.ts @@ -0,0 +1,40 @@ +import { Type } from "class-transformer"; +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; + +import { DropdownOptionMetaDto } from "./dropdown-option-meta.dto"; + +export class CreateDropdownOptionDto { + @IsString() + @MaxLength(256) + value!: string; + + @IsString() + @MaxLength(256) + label!: string; + + @IsOptional() + @IsString() + note?: string; + + @IsOptional() + @IsBoolean() + disabled?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + order?: number; + + @IsOptional() + @ValidateNested() + @Type(() => DropdownOptionMetaDto) + meta?: DropdownOptionMetaDto; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-setting.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-setting.dto.ts new file mode 100644 index 000000000..a4cedb1df --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-setting.dto.ts @@ -0,0 +1,45 @@ +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsOptional, + IsString, + Matches, + MaxLength, + ValidateNested, +} from "class-validator"; + +import { CreateDropdownOptionDto } from "./create-dropdown-option.dto"; +import { DropdownSettingMetaDto } from "./dropdown-setting-meta.dto"; + +export class CreateDropdownSettingDto { + @IsString() + @MaxLength(128) + @Matches(/^[a-z][a-z0-9_]*$/i, { + message: "code must be snake_case-friendly (letters, digits, underscores)", + }) + code!: string; + + @IsString() + @MaxLength(256) + label!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + multiple?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => DropdownSettingMetaDto) + meta?: DropdownSettingMetaDto; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateDropdownOptionDto) + children?: CreateDropdownOptionDto[]; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-option-meta.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-option-meta.dto.ts new file mode 100644 index 000000000..9d7381fb0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-option-meta.dto.ts @@ -0,0 +1,18 @@ +import { IsOptional, IsString, MaxLength } from "class-validator"; + +export class DropdownOptionMetaDto { + @IsOptional() + @IsString() + @MaxLength(64) + icon?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + badge?: string; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-setting-meta.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-setting-meta.dto.ts new file mode 100644 index 000000000..7eb16aa33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-setting-meta.dto.ts @@ -0,0 +1,43 @@ +import { Transform } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; + +export class DropdownSettingMetaDto { + @IsOptional() + @IsString() + @MaxLength(64) + icon?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + color?: string; + + @IsOptional() + @IsBoolean() + searchable?: boolean; + + @IsOptional() + @IsBoolean() + clearable?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => + Array.isArray(value) + ? (value as string[]).map((s) => s.trim()).filter(Boolean) + : value, + ) + permissions?: string[]; + + @IsOptional() + @IsString() + @MaxLength(32) + version?: string; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts new file mode 100644 index 000000000..bbe0a3ecc --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreateDropdownOptionDto } from "./create-dropdown-option.dto"; + +export class UpdateDropdownOptionDto extends PartialType( + CreateDropdownOptionDto, +) {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts new file mode 100644 index 000000000..35908c419 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts @@ -0,0 +1,7 @@ +import { OmitType, PartialType } from "@nestjs/mapped-types"; + +import { CreateDropdownSettingDto } from "./create-dropdown-setting.dto"; + +export class UpdateDropdownSettingDto extends PartialType( + OmitType(CreateDropdownSettingDto, ["children"] as const), +) {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts new file mode 100644 index 000000000..619031672 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts @@ -0,0 +1,47 @@ +import { BaseEntity } from "@edr/api-common"; +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, +} from "typeorm"; + +import { DropdownSetting } from "./dropdown-setting.entity"; + +export interface DropdownOptionMeta { + icon?: string; + color?: string; + badge?: string; +} + +@Entity({schema:"freight", name: "dropdown_options" }) +@Index(["settingId", "value"], { unique: true }) +export class DropdownOption extends BaseEntity { + @ManyToOne(() => DropdownSetting, (setting) => setting.children, { + onDelete: "CASCADE", + }) + @JoinColumn({ name: "setting_id" }) + setting!: DropdownSetting; + + @Column({ name: "setting_id", type: "uuid" }) + settingId!: string; + + @Column({ name: "value", type: "varchar", length: 256 }) + value!: string; + + @Column({ name: "label", type: "varchar", length: 256 }) + label!: string; + + @Column({ name: "note", type: "text", nullable: true }) + note?: string | null; + + @Column({ name: "is_disabled", type: "boolean", default: false }) + disabled!: boolean; + + @Column({ name: "display_order", type: "integer", default: 0 }) + order!: number; + + @Column({ name: "meta", type: "jsonb", nullable: true }) + meta?: DropdownOptionMeta | null; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts new file mode 100644 index 000000000..5c87c8125 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, OneToMany } from "typeorm"; + +import { DropdownOption } from "./dropdown-option.entity"; + +export interface DropdownSettingMeta { + icon?: string; + color?: string; + searchable?: boolean; + clearable?: boolean; + permissions?: string[]; + version?: string; +} + +@Entity({schema:"freight", name: "dropdown_settings" }) +@Index(["code"], { unique: true }) +export class DropdownSetting extends BaseEntity { + @Column({ name: "code", type: "varchar", length: 128, unique: true }) + code!: string; + + @Column({ name: "label", type: "varchar", length: 256 }) + label!: string; + + @Column({ name: "description", type: "text", nullable: true }) + description?: string | null; + + @Column({ name: "multiple", type: "boolean", default: false }) + multiple!: boolean; + + @Column({ name: "meta", type: "jsonb", nullable: true }) + meta?: DropdownSettingMeta | null; + + @OneToMany(() => DropdownOption, (option) => option.setting, { + cascade: true, + }) + children!: DropdownOption[]; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts b/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts new file mode 100644 index 000000000..7b004a066 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts @@ -0,0 +1,38 @@ +import { DropdownOption } from "../entities/dropdown-option.entity"; +import { DropdownSetting } from "../entities/dropdown-setting.entity"; + +/** + * Contract every DropdownSettings repository must satisfy. Lets services + * depend on the abstraction and lets tests swap in an in-memory fake. + */ +export const DROPDOWN_SETTINGS_REPOSITORY = Symbol( + "DROPDOWN_SETTINGS_REPOSITORY", +); + +export interface IDropdownSettingsRepository { + findAll(): Promise; + findById(id: string): Promise; + findByCode(code: string): Promise; + + create(data: Partial): Promise; + update( + id: string, + data: Partial, + ): Promise; + softDelete(id: string): Promise; + + /* Option-level helpers */ + replaceOptions( + settingId: string, + options: Array>, + ): Promise; + addOption( + settingId: string, + option: Partial, + ): Promise; + updateOption( + optionId: string, + data: Partial, + ): Promise; + removeOption(optionId: string): Promise; +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-field.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-field.dto.ts new file mode 100644 index 000000000..6d670dae3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-field.dto.ts @@ -0,0 +1,51 @@ +import { + ArrayNotEmpty, + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from "class-validator"; + +export class CreateFileUploadFieldDto { + @IsString() + @MaxLength(128) + fileKey!: string; + + @IsString() + @MaxLength(256) + fileLabel!: string; + + @IsOptional() + @IsString() + helpText?: string; + + @IsBoolean() + isRequired!: boolean; + + @IsBoolean() + isMultiple!: boolean; + + @IsInt() + @Min(1) + @Max(50) + maxFiles!: number; + + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + allowedExtensions!: string[]; + + @IsInt() + @Min(1) + @Max(500) + maxSizeMb!: number; + + @IsOptional() + @IsInt() + @Min(0) + order?: number; +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-setting.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-setting.dto.ts new file mode 100644 index 000000000..6a4d62f9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-setting.dto.ts @@ -0,0 +1,44 @@ +import { Type } from "class-transformer"; +import { + IsArray, + IsEnum, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; + +import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto"; + +export enum FileUploadEntityDto { + Customer = "customer", + Booking = "booking", + Consignment = "consignment", + Shipment = "shipment", + Invoice = "invoice", + Train = "train", + Other = "other", +} + +export class CreateFileUploadSettingDto { + @IsString() + @MaxLength(128) + code!: string; + + @IsString() + @MaxLength(256) + label!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(FileUploadEntityDto) + entity!: FileUploadEntityDto; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateFileUploadFieldDto) + fields?: CreateFileUploadFieldDto[]; +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts new file mode 100644 index 000000000..abef2b5b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto"; + +export class UpdateFileUploadFieldDto extends PartialType( + CreateFileUploadFieldDto, +) {} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts new file mode 100644 index 000000000..f055f4ebf --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts @@ -0,0 +1,7 @@ +import { OmitType, PartialType } from "@nestjs/mapped-types"; + +import { CreateFileUploadSettingDto } from "./create-file-upload-setting.dto"; + +export class UpdateFileUploadSettingDto extends PartialType( + OmitType(CreateFileUploadSettingDto, ["fields"] as const), +) {} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts new file mode 100644 index 000000000..e76c5716b --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts @@ -0,0 +1,58 @@ +import { BaseEntity } from "@edr/api-common"; +import { + Check, + Column, + Entity, + Index, + JoinColumn, + ManyToOne, +} from "typeorm"; + +import { FileUploadSetting } from "./file-upload-setting.entity"; + +@Entity({schema:"freight", name: "file_upload_fields" }) +@Index(["settingId", "fileKey"], { unique: true }) +@Check(`"max_files" > 0`) +@Check(`"max_size_mb" > 0`) +export class FileUploadField extends BaseEntity { + @ManyToOne(() => FileUploadSetting, (setting) => setting.fields, { + onDelete: "CASCADE", + }) + @JoinColumn({ name: "setting_id" }) + setting!: FileUploadSetting; + + @Column({ name: "setting_id", type: "uuid" }) + settingId!: string; + + @Column({ name: "file_key", type: "varchar", length: 128 }) + fileKey!: string; + + @Column({ name: "file_label", type: "varchar", length: 256 }) + fileLabel!: string; + + @Column({ name: "help_text", type: "text", nullable: true }) + helpText?: string | null; + + @Column({ name: "is_required", type: "boolean", default: false }) + isRequired!: boolean; + + @Column({ name: "is_multiple", type: "boolean", default: false }) + isMultiple!: boolean; + + @Column({ name: "max_files", type: "integer", default: 1 }) + maxFiles!: number; + + @Column({ + name: "allowed_extensions", + type: "text", + array: true, + default: () => "'{}'::text[]", + }) + allowedExtensions!: string[]; + + @Column({ name: "max_size_mb", type: "integer", default: 10 }) + maxSizeMb!: number; + + @Column({ name: "display_order", type: "integer", default: 0 }) + displayOrder!: number; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts new file mode 100644 index 000000000..2318078c1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts @@ -0,0 +1,52 @@ +import { BaseEntity } from "@edr/api-common"; +import { + Column, + Entity, + Index, + OneToMany, +} from "typeorm"; + +import { FileUploadField } from "./file-upload-field.entity"; + +@Entity({ schema:"freight",name: "file_upload_settings" }) +@Index(["code"], { unique: true }) +export class FileUploadSetting extends BaseEntity { + @Column({ + name: "code", + type: "varchar", + length: 128, + unique: true, + }) + code!: string; + + @Column({ + name: "label", + type: "varchar", + length: 256, + }) + label!: string; + + @Column({ + name: "description", + type: "text", + nullable: true, + }) + description?: string | null; + + @Column({ + name: "entity", + type: "varchar", + length: 32, + default: "other", + }) + entity!: string; + + @OneToMany( + () => FileUploadField, + (field) => field.setting, + { + cascade: true, + } + ) + fields!: FileUploadField[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts new file mode 100644 index 000000000..ecdecffc3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts @@ -0,0 +1,108 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto"; +import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto"; +import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto"; +import { UpdateFileUploadSettingDto } from "./dto/update-file-upload-setting.dto"; +import { FileUploadSettingsService } from "./file-upload-settings.service"; + +@ApiTags("file-upload-settings") +@Controller("file-upload-settings") +export class FileUploadSettingsController { + constructor(private readonly service: FileUploadSettingsService) {} + + @Get() + @ApiOperation({ summary: "List all file upload settings" }) + list() { + return this.service.list(); + } + + @Get(":id") + @ApiOperation({ summary: "Get a file upload setting by ID" }) + getById(@Param("id", ParseUUIDPipe) id: string) { + return this.service.getById(id); + } + + @Get("by-code/:code") + @ApiOperation({ summary: "Get a file upload setting by its stable code" }) + getByCode(@Param("code") code: string) { + return this.service.getByCode(code); + } + + @Get("by-entity/:entity") + @ApiOperation({ summary: "Get all file upload settings for an entity type (customer, booking, etc.)" }) + getByEntity(@Param("entity") entity: string) { + return this.service.getByEntity(entity); + } + + @Post() + @ApiOperation({ summary: "Create a new file upload setting" }) + create(@Body() dto: CreateFileUploadSettingDto) { + return this.service.create(dto); + } + + @Patch(":id") + @ApiOperation({ summary: "Update a file upload setting's metadata" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateFileUploadSettingDto, + ) { + return this.service.update(id, dto); + } + + @Delete(":id") + @ApiOperation({ summary: "Soft-delete a file upload setting" }) + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.service.remove(id); + } + + /* ------------------------- field routes ------------------------- */ + + @Put(":id/fields") + @ApiOperation({ summary: "Replace the full field list for a setting" }) + replaceFields( + @Param("id", ParseUUIDPipe) id: string, + @Body() fields: CreateFileUploadFieldDto[], + ) { + return this.service.replaceFields(id, fields); + } + + @Post(":id/fields") + @ApiOperation({ summary: "Append a single field to a setting" }) + addField( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CreateFileUploadFieldDto, + ) { + return this.service.addField(id, dto); + } + + @Patch("fields/:fieldId") + @ApiOperation({ summary: "Update a single field" }) + updateField( + @Param("fieldId", ParseUUIDPipe) fieldId: string, + @Body() dto: UpdateFileUploadFieldDto, + ) { + return this.service.updateField(fieldId, dto); + } + + @Delete("fields/:fieldId") + @ApiOperation({ summary: "Soft-delete a single field" }) + @HttpCode(HttpStatus.NO_CONTENT) + removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) { + return this.service.removeField(fieldId); + } +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.module.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.module.ts new file mode 100644 index 000000000..e32762394 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.module.ts @@ -0,0 +1,26 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { FileUploadField } from "./entities/file-upload-field.entity"; +import { FileUploadSetting } from "./entities/file-upload-setting.entity"; +import { FileUploadSettingsController } from "./file-upload-settings.controller"; +import { FileUploadSettingsRepository } from "./file-upload-settings.repository"; +import { FileUploadSettingsService } from "./file-upload-settings.service"; +import { FILE_UPLOAD_SETTINGS_REPOSITORY } from "./interfaces/file-upload-settings.repository.interface"; + +@Module({ + imports: [TypeOrmModule.forFeature([FileUploadSetting, FileUploadField])], + controllers: [FileUploadSettingsController], + providers: [ + FileUploadSettingsRepository, + { + // Bind the interface token to the concrete TypeORM repository so the + // service can inject the abstraction (handy for tests / swap-out). + provide: FILE_UPLOAD_SETTINGS_REPOSITORY, + useExisting: FileUploadSettingsRepository, + }, + FileUploadSettingsService, + ], + exports: [FileUploadSettingsService], +}) +export class FileUploadSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts new file mode 100644 index 000000000..c14b30052 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts @@ -0,0 +1,84 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { FileUploadField } from "./entities/file-upload-field.entity"; +import { FileUploadSetting } from "./entities/file-upload-setting.entity"; +import type { IFileUploadSettingsRepository } from "./interfaces/file-upload-settings.repository.interface"; + +@Injectable() +export class FileUploadSettingsRepository + extends BaseRepository + implements IFileUploadSettingsRepository +{ + constructor( + @InjectRepository(FileUploadSetting) + repository: Repository, + @InjectRepository(FileUploadField) + private readonly fieldsRepository: Repository, + ) { + super(repository); + } + + /** Look up all settings for a given entity (e.g. "customer", "booking"). */ + findByEntity(entity: string): Promise { + return this.repository.find({ + where: { entity }, + order: { label: "ASC" }, + relations: { fields: true }, + }); + } + + /** Look up a setting by its stable code. */ + findByCode(code: string): Promise { + return this.repository.findOne({ + where: { code }, + relations: { fields: true }, + }); + } + + override findAll(): Promise { + return this.repository.find({ + order: { label: "ASC" }, + relations: { fields: true }, + }); + } + + /** Replace the whole field list for a setting. Returns the saved rows. */ + async replaceFields( + settingId: string, + fields: Array>, + ): Promise { + await this.fieldsRepository.delete({ settingId }); + if (fields.length === 0) return []; + const entities = fields.map((f, idx) => + this.fieldsRepository.create({ + ...f, + settingId, + displayOrder: f.displayOrder ?? idx + 1, + }), + ); + return this.fieldsRepository.save(entities); + } + + async addField( + settingId: string, + field: Partial, + ): Promise { + const entity = this.fieldsRepository.create({ ...field, settingId }); + return this.fieldsRepository.save(entity); + } + + async updateField( + fieldId: string, + data: Partial, + ): Promise { + await this.fieldsRepository.update(fieldId, data as never); + return this.fieldsRepository.findOne({ where: { id: fieldId } }); + } + + async removeField(fieldId: string): Promise { + await this.fieldsRepository.softDelete(fieldId); + } +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts new file mode 100644 index 000000000..947bb5ffb --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -0,0 +1,113 @@ +import { + ConflictException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto"; +import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto"; +import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto"; +import { UpdateFileUploadSettingDto } from "./dto/update-file-upload-setting.dto"; +import { FileUploadField } from "./entities/file-upload-field.entity"; +import { FileUploadSetting } from "./entities/file-upload-setting.entity"; +import { + FILE_UPLOAD_SETTINGS_REPOSITORY, + IFileUploadSettingsRepository, +} from "./interfaces/file-upload-settings.repository.interface"; + +@Injectable() +export class FileUploadSettingsService { + constructor( + @Inject(FILE_UPLOAD_SETTINGS_REPOSITORY) + private readonly repository: IFileUploadSettingsRepository, + ) {} + + list(): Promise { + return this.repository.findAll(); + } + + async getById(id: string): Promise { + const setting = await this.repository.findById(id); + if (!setting) throw new NotFoundException(`Setting ${id} not found`); + return setting; + } + + getByEntity(entity: string): Promise { + return this.repository.findByEntity(entity); + } + + async getByCode(code: string): Promise { + const setting = await this.repository.findByCode(code); + if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return setting; + } + + async create(dto: CreateFileUploadSettingDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) { + throw new ConflictException( + `File upload setting with code "${dto.code}" already exists`, + ); + } + + const setting = await this.repository.create({ + code: dto.code, + label: dto.label, + description: dto.description ?? null, + entity: dto.entity, + }); + + if (dto.fields && dto.fields.length > 0) { + await this.repository.replaceFields(setting.id, dto.fields); + } + + return this.getById(setting.id); + } + + async update( + id: string, + dto: UpdateFileUploadSettingDto, + ): Promise { + await this.getById(id); // existence check + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Setting ${id} not found`); + return updated; + } + + async remove(id: string): Promise { + await this.getById(id); + await this.repository.softDelete(id); + } + + /* ------------------------ field operations ------------------------ */ + + async replaceFields( + settingId: string, + fields: CreateFileUploadFieldDto[], + ): Promise { + await this.getById(settingId); + return this.repository.replaceFields(settingId, fields); + } + + async addField( + settingId: string, + dto: CreateFileUploadFieldDto, + ): Promise { + await this.getById(settingId); + return this.repository.addField(settingId, dto); + } + + async updateField( + fieldId: string, + dto: UpdateFileUploadFieldDto, + ): Promise { + const updated = await this.repository.updateField(fieldId, dto); + if (!updated) throw new NotFoundException(`Field ${fieldId} not found`); + return updated; + } + + async removeField(fieldId: string): Promise { + await this.repository.removeField(fieldId); + } +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts b/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts new file mode 100644 index 000000000..a0aa01d06 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts @@ -0,0 +1,39 @@ +import { FileUploadField } from "../entities/file-upload-field.entity"; +import { FileUploadSetting } from "../entities/file-upload-setting.entity"; + +/** + * Contract every FileUploadSettings repository must satisfy. Lets services + * depend on the abstraction and lets tests swap in an in-memory fake. + */ +export const FILE_UPLOAD_SETTINGS_REPOSITORY = Symbol( + "FILE_UPLOAD_SETTINGS_REPOSITORY", +); + +export interface IFileUploadSettingsRepository { + findAll(): Promise; + findById(id: string): Promise; + findByCode(code: string): Promise; + findByEntity(entity: string): Promise; + + create(data: Partial): Promise; + update( + id: string, + data: Partial, + ): Promise; + softDelete(id: string): Promise; + + /* Field-level helpers */ + replaceFields( + settingId: string, + fields: Array>, + ): Promise; + addField( + settingId: string, + field: Partial, + ): Promise; + updateField( + fieldId: string, + data: Partial, + ): Promise; + removeField(fieldId: string): Promise; +} diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts new file mode 100644 index 000000000..221b7c29b --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +@Entity({ schema: "freight", name: "files" }) +export class FileRecord extends BaseEntity { + @Column({ name: "resource_id", type: "uuid" }) + resourceId!: string; + + @Column({ name: "resource", type: "varchar", length: 100 }) + resource!: string; + + @Column({ name: "code", type: "varchar", length: 100 }) + code!: string; + + @Column({ name: "name", type: "varchar", length: 500 }) + name!: string; + + @Column({ name: "url", type: "text" }) + url!: string; + + @Column({ name: "size", type: "integer" }) + size!: number; + + @Column({ name: "mime_type", type: "varchar", length: 255 }) + mimeType!: string; +} diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts new file mode 100644 index 000000000..acf274ff0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Response } from "express"; + +import { FilesService } from "./files.service"; + +@ApiTags("files") +@Controller("files") +export class FilesController { + constructor(private readonly filesService: FilesService) {} + + @Get(":fileId") + @ApiOperation({ + summary: "Download a file by ID", + description: + "Global endpoint — streams any uploaded file directly from MinIO by its UUID. " + + "No resource context (e.g. booking ID) required.", + }) + async download( + @Param("fileId", ParseUUIDPipe) fileId: string, + @Res() res: Response, + ) { + const { stream, record } = await this.filesService.streamById(fileId); + res.setHeader("Content-Type", record.mimeType); + res.setHeader("Content-Disposition", `attachment; filename="${record.name}"`); + stream.pipe(res); + } +} diff --git a/apps/edr-freight-api/src/modules/files/files.module.ts b/apps/edr-freight-api/src/modules/files/files.module.ts new file mode 100644 index 000000000..fa04c9fa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { MinioModule } from "../minio/minio.module"; +import { FilesController } from "./files.controller"; +import { FilesRepository } from "./files.repository"; +import { FilesService } from "./files.service"; +import { FileRecord } from "./entities/file.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([FileRecord]), MinioModule], + controllers: [FilesController], + providers: [FilesService, FilesRepository], + exports: [FilesService], +}) +export class FilesModule {} diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts new file mode 100644 index 000000000..ea4bfd19e --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -0,0 +1,36 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { FileRecord } from "./entities/file.entity"; + +@Injectable() +export class FilesRepository extends BaseRepository { + constructor( + @InjectRepository(FileRecord) + repository: Repository, + ) { + super(repository); + } + + findByResource(resourceId: string, resource: string): Promise { + return this.repository.find({ where: { resourceId, resource } }); + } + + findByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + return this.repository.findOne({ where: { resourceId, resource, code } }); + } + + async deleteByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + await this.repository.delete({ resourceId, resource, code }); + } +} diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts new file mode 100644 index 000000000..97a5e9e34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -0,0 +1,86 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Readable } from "stream"; + +import { MinioService } from "../minio/minio.service"; +import { FilesRepository } from "./files.repository"; +import { FileRecord } from "./entities/file.entity"; + +export interface CreateFileInput { + resourceId: string; + resource: string; + code: string; + file: Express.Multer.File; +} + +@Injectable() +export class FilesService { + constructor( + private readonly filesRepository: FilesRepository, + private readonly minioService: MinioService, + ) {} + + async upload(input: CreateFileInput): Promise { + const { resourceId, resource, code, file } = input; + const objectName = `${resource}/${resourceId}/${Date.now()}_${file.originalname}`; + const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype); + + return this.filesRepository.create({ + resourceId, + resource, + code, + name: file.originalname, + url, + size: file.size, + mimeType: file.mimetype, + }); + } + + /** Replace existing file row for the same resource + code (e.g. contract PDF). */ + async upsertByCode(input: CreateFileInput): Promise { + const { resourceId, resource, code } = input; + await this.filesRepository.deleteByCode(resourceId, resource, code); + return this.upload(input); + } + + async uploadMany( + resourceId: string, + resource: string, + files: Express.Multer.File[], + ): Promise { + return Promise.all( + files.map((file) => + this.upload({ resourceId, resource, code: file.fieldname, file }), + ), + ); + } + + async findById(id: string): Promise { + const record = await this.filesRepository.findById(id); + if (!record) throw new NotFoundException(`File ${id} not found`); + return record; + } + + findByResource(resourceId: string, resource: string): Promise { + return this.filesRepository.findByResource(resourceId, resource); + } + + async findByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + const record = await this.filesRepository.findByCode(resourceId, resource, code); + if (!record) + throw new NotFoundException( + `File with code "${code}" not found for ${resource} ${resourceId}`, + ); + return record; + } + + async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> { + const record = await this.findById(id); + const objectName = this.minioService.getObjectNameFromUrl(record.url); + const stream = await this.minioService.getFileStream(objectName); + return { stream, record }; + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts new file mode 100644 index 000000000..1469630ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity'; + +export class CreateLocomotiveDto { + @ApiProperty({ example: 'LOCO-001' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiProperty({ enum: LOCOMOTIVE_TYPES }) + @IsIn([...LOCOMOTIVE_TYPES]) + locomotiveType!: string; + + @ApiProperty({ enum: LOCOMOTIVE_STATUSES }) + @IsIn([...LOCOMOTIVE_STATUSES]) + status!: string; + + @ApiProperty({ example: 3500 }) + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0) + maxPullWeightTons!: number; + + @ApiProperty({ example: 760 }) + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0) + maxTrainLengthMeters!: number; + + @ApiPropertyOptional({ example: 4200 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + powerKw?: number; + + @ApiPropertyOptional({ example: 300 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + tractionForceKn?: number; + + @ApiPropertyOptional({ example: 120 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + maxSpeedKmh?: number; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts new file mode 100644 index 000000000..1ea5ef29d --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity'; + +export class FilterLocomotivesDto { + @ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES }) + @IsOptional() + @IsIn([...LOCOMOTIVE_STATUSES]) + status?: string; + + @ApiPropertyOptional({ enum: LOCOMOTIVE_TYPES }) + @IsOptional() + @IsIn([...LOCOMOTIVE_TYPES]) + locomotiveType?: string; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts new file mode 100644 index 000000000..0f5cd2761 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateLocomotiveDto } from './create-locomotive.dto'; + +export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts new file mode 100644 index 000000000..2c5aa463a --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { TrainSet } from '../../train-sets/entities/train-set.entity'; + +export const LOCOMOTIVE_STATUSES = [ + 'AVAILABLE', + 'ASSIGNED', + 'MAINTENANCE', + 'OUT_OF_SERVICE', +] as const; + +export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const; + +export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number]; +export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'locomotives' }) +@Index(['code']) +@Index(['status']) +export class Locomotive extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100, nullable: true }) + name?: string | null; + + @Column({ name: 'locomotive_type', type: 'varchar', length: 20, default: 'DIESEL' }) + locomotiveType!: LocomotiveType; + + @Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + maxPullWeightTons!: number; + + @Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 }) + maxTrainLengthMeters!: number; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) + status!: LocomotiveStatus; + + @Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true }) + powerKw?: number | null; + + @Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true }) + tractionForceKn?: number | null; + + @Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true }) + maxSpeedKmh?: number | null; + + @OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive) + trainSets?: TrainSet[]; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts new file mode 100644 index 000000000..f7ccdde1d --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; +import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; +import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; +import { LocomotivesService } from './locomotives.service'; + +@ApiTags('locomotives') +@ApiBearerAuth() +@Controller('locomotives') +export class LocomotivesController { + constructor(private readonly locomotivesService: LocomotivesService) {} + + @Get() + @ApiOperation({ summary: 'List locomotives' }) + findAll(@Query() filter: FilterLocomotivesDto) { + return this.locomotivesService.findAll(filter); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a locomotive by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.locomotivesService.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a locomotive' }) + create(@Body() dto: CreateLocomotiveDto) { + return this.locomotivesService.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a locomotive' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) { + return this.locomotivesService.update(id, dto); + } + + @Post(':id/decommission') + @ApiOperation({ summary: 'Decommission a locomotive' }) + decommission(@Param('id', ParseUUIDPipe) id: string) { + return this.locomotivesService.decommission(id); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts new file mode 100644 index 000000000..264b9cb44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { LocomotivesController } from './locomotives.controller'; +import { Locomotive } from './entities/locomotive.entity'; +import { LocomotivesRepository } from './locomotives.repository'; +import { LocomotivesService } from './locomotives.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Locomotive])], + controllers: [LocomotivesController], + providers: [LocomotivesRepository, LocomotivesService], + exports: [LocomotivesRepository, LocomotivesService], +}) +export class LocomotivesModule {} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts new file mode 100644 index 000000000..af2a40f50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Locomotive } from './entities/locomotive.entity'; + +@Injectable() +export class LocomotivesRepository extends BaseRepository { + constructor( + @InjectRepository(Locomotive) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts new file mode 100644 index 000000000..ac030d5d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -0,0 +1,98 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; + +import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; +import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; +import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; +import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity'; +import { LocomotivesRepository } from './locomotives.repository'; + +@Injectable() +export class LocomotivesService { + constructor(private readonly locomotivesRepository: LocomotivesRepository) {} + + findAll(filter: FilterLocomotivesDto): Promise { + return this.locomotivesRepository.findAll({ + where: { + ...(filter.status ? { status: filter.status as LocomotiveStatus } : {}), + ...(filter.locomotiveType + ? { locomotiveType: filter.locomotiveType as LocomotiveType } + : {}), + }, + order: { code: 'ASC' }, + }); + } + + async create(dto: CreateLocomotiveDto): Promise { + const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } }); + + if (existing) { + throw new ConflictException(`Locomotive code ${dto.code} already exists`); + } + + return this.locomotivesRepository.create({ + code: dto.code, + name: dto.name?.trim() || null, + locomotiveType: dto.locomotiveType as LocomotiveType, + status: dto.status as LocomotiveStatus, + maxPullWeightTons: dto.maxPullWeightTons, + maxTrainLengthMeters: dto.maxTrainLengthMeters, + powerKw: dto.powerKw ?? null, + tractionForceKn: dto.tractionForceKn ?? null, + maxSpeedKmh: dto.maxSpeedKmh ?? null, + }); + } + + async findById(id: string): Promise { + const locomotive = await this.locomotivesRepository.findById(id); + + if (!locomotive) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return locomotive; + } + + async update(id: string, dto: UpdateLocomotiveDto): Promise { + const locomotive = await this.findById(id); + + if (dto.code && dto.code !== locomotive.code) { + const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } }); + if (existing && existing.id !== id) { + throw new ConflictException(`Locomotive code ${dto.code} already exists`); + } + } + + const updated = await this.locomotivesRepository.update(id, { + ...dto, + locomotiveType: + dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType, + status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus, + name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null, + powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null, + tractionForceKn: + dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null, + maxSpeedKmh: + dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null, + }); + + if (!updated) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return updated; + } + + async decommission(id: string): Promise { + await this.findById(id); + + const updated = await this.locomotivesRepository.update(id, { + status: 'OUT_OF_SERVICE', + }); + + if (!updated) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return updated; + } +} diff --git a/apps/edr-freight-api/src/modules/minio/index.ts b/apps/edr-freight-api/src/modules/minio/index.ts new file mode 100644 index 000000000..c5891e495 --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/index.ts @@ -0,0 +1,2 @@ +export * from "./minio.module"; +export * from "./minio.service"; diff --git a/apps/edr-freight-api/src/modules/minio/minio.config.ts b/apps/edr-freight-api/src/modules/minio/minio.config.ts new file mode 100644 index 000000000..10482a325 --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export const minioConfig = registerAs("minio", () => ({ + endPoint: process.env.MINIO_ENDPOINT || "minio-dev.smart.aaca.gov.et", + port: parseInt(process.env.MINIO_PORT || "443", 10), + useSSL: process.env.MINIO_USE_SSL !== "false", + accessKey: process.env.MINIO_ACCESS_KEY || "", + secretKey: process.env.MINIO_SECRET_KEY || "", + bucket: process.env.MINIO_BUCKET || "fhc", +})); diff --git a/apps/edr-freight-api/src/modules/minio/minio.module.ts b/apps/edr-freight-api/src/modules/minio/minio.module.ts new file mode 100644 index 000000000..d4745beaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { minioConfig } from "./minio.config"; +import { MinioService } from "./minio.service"; + +@Module({ + imports: [ConfigModule.forFeature(minioConfig)], + providers: [MinioService], + exports: [MinioService], +}) +export class MinioModule {} diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts new file mode 100644 index 000000000..9f7a5e65d --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -0,0 +1,108 @@ +import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { ConfigType } from "@nestjs/config"; +import { Client } from "minio"; +import { Readable } from "stream"; +import { minioConfig } from "./minio.config"; + +@Injectable() +export class MinioService { + private readonly client: Client; + private readonly logger = new Logger(MinioService.name); + private readonly bucket: string; + + constructor( + @Inject(minioConfig.KEY) + private readonly config: ConfigType, + ) { + console.log('[MinioService] Configuration loaded:', { + endPoint: config.endPoint, + port: config.port, + useSSL: config.useSSL, + accessKey: config.accessKey, + secretKey: config.secretKey ? '***HIDDEN***' : 'EMPTY', + bucket: config.bucket, + }); + this.bucket = config.bucket; + this.client = new Client({ + endPoint: config.endPoint, + port: config.port, + useSSL: config.useSSL, + accessKey: config.accessKey, + secretKey: config.secretKey, + }); + } + + async uploadFile( + objectName: string, + buffer: Buffer, + contentType: string, + ): Promise { + try { + await this.client.putObject(this.bucket, objectName, buffer, buffer.length, { + "Content-Type": contentType, + }); + this.logger.log(`File uploaded successfully: ${objectName}`); + return this.getPublicUrl(objectName); + } catch (error) { + this.logger.error(`Failed to upload file ${objectName}:`, error); + throw error; + } + } + + getPublicUrl(objectName: string): string { + const protocol = this.config.useSSL ? "https" : "http"; + return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`; + } + + getObjectNameFromUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new NotFoundException("File object path is empty"); + } + + if (!/^https?:\/\//i.test(trimmed)) { + return trimmed.replace(/^\/+/, ""); + } + + const url = new URL(trimmed); + const parts = url.pathname.split("/").filter(Boolean); + if (parts[0] === this.bucket) { + parts.shift(); + } + + const objectName = parts.join("/"); + if (!objectName) { + throw new NotFoundException("File object path is empty"); + } + + return objectName; + } + + async deleteFile(objectName: string): Promise { + try { + await this.client.removeObject(this.bucket, objectName); + this.logger.log(`File deleted successfully: ${objectName}`); + } catch (error) { + this.logger.error(`Failed to delete file ${objectName}:`, error); + throw error; + } + } + + async getFileStream(objectName: string): Promise { + try { + return this.client.getObject(this.bucket, objectName); + } catch (error) { + this.logger.error(`Failed to get file ${objectName}:`, error); + throw error; + } + } + + async getSignedUrl(objectName: string, expirySeconds: number = 300): Promise { + try { + return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds); + } catch (error) { + this.logger.error(`Failed to generate signed URL for ${objectName}:`, error); + throw error; + } + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 39fe1b5c7..2ff2f9727 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -1,9 +1,14 @@ import { Module } from "@nestjs/common"; import { NotificationsService } from "./notifications.service"; +import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; +import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; +import { HttpModule } from "@nestjs/axios"; @Module({ - providers: [NotificationsService], + imports: [HttpModule], + controllers: [], + providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService], exports: [NotificationsService], }) -export class NotificationsModule {} +export class NotificationsModule { } diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts index c17250264..35e8ff07d 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts @@ -1,14 +1,35 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; +import { NotificationStrategy } from "./strategies/notification.strategy"; +import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; + + +type StrategyMethod = "sms" | "email" @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); + private readonly strategies: Map + constructor(private readonly email: EmailNotificationStrategy, private readonly sms: SmsNotificationStrategy) { + this.strategies = new Map([ + ["sms", this.sms as NotificationStrategy], + ["email", this.email as NotificationStrategy] + ]) + } /** * Dispatch a notification to an operator or customer. * TODO: wire to email/SMS provider (SendGrid, SMS API, etc.) via a mailer service. */ - async send(recipient: string, subject: string, body: string): Promise { - this.logger.log(`[notify] ${recipient} :: ${subject} :: ${body}`); + + async directSend(method: StrategyMethod, recipient: string, message: string) { + const strategy = this.strategies.get(method); + if (!strategy) { + throw new NotFoundException(); + } + const sent = await strategy.send(recipient, message) + this.logger.log(`is sent - ${sent}`) } + + } diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts new file mode 100644 index 000000000..57873639b --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts @@ -0,0 +1,12 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { NotificationStrategy } from "./notification.strategy"; + +@Injectable() +export class EmailNotificationStrategy implements NotificationStrategy { + private readonly logger = new Logger(EmailNotificationStrategy.name); + constructor() { } + async send(recipient: string, message: string): Promise { + this.logger.log(`${recipient}, ${message}`) + return false; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts new file mode 100644 index 000000000..2f8916845 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -0,0 +1,25 @@ +import { Injectable} from "@nestjs/common"; +import { NotificationStrategy } from "./notification.strategy"; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from "@nestjs/config"; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class SmsNotificationStrategy implements NotificationStrategy { + constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { } + async send(recipient: string, message: string) { + const url = this.configService.get("OZIKING_SMS_URL") + const body = { + to: recipient, + text: message + } + const response = await firstValueFrom( + this.httpService.post( + url, + body, + ), + ); + + return response.status === 201; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts new file mode 100644 index 000000000..2bc53120c --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts @@ -0,0 +1,6 @@ +import { Injectable } from "@nestjs/common"; + +@Injectable() +export abstract class NotificationStrategy { + abstract send(recipient: string, message: string): Promise +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts new file mode 100644 index 000000000..cac5fdba0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OtpController } from './otp.controller'; + +describe('OtpController', () => { + let controller: OtpController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [OtpController], + }).compile(); + + controller = module.get(OtpController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts new file mode 100644 index 000000000..9866ca570 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -0,0 +1,53 @@ +// otp.controller.ts + +import { + Body, + Controller, + Post, +} from "@nestjs/common"; + + +import { OtpService } from "./otp.service"; +import { Public } from "@edr/api-common"; + +@Controller("otp") +@Public() +export class OtpController { + constructor( + private readonly otpService: OtpService + ) {} + + // --------------------------------------------------------------------------- + // Send OTP + // --------------------------------------------------------------------------- + + @Post("send") + async sendOtp( + @Body("phone") + phone: string, + @Body("otp") + otp: string + ) { + return this.otpService.sendOtp( + phone,otp + ); + } + + // --------------------------------------------------------------------------- + // Verify OTP + // --------------------------------------------------------------------------- + + @Post("verify") + async verifyOtp( + @Body("phone") + phone: string, + + @Body("otp") + otp: string + ) { + return this.otpService.verifyOtp( + phone, + otp + ); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts new file mode 100644 index 000000000..f5900f6b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -0,0 +1,25 @@ +// otp.entity.ts + +import { + Column, + Entity, +} from "typeorm"; +import { BaseEntity } from "@edr/api-common"; + +@Entity({ + name: "otp_verifications", +}) +export class OtpVerification extends BaseEntity{ + @Column({ + unique: true, + }) + phone!: string; + + @Column() + otp!: string; + + @Column({ + default: false, + }) + verified!: boolean; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts new file mode 100644 index 000000000..7a6d1faa6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -0,0 +1,33 @@ +// otp.module.ts + +import { Module } from "@nestjs/common"; + +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { OtpVerification } from "./otp.entity"; + +import { OtpController } from "./otp.controller"; + +import { OtpService } from "./otp.service"; + +import { OtpRepository } from "./otp.repository"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + OtpVerification, + ]), + ], + + controllers: [OtpController], + + providers: [ + OtpService, + OtpRepository, + ], + + exports: [ + OtpRepository, + ], +}) +export class OtpModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.repository.ts b/apps/edr-freight-api/src/modules/otp/otp.repository.ts new file mode 100644 index 000000000..8aa69dcd6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.repository.ts @@ -0,0 +1,86 @@ +// otp.repository.ts + +import { Injectable } from "@nestjs/common"; + +import { InjectRepository } from "@nestjs/typeorm"; + +import { Repository } from "typeorm"; + +import { OtpVerification } from "./otp.entity"; + +@Injectable() +export class OtpRepository { + constructor( + @InjectRepository( + OtpVerification + ) + private readonly repository: Repository + ) {} + + // --------------------------------------------------------------------------- + // Find By Phone + // --------------------------------------------------------------------------- + + async findByPhone( + phone: string + ) { + return this.repository.findOne({ + where: { + phone, + }, + }); + } + + // --------------------------------------------------------------------------- + // Create OTP + // --------------------------------------------------------------------------- + + async createOtp( + phone: string, + otp: string + ) { + const entity = + this.repository.create({ + phone, + otp, + verified: false, + }); + + return this.repository.save( + entity + ); + } + + // --------------------------------------------------------------------------- + // Update OTP + // --------------------------------------------------------------------------- + + async updateOtp( + otpVerification: OtpVerification, + otp: string + ) { + otpVerification.otp = otp; + + otpVerification.verified = + false; + + return this.repository.save( + otpVerification + ); + } + + // --------------------------------------------------------------------------- + // Verify Phone + // --------------------------------------------------------------------------- + + async verifyPhone( + otpVerification: OtpVerification + ) { + otpVerification.verified = + true; + + return this.repository.save( + otpVerification + ); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts new file mode 100644 index 000000000..28e2afc26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OtpService } from './otp.service'; + +describe('OtpService', () => { + let service: OtpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [OtpService], + }).compile(); + + service = module.get(OtpService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts new file mode 100644 index 000000000..4e16be20a --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -0,0 +1,141 @@ +// otp.service.ts + +import { + BadRequestException, + Injectable, +} from "@nestjs/common"; + +import axios from "axios"; + +import { OtpRepository } from "./otp.repository"; + +@Injectable() +export class OtpService { + constructor( + private readonly otpRepository: OtpRepository + ) {} + + // --------------------------------------------------------------------------- + // Generate OTP + // --------------------------------------------------------------------------- + + generateOtp(): string { + return Math.floor( + 100000 + Math.random() * 900000 + ).toString(); + } + + // --------------------------------------------------------------------------- + // Send OTP + // --------------------------------------------------------------------------- + + async sendOtp(phone: string, otp: string) { + try { + // generate otp + // const otp = + // this.generateOtp(); + + // find existing phone + const existingPhone = + await this.otpRepository.findByPhone( + phone + ); + + // update existing otp + if (existingPhone) { + await this.otpRepository.updateOtp( + existingPhone, + otp + ); + } else { + // create new otp + await this.otpRepository.createOtp( + phone, + otp + ); + } + + // send sms + await axios.post( + "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms", + { + to: phone, + + sourceId: "EDR", + + sourceName: + "EDR Freight", + + appKey: + "YOUR_APP_KEY", + + text: `Your verification code is ${otp}`, + + callbackUrl: "", + }, + { + headers: { + accept: "*/*", + + "Content-Type": + "application/json", + }, + } + ); + + return { + success: true, + + message: + "OTP sent successfully", + }; + } catch (error) { + console.log(error); + + throw new BadRequestException( + "Failed to send OTP" + ); + } + } + + // --------------------------------------------------------------------------- + // Verify OTP + // --------------------------------------------------------------------------- + + async verifyOtp( + phone: string, + otp: string + ) { + // find phone + const otpData = + await this.otpRepository.findByPhone( + phone + ); + + // phone not found + if (!otpData) { + throw new BadRequestException( + "Phone number not found" + ); + } + + // invalid otp + if (otpData.otp !== otp) { + throw new BadRequestException( + "Invalid OTP" + ); + } + + // verify phone + await this.otpRepository.verifyPhone( + otpData + ); + + return { + success: true, + + message: + "Phone verified successfully", + }; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/dto/update-payment-status.dto.ts b/apps/edr-freight-api/src/modules/payment/dto/update-payment-status.dto.ts new file mode 100644 index 000000000..de72c8ba7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/dto/update-payment-status.dto.ts @@ -0,0 +1,23 @@ +import { IsEnum, IsOptional, IsString } from "class-validator"; + +export enum PaymentStatus { + REQUIRES_ACTION, + PROCESSING, + SUCCEEDED, + FAILED, + CANCELLED, + REFUNDED, + +} +export class UpdatePaymentStatusDto { + @IsString() + orderId!: string; + + + @IsEnum(PaymentStatus) + status!: PaymentStatus + + @IsOptional() + @IsString() + failureMessage?: string +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts new file mode 100644 index 000000000..83b4d00dd --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -0,0 +1,65 @@ +import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm"; + + +type PaymentType = "booking" +type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" +type Currency = "ETB" | "USD" +export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" + +@Entity({ schema: 'freight', name: 'payments' }) +export class PaymentEntity extends BaseEntity { + @PrimaryGeneratedColumn("uuid") + id!: string + + @Column({ type: 'varchar', length: 255, name: "ref_id" }) + refId!: string + + @Column({ type: "enum", enum: ["booking"] }) + type!: PaymentType; + + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] }) + method!: PaymentMethod + + @Column({ type: "enum", enum: ["ETB", "USD"] }) + currency!: Currency + + @Column({ type: "numeric" }) + amount!: number + + @Column({ type: "varchar", length: 255, name: "reason", }) + reason?: string; + + @Column({ type: "jsonb", default: {}, name: "raw_initiation" }) + rawInitiation?: Record + + @Column({ type: "jsonb", name: "client_action" }) + clientAction?: Record; + + @Column({ type: "varchar", length: 255, unique: true, name: "merchant_order_id", }) + merchantOrderId!: string + + @Column({ type: "varchar", length: 255, unique: true, name: "transaction_id", }) + transactionId?: string + + @Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" }) + status!: PaymentStatus + + @Column({ type: "date", nullable: true, name: "paid_at" }) + paidAt?: Date + + @Column({ type: "timestamp", nullable: true, name: "refunded_at" }) + refundedAt?: Date + + @Column({ type: "timestamp", nullable: true, name: "expires_at" }) + expiresAt?: Date + + @Column({ type: "varchar", length: 30, nullable: true, name: "failer_code" }) + failerCode?: string + + @Column({ type: "varchar", length: 255, nullable: true, name: "failer_message" }) + failureMessage?: string + + @CreateDateColumn({ name: "created_at" }) + createdAt!: Date + +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts new file mode 100644 index 000000000..4799a4c37 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common"; +import { PaymentService } from "./payment.service"; +import { Public } from "@edr/api-common"; +import { Response } from "express" + +@Public() +@Controller("payments") +export class PaymentController { + constructor(private readonly paymentService: PaymentService,) { } + + @Post("/initiate") + initiate() { + return this.paymentService.initBookingTelebirr("123", "web") + } + + @Post("/bookings/check-payment/:orderId") + checkPayment(@Param("orderId") orderId: string) { + return this.paymentService.checkStatusAndUpdate(orderId) + } + + @Get("/bookings/telebirr/redirect/:orderId") + async pay(@Param("orderId") orderId: string, @Res() res: Response) { + const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr") + if (!payment) { + throw new NotFoundException('payment not found') + } + + return res.send(` + + + + Redirecting... + + +

Redirecting...

+ + + + + `); + } +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts new file mode 100644 index 000000000..ac38503b9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { PaymentService } from "./payment.service"; +import { HttpModule } from "@nestjs/axios"; +import { PaymentController } from "./payment.controller"; +import { ConfigModule } from "@nestjs/config"; +import { PaymentRepository } from "./payment.repository"; +import { WebhookController } from "./webhooks/webhook.controller"; +import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service"; +import { TelebirrProvider } from "@edr/payment-providers"; + +@Module({ + imports: [HttpModule, ConfigModule], + providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider], + controllers: [PaymentController, WebhookController], + exports: [PaymentService] +}) +export class PaymentModule { } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts new file mode 100644 index 000000000..3a713c357 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -0,0 +1,62 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource, FindOptionsWhere, QueryDeepPartialEntity, QueryRunner, Repository } from "typeorm"; +import { PaymentEntity } from "./entities/payment.entity"; + +@Injectable() +export class PaymentRepository { + private readonly paymentRepo: Repository; + constructor(private readonly dataSource: DataSource) { + this.paymentRepo = this.dataSource.getRepository(PaymentEntity) + } + + async createTr(qr: QueryRunner, data: Pick): Promise { + const payment = qr.manager.create(PaymentEntity, data) + return qr.manager.save(payment) + } + + async create(data: Pick): Promise { + const payment = this.paymentRepo.create(data) + return this.paymentRepo.save(payment) + } + + + + findOneBy(options: FindOptionsWhere | FindOptionsWhere[]): Promise { + return this.paymentRepo.findOneBy(options); + } + + update(where: FindOptionsWhere, data: QueryDeepPartialEntity) { + return this.paymentRepo.update(where, data) + } + + getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]) { + return this.paymentRepo + .createQueryBuilder('payment') + .where('payment.method = :method', { method }) + .andWhere('payment.refId = :refId', { refId }) + .andWhere('payment.status IN (:...statuses)', { + statuses: ['action-required'], + }) + .andWhere('payment.expiresAt > :now', { now: new Date() }) + .getOne(); + } + + + + + + getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]) { + return this.paymentRepo + .createQueryBuilder('payment') + .where('payment.method = :method', { method }) + .andWhere('payment.merchantOrderId = :orderId', { orderId }) + .andWhere('payment.status IN (:...statuses)', { + statuses: ['action-required'], + }) + .andWhere('payment.expiresAt > :now', { now: new Date() }) + .getOne(); + } + + + +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts new file mode 100644 index 000000000..ccc0e8833 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -0,0 +1,160 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from "@nestjs/common"; +import { DataSource } from "typeorm"; +import { PaymentEntity } from "./entities/payment.entity"; +import { PaymentRepository } from "./payment.repository"; + +import * as fs from "fs"; +import * as path from "path"; +import * as Handlebars from "handlebars"; +import { ConfigService } from "@nestjs/config"; +import { Booking } from "../bookings/entities/booking.entity"; + +import { + ClientAction, + createMerchantOrderId, + ProviderPaymentStatus, + TelebirrProvider, +} from "@edr/payment-providers"; +import { ProviderInitiationInput } from "@edr/types" +import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto"; + +const DEFAULT_CURRENCY = "ETB"; + +@Injectable() +export class PaymentService { + constructor( + private readonly configService: ConfigService, + private readonly datasource: DataSource, + private readonly paymentRepo: PaymentRepository, + private readonly telebirrProvider: TelebirrProvider, + ) { } + + async initBookingTelebirr( + bookingId: string, + platform: PaymentPlatformDto, + ): Promise<{ redirectUrl: string }> { + // const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId }); + // if (!booking) throw new NotFoundException("Booking not found"); + + // const booking = new Booking() + // booking.totalAmount = 20 + // booking.id = randomUUID + const amount = 20 + const merchantOrderId = createMerchantOrderId(); + const redirectBase = this.configService.get("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL"); + const redirectUrl = `${redirectBase}/${merchantOrderId}`; + const amountMinor = Math.round(Number(amount) * 100); + + const input: ProviderInitiationInput = { + merchantOrderId, + orderRef: bookingId, + amountMinor, + currency: DEFAULT_CURRENCY, + platform: platform || "web", + redirectUrl, + }; + + const result = await this.telebirrProvider.initiate(input); + + const payment = await this.paymentRepo.create({ + amount: amount, + currency: DEFAULT_CURRENCY, + method: "telebirr", + refId: bookingId, + type: "booking", + merchantOrderId, + rawInitiation: result.rawInitiation, + clientAction: result.clientAction as Record, + expiresAt: result.expiresAt, + reason: `Payment for booking`, + }); + + return { + redirectUrl: `${this.configService.get("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}` + } + } + + + async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { + return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method) + } + + + async genReceiptHtml(orderId: string) { + const payment = await this.paymentRepo.findOneBy({ + merchantOrderId: orderId, + status: "success" + }) + if (!payment) { + throw new BadRequestException() + } + + const filePath = path.join(__dirname, "templates", "receipt.hbs"); + if (!fs.existsSync(filePath)) { + throw new InternalServerErrorException() + } + const source = fs.readFileSync(filePath, "utf8"); + const template = Handlebars.compile(source); + + const html = template({ + vendorName: "Ethio Djibouti Railway Ticket Booking", + vendorAddress: "Addis Ababa", + receiptDate: payment.paidAt, + paymentMethod: payment?.method, + subtotal: payment?.amount.toString(), + total: payment?.amount.toString(), + currency: payment?.currency, + reason: payment?.reason + }); + + return html; + } + + async checkStatusAndUpdate(orderId: string) { + const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId }) + if (!resp) { + throw new NotFoundException("order id not found") + } + const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId) + + if (result.status === ProviderPaymentStatus.SUCCEEDED) { + await this.datasource.transaction(async (mg) => { + await mg.update(Booking, { id: resp.refId }, { status: "PAID" }) + await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }) + }) + } + return { + status: result.status + } + } + + findBookingById(id: string) { + return this.paymentRepo.findOneBy({ refId: id, type: "booking" }) + } + + formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { + const clientAction = + intent.clientAction && typeof intent.clientAction === "object" + ? (intent.clientAction as unknown as ClientAction) + : undefined; + const statusMap: Record = { + "action-required": ProviderPaymentStatus.REQUIRES_ACTION, + "processing": ProviderPaymentStatus.PROCESSING, + "success": ProviderPaymentStatus.SUCCEEDED, + "failed": ProviderPaymentStatus.FAILED, + "canceled": ProviderPaymentStatus.CANCELLED, + "refunded": ProviderPaymentStatus.CANCELLED, + }; + return { + intentId: intent.id, + status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING, + clientAction, + merchantOrderId: intent.merchantOrderId ?? undefined, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts new file mode 100644 index 000000000..a3e3d256e --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -0,0 +1,62 @@ +import { ProviderPaymentStatus } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString } from "class-validator"; + +export type PaymentPlatformDto = "web" | "mobile"; + +export class InitiatePaymentDto { + @ApiProperty({ example: "booking-uuid" }) + @IsString() + bookingId!: string; + + @ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" }) + @IsIn(["TELEBIRR"]) + method!: "TELEBIRR"; + + @ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" }) + @IsOptional() + @IsIn(["web", "mobile"]) + platform?: PaymentPlatformDto; +} + +export class ClientActionDto { + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] }) + type!: "REDIRECT" | "LAUNCH_APP"; + + @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) + url?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + appId?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + receiveCode?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + shortCode?: string; +} + +export class InitiateResponseDto { + @ApiProperty() + intentId!: string; + + @ApiProperty({ enum: ProviderPaymentStatus }) + status!: ProviderPaymentStatus; + + @ApiPropertyOptional({ type: ClientActionDto }) + clientAction?: ClientActionDto; + + @ApiPropertyOptional() + merchantOrderId?: string; +} + +export class IntentStatusDto extends InitiateResponseDto { + @ApiPropertyOptional() + paidAt?: string; + + @ApiPropertyOptional() + failureCode?: string; + + @ApiPropertyOptional() + failureMessage?: string; +} diff --git a/apps/edr-freight-api/src/modules/payment/templates/payment.hbs b/apps/edr-freight-api/src/modules/payment/templates/payment.hbs new file mode 100644 index 000000000..f590764f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/templates/payment.hbs @@ -0,0 +1,15 @@ + + + + + Redirecting... + + +

Redirecting...

+ + + + + \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/templates/receipt.hbs b/apps/edr-freight-api/src/modules/payment/templates/receipt.hbs new file mode 100644 index 000000000..0f156f6dc --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/templates/receipt.hbs @@ -0,0 +1,191 @@ + + + + + + + Receipt - {{vendorName}} + + + + + +
+
+

{{vendorName}}

+

{{vendorAddress}}

+
+ + + + + + + + + + + + + + + + +
Date{{receiptDate}}
Payment Method + {{paymentMethod}} +
Description{{reason}}
+ +
+ + + + + + + + + + +
Subtotal + {{currency}} {{subtotal}} +
+ Total Paid + + {{currency}} {{total}} +
+
+ + + +
+ +
+
+ + + + + \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/webhooks/dto/telebirr.dto.ts b/apps/edr-freight-api/src/modules/payment/webhooks/dto/telebirr.dto.ts new file mode 100644 index 000000000..4604097d1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/webhooks/dto/telebirr.dto.ts @@ -0,0 +1,48 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsOptional, IsString } from "class-validator"; + +export class TelebirrDto { + @ApiProperty() + @IsString() + merch_order_id!: string; + + + @IsOptional() + @IsString() + payment_order_id!: string; + + @ApiProperty({ default: "SUCCEEDED"}) + @IsString() + trade_status!: string; + + @IsOptional() + @IsString() + trans_id?: string; + + @IsOptional() + @IsString() + total_amount?: string; + + @IsOptional() + @IsString() + trans_currency?: string; + + @IsOptional() + @IsString() + notify_time?: string; + + @IsOptional() + @IsString() + trans_end_time?: string; + + @IsOptional() + @IsString() + sign!: string; + + @IsOptional() + @IsString() + sign_type?: string; + + + [key: string]: unknown; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts b/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts new file mode 100644 index 000000000..cf89a2d60 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts @@ -0,0 +1,53 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { TelebirrDto } from '../dto/telebirr.dto'; +import { PaymentRepository } from '../../payment.repository'; +import { DataSource } from 'typeorm'; +import { Booking } from '../../../bookings/entities/booking.entity'; +import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers'; + +@Injectable() +export class TelebirrWebhookService { + private readonly logger = new Logger(TelebirrWebhookService.name); + + constructor( + private readonly datasource: DataSource, + private readonly paymentRepo: PaymentRepository, + private readonly telebirrProvider: TelebirrProvider, + ) { } + + verifyTelebirrNotification(payload: TelebirrDto) { + return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record); + } + + async handle(payload: TelebirrDto): Promise { + const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id }) + if (!payment) { + this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`); + return; + } + + const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status); + + switch (mapped) { + case ProviderPaymentStatus.SUCCEEDED: + await this.paymentRepo.update( + { id: payment.id }, + { status: "success", paidAt: new Date() }, + ); + if (payment.type === "booking") { + await this.datasource.manager.update( + Booking, + { id: payment.refId }, + { paymentStatus: "PAID" }, + ); + } + break; + case ProviderPaymentStatus.FAILED: + await this.paymentRepo.update({ id: payment.id }, { status: "failed" }); + break; + case ProviderPaymentStatus.PROCESSING: + await this.paymentRepo.update({ id: payment.id }, { status: "processing" }); + break; + } + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts b/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts new file mode 100644 index 000000000..16473e614 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/webhooks/webhook.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, HttpCode, HttpStatus, Logger, Post, } from '@nestjs/common'; +import { TelebirrWebhookService } from './providers/telebirr.service'; +import { ApiOperation } from '@nestjs/swagger'; +import { TelebirrDto } from './dto/telebirr.dto'; +import { Public } from '@edr/api-common'; + +@Controller("payments-webhooks") +@Public() +export class WebhookController { + constructor(private readonly telebirr: TelebirrWebhookService) { } + private readonly logger = new Logger(WebhookController.name); + + @Post('telebirr') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Telebirr payment notification callback (Ethiopia)', + description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.' + }) + async receiveTelebirr(@Body() payload: TelebirrDto) { + this.logger.log( + `Telebirr webhook Called`, + ); + + try { + const verified = this.telebirr.verifyTelebirrNotification(payload) + if (!verified) { + throw new Error("Telebirr webhook signature verification failed") + } + await this.telebirr.handle(payload); + + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error(`Telebirr webhook handler threw: ${message}`); + } + return { code: '0', message: 'OK' }; + } + +} diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts new file mode 100644 index 000000000..45e737607 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; + +export class CreateRouteMilestoneDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; +} + +export class CreateRouteDto { + @ApiProperty() + @IsString() + @MaxLength(120) + name!: string; + + @ApiProperty({ type: [CreateRouteMilestoneDto] }) + @IsArray() + @ArrayMinSize(2) + @ValidateNested({ each: true }) + @Type(() => CreateRouteMilestoneDto) + milestones!: CreateRouteMilestoneDto[]; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts new file mode 100644 index 000000000..020a34cdf --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class FilterRoutesDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional() + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts new file mode 100644 index 000000000..ccda6bd61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateRouteDto } from './create-route.dto'; + +export class UpdateRouteDto extends PartialType(CreateRouteDto) {} diff --git a/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts new file mode 100644 index 000000000..63e37b8ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Route } from './route.entity'; + +@Entity({ schema: 'freight', name: 'route_milestones' }) +@Index(['routeId', 'sequenceNo'], { unique: true }) +export class RouteMilestone extends BaseEntity { + @Column({ name: 'route_id', type: 'uuid' }) + routeId!: string; + + @ManyToOne(() => Route, (route) => route.milestones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'route_id' }) + route?: Route; + + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + @Column({ name: 'sequence_no', type: 'int' }) + sequenceNo!: number; +} diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts new file mode 100644 index 000000000..8c6e4785e --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { RouteMilestone } from './route-milestone.entity'; + +@Entity({ schema: 'freight', name: 'routes' }) +@Index(['name']) +@Index(['isActive']) +export class Route extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 120, unique: true }) + name!: string; + + @Column({ name: 'origin_yard_id', type: 'uuid' }) + originYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard; + + @Column({ name: 'destination_yard_id', type: 'uuid' }) + destinationYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) + milestones?: RouteMilestone[]; +} diff --git a/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts b/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts new file mode 100644 index 000000000..a0e97cd23 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { RouteMilestone } from './entities/route-milestone.entity'; + +@Injectable() +export class RouteMilestonesRepository extends BaseRepository { + constructor(@InjectRepository(RouteMilestone) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts new file mode 100644 index 000000000..4af088727 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateRouteDto } from './dto/create-route.dto'; +import { FilterRoutesDto } from './dto/filter-routes.dto'; +import { UpdateRouteDto } from './dto/update-route.dto'; +import { RoutesService } from './routes.service'; + +@ApiTags('routes') +@ApiBearerAuth() +@Controller('routes') +export class RoutesController { + constructor(private readonly routesService: RoutesService) {} + + @Get() + @ApiOperation({ summary: 'List routes' }) + findAll(@Query() filter: FilterRoutesDto) { + return this.routesService.findAll(filter); + } + + @Get(':id') + @ApiOperation({ summary: 'Get route by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.routesService.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create route' }) + create(@Body() dto: CreateRouteDto) { + return this.routesService.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update route' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) { + return this.routesService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Deactivate route' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.routesService.deactivate(id); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.module.ts b/apps/edr-freight-api/src/modules/routes/routes.module.ts new file mode 100644 index 000000000..c7033f25b --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Yard } from '../rule-engine/entities/yard.entity'; +import { RouteMilestone } from './entities/route-milestone.entity'; +import { Route } from './entities/route.entity'; +import { RouteMilestonesRepository } from './route-milestones.repository'; +import { RoutesController } from './routes.controller'; +import { RoutesRepository } from './routes.repository'; +import { RoutesService } from './routes.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Route, RouteMilestone, Yard])], + controllers: [RoutesController], + providers: [RoutesRepository, RouteMilestonesRepository, RoutesService], + exports: [RoutesRepository, RouteMilestonesRepository, RoutesService], +}) +export class RoutesModule {} diff --git a/apps/edr-freight-api/src/modules/routes/routes.repository.ts b/apps/edr-freight-api/src/modules/routes/routes.repository.ts new file mode 100644 index 000000000..df6df41d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Route } from './entities/route.entity'; + +@Injectable() +export class RoutesRepository extends BaseRepository { + constructor(@InjectRepository(Route) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts new file mode 100644 index 000000000..4c8e62498 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -0,0 +1,171 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, ILike } from 'typeorm'; + +import { Yard } from '../rule-engine/entities/yard.entity'; +import { CreateRouteDto } from './dto/create-route.dto'; +import { FilterRoutesDto } from './dto/filter-routes.dto'; +import { UpdateRouteDto } from './dto/update-route.dto'; +import { RouteMilestone } from './entities/route-milestone.entity'; +import { Route } from './entities/route.entity'; +import { RoutesRepository } from './routes.repository'; + +@Injectable() +export class RoutesService { + constructor( + private readonly dataSource: DataSource, + private readonly routesRepository: RoutesRepository, + ) {} + + findAll(filter: FilterRoutesDto): Promise { + return this.routesRepository.findAll({ + where: { + ...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}), + ...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}), + }, + relations: { + originYard: true, + destinationYard: true, + milestones: { yard: true }, + }, + order: { + name: 'ASC', + milestones: { sequenceNo: 'ASC' }, + }, + }); + } + + async findById(id: string): Promise { + const route = await this.dataSource.getRepository(Route).findOne({ + where: { id }, + relations: { + originYard: true, + destinationYard: true, + milestones: { yard: true }, + }, + order: { milestones: { sequenceNo: 'ASC' } }, + }); + + if (!route) { + throw new NotFoundException(`Route ${id} not found`); + } + + return route; + } + + async create(dto: CreateRouteDto): Promise { + await this.validateRouteName(dto.name); + const validated = await this.validateMilestones(dto.milestones); + + const route = await this.dataSource.transaction(async (manager) => { + const savedRoute = await manager.getRepository(Route).save( + manager.getRepository(Route).create({ + name: dto.name.trim(), + originYardId: validated.originYardId, + destinationYardId: validated.destinationYardId, + isActive: dto.isActive ?? true, + }), + ); + + await manager.getRepository(RouteMilestone).save( + validated.milestones.map((milestone) => + manager.getRepository(RouteMilestone).create({ + routeId: savedRoute.id, + yardId: milestone.yardId, + sequenceNo: milestone.sequenceNo, + }), + ), + ); + + return savedRoute; + }); + + return this.findById(route.id); + } + + async update(id: string, dto: UpdateRouteDto): Promise { + const existing = await this.findById(id); + + if (dto.name && dto.name.trim() !== existing.name) { + await this.validateRouteName(dto.name, id); + } + + const milestoneInput = dto.milestones + ? await this.validateMilestones(dto.milestones) + : null; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Route).update(id, { + name: dto.name?.trim() ?? existing.name, + originYardId: milestoneInput?.originYardId ?? existing.originYardId, + destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId, + isActive: dto.isActive ?? existing.isActive, + }); + + if (milestoneInput) { + await manager.getRepository(RouteMilestone).delete({ routeId: id }); + await manager.getRepository(RouteMilestone).save( + milestoneInput.milestones.map((milestone) => + manager.getRepository(RouteMilestone).create({ + routeId: id, + yardId: milestone.yardId, + sequenceNo: milestone.sequenceNo, + }), + ), + ); + } + }); + + return this.findById(id); + } + + async deactivate(id: string): Promise { + await this.findById(id); + const updated = await this.routesRepository.update(id, { isActive: false }); + + if (!updated) { + throw new NotFoundException(`Route ${id} not found`); + } + + return this.findById(id); + } + + private async validateRouteName(name: string, routeId?: string) { + const trimmedName = name.trim(); + const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } }); + + if (existing && existing.id !== routeId) { + throw new ConflictException(`Route name ${trimmedName} already exists`); + } + } + + private async validateMilestones(milestones: Array<{ yardId: string }>) { + if (milestones.length < 2) { + throw new BadRequestException('A route requires at least two yards'); + } + + const normalized = milestones.map((milestone, index) => ({ + yardId: milestone.yardId, + sequenceNo: index + 1, + })); + + const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; + const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) }); + const yardIds = new Set(yards.map((yard) => yard.id)); + + for (const milestone of normalized) { + if (!yardIds.has(milestone.yardId)) { + throw new BadRequestException(`Yard ${milestone.yardId} does not exist`); + } + } + + if (normalized[0].yardId === normalized[normalized.length - 1].yardId) { + throw new BadRequestException('Origin and destination yards must be different'); + } + + return { + originYardId: normalized[0].yardId, + destinationYardId: normalized[normalized.length - 1].yardId, + milestones: normalized, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts b/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts new file mode 100644 index 000000000..19fd8c267 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts @@ -0,0 +1,31 @@ +/** ITMLS US-06 default approval chains — seeded automatically when missing. */ +export const DEFAULT_APPROVAL_RULE_ROWS = [ + { + requiresDirectorApproval: false, + stepOrder: 1, + requiredRole: 'LINE_STAFF', + actionLabel: 'Review & Approve', + blocksRole: null as string | null, + }, + { + requiresDirectorApproval: false, + stepOrder: 2, + requiredRole: 'DIRECTOR', + actionLabel: 'Final Signature', + blocksRole: 'LINE_STAFF', + }, + { + requiresDirectorApproval: true, + stepOrder: 1, + requiredRole: 'DIRECTOR', + actionLabel: 'Review & Approve', + blocksRole: 'LINE_STAFF', + }, + { + requiresDirectorApproval: true, + stepOrder: 2, + requiredRole: 'CEO', + actionLabel: 'Final Signature', + blocksRole: null as string | null, + }, +] as const; 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..72e35b296 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts @@ -0,0 +1,66 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +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') +@ApiBearerAuth() +export class ApprovalRulesController { + constructor(private readonly service: ApprovalRulesService) {} + + @Get() + @RuleEngineView('approval-rules') + @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') + @RuleEngineView('approval-rules') + @ApiOperation({ summary: 'Get approval chain for cargo routing flag' }) + findChain(@Query('requiresDirectorApproval') flag: string) { + return this.service.findChain(flag === 'true'); + } + + @Get(':id') + @RuleEngineView('approval-rules') + @ApiOperation({ summary: 'Get an approval rule by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('approval-rules') + @ApiOperation({ summary: 'Create an approval rule step' }) + create(@Body() dto: CreateApprovalRuleDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('approval-rules') + @ApiOperation({ summary: 'Update an approval rule' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('approval-rules') + @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 new file mode 100644 index 000000000..4941a5ebb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -0,0 +1,63 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; +import { CargoTypesService } from '../services/cargo-types.service'; + +@ApiTags('cargo-types') +@Controller('cargo-types') +@ApiBearerAuth() +export class CargoTypesController { + constructor(private readonly service: CargoTypesService) {} + + @Get() + @RuleEngineView('cargo-types') + @ApiOperation({ summary: 'List cargo types' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + requiresDirectorApproval: query['requiresDirectorApproval'] !== undefined + ? query['requiresDirectorApproval'] === 'true' + : undefined, + parentGroupId: query['parentGroupId'], + search: query['search'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + sortBy: query['sortBy'], + sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC', + }); + } + + @Get(':id') + @RuleEngineView('cargo-types') + @ApiOperation({ summary: 'Get a cargo type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('cargo-types') + @ApiOperation({ summary: 'Create a cargo type' }) + create(@Body() dto: CreateCargoTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('cargo-types') + @ApiOperation({ summary: 'Update a cargo type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('cargo-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a cargo type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts new file mode 100644 index 000000000..43dfcec33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -0,0 +1,56 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; +import { ContainerTypesService } from '../services/container-types.service'; + +@ApiTags('container-types') +@Controller('container-types') +@ApiBearerAuth() +export class ContainerTypesController { + constructor(private readonly service: ContainerTypesService) {} + + @Get() + @RuleEngineView('container-types') + @ApiOperation({ summary: 'List container types' }) + 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') + @RuleEngineView('container-types') + @ApiOperation({ summary: 'Get a container type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('container-types') + @ApiOperation({ summary: 'Create a container type' }) + create(@Body() dto: CreateContainerTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('container-types') + @ApiOperation({ summary: 'Update a container type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('container-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a container type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts new file mode 100644 index 000000000..bee5cf85b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts @@ -0,0 +1,56 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto'; +import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto'; +import { PriorityRulesService } from '../services/priority-rules.service'; + +@ApiTags('priority-rules') +@Controller('priority-rules') +@ApiBearerAuth() +export class PriorityRulesController { + constructor(private readonly service: PriorityRulesService) {} + + @Get() + @RuleEngineView('priority-rules') + @ApiOperation({ summary: 'List priority rules' }) + 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') + @RuleEngineView('priority-rules') + @ApiOperation({ summary: 'Get a priority rule by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('priority-rules') + @ApiOperation({ summary: 'Create a priority rule' }) + create(@Body() dto: CreatePriorityRuleDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('priority-rules') + @ApiOperation({ summary: 'Update a priority rule' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('priority-rules') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a priority rule' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts new file mode 100644 index 000000000..3c7776a27 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts @@ -0,0 +1,89 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { CreateRateDto } from '../dto/create-rate.dto'; +import { + type AuthUserPayload, + resolveAuthUserId, +} from '../../../common/resolve-auth-user-id'; +import { UpdateRateDto } from '../dto/update-rate.dto'; +import { RatesService } from '../services/rates.service'; + +@ApiTags('rates') +@Controller('rates') +@ApiBearerAuth() +export class RatesController { + constructor(private readonly service: RatesService) {} + + @Get() + @RuleEngineView('rates') + @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') + @RuleEngineView('rates') + @ApiOperation({ summary: 'List all LIVE rates effective now' }) + findLive() { + return this.service.findLiveRates(); + } + + @Get(':id') + @RuleEngineView('rates') + @ApiOperation({ summary: 'Get a rate by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Create a rate (DRAFT)' }) + create( + @Body() dto: CreateRateDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.service.create(dto, resolveAuthUserId(user)); + } + + @Patch(':id') + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Update a DRAFT rate' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) { + return this.service.update(id, dto); + } + + @Post(':id/submit') + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Submit rate for CEO approval' }) + submit(@Param('id', ParseUUIDPipe) id: string) { + return this.service.submitForApproval(id); + } + + @Post(':id/approve') + @RuleEngineManage('rates') + @ApiOperation({ summary: 'CEO approves a rate' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.service.approve(id, resolveAuthUserId(user)); + } + + @Delete(':id') + @RuleEngineManage('rates') + @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/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts new file mode 100644 index 000000000..3044515fb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -0,0 +1,60 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; +import { ServiceTypesService } from '../services/service-types.service'; + +@ApiTags('service-types') +@Controller('service-types') +@ApiBearerAuth() +export class ServiceTypesController { + constructor(private readonly service: ServiceTypesService) {} + + @Get() + @RuleEngineView('service-types') + @ApiOperation({ summary: 'List service types' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + canBeBookedAlone: query['canBeBookedAlone'] !== undefined ? query['canBeBookedAlone'] === 'true' : undefined, + search: query['search'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + sortBy: query['sortBy'], + sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC', + }); + } + + @Get(':id') + @RuleEngineView('service-types') + @ApiOperation({ summary: 'Get a service type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('service-types') + @ApiOperation({ summary: 'Create a service type' }) + create(@Body() dto: CreateServiceTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('service-types') + @ApiOperation({ summary: 'Update a service type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('service-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a service type' }) + 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..40a67c7f5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -0,0 +1,56 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +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') +@ApiBearerAuth() +export class ShippingLinesController { + constructor(private readonly service: ShippingLinesService) {} + + @Get() + @RuleEngineView('shipping-lines') + @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') + @RuleEngineView('shipping-lines') + @ApiOperation({ summary: 'Get a shipping line by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('shipping-lines') + @ApiOperation({ summary: 'Create a shipping line' }) + create(@Body() dto: CreateShippingLineDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('shipping-lines') + @ApiOperation({ summary: 'Update a shipping line' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('shipping-lines') + @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/surcharge-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts new file mode 100644 index 000000000..be4c3011a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts @@ -0,0 +1,56 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto'; +import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto'; +import { SurchargeTypesService } from '../services/surcharge-types.service'; + +@ApiTags('surcharge-types') +@Controller('surcharge-types') +@ApiBearerAuth() +export class SurchargeTypesController { + constructor(private readonly service: SurchargeTypesService) {} + + @Get() + @RuleEngineView('surcharge-types') + @ApiOperation({ summary: 'List surcharge types' }) + 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') + @RuleEngineView('surcharge-types') + @ApiOperation({ summary: 'Get a surcharge type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('surcharge-types') + @ApiOperation({ summary: 'Create a surcharge type' }) + create(@Body() dto: CreateSurchargeTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('surcharge-types') + @ApiOperation({ summary: 'Update a surcharge type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('surcharge-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a surcharge type' }) + 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 new file mode 100644 index 000000000..c3f0c1472 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts @@ -0,0 +1,57 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; +import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; +import { WeightLimitRulesService } from '../services/weight-limit-rules.service'; + +@ApiTags('weight-limit-rules') +@Controller('weight-limit-rules') +@ApiBearerAuth() +export class WeightLimitRulesController { + constructor(private readonly service: WeightLimitRulesService) {} + + @Get() + @RuleEngineView('weight-limit-rules') + @ApiOperation({ summary: 'List weight limit rules' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + tradeDirection: query['tradeDirection'], + containerTypeId: query['containerTypeId'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @RuleEngineView('weight-limit-rules') + @ApiOperation({ summary: 'Get a weight limit rule by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('weight-limit-rules') + @ApiOperation({ summary: 'Create a weight limit rule' }) + create(@Body() dto: CreateWeightLimitRuleDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('weight-limit-rules') + @ApiOperation({ summary: 'Update a weight limit rule' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('weight-limit-rules') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a weight limit rule' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts new file mode 100644 index 000000000..88523967e --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -0,0 +1,57 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateYardDto } from '../dto/create-yard.dto'; +import { UpdateYardDto } from '../dto/update-yard.dto'; +import { YardsService } from '../services/yards.service'; + +@ApiTags('yards') +@Controller('yards') +@ApiBearerAuth() +export class YardsController { + constructor(private readonly service: YardsService) {} + + @Get() + @RuleEngineView('yards') + @ApiOperation({ summary: 'List yards' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + country: query['country'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @RuleEngineView('yards') + @ApiOperation({ summary: 'Get a yard by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('yards') + @ApiOperation({ summary: 'Create a yard' }) + create(@Body() dto: CreateYardDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('yards') + @ApiOperation({ summary: 'Update a yard' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('yards') + @HttpCode(HttpStatus.NO_CONTENT) + @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 new file mode 100644 index 000000000..ae2e23c33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +export class CreateCargoTypeDto { + @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) + @IsString() + @MaxLength(255) + cargoTypeName!: string; + + @ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' }) + @IsOptional() + @IsUUID() + parentGroupId?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + showFreeTextBox?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + requiresDirectorApproval?: boolean; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number; +} 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 new file mode 100644 index 000000000..dbfb5ca2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +export class CreateContainerTypeDto { + @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) + @IsString() + @MaxLength(100) + label!: string; + + @ApiProperty({ description: 'Container size in feet: 20 or 40', enum: [20, 40] }) + @IsInt() + @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 new file mode 100644 index 000000000..16b01fc81 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-rule.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class CreatePriorityRuleDto { + @ApiProperty({ description: 'Human-readable label', maxLength: 100 }) + @IsString() + @MaxLength(100) + label!: string; + + @ApiProperty({ description: 'Points added to booking.priority_score when condition matches', default: 0 }) + @IsInt() + @Min(0) + score!: number; + + @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..2f73780d9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -0,0 +1,54 @@ +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: '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 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 new file mode 100644 index 000000000..b20203e13 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class CreateServiceTypeDto { + @ApiProperty({ description: 'Service type display name', maxLength: 255 }) + @IsString() + @MaxLength(255) + serviceName!: string; + + @ApiPropertyOptional({ description: 'Detailed description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + canBeBookedAlone?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + includesFirstMile?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + includesLastMile?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + includesCustoms?: boolean; + + @ApiPropertyOptional({ description: 'Priority bonus points awarded when this service is used', default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + priorityBonusPoints?: number; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number; +} 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 new file mode 100644 index 000000000..7aef9bbde --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +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: 'Human-readable label', maxLength: 100 }) + @IsString() + @MaxLength(100) + label!: 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() + @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 new file mode 100644 index 000000000..87cd8ccdf --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +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: TRADE_DIRECTIONS, description: 'Trade direction: IMPORT, EXPORT, or BOTH' }) + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection!: string; + + @ApiProperty({ description: 'Maximum allowed VGM in tons', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + maxVgmTons!: number; + + @ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' }) + @IsDateString() + effectiveFrom!: string; + + @ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' }) + @IsOptional() + @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-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-cargo-type.dto.ts new file mode 100644 index 000000000..fd7e82cff --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-cargo-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCargoTypeDto } from './create-cargo-type.dto'; + +export class UpdateCargoTypeDto extends PartialType(CreateCargoTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-container-type.dto.ts new file mode 100644 index 000000000..6fd94ceb8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-container-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateContainerTypeDto } from './create-container-type.dto'; + +export class UpdateContainerTypeDto extends PartialType(CreateContainerTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-priority-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-priority-rule.dto.ts new file mode 100644 index 000000000..f1e5c9be3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-priority-rule.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreatePriorityRuleDto } from './create-priority-rule.dto'; + +export class UpdatePriorityRuleDto extends PartialType(CreatePriorityRuleDto) {} 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-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-service-type.dto.ts new file mode 100644 index 000000000..8f85a656a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-service-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateServiceTypeDto } from './create-service-type.dto'; + +export class UpdateServiceTypeDto extends PartialType(CreateServiceTypeDto) {} 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-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts new file mode 100644 index 000000000..cb9be80eb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateSurchargeTypeDto } from './create-surcharge-type.dto'; + +export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-weight-limit-rule.dto.ts new file mode 100644 index 000000000..4841e9e42 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-weight-limit-rule.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateWeightLimitRuleDto } from './create-weight-limit-rule.dto'; + +export class UpdateWeightLimitRuleDto extends PartialType(CreateWeightLimitRuleDto) {} 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/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts new file mode 100644 index 000000000..a0bd9ddaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'cargo_types' }) +@Index(['isActive']) +@Index(['displayOrder']) +@Index(['parentGroupId']) +@Index(['code']) +export class CargoType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) + code!: string; + + @Column({ name: 'cargo_type_name', type: 'varchar', length: 255 }) + cargoTypeName!: string; + + @Column({ name: 'parent_group_id', type: 'uuid', nullable: true }) + parentGroupId?: string | null; + + @Column({ name: 'show_free_text_box', type: 'boolean', default: false }) + showFreeTextBox!: boolean; + + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) + requiresDirectorApproval!: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'display_order', type: 'int', default: 1 }) + displayOrder!: number; + + @ManyToOne(() => CargoType, (ct) => ct.children, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_group_id' }) + parent?: CargoType | null; + + @OneToMany(() => CargoType, (ct) => ct.parent) + children?: CargoType[]; +} 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 new file mode 100644 index 000000000..e03078c19 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { WeightLimitRule } from './weight-limit-rule.entity'; + +@Entity({ schema: 'freight', name: 'container_types' }) +@Index(['code']) +@Index(['isActive']) +export class ContainerType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) + code!: string; + + @Column({ name: 'label', type: 'varchar', length: 100, nullable: true }) + label!: string; + + @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 new file mode 100644 index 000000000..b04cca95d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule.entity.ts @@ -0,0 +1,22 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'priority_rules' }) +@Index(['code']) +@Index(['isActive']) +export class PriorityRule extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) + code!: string; + + @Column({ name: 'label', type: 'varchar', length: 100, nullable: true }) + label!: string; + + @Column({ name: 'score', type: 'int', default: 0, nullable: true }) + score!: 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/service-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts new file mode 100644 index 000000000..2b7cb3f23 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts @@ -0,0 +1,38 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'service_types' }) +@Index(['isActive']) +@Index(['displayOrder']) +@Index(['code']) +export class ServiceType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) + code!: string; + + @Column({ name: 'service_name', type: 'varchar', length: 255 }) + serviceName!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'can_be_booked_alone', type: 'boolean', default: true }) + canBeBookedAlone!: boolean; + + @Column({ name: 'includes_first_mile', type: 'boolean', default: false }) + includesFirstMile!: boolean; + + @Column({ name: 'includes_last_mile', type: 'boolean', default: false }) + includesLastMile!: boolean; + + @Column({ name: 'includes_customs', type: 'boolean', default: false }) + includesCustoms!: boolean; + + @Column({ name: 'priority_bonus_points', type: 'int', default: 0 }) + priorityBonusPoints!: number; + + @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/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 new file mode 100644 index 000000000..2b9934a1e --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts @@ -0,0 +1,38 @@ +import { BaseEntity } from '@edr/api-common'; +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: 40, unique: true }) + code!: string; + + @Column({ name: 'label', type: 'varchar', length: 100, nullable: true }) + label!: string; + + @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; +} 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 new file mode 100644 index 000000000..39557eec9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -0,0 +1,28 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ContainerType } from './container-type.entity'; + +@Entity({ schema: 'freight', name: 'weight_limit_rules' }) +@Index(['containerTypeId']) +@Index(['tradeDirection']) +@Index(['effectiveFrom']) +export class WeightLimitRule extends BaseEntity { + @Column({ name: 'container_type_id', type: 'uuid' }) + containerTypeId!: string; + + @ManyToOne(() => ContainerType, (ct) => ct.weightLimitRules) + @JoinColumn({ name: 'container_type_id' }) + containerType!: ContainerType; + + @Column({ name: 'trade_direction', type: 'varchar', length: 10 }) + tradeDirection!: string; + + @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) + maxVgmTons!: number; + + @Column({ name: 'effective_from', type: 'date', nullable: true }) + 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/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/cargo-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts new file mode 100644 index 000000000..d757b6569 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { CargoType } from '../entities/cargo-type.entity'; + +export interface ICargoTypesRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[CargoType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const CARGO_TYPES_REPOSITORY = Symbol('CARGO_TYPES_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 new file mode 100644 index 000000000..f8e097309 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { ContainerType } from '../entities/container-type.entity'; + +export interface IContainerTypesRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[ContainerType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const CONTAINER_TYPES_REPOSITORY = Symbol('CONTAINER_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-rules.repository.interface.ts new file mode 100644 index 000000000..608d06e4c --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-rules.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { PriorityRule } from '../entities/priority-rule.entity'; + +export interface IPriorityRulesRepository { + findById(id: string): Promise; + findAllActive(): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[PriorityRule[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const PRIORITY_RULES_REPOSITORY = Symbol('PRIORITY_RULES_REPOSITORY'); 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/service-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts new file mode 100644 index 000000000..49c7f08e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { ServiceType } from '../entities/service-type.entity'; + +export interface IServiceTypesRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[ServiceType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const SERVICE_TYPES_REPOSITORY = Symbol('SERVICE_TYPES_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 new file mode 100644 index 000000000..a6931aaf2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts @@ -0,0 +1,15 @@ +import { FindManyOptions } from 'typeorm'; +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; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const SURCHARGE_TYPES_REPOSITORY = Symbol('SURCHARGE_TYPES_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 new file mode 100644 index 000000000..cedbd1eee --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -0,0 +1,17 @@ +import { FindManyOptions } from 'typeorm'; +import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; + +export interface IWeightLimitRulesRepository { + findById(id: string): Promise; + findActiveByContainerTypeId( + containerTypeId: string, + tradeDirection: string, + ): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const WEIGHT_LIMIT_RULES_REPOSITORY = Symbol('WEIGHT_LIMIT_RULES_REPOSITORY'); 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/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts new file mode 100644 index 000000000..496c2ce7b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { CargoType } from '../entities/cargo-type.entity'; +import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface'; + +@Injectable() +export class CargoTypesRepository implements ICargoTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(CargoType); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id }, relations: { parent: true } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[CargoType[], 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 new file mode 100644 index 000000000..fe0a8f41e --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { ContainerType } from '../entities/container-type.entity'; +import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface'; + +@Injectable() +export class ContainerTypesRepository implements IContainerTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ContainerType); + } + + 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<[ContainerType[], 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/priority-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-rules.repository.ts new file mode 100644 index 000000000..fa51de65a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-rules.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { PriorityRule } from '../entities/priority-rule.entity'; +import { IPriorityRulesRepository } from '../interfaces/priority-rules.repository.interface'; + +@Injectable() +export class PriorityRulesRepository implements IPriorityRulesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(PriorityRule); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findAllActive(): Promise { + return this.repo.find({ where: { isActive: true } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[PriorityRule[], 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/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/service-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts new file mode 100644 index 000000000..5e5f88b0a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { ServiceType } from '../entities/service-type.entity'; +import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface'; + +@Injectable() +export class ServiceTypesRepository implements IServiceTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ServiceType); + } + + 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<[ServiceType[], 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 new file mode 100644 index 000000000..7b44e73e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { SurchargeType } from '../entities/surcharge-type.entity'; +import { ISurchargeTypesRepository } from '../interfaces/surcharge-types.repository.interface'; + +@Injectable() +export class SurchargeTypesRepository implements ISurchargeTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(SurchargeType); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findByCode(code: string): Promise { + 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); + } + + findAndCount(options?: FindManyOptions): Promise<[SurchargeType[], 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 new file mode 100644 index 000000000..0d151c561 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; +import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface'; + +@Injectable() +export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(WeightLimitRule); + } + + findById(id: string): Promise { + return this.repo.findOne({ + where: { id }, + relations: { containerType: true }, + }); + } + + findActiveByContainerTypeId( + containerTypeId: string, + tradeDirection: string, + ): Promise { + const now = new Date(); + return this.repo + .createQueryBuilder('rule') + .innerJoinAndSelect('rule.containerType', 'ct') + .where('rule.container_type_id = :containerTypeId', { containerTypeId }) + .andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :both)', { + dir: tradeDirection, + both: 'BOTH', + }) + .andWhere('rule.effective_from <= :now', { now }) + .andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now }) + .getMany(); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], 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/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 new file mode 100644 index 000000000..9657e6865 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -0,0 +1,150 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +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 { 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: [ + TypeOrmModule.forFeature([ + CargoType, + ContainerType, + PriorityRule, + SurchargeType, + ServiceType, + WeightLimitRule, + Yard, + ShippingLine, + Rate, + ApprovalRule, + BookingContainer, + BookingCargoModifier, + BookingApprovalStep, + BookingRateSnapshot, + ]), + ], + controllers: [ + CargoTypesController, + ContainerTypesController, + PriorityRulesController, + SurchargeTypesController, + ServiceTypesController, + WeightLimitRulesController, + YardsController, + ShippingLinesController, + RatesController, + ApprovalRulesController, + ], + providers: [ + CargoTypesRepository, + { provide: CARGO_TYPES_REPOSITORY, useExisting: CargoTypesRepository }, + ContainerTypesRepository, + { provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository }, + PriorityRulesRepository, + { provide: PRIORITY_RULES_REPOSITORY, useExisting: PriorityRulesRepository }, + SurchargeTypesRepository, + { provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository }, + ServiceTypesRepository, + { provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository }, + WeightLimitRulesRepository, + { provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository }, + 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, + SurchargeTypesService, + ServiceTypesService, + WeightLimitRulesService, + YardsService, + ShippingLinesService, + RatesService, + ApprovalRulesService, + RuleEngineService, + ], + exports: [ + RuleEngineService, + CargoTypesService, + ServiceTypesService, + ContainerTypesService, + SurchargeTypesService, + 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 new file mode 100644 index 000000000..98aeaffbf --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -0,0 +1,374 @@ +import { Inject, Injectable, BadRequestException } from '@nestjs/common'; +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, +} from './interfaces/cargo-types.repository.interface'; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from './interfaces/service-types.repository.interface'; +import { + IWeightLimitRulesRepository, + WEIGHT_LIMIT_RULES_REPOSITORY, +} from './interfaces/weight-limit-rules.repository.interface'; +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'; +import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults'; + +export interface BookingContainerEvalInput { + containerTypeId: string; + quantity: number; + vgmPerUnitTons: number; + totalVgmTons: number; + isReefer?: boolean; + isOverweight?: boolean; + overweightExcessTons?: number | null; +} + +export interface BookingEvaluationInput { + cargoTypeId?: string | null; + freightType?: 'CONTAINER' | 'BULK'; + 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; +} + +export interface ContainerWeightResult { + containerTypeId: string; + weightLimitRuleId: string | null; + isOverweight: boolean; + overweightExcessTons: number | null; +} + +export interface RuleEvaluationResult { + priorityScore: number; + appliedModifiers: AppliedCargoModifier[]; + containerWeightResults: ContainerWeightResult[]; + warnings: string[]; + hardBlocked: string[]; + requiresDirectorApproval: boolean; +} + +@Injectable() +export class RuleEngineService { + constructor( + @Inject(CARGO_TYPES_REPOSITORY) + private readonly cargoTypesRepo: ICargoTypesRepository, + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly serviceTypesRepo: IServiceTypesRepository, + @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. + */ + async evaluate(input: BookingEvaluationInput): Promise { + const warnings: string[] = []; + const hardBlocked: string[] = []; + const appliedModifiers: AppliedCargoModifier[] = []; + const containerWeightResults: ContainerWeightResult[] = []; + let priorityScore = 0; + let requiresDirectorApproval = false; + + if (input.freightType === 'BULK') { + requiresDirectorApproval = true; + } + + if (input.cargoTypeId) { + const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); + if (!cargoType) { + hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`); + } else if (cargoType.requiresDirectorApproval) { + requiresDirectorApproval = true; + } + } + + 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; + + 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.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, + }); + } + } + + const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId); + if (serviceType) { + priorityScore += serviceType.priorityBonusPoints; + } + + const priorityRules = await this.priorityRulesRepo.findAllActive(); + for (const rule of priorityRules) { + if ( + rule.conditionCurrency === null || + rule.conditionCurrency === input.paymentCurrency + ) { + priorityScore += rule.score; + } + } + + 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, + }; + } + + /** + * Ensure ITMLS default approval chains exist (container + bulk). Idempotent. + */ + async ensureDefaultApprovalRules(): Promise { + for (const flag of [false, true] as const) { + const existing = await this.approvalRulesRepo.findChainForCargo(flag); + if (existing.length > 0) continue; + + const rows = DEFAULT_APPROVAL_RULE_ROWS.filter( + (r) => r.requiresDirectorApproval === flag, + ); + for (const row of rows) { + await this.approvalRulesRepo.create({ + requiresDirectorApproval: row.requiresDirectorApproval, + stepOrder: row.stepOrder, + requiredRole: row.requiredRole, + actionLabel: row.actionLabel, + blocksRole: row.blocksRole, + }); + } + } + } + + /** + * Instantiate booking_approval_step rows from approval_rules by freight type. + */ + async instantiateApprovalSteps( + bookingId: string, + options: { + freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; + }, + ): Promise { + await this.ensureDefaultApprovalRules(); + + let requiresDirectorApproval = options.freightType === 'BULK'; + + if (options.cargoTypeId) { + const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId); + if (!cargoType) { + throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`); + } + if (cargoType.requiresDirectorApproval) { + requiresDirectorApproval = true; + } + } + + const chain = await this.approvalRulesRepo.findChainForCargo( + requiresDirectorApproval, + ); + + if (chain.length === 0) { + throw new BadRequestException( + `Approval chain could not be loaded for requiresDirectorApproval=${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, + blocksRole: rule.blocksRole ?? null, + 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 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 new file mode 100644 index 000000000..130b1d605 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -0,0 +1,98 @@ +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'; +import { + CARGO_TYPES_REPOSITORY, + ICargoTypesRepository, +} from '../interfaces/cargo-types.repository.interface'; + +@Injectable() +export class CargoTypesService { + constructor( + @Inject(CARGO_TYPES_REPOSITORY) + private readonly repository: ICargoTypesRepository, + ) {} + + /** List cargo types with pagination and optional filtering. */ + async findAll(filter: { + isActive?: boolean; + requiresDirectorApproval?: boolean; + parentGroupId?: string; + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ data: CargoType[]; 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.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval; + if (filter.parentGroupId !== undefined) where.parentGroupId = filter.parentGroupId; + if (filter.search) where.cargoTypeName = ILike(`%${filter.search}%`); + + const [data, total] = await this.repository.findAndCount({ + where, + order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + relations: { parent: true }, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single cargo type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Cargo type ${id} not found`); + return entity; + } + + /** Get a cargo type by code. */ + async findByCode(code: string): Promise { + return this.repository.findByCode(code); + } + + /** Create a new cargo type. */ + async create(dto: CreateCargoTypeDto): Promise { + 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, + cargoTypeName: dto.cargoTypeName, + parentGroupId: dto.parentGroupId ?? null, + showFreeTextBox: dto.showFreeTextBox ?? false, + requiresDirectorApproval: dto.requiresDirectorApproval ?? false, + isActive: dto.isActive ?? true, + displayOrder: dto.displayOrder ?? 1, + }); + } + + /** Update an existing cargo type. */ + async update(id: string, dto: UpdateCargoTypeDto): Promise { + await this.findById(id); + 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); + if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); + } + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); + return updated; + } + + /** Soft-delete a cargo type. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts new file mode 100644 index 000000000..9b0209311 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -0,0 +1,75 @@ +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'; +import { + CONTAINER_TYPES_REPOSITORY, + IContainerTypesRepository, +} from '../interfaces/container-types.repository.interface'; + +@Injectable() +export class ContainerTypesService { + constructor( + @Inject(CONTAINER_TYPES_REPOSITORY) + private readonly repository: IContainerTypesRepository, + ) {} + + /** List container types with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: ContainerType[]; 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: { displayOrder: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single container type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Container type ${id} not found`); + return entity; + } + + /** Create a new container type. */ + async create(dto: CreateContainerTypeDto): Promise { + 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({ + 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); + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Container type ${id} not found`); + return updated; + } + + /** Soft-delete a container type. */ + 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/priority-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rules.service.ts new file mode 100644 index 000000000..07e282aba --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rules.service.ts @@ -0,0 +1,75 @@ +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'; +import { + IPriorityRulesRepository, + PRIORITY_RULES_REPOSITORY, +} from '../interfaces/priority-rules.repository.interface'; + +@Injectable() +export class PriorityRulesService { + constructor( + @Inject(PRIORITY_RULES_REPOSITORY) + private readonly repository: IPriorityRulesRepository, + ) {} + + /** List priority rules with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: PriorityRule[]; 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: { label: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single priority rule by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Priority rule ${id} not found`); + return entity; + } + + /** Create a new priority rule. */ + async create(dto: CreatePriorityRuleDto): Promise { + const code = generateCode(dto.label); + const existing = await this.repository.findAll({ where: { code } }); + if (existing.length > 0) { + throw new ConflictException(`Priority rule with label "${dto.label}" conflicts with existing code "${code}"`); + } + return this.repository.create({ + code, + label: dto.label, + score: dto.score, + conditionCurrency: dto.conditionCurrency ?? null, + isActive: dto.isActive ?? false, + }); + } + + /** Update an existing priority rule. */ + async update(id: string, dto: UpdatePriorityRuleDto): Promise { + await this.findById(id); + const { ...patch } = dto; + const updated = await this.repository.update(id, patch); + if (!updated) throw new NotFoundException(`Priority rule ${id} not found`); + return updated; + } + + /** Soft-delete a priority 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/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts new file mode 100644 index 000000000..0202ef44a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -0,0 +1,113 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { 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, proposedByStaffId: string): 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, + 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.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, approverUserId: string): 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: approverUserId, + 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 new file mode 100644 index 000000000..1d54582a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -0,0 +1,90 @@ +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'; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from '../interfaces/service-types.repository.interface'; + +@Injectable() +export class ServiceTypesService { + constructor( + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly repository: IServiceTypesRepository, + ) {} + + /** List service types with pagination and optional filtering. */ + async findAll(filter: { + isActive?: boolean; + canBeBookedAlone?: boolean; + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ data: ServiceType[]; 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.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone; + if (filter.search) where.serviceName = ILike(`%${filter.search}%`); + + const [data, total] = await this.repository.findAndCount({ + where, + order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single service type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Service type ${id} not found`); + return entity; + } + + /** Get a service type by code. */ + async findByCode(code: string): Promise { + return this.repository.findByCode(code); + } + + /** Create a new service type. */ + async create(dto: CreateServiceTypeDto): Promise { + 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, + serviceName: dto.serviceName, + description: dto.description ?? null, + canBeBookedAlone: dto.canBeBookedAlone ?? true, + includesFirstMile: dto.includesFirstMile ?? false, + includesLastMile: dto.includesLastMile ?? false, + includesCustoms: dto.includesCustoms ?? false, + priorityBonusPoints: dto.priorityBonusPoints ?? 0, + isActive: dto.isActive ?? true, + displayOrder: dto.displayOrder ?? 1, + }); + } + + /** Update an existing service type. */ + async update(id: string, dto: UpdateServiceTypeDto): Promise { + await this.findById(id); + const { ...patch } = dto; + const updated = await this.repository.update(id, patch); + if (!updated) throw new NotFoundException(`Service type ${id} not found`); + return updated; + } + + /** Soft-delete a service type. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts new file mode 100644 index 000000000..4eaa47a26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts @@ -0,0 +1,76 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; +import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; +import { ShippingLine } from '../entities/shipping-line.entity'; +import { + IShippingLinesRepository, + SHIPPING_LINES_REPOSITORY, +} from '../interfaces/shipping-lines.repository.interface'; + +@Injectable() +export class ShippingLinesService { + constructor( + @Inject(SHIPPING_LINES_REPOSITORY) + private readonly repository: IShippingLinesRepository, + ) {} + + /** List shipping lines with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: ShippingLine[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { code: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a shipping line by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Shipping line ${id} not found`); + return entity; + } + + /** Create a shipping line. */ + async create(dto: CreateShippingLineDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) throw new ConflictException(`Shipping line with code "${dto.code}" already exists`); + return this.repository.create({ + code: dto.code, + label: dto.label, + mappedToCode: dto.mappedToCode, + showExtraFeeNotice: dto.showExtraFeeNotice ?? false, + isActive: dto.isActive ?? true, + }); + } + + /** Update a shipping line. */ + async update(id: string, dto: UpdateShippingLineDto): Promise { + await this.findById(id); + if (dto.code) { + const conflict = await this.repository.findByCode(dto.code); + if (conflict && conflict.id !== id) { + throw new ConflictException(`Shipping line with code "${dto.code}" already exists`); + } + } + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Shipping line ${id} not found`); + return updated; + } + + /** Soft-delete a shipping line. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts new file mode 100644 index 000000000..387e26ba9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts @@ -0,0 +1,78 @@ +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'; +import { + ISurchargeTypesRepository, + SURCHARGE_TYPES_REPOSITORY, +} from '../interfaces/surcharge-types.repository.interface'; + +@Injectable() +export class SurchargeTypesService { + constructor( + @Inject(SURCHARGE_TYPES_REPOSITORY) + private readonly repository: ISurchargeTypesRepository, + ) {} + + /** List surcharge types with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: SurchargeType[]; 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, + relations: { rate: true }, + order: { label: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single surcharge type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Surcharge type ${id} not found`); + return entity; + } + + /** Create a new surcharge type. */ + async create(dto: CreateSurchargeTypeDto): Promise { + 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, + label: dto.label, + triggerCondition: dto.triggerCondition as SurchargeType['triggerCondition'], + rateId: dto.rateId, + isActive: dto.isActive ?? true, + }); + } + + /** Update an existing surcharge type. */ + async update(id: string, dto: UpdateSurchargeTypeDto): Promise { + await this.findById(id); + 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; + } + + /** Soft-delete a surcharge type. */ + 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 new file mode 100644 index 000000000..d171f55aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -0,0 +1,77 @@ +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'; +import { + IWeightLimitRulesRepository, + WEIGHT_LIMIT_RULES_REPOSITORY, +} from '../interfaces/weight-limit-rules.repository.interface'; + +@Injectable() +export class WeightLimitRulesService { + constructor( + @Inject(WEIGHT_LIMIT_RULES_REPOSITORY) + private readonly repository: IWeightLimitRulesRepository, + ) {} + + /** List weight limit rules with pagination. */ + async findAll(filter: { + 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.containerTypeId) where.containerTypeId = filter.containerTypeId; + if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; + + const [data, total] = await this.repository.findAndCount({ + where, + relations: { containerType: true }, + order: { effectiveFrom: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single weight limit rule by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Weight limit rule ${id} not found`); + return entity; + } + + /** Create a new weight limit rule. */ + async create(dto: CreateWeightLimitRuleDto): Promise { + return this.repository.create({ + containerTypeId: dto.containerTypeId, + tradeDirection: dto.tradeDirection, + 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 { + 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; + } + + /** Soft-delete a weight limit 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/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/modules/tracking/entities/tracking-event.entity.ts b/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts index aed5420d7..40e0594ae 100644 --- a/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts +++ b/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "tracking_events" }) +@Entity({schema:"freight", name: "tracking_events" }) export class TrackingEvent extends BaseEntity { @Column({ name: "consignment_id", type: "uuid" }) consignmentId!: string; diff --git a/apps/edr-freight-api/src/modules/tracking/tracking.controller.ts b/apps/edr-freight-api/src/modules/tracking/tracking.controller.ts index 6d645c40a..0e996fe7c 100644 --- a/apps/edr-freight-api/src/modules/tracking/tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/tracking/tracking.controller.ts @@ -4,7 +4,6 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { TrackingService } from "./tracking.service"; @ApiTags("tracking") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth @Controller("tracking") export class TrackingController { constructor(private readonly trackingService: TrackingService) {} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts new file mode 100644 index 000000000..4ffecea26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from './train-schedule.entity'; + +@Entity({ schema: 'freight', name: 'train_schedule_bookings' }) +@Index(['trainScheduleId', 'bookingId'], { unique: true }) +@Index(['bookingId'], { unique: true }) +export class TrainScheduleBooking extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts new file mode 100644 index 000000000..4edd09f09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Route } from '../../routes/entities/route.entity'; +import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { TrainScheduleBooking } from './train-schedule-booking.entity'; + +export const TRAIN_SCHEDULE_STATUSES = [ + 'DRAFT', + 'SCHEDULED', + 'DISPATCHED', + 'ARRIVED', + 'CANCELLED', +] as const; + +export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'train_schedules' }) +@Index(['scheduledDepartureDate']) +@Index(['status']) +export class TrainSchedule extends BaseEntity { + @Column({ name: 'train_set_id', type: 'uuid', unique: true }) + trainSetId!: string; + + @OneToOne(() => TrainSet, (trainSet) => trainSet.trainSchedule) + @JoinColumn({ name: 'train_set_id' }) + trainSet?: TrainSet; + + @Column({ name: 'route_id', type: 'uuid', nullable: true }) + routeId?: string | null; + + @ManyToOne(() => Route) + @JoinColumn({ name: 'route_id' }) + route?: Route | null; + + @Column({ name: 'origin_station_id', type: 'uuid' }) + originStationId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_station_id' }) + originStation?: Yard; + + @Column({ name: 'destination_station_id', type: 'uuid' }) + destinationStationId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_station_id' }) + destinationStation?: Yard; + + @Column({ name: 'scheduled_departure_date', type: 'timestamptz' }) + scheduledDepartureDate!: Date; + + @Column({ name: 'scheduled_arrival_date', type: 'timestamptz', nullable: true }) + scheduledArrivalDate?: Date | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: TrainScheduleStatus; + + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) + scheduleBookings?: TrainScheduleBooking[]; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts new file mode 100644 index 000000000..4c78256fe --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; + +@Entity({ schema: 'freight', name: 'wagon_booking_allocations' }) +@Index(['trainSetWagonId', 'bookingId']) +export class WagonBookingAllocation extends BaseEntity { + @Column({ name: 'train_set_wagon_id', type: 'uuid' }) + trainSetWagonId!: string; + + @ManyToOne(() => TrainSetWagon, (wagon) => wagon.allocations, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_set_wagon_id' }) + trainSetWagon?: TrainSetWagon; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + allocatedWeightTons!: number; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts new file mode 100644 index 000000000..d7360226f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; + +@Injectable() +export class TrainScheduleBookingsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainScheduleBooking) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts new file mode 100644 index 000000000..9fa40897a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; +import { TrainSchedule } from './entities/train-schedule.entity'; +import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; +import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository'; +import { TrainSchedulesRepository } from './train-schedules.repository'; +import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])], + providers: [ + TrainSchedulesRepository, + TrainScheduleBookingsRepository, + WagonBookingAllocationsRepository, + ], + exports: [ + TrainSchedulesRepository, + TrainScheduleBookingsRepository, + WagonBookingAllocationsRepository, + ], +}) +export class TrainSchedulesModule {} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts new file mode 100644 index 000000000..b6f18eaf2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSchedule } from './entities/train-schedule.entity'; + +@Injectable() +export class TrainSchedulesRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSchedule) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts new file mode 100644 index 000000000..067dddaf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; + +@Injectable() +export class WagonBookingAllocationsRepository extends BaseRepository { + constructor( + @InjectRepository(WagonBookingAllocation) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts new file mode 100644 index 000000000..1b4fa29d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsUUID } from 'class-validator'; + +export class CreateContainerTrainScheduleDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + routeId!: string; + + @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) + @IsDateString() + scheduleDate!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + locomotiveId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts new file mode 100644 index 000000000..2ca15dc0c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsUUID } from 'class-validator'; + +export class GetEligibleContainerBookingsDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' }) + @IsOptional() + @IsDateString() + scheduleDate?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts new file mode 100644 index 000000000..e3e142a61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator'; + +export class PreviewContainerTrainScheduleDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) + @IsDateString() + scheduleDate!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + originStationId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + destinationStationId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts new file mode 100644 index 000000000..1dd01ffba --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -0,0 +1,58 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; +import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; +import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; +import { TrainSchedulingService } from './train-scheduling.service'; + +@ApiTags('train-scheduling') +@ApiBearerAuth() +@Controller('train-scheduling') +export class TrainSchedulingController { + constructor(private readonly trainSchedulingService: TrainSchedulingService) {} + + @Get('container/eligible-bookings') + @ApiOperation({ summary: 'List eligible container bookings' }) + getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) { + return this.trainSchedulingService.getEligibleContainerBookings(query); + } + + @Post('container/preview') + @ApiOperation({ summary: 'Preview a container train schedule' }) + previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) { + return this.trainSchedulingService.previewContainerTrainSchedule(dto); + } + + @Post('container/schedules') + @ApiOperation({ summary: 'Create a container train schedule' }) + createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { + return this.trainSchedulingService.createContainerTrainSchedule(dto); + } + + @Get('container/schedules') + @ApiOperation({ summary: 'List container train schedules' }) + getContainerTrainSchedules() { + return this.trainSchedulingService.getContainerTrainSchedules(); + } + + @Get('container/schedules/:id') + @ApiOperation({ summary: 'Get container train schedule detail' }) + getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Post('container/schedules/:id/cancel') + @ApiOperation({ summary: 'Cancel container train schedule' }) + cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.cancelTrainSchedule(id); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts new file mode 100644 index 000000000..dbf79e403 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { BookingsModule } from '../bookings/bookings.module'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { LocomotivesModule } from '../locomotives/locomotives.module'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { WagonTypesModule } from '../wagon-types/wagon-types.module'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSetsModule } from '../train-sets/train-sets.module'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { TrainSchedulingController } from './train-scheduling.controller'; +import { TrainSchedulingService } from './train-scheduling.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Booking, + BookingContainer, + Locomotive, + WagonType, + TrainSet, + TrainSetWagon, + TrainSchedule, + TrainScheduleBooking, + WagonBookingAllocation, + Yard, + ]), + BookingsModule, + LocomotivesModule, + WagonTypesModule, + TrainSetsModule, + TrainSchedulesModule, + ], + controllers: [TrainSchedulingController], + providers: [TrainSchedulingService], + exports: [TrainSchedulingService], +}) +export class TrainSchedulingModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts new file mode 100644 index 000000000..8f2b77bb4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -0,0 +1,299 @@ +import { ConflictException } from '@nestjs/common'; + +import { TrainSchedulingService } from './train-scheduling.service'; + +const nw5 = { + id: 'wagon-type-1', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, +}; + +const locomotive = { + id: 'loc-1', + code: 'LOC-001', + maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', +}; + +const makeBooking = ( + id: string, + reference: string, + weight: number, + quantity: number, + containerCode: string, + scheduledDate = '2026-06-20T08:00:00.000Z', + originYardId = 'yard-origin', + destinationYardId = 'yard-destination', +) => ({ + id, + reference, + freightType: 'CONTAINER', + cargoTotalWeightVgm: weight, + scheduledDate: new Date(scheduledDate), + originYardId, + destinationYardId, + status: 'PAID', + customer: { companyName: 'Demo Customer' }, + originYard: { label: 'Djibouti', code: 'DJIBOUTI' }, + destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' }, + bookingContainers: [ + { + quantity, + containerType: { code: containerCode, label: containerCode }, + }, + ], +}); + +describe('TrainSchedulingService', () => { + let service: TrainSchedulingService; + let dataSource: { + getRepository: jest.Mock; + transaction: jest.Mock; + }; + let locomotivesRepository: { + findById: jest.Mock; + }; + let wagonTypesRepository: { + findAll: jest.Mock; + }; + + beforeEach(() => { + dataSource = { + getRepository: jest.fn(), + transaction: jest.fn(), + }; + locomotivesRepository = { + findById: jest.fn(), + }; + wagonTypesRepository = { + findAll: jest.fn(), + }; + + service = new TrainSchedulingService( + dataSource as never, + locomotivesRepository as never, + wagonTypesRepository as never, + ); + }); + + it('computes the expected valid preview for Group A', async () => { + const bookings = [ + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'), + makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Booking') { + return { find: jest.fn().mockResolvedValue(bookings) }; + } + if (entity?.name === 'TrainScheduleBooking') { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity?.name === 'Locomotive') { + return { + count: jest.fn().mockResolvedValue(2), + find: jest.fn().mockResolvedValue([locomotive]), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: bookings.map((booking) => booking.id), + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(true); + expect(result.violations).toEqual([]); + expect(result.summary).toEqual({ + totalBookings: 3, + totalWeightTons: 1250, + wagonType: 'NW5', + wagonsNeeded: 18, + totalLengthMeters: 252, + }); + expect(result.wagonPlan).toHaveLength(18); + expect(result.wagonPlan[0]?.allocations[0]).toEqual({ + bookingId: 'b1', + bookingReference: 'BKG-CONT-001', + allocatedWeightTons: 70, + }); + }); + + it('flags the overweight booking as invalid', async () => { + const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Booking') { + return { find: jest.fn().mockResolvedValue(bookings) }; + } + if (entity?.name === 'TrainScheduleBooking') { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity?.name === 'Locomotive') { + return { + count: jest.fn().mockResolvedValue(1), + find: jest.fn().mockResolvedValue([locomotive]), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b6'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(false); + expect(result.summary.totalWeightTons).toBe(3600); + expect(result.violations).toContain( + 'Total booking weight 3600T exceeds max train weight 3500T', + ); + }); + + it('rejects bookings that are not in schedulable status', async () => { + const bookings = [ + { + ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'), + status: 'APPROVED', + }, + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Booking') { + return { find: jest.fn().mockResolvedValue(bookings) }; + } + if (entity?.name === 'TrainScheduleBooking') { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity?.name === 'Locomotive') { + return { + count: jest.fn().mockResolvedValue(1), + find: jest.fn().mockResolvedValue([locomotive]), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b7'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(false); + expect(result.violations).toContain( + 'Only PAID bookings can be scheduled; received: APPROVED', + ); + }); + + it('creates a schedule transactionally when validation passes', async () => { + const route = { + id: 'route-1', + name: 'Djibouti to Addis', + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + isActive: true, + }; + + const lockedLocomotiveRepo = { + findOne: jest.fn().mockResolvedValue(locomotive), + update: jest.fn().mockResolvedValue(undefined), + }; + const trainScheduleRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), + }; + const trainSetRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue({ id: 'train-set-1' }), + }; + const manager = { + getRepository: jest.fn((entity: { name?: string }) => { + switch (entity?.name) { + case 'Locomotive': + return lockedLocomotiveRepo; + case 'TrainSchedule': + return trainScheduleRepo; + case 'TrainSet': + return trainSetRepo; + default: + throw new Error(`Unexpected transaction repository ${entity?.name}`); + } + }), + }; + + jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Route') { + return { findOne: jest.fn().mockResolvedValue(route) }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never); + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + const result = await service.createContainerTrainSchedule({ + routeId: 'route-1', + scheduleDate: '2026-06-20T08:00:00.000Z', + locomotiveId: 'loc-1', + }); + + expect(trainSetRepo.save).toHaveBeenCalled(); + expect(trainScheduleRepo.save).toHaveBeenCalled(); + expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); + expect(result).toEqual({ id: 'schedule-1' }); + }); + + it('rejects create when the locked locomotive is no longer available', async () => { + const manager = { + getRepository: jest.fn(() => ({ + findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), + })), + }; + + jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Route') { + return { + findOne: jest.fn().mockResolvedValue({ + id: 'route-1', + name: 'Djibouti to Addis', + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + isActive: true, + }), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + await expect( + service.createContainerTrainSchedule({ + routeId: 'route-1', + scheduleDate: '2026-06-20T08:00:00.000Z', + locomotiveId: 'loc-1', + }), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts new file mode 100644 index 000000000..64147940d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -0,0 +1,805 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource, EntityManager, In } from "typeorm"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { + Locomotive, + type LocomotiveStatus, +} from "../locomotives/entities/locomotive.entity"; +import { LocomotivesRepository } from "../locomotives/locomotives.repository"; +import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity"; +import { TrainSet } from "../train-sets/entities/train-set.entity"; +import { Route } from "../routes/entities/route.entity"; +import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { WagonType } from "../wagon-types/entities/wagon-type.entity"; +import { WagonTypesRepository } from "../wagon-types/wagon-types.repository"; +import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; +import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; +import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; + +const DEFAULT_WAGON_TYPE_CODE = "NW5"; +const MAX_TRAIN_WEIGHT_TONS = 3500; +const MAX_TRAIN_LENGTH_METERS = 760; +const SCHEDULABLE_BOOKING_STATUSES = ["PAID"] as const; + +type EligibleBookingItem = { + id: string; + reference: string; + customer: string; + containerType: string; + quantity: number; + weightTons: number; + origin: string; + destination: string; + preferredDepartureDate: string; + status: string; +}; + +type WagonAllocationRecord = { + bookingId: string; + bookingReference: string; + allocatedWeightTons: number; +}; + +type WagonPlanRecord = { + sequenceNo: number; + capacityTons: number; + lengthMeters: number; + assignedWeightTons: number; + allocations: WagonAllocationRecord[]; +}; + +type ValidationResult = { + valid: boolean; + violations: string[]; + bookings: Booking[]; + wagonType: WagonType; + summary: { + totalBookings: number; + totalWeightTons: number; + wagonType: string; + wagonsNeeded: number; + totalLengthMeters: number; + }; + wagonPlan: WagonPlanRecord[]; +}; + +@Injectable() +export class TrainSchedulingService { + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly locomotivesRepository: LocomotivesRepository, + private readonly wagonTypesRepository: WagonTypesRepository, + ) { } + + async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { + const bookingRepository = this.dataSource.getRepository(Booking); + const queryBuilder = bookingRepository + .createQueryBuilder("booking") + .leftJoinAndSelect("booking.company", "company") + .leftJoinAndSelect("booking.originYard", "originYard") + .leftJoinAndSelect("booking.destinationYard", "destinationYard") + .leftJoinAndSelect("booking.bookingContainers", "bookingContainer") + .leftJoinAndSelect("bookingContainer.containerType", "containerType") + .leftJoin( + TrainScheduleBooking, + "scheduleBooking", + "scheduleBooking.booking_id = booking.id", + ) + .where("booking.freightType = :freightType", { freightType: "CONTAINER" }) + .andWhere("scheduleBooking.id IS NULL"); + + queryBuilder.andWhere("booking.status IN (:...schedulableStatuses)", { + schedulableStatuses: SCHEDULABLE_BOOKING_STATUSES, + }); + + if (query.originStationId) { + queryBuilder.andWhere("booking.originYardId = :originStationId", { + originStationId: query.originStationId, + }); + } + + if (query.destinationStationId) { + queryBuilder.andWhere( + "booking.destinationYardId = :destinationStationId", + { + destinationStationId: query.destinationStationId, + }, + ); + } + + if (query.scheduleDate) { + queryBuilder.andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`, + { scheduleDate: this.toUtcDateKey(query.scheduleDate) }, + ); + } + + const bookings = await queryBuilder + .orderBy("booking.scheduled_date", "ASC") + .addOrderBy("booking.created_at", "ASC") + .getMany(); + + const items: EligibleBookingItem[] = bookings.map((booking) => ({ + id: booking.id, + reference: booking.reference, + customer: + booking.company?.name ?? booking.company?.email ?? "Unknown customer", + containerType: + booking.bookingContainers + ?.map( + (container) => + container.containerType?.label ?? + container.containerType?.code ?? + "Container", + ) + .join(", ") ?? "Container", + quantity: + booking.bookingContainers?.reduce( + (sum, container) => sum + Number(container.quantity ?? 0), + 0, + ) ?? 0, + weightTons: this.roundTons(booking.cargoTotalWeightVgm), + origin: + booking.originYard?.label ?? + booking.originYard?.code ?? + "Unknown origin", + destination: + booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "Unknown destination", + preferredDepartureDate: booking.scheduledDate.toISOString(), + status: booking.status, + })); + + return { + count: items.length, + items, + }; + } + + async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { + const validation = await this.validateContainerBookingsForScheduling(dto); + + return { + valid: validation.valid, + violations: validation.violations, + summary: validation.summary, + bookingIds: validation.bookings.map((booking) => booking.id), + wagonPlan: validation.wagonPlan, + }; + } + + async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { + const route = await this.getActiveRoute(dto.routeId); + + const locomotive = await this.selectOrValidateLocomotive( + dto.locomotiveId, + 0, + 0, + ); + + const createdSchedule = await this.dataSource.transaction( + async (manager) => { + const locomotiveRepository = manager.getRepository(Locomotive); + const lockedLocomotive = await locomotiveRepository.findOne({ + where: { id: locomotive.id }, + lock: { mode: "pessimistic_write" }, + }); + + if (!lockedLocomotive) { + throw new NotFoundException(`Locomotive ${locomotive.id} not found`); + } + + if (lockedLocomotive.status !== "AVAILABLE") { + throw new ConflictException( + `Locomotive ${lockedLocomotive.code} is not available`, + ); + } + + const trainSet = await this.buildEmptyTrainSet( + manager, + lockedLocomotive, + ); + + const schedule = manager.getRepository(TrainSchedule).create({ + trainSetId: trainSet.id, + routeId: route.id, + originStationId: route.originYardId, + destinationStationId: route.destinationYardId, + scheduledDepartureDate: new Date(dto.scheduleDate), + status: "DRAFT", + }); + + const savedSchedule = await manager + .getRepository(TrainSchedule) + .save(schedule); + + await locomotiveRepository.update(lockedLocomotive.id, { + status: "ASSIGNED", + }); + + return savedSchedule.id; + }, + ); + + return this.getContainerTrainScheduleById(createdSchedule); + } + + async validateContainerBookingsForScheduling( + dto: PreviewContainerTrainScheduleDto, + ): Promise { + const bookingIds = [...new Set(dto.bookingIds)]; + + if (!bookingIds.length) { + throw new BadRequestException("At least one booking is required"); + } + + const [wagonType] = await this.wagonTypesRepository.findAll({ + where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true }, + }); + + if (!wagonType) { + throw new NotFoundException( + `Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`, + ); + } + + const bookings = await this.loadBookingsForScheduling(bookingIds); + const violations: string[] = []; + + if (bookings.length !== bookingIds.length) { + const foundIds = new Set(bookings.map((booking) => booking.id)); + const missing = bookingIds.filter((id) => !foundIds.has(id)); + violations.push(`Bookings not found: ${missing.join(", ")}`); + } + + const scheduledLinks = await this.dataSource + .getRepository(TrainScheduleBooking) + .find({ + where: { bookingId: In(bookingIds) }, + select: { bookingId: true }, + }); + + if (scheduledLinks.length > 0) { + violations.push( + "One or more selected bookings are already assigned to a train schedule", + ); + } + + const nonContainerBookings = bookings.filter( + (booking) => booking.freightType !== "CONTAINER", + ); + if (nonContainerBookings.length > 0) { + violations.push( + "Only CONTAINER bookings are supported for train scheduling", + ); + } + + const invalidStatusBookings = bookings.filter( + (booking) => !SCHEDULABLE_BOOKING_STATUSES.includes(booking.status as "PAID"), + ); + if (invalidStatusBookings.length > 0) { + const invalidStatuses = [...new Set(invalidStatusBookings.map((booking) => booking.status))]; + violations.push( + `Only ${SCHEDULABLE_BOOKING_STATUSES.join(", ")} bookings can be scheduled; received: ${invalidStatuses.join(", ")}`, + ); + } + + const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate); + const routeMismatch = bookings.some( + (booking) => + booking.originYardId !== dto.originStationId || + booking.destinationYardId !== dto.destinationStationId, + ); + if (routeMismatch) { + violations.push( + "Selected bookings must share the same origin and destination as the schedule", + ); + } + + const dateMismatch = bookings.some( + (booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey, + ); + if (dateMismatch) { + violations.push("Selected bookings must share the same schedule date"); + } + + const uniqueOriginCount = new Set( + bookings.map((booking) => booking.originYardId), + ).size; + if (uniqueOriginCount > 1) { + violations.push("Selected bookings must share the same origin station"); + } + + const uniqueDestinationCount = new Set( + bookings.map((booking) => booking.destinationYardId), + ).size; + if (uniqueDestinationCount > 1) { + violations.push( + "Selected bookings must share the same destination station", + ); + } + + const uniqueDateCount = new Set( + bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)), + ).size; + if (uniqueDateCount > 1) { + violations.push( + "Selected bookings must share the same preferred departure date", + ); + } + + const totalWeightTons = this.roundTons( + bookings.reduce( + (sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), + 0, + ), + ); + + const wagonPlan = this.allocateBookingsToWagons( + bookings, + this.calculateNW5WagonPlan(totalWeightTons, wagonType), + ); + const totalLengthMeters = this.roundTons( + wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0), + ); + + if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) { + violations.push( + `Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`, + ); + } + + if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) { + violations.push( + `Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`, + ); + } + + if ( + wagonType.maxWagonsPerTrain != null && + wagonPlan.length > Number(wagonType.maxWagonsPerTrain) + ) { + violations.push( + `Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`, + ); + } + + const availableLocomotiveCount = await this.dataSource + .getRepository(Locomotive) + .count({ + where: { status: "AVAILABLE" as LocomotiveStatus }, + }); + + if (availableLocomotiveCount === 0) { + violations.push("No available locomotive exists for scheduling"); + } else { + const capableLocomotives = await this.dataSource + .getRepository(Locomotive) + .find({ + where: { status: "AVAILABLE" }, + }); + const canPull = capableLocomotives.some( + (locomotive) => + Number(locomotive.maxPullWeightTons) >= totalWeightTons && + Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters, + ); + if (!canPull) { + violations.push( + 'No available locomotive can support the total train weight and length', + ); + } + } + + return { + valid: violations.length === 0, + violations, + bookings, + wagonType, + summary: { + totalBookings: bookings.length, + totalWeightTons, + wagonType: wagonType.code, + wagonsNeeded: wagonPlan.length, + totalLengthMeters, + }, + wagonPlan, + }; + } + + calculateNW5WagonPlan( + totalBookingWeightTons: number, + wagonType: WagonType, + ): WagonPlanRecord[] { + const wagonCapacityTons = Number(wagonType.capacityTons); + const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons); + let remainingWeight = this.roundTons(totalBookingWeightTons); + + return Array.from({ length: wagonsNeeded }, (_, index) => { + const assignedWeightTons = this.roundTons( + Math.min(wagonCapacityTons, remainingWeight), + ); + remainingWeight = this.roundTons( + Math.max(0, remainingWeight - assignedWeightTons), + ); + + return { + sequenceNo: index + 1, + capacityTons: wagonCapacityTons, + lengthMeters: this.roundTons(Number(wagonType.lengthMeters)), + assignedWeightTons, + allocations: [], + }; + }); + } + + async selectOrValidateLocomotive( + locomotiveId: string, + totalWeightTons: number, + totalLengthMeters: number, + ) { + const locomotive = await this.locomotivesRepository.findById(locomotiveId); + + if (!locomotive) { + throw new NotFoundException(`Locomotive ${locomotiveId} not found`); + } + + if (locomotive.status !== "AVAILABLE") { + throw new BadRequestException( + `Locomotive ${locomotive.code} is not available`, + ); + } + + if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`, + ); + } + + if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, + ); + } + + return locomotive; + } + + async buildTrainSet( + manager: EntityManager, + locomotive: Locomotive, + wagonType: WagonType, + totalWeightTons: number, + totalLengthMeters: number, + wagonPlan: WagonPlanRecord[], + ) { + const trainSet = manager.getRepository(TrainSet).create({ + locomotiveId: locomotive.id, + totalWeightTons, + totalLengthMeters, + wagonCount: wagonPlan.length, + status: "ASSIGNED", + }); + const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet); + + const wagons = wagonPlan.map((wagon) => + manager.getRepository(TrainSetWagon).create({ + trainSetId: savedTrainSet.id, + wagonTypeId: wagonType.id, + sequenceNo: wagon.sequenceNo, + capacityTons: wagon.capacityTons, + lengthMeters: wagon.lengthMeters, + assignedWeightTons: wagon.assignedWeightTons, + }), + ); + + await manager.getRepository(TrainSetWagon).save(wagons); + + return savedTrainSet; + } + + async buildEmptyTrainSet( + manager: EntityManager, + locomotive: Locomotive, + ) { + const trainSet = manager.getRepository(TrainSet).create({ + locomotiveId: locomotive.id, + totalWeightTons: 0, + totalLengthMeters: 0, + wagonCount: 0, + status: 'DRAFT', + }); + + return manager.getRepository(TrainSet).save(trainSet); + } + + allocateBookingsToWagons( + bookings: Booking[], + baseWagonPlan: WagonPlanRecord[], + ): WagonPlanRecord[] { + const remaining = bookings.map((booking) => ({ + bookingId: booking.id, + bookingReference: booking.reference, + remainingWeightTons: this.roundTons( + Number(booking.cargoTotalWeightVgm ?? 0), + ), + })); + let bookingIndex = 0; + + return baseWagonPlan.map((wagon) => { + let wagonRemaining = this.roundTons(wagon.capacityTons); + const allocations: WagonAllocationRecord[] = []; + let assignedWeightTons = 0; + + while (wagonRemaining > 0 && bookingIndex < remaining.length) { + const booking = remaining[bookingIndex]; + const allocatedWeightTons = this.roundTons( + Math.min(wagonRemaining, booking.remainingWeightTons), + ); + + if (allocatedWeightTons <= 0) { + bookingIndex += 1; + continue; + } + + allocations.push({ + bookingId: booking.bookingId, + bookingReference: booking.bookingReference, + allocatedWeightTons, + }); + booking.remainingWeightTons = this.roundTons( + booking.remainingWeightTons - allocatedWeightTons, + ); + wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons); + assignedWeightTons = this.roundTons( + assignedWeightTons + allocatedWeightTons, + ); + + if (booking.remainingWeightTons <= 0) { + bookingIndex += 1; + } + } + + return { + ...wagon, + assignedWeightTons, + allocations, + }; + }); + } + + async getContainerTrainSchedules() { + const schedules = await this.dataSource.getRepository(TrainSchedule).find({ + relations: { + trainSet: { locomotive: true }, + route: true, + originStation: true, + destinationStation: true, + scheduleBookings: true, + }, + order: { scheduledDepartureDate: "DESC", createdAt: "DESC" }, + }); + + return schedules.map((schedule) => ({ + id: schedule.id, + scheduleDate: schedule.scheduledDepartureDate, + routeName: schedule.route?.name ?? null, + origin: + schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + null, + locomotive: schedule.trainSet?.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name ?? null, + } + : null, + wagonCount: schedule.trainSet?.wagonCount ?? 0, + totalWeightTons: this.roundTons( + Number(schedule.trainSet?.totalWeightTons ?? 0), + ), + totalLengthMeters: this.roundTons( + Number(schedule.trainSet?.totalLengthMeters ?? 0), + ), + bookingsCount: schedule.scheduleBookings?.length ?? 0, + status: schedule.status, + })); + } + + async getContainerTrainScheduleById(id: string) { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ + where: { id }, + relations: { + route: true, + trainSet: { + locomotive: true, + wagons: { wagonType: true, allocations: { booking: true } }, + }, + originStation: true, + destinationStation: true, + scheduleBookings: { + booking: { company: true, originYard: true, destinationYard: true }, + }, + }, + }); + + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + return { + id: schedule.id, + status: schedule.status, + route: schedule.route + ? { + id: schedule.route.id, + name: schedule.route.name, + } + : null, + scheduledDepartureDate: schedule.scheduledDepartureDate, + scheduledArrivalDate: schedule.scheduledArrivalDate, + originStation: schedule.originStation, + destinationStation: schedule.destinationStation, + trainSet: schedule.trainSet + ? { + id: schedule.trainSet.id, + status: schedule.trainSet.status, + wagonCount: schedule.trainSet.wagonCount, + totalWeightTons: this.roundTons( + Number(schedule.trainSet.totalWeightTons), + ), + totalLengthMeters: this.roundTons( + Number(schedule.trainSet.totalLengthMeters), + ), + locomotive: schedule.trainSet.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name, + status: schedule.trainSet.locomotive.status, + maxPullWeightTons: this.roundTons( + Number(schedule.trainSet.locomotive.maxPullWeightTons), + ), + maxTrainLengthMeters: this.roundTons( + Number(schedule.trainSet.locomotive.maxTrainLengthMeters), + ), + } + : null, + wagons: [...(schedule.trainSet.wagons ?? [])] + .sort((left, right) => left.sequenceNo - right.sequenceNo) + .map((wagon) => ({ + id: wagon.id, + sequenceNo: wagon.sequenceNo, + capacityTons: this.roundTons(Number(wagon.capacityTons)), + lengthMeters: this.roundTons(Number(wagon.lengthMeters)), + assignedWeightTons: this.roundTons( + Number(wagon.assignedWeightTons), + ), + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, + } + : null, + allocations: + wagon.allocations?.map((allocation) => ({ + id: allocation.id, + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: this.roundTons( + Number(allocation.allocatedWeightTons), + ), + })) ?? [], + })), + } + : null, + bookings: + schedule.scheduleBookings?.map((scheduleBooking) => ({ + id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId, + reference: scheduleBooking.booking?.reference ?? null, + customer: + scheduleBooking.booking?.company?.name ?? + scheduleBooking.booking?.company?.email ?? + null, + weightTons: this.roundTons( + Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0), + ), + status: scheduleBooking.booking?.status ?? null, + })) ?? [], + }; + } + + async cancelTrainSchedule(id: string) { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ + where: { id }, + relations: { trainSet: { locomotive: true } }, + }); + + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(TrainSchedule).update(schedule.id, { + status: "CANCELLED", + }); + + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + status: "CANCELLED", + }); + } + + if (schedule.trainSet?.locomotiveId) { + await manager + .getRepository(Locomotive) + .update(schedule.trainSet.locomotiveId, { + status: "AVAILABLE", + }); + } + }); + + return this.getContainerTrainScheduleById(id); + } + + private async loadBookingsForScheduling(bookingIds: string[]) { + return this.dataSource.getRepository(Booking).find({ + where: { id: In(bookingIds) }, + relations: { + company: true, + originYard: true, + destinationYard: true, + bookingContainers: { containerType: true }, + }, + order: { createdAt: "ASC" }, + }); + } + + private async getActiveRoute(routeId: string) { + const route = await this.dataSource.getRepository(Route).findOne({ + where: { id: routeId }, + }); + + if (!route) { + throw new NotFoundException(`Route ${routeId} not found`); + } + + if (!route.isActive) { + throw new BadRequestException(`Route ${route.name} is inactive`); + } + + return route; + } + + private toUtcDateKey(value: Date | string) { + const date = value instanceof Date ? value : new Date(value); + return date.toISOString().slice(0, 10); + } + + private roundTons(value: number | string | null | undefined) { + const numericValue = typeof value === "number" ? value : Number(value ?? 0); + + if (!Number.isFinite(numericValue)) { + return 0; + } + + return Number(numericValue.toFixed(3)); + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts new file mode 100644 index 000000000..780bc1977 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { TrainSet } from './train-set.entity'; + +@Entity({ schema: 'freight', name: 'train_set_wagons' }) +@Index(['trainSetId', 'sequenceNo'], { unique: true }) +export class TrainSetWagon extends BaseEntity { + @Column({ name: 'train_set_id', type: 'uuid' }) + trainSetId!: string; + + @ManyToOne(() => TrainSet, (trainSet) => trainSet.wagons, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_set_id' }) + trainSet?: TrainSet; + + @Column({ name: 'wagon_type_id', type: 'uuid' }) + wagonTypeId!: string; + + @ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType; + + @Column({ name: 'sequence_no', type: 'int' }) + sequenceNo!: number; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 }) + capacityTons!: number; + + @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) + lengthMeters!: number; + + @Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 }) + assignedWeightTons!: number; + + @OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon) + allocations?: WagonBookingAllocation[]; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts new file mode 100644 index 000000000..9099824d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; + +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from './train-set-wagon.entity'; + +export const TRAIN_SET_STATUSES = [ + 'DRAFT', + 'ASSIGNED', + 'DISPATCHED', + 'COMPLETED', + 'CANCELLED', +] as const; + +export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'train_sets' }) +@Index(['locomotiveId']) +@Index(['status']) +export class TrainSet extends BaseEntity { + @Column({ name: 'locomotive_id', type: 'uuid' }) + locomotiveId!: string; + + @ManyToOne(() => Locomotive, (locomotive) => locomotive.trainSets) + @JoinColumn({ name: 'locomotive_id' }) + locomotive?: Locomotive; + + @Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + totalWeightTons!: number; + + @Column({ name: 'total_length_meters', type: 'numeric', precision: 10, scale: 3 }) + totalLengthMeters!: number; + + @Column({ name: 'wagon_count', type: 'int' }) + wagonCount!: number; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: TrainSetStatus; + + @OneToMany(() => TrainSetWagon, (wagon) => wagon.trainSet) + wagons?: TrainSetWagon[]; + + @OneToOne(() => TrainSchedule, (schedule) => schedule.trainSet) + trainSchedule?: TrainSchedule; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts new file mode 100644 index 000000000..5296b04a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSetWagon } from './entities/train-set-wagon.entity'; + +@Injectable() +export class TrainSetWagonsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSetWagon) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts new file mode 100644 index 000000000..f11052727 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TrainSet } from './entities/train-set.entity'; +import { TrainSetWagon } from './entities/train-set-wagon.entity'; +import { TrainSetWagonsRepository } from './train-set-wagons.repository'; +import { TrainSetsRepository } from './train-sets.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])], + providers: [TrainSetsRepository, TrainSetWagonsRepository], + exports: [TrainSetsRepository, TrainSetWagonsRepository], +}) +export class TrainSetsModule {} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts new file mode 100644 index 000000000..a6cadbf2d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSet } from './entities/train-set.entity'; + +@Injectable() +export class TrainSetsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSet) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts index f41dfa275..f166254a9 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts @@ -1,5 +1,5 @@ -import { Freight } from "@edr/types"; -import { IsEnum, IsNumber, IsOptional, IsString, Min } from "class-validator"; +import { IsString, IsNumber, IsOptional, IsUUID, IsDateString, Min, IsEnum } from 'class-validator'; +import { Freight } from '@edr/types'; export class CreateTrainDto { @IsString() @@ -11,9 +11,45 @@ export class CreateTrainDto { @IsOptional() @IsEnum(Freight.TrainStatus) - status?: Freight.TrainStatus; + status?: Freight.TrainStatus; // ✅ uses enum, not string @IsOptional() @IsString() notes?: string; -} + + @IsOptional() + @IsString() + trainNumber?: string; + + @IsOptional() + @IsString() + trainName?: string; + + @IsOptional() + @IsUUID() + routeId?: string; + + @IsOptional() + @IsUUID() + originStationId?: string; + + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @IsOptional() + @IsDateString() + departureTime?: string; + + @IsOptional() + @IsDateString() + arrivalTime?: string; + + @IsOptional() + @IsString() + locomotiveNumber?: string; + + @IsOptional() + @IsString() + remarks?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts new file mode 100644 index 000000000..cbd36eed9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateTrainDto } from './create-train.dto'; + +export class UpdateTrainDto extends PartialType(CreateTrainDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index c14b66ad0..184b9c88d 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -1,23 +1,58 @@ -import { BaseEntity } from "@edr/api-common"; -import { Freight } from "@edr/types"; -import { Column, Entity } from "typeorm"; +// apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +import { BaseEntity } from '@edr/api-common'; +import { Freight } from '@edr/types'; +import { Column, Entity, OneToMany } from 'typeorm'; +import { Wagon } from '../../wagons/entities/wagon.entity'; -@Entity({ name: "trains" }) +@Entity({ schema: 'freight', name: 'trains' }) export class Train extends BaseEntity { - @Column({ name: "code", type: "varchar", length: 32, unique: true }) + // --- existing fields (keep for backward compatibility) --- + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) code!: string; - @Column({ name: "capacity_tons", type: "numeric", precision: 10, scale: 2 }) + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 2 }) capacityTons!: number; @Column({ - name: "status", - type: "enum", + name: 'status', + type: 'enum', enum: Freight.TrainStatus, default: Freight.TrainStatus.Available, }) status!: Freight.TrainStatus; - @Column({ name: "notes", type: "text", nullable: true }) + @Column({ name: 'notes', type: 'text', nullable: true }) notes?: string | null; -} + + // --- new required fields --- + @Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true }) + trainNumber?: string; + + @Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true }) + trainName?: string; + + @Column({ name: 'route_id', type: 'uuid', nullable: true }) + routeId?: string; + + @Column({ name: 'origin_station_id', type: 'uuid', nullable: true }) + originStationId?: string; + + @Column({ name: 'destination_station_id', type: 'uuid', nullable: true }) + destinationStationId?: string; + + @Column({ name: 'departure_time', type: 'timestamp', nullable: true }) + departureTime?: Date; + + @Column({ name: 'arrival_time', type: 'timestamp', nullable: true }) + arrivalTime?: Date; + + @Column({ name: 'locomotive_number', type: 'varchar', length: 50, nullable: true }) + locomotiveNumber?: string; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string; + + // --- relationships --- + @OneToMany(() => Wagon, (wagon) => wagon.train) + wagons!: Wagon[]; // fixed typo: was 'wagens' +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/trains.controller.ts b/apps/edr-freight-api/src/modules/trains/trains.controller.ts index c087d59e3..c58fc086e 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -1,18 +1,21 @@ import { Body, Controller, + Delete, Get, Param, ParseUUIDPipe, + Patch, Post, + Query, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { CreateTrainDto } from "./dto/create-train.dto"; +import { UpdateTrainDto } from "./dto/update-train.dto"; import { TrainsService } from "./trains.service"; @ApiTags("trains") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth @Controller("trains") export class TrainsController { constructor(private readonly trainsService: TrainsService) {} @@ -25,8 +28,8 @@ export class TrainsController { @Get() @ApiOperation({ summary: "List all trains" }) - findAll() { - return this.trainsService.findAll(); + findAll(@Query() query: Record) { + return this.trainsService.findAll(query); } @Get(":id") @@ -34,4 +37,16 @@ export class TrainsController { findOne(@Param("id", ParseUUIDPipe) id: string) { return this.trainsService.findById(id); } + + @Patch(":id") + @ApiOperation({ summary: "Update a train" }) + update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) { + return this.trainsService.update(id, dto); + } + + @Delete(":id") + @ApiOperation({ summary: "Delete a train" }) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.trainsService.remove(id); + } } diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 094120f33..61098ff40 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -1,15 +1,14 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { Train } from "./entities/train.entity"; -import { TrainsController } from "./trains.controller"; -import { TrainsRepository } from "./trains.repository"; -import { TrainsService } from "./trains.service"; +// apps/edr-freight-api/src/modules/trains/trains.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Train } from './entities/train.entity'; +import { TrainsController } from './trains.controller'; +import { TrainsService } from './trains.service'; @Module({ imports: [TypeOrmModule.forFeature([Train])], controllers: [TrainsController], - providers: [TrainsService, TrainsRepository], - exports: [TrainsService], + providers: [TrainsService], + exports: [TrainsService], // if other modules need it }) -export class TrainsModule {} +export class TrainsModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/trains.service.ts b/apps/edr-freight-api/src/modules/trains/trains.service.ts index db689a19a..9cf37760e 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.service.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.service.ts @@ -1,29 +1,61 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; - -import { CreateTrainDto } from "./dto/create-train.dto"; -import { Train } from "./entities/train.entity"; -import { TrainsRepository } from "./trains.repository"; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { CreateTrainDto } from './dto/create-train.dto'; +import { UpdateTrainDto } from './dto/update-train.dto'; +import { Train } from './entities/train.entity'; @Injectable() export class TrainsService { - constructor(private readonly trainsRepository: TrainsRepository) {} + constructor( + @InjectRepository(Train) + private readonly trainRepo: Repository, + ) {} - /** Register a new train in the fleet. */ create(dto: CreateTrainDto): Promise { - return this.trainsRepository.create(dto); + const train = this.trainRepo.create(dto); + return this.trainRepo.save(train); } - /** List every active train. */ - findAll(): Promise { - return this.trainsRepository.findAll({ order: { code: "ASC" } }); - } + findAll(query: Record = {}): Promise { + const where: FindOptionsWhere[] | FindOptionsWhere = []; + const search = query.search?.trim(); + const status = query.status?.trim(); - /** Get a single train by ID. */ - async findById(id: string): Promise { - const train = await this.trainsRepository.findById(id); - if (!train) { - throw new NotFoundException(`Train ${id} not found`); + if (search) { + where.push({ code: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) }); + where.push({ trainNumber: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) }); + where.push({ trainName: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) }); } + + const sortBy = ['code', 'trainNumber', 'trainName', 'capacityTons', 'status'].includes(query.sortBy ?? '') + ? (query.sortBy as keyof Train) + : 'code'; + const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + return this.trainRepo.find({ + where: search ? where : status ? { status: status as Train['status'] } : {}, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, + take: query.limit ? Number(query.limit) : undefined, + }); + } + + async findById(id: string): Promise { + const train = await this.trainRepo.findOne({ where: { id } }); + if (!train) throw new NotFoundException(`Train ${id} not found`); return train; } + + async update(id: string, dto: UpdateTrainDto): Promise { + const train = await this.findById(id); + Object.assign(train, dto); + // Convert undefined to null for optional fields if needed + return this.trainRepo.save(train); + } + + async remove(id: string): Promise { + const train = await this.findById(id); + await this.trainRepo.remove(train); + } } diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts new file mode 100644 index 000000000..6de5debda --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +const toOptionalNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? undefined : Number(value); + +const toBoolean = ({ value }: { value: unknown }) => { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return value; +}; + +const toStringArray = ({ value }: { value: unknown }) => { + if (Array.isArray(value)) { + return value.map((entry) => String(entry).trim()).filter(Boolean); + } + + if (typeof value !== 'string') return []; + + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +}; + +export class CreateWagonTypeDto { + @ApiProperty({ maxLength: 32, example: 'NW5' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiProperty({ maxLength: 100, example: 'Flat wagon container' }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 70 }) + @Transform(toNumber) + @IsNumber() + @Min(0.001) + capacityTons!: number; + + @ApiProperty({ description: 'Wagon length in meters', example: 14 }) + @Transform(toNumber) + @IsNumber() + @Min(0.001) + lengthMeters!: number; + + @ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 }) + @IsOptional() + @Transform(toOptionalNumber) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ + description: 'Supported load types, e.g. CONTAINER,BULK', + type: [String], + default: [], + }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @IsString({ each: true }) + supportedLoadTypes?: string[]; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts new file mode 100644 index 000000000..846987556 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateWagonTypeDto } from './create-wagon-type.dto'; + +export class UpdateWagonTypeDto extends PartialType(CreateWagonTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts new file mode 100644 index 000000000..f1bfeedea --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; + +@Entity({ schema: 'freight', name: 'wagon_types' }) +@Index(['code']) +@Index(['isActive']) +export class WagonType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name!: string; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 }) + capacityTons!: number; + + @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) + lengthMeters!: number; + + @Column({ name: 'max_wagons_per_train', type: 'int', nullable: true }) + maxWagonsPerTrain?: number | null; + + @Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' }) + supportedLoadTypes!: string[]; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType) + trainSetWagons?: TrainSetWagon[]; +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts new file mode 100644 index 000000000..8f6417220 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards'; + +import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; +import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; +import { WagonTypesService } from './wagon-types.service'; + +@ApiTags('wagon-types') +@Controller('wagon-types') +@ApiBearerAuth() +export class WagonTypesController { + constructor(private readonly wagonTypesService: WagonTypesService) {} + + @Get() + @RuleEngineView('wagon-types') + @ApiOperation({ summary: 'List wagon types' }) + findAll(@Query() query: Record) { + return this.wagonTypesService.findAll({ + isActive: + query.isActive === 'all' + ? undefined + : query.isActive !== undefined + ? query.isActive === 'true' + : true, + page: query.page ? parseInt(query.page, 10) : undefined, + pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); + } + + @Get(':id') + @RuleEngineView('wagon-types') + @ApiOperation({ summary: 'Get a wagon type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonTypesService.findById(id); + } + + @Post() + @RuleEngineManage('wagon-types') + @ApiOperation({ summary: 'Create a wagon type' }) + create(@Body() dto: CreateWagonTypeDto) { + return this.wagonTypesService.create(dto); + } + + @Patch(':id') + @RuleEngineManage('wagon-types') + @ApiOperation({ summary: 'Update a wagon type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) { + return this.wagonTypesService.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('wagon-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a wagon type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonTypesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts new file mode 100644 index 000000000..d2d770585 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WagonType } from './entities/wagon-type.entity'; +import { WagonTypesController } from './wagon-types.controller'; +import { WagonTypesRepository } from './wagon-types.repository'; +import { WagonTypesService } from './wagon-types.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([WagonType])], + controllers: [WagonTypesController], + providers: [WagonTypesRepository, WagonTypesService], + exports: [WagonTypesRepository, WagonTypesService], +}) +export class WagonTypesModule {} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts new file mode 100644 index 000000000..ce7166e67 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts @@ -0,0 +1,20 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WagonType } from './entities/wagon-type.entity'; + +@Injectable() +export class WagonTypesRepository extends BaseRepository { + constructor( + @InjectRepository(WagonType) + repository: Repository, + ) { + super(repository); + } + + findByCode(code: string): Promise { + return this.repository.findOne({ where: { code } }); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts new file mode 100644 index 000000000..ec69bb76a --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -0,0 +1,120 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsOrder } from 'typeorm'; + +import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; +import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; +import { WagonType } from './entities/wagon-type.entity'; +import { WagonTypesRepository } from './wagon-types.repository'; + +type WagonTypeListFilter = { + isActive?: boolean; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: string; +}; + +@Injectable() +export class WagonTypesService { + constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} + + async findAll(filter: WagonTypeListFilter = {}): Promise<{ + data: WagonType[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 500; + const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes( + filter.sortBy ?? '', + ) + ? (filter.sortBy as keyof WagonType) + : 'code'; + const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + const [data, total] = await this.wagonTypesRepository.findAndCount({ + where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data, + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + async findById(id: string): Promise { + const wagonType = await this.wagonTypesRepository.findById(id); + + if (!wagonType) { + throw new NotFoundException(`Wagon type ${id} not found`); + } + + return wagonType; + } + + async findByCode(code: string): Promise { + const wagonType = await this.wagonTypesRepository.findByCode(code); + if (!wagonType) { + throw new NotFoundException(`Wagon type ${code} not found`); + } + return wagonType; + } + + async create(dto: CreateWagonTypeDto): Promise { + const code = dto.code.trim().toUpperCase(); + const existing = await this.wagonTypesRepository.findByCode(code); + + if (existing) { + throw new ConflictException(`Wagon type code "${code}" already exists`); + } + + return this.wagonTypesRepository.create({ + code, + name: dto.name.trim(), + capacityTons: dto.capacityTons, + lengthMeters: dto.lengthMeters, + maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null, + supportedLoadTypes: dto.supportedLoadTypes ?? [], + isActive: dto.isActive ?? true, + }); + } + + async update(id: string, dto: UpdateWagonTypeDto): Promise { + const wagonType = await this.findById(id); + const nextCode = dto.code?.trim().toUpperCase(); + + if (nextCode && nextCode !== wagonType.code) { + const existing = await this.wagonTypesRepository.findByCode(nextCode); + if (existing) { + throw new ConflictException(`Wagon type code "${nextCode}" already exists`); + } + } + + const updated = await this.wagonTypesRepository.update(id, { + ...dto, + ...(nextCode ? { code: nextCode } : {}), + ...(dto.name ? { name: dto.name.trim() } : {}), + maxWagonsPerTrain: + dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null, + supportedLoadTypes: dto.supportedLoadTypes ?? undefined, + }); + + if (!updated) { + throw new NotFoundException(`Wagon type ${id} not found`); + } + + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.wagonTypesRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts new file mode 100644 index 000000000..66a837e68 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts @@ -0,0 +1,11 @@ +import { IsUUID, IsOptional, IsInt, Min } from 'class-validator'; + +export class AssignWagonToTrainDto { + @IsUUID() + trainId!: string; + + @IsOptional() + @IsInt() + @Min(1) + sequenceNumber?: number; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts new file mode 100644 index 000000000..c3108d68b --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -0,0 +1,34 @@ +import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator'; + +export class CreateWagonDto { + @IsString() + wagonNumber!: string; + + @IsUUID() + wagonTypeId!: string; + + @IsOptional() + @IsUUID() + trainId?: string; + + @IsOptional() + @IsInt() + @Min(1) + sequenceNumber?: number; + + @IsNumber() + @Min(0) + tareWeight!: number; + + @IsNumber() + @Min(0) + maxPayloadWeight!: number; + + @IsOptional() + @IsIn(['AVAILABLE', 'ASSIGNED', 'MAINTENANCE', 'RETIRED']) + status?: string; + + @IsOptional() + @IsString() + notes?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts new file mode 100644 index 000000000..0395adb8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsUUID } from 'class-validator'; + +export class ReorderWagonsDto { + @IsArray() + @IsUUID(4, { each: true }) + wagonIds!: string[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts new file mode 100644 index 000000000..3414d1f2c --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateWagonDto } from './create-wagon.dto'; + +export class UpdateWagonDto extends PartialType(CreateWagonDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts new file mode 100644 index 000000000..cdff14330 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -0,0 +1,41 @@ +// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; +import { Train } from '../../trains/entities/train.entity'; +import { Container } from '../../container-management/entities/container.entity'; + +@Entity({ name: 'wagons', schema: 'freight' }) +export class Wagon extends BaseEntity { + @Column({ unique: true, name: 'wagon_number' }) + wagonNumber!: string; + + @Column({ name: 'wagon_type_id', type: 'uuid' }) + wagonTypeId!: string; + + @Column({ name: 'train_id', type: 'uuid', nullable: true }) + trainId!: string | null; + + @Column({ name: 'sequence_number', type: 'int', nullable: true }) + sequenceNumber!: number | null; + + @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) + tareWeight!: number; + + @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 }) + maxPayloadWeight!: number; + + @Column({ type: 'varchar', default: 'AVAILABLE' }) + status!: string; // AVAILABLE, ASSIGNED, MAINTENANCE, RETIRED + + @Column({ type: 'text', nullable: true }) + notes!: string | null; + + // Relationship to Train + @ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'train_id' }) + train!: Train | null; + + // Relationship to Container + @OneToMany(() => Container, (container) => container.wagon) + containers!: Container[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts new file mode 100644 index 000000000..fd2305f93 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateWagonDto } from './dto/create-wagon.dto'; +import { UpdateWagonDto } from './dto/update-wagon.dto'; +import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; +import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { WagonsService } from './wagons.service'; + +@ApiTags('wagons') +@Controller('wagons') +export class WagonsController { + constructor(private readonly wagonsService: WagonsService) {} + + @Post() + @ApiOperation({ summary: 'Create a new wagon' }) + create(@Body() dto: CreateWagonDto) { + return this.wagonsService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all wagons' }) + findAll(@Query() query: Record) { + return this.wagonsService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a wagon by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a wagon' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { + return this.wagonsService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a wagon' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.remove(id); + } + + @Post(':id/assign-train') + @ApiOperation({ summary: 'Assign wagon to a train' }) + assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { + return this.wagonsService.assignToTrain(id, dto); + } + + @Post(':id/unassign-train') + @ApiOperation({ summary: 'Unassign wagon from train' }) + unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.unassignFromTrain(id); + } +} + +// Separate controller for train‑specific reorder (registered in module) +@Controller('trains/:trainId/reorder-wagons') +export class TrainWagonsReorderController { + constructor(private readonly wagonsService: WagonsService) {} + + @Post() + @ApiOperation({ summary: 'Reorder wagons of a train' }) + reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { + return this.wagonsService.reorderWagons(trainId, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts new file mode 100644 index 000000000..914de4cbd --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Wagon } from './entities/wagon.entity'; +import { Train } from '../trains/entities/train.entity'; +import { WagonsController, TrainWagonsReorderController } from './wagons.controller'; +import { WagonsService } from './wagons.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Wagon, Train])], + controllers: [WagonsController, TrainWagonsReorderController], + providers: [WagonsService], + exports: [WagonsService], +}) +export class WagonsModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts b/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts new file mode 100644 index 000000000..f0e12842e --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Wagon } from './entities/wagon.entity'; + +@Injectable() +export class WagonsRepository extends BaseRepository { + constructor( + @InjectRepository(Wagon) + repository: Repository, + ) { + super(repository); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts new file mode 100644 index 000000000..bc0b52a69 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -0,0 +1,122 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; +import { CreateWagonDto } from './dto/create-wagon.dto'; +import { UpdateWagonDto } from './dto/update-wagon.dto'; +import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; +import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { Wagon } from './entities/wagon.entity'; +import { Train } from '../trains/entities/train.entity'; + +@Injectable() +export class WagonsService { + constructor( + @InjectRepository(Wagon) + private readonly wagonRepo: Repository, + @InjectRepository(Train) + private readonly trainRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async create(dto: CreateWagonDto): Promise { + const wagon = this.wagonRepo.create(dto); + // Convert undefined to null for nullable fields + if (dto.trainId === undefined) wagon.trainId = null; + if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; + return this.wagonRepo.save(wagon); + } + + async findAll(query: Record = {}): Promise { + const where: FindOptionsWhere[] | FindOptionsWhere = []; + const search = query.search?.trim(); + const status = query.status?.trim(); + const trainId = query.trainId?.trim(); + + if (search) { + where.push({ + wagonNumber: ILike(`%${search}%`), + ...(status ? { status } : {}), + ...(trainId ? { trainId } : {}), + }); + } + + const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'sequenceNumber'].includes(query.sortBy ?? '') + ? (query.sortBy as keyof Wagon) + : 'wagonNumber'; + const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + return this.wagonRepo.find({ + where: search ? where : { ...(status ? { status } : {}), ...(trainId ? { trainId } : {}) }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, + take: query.limit ? Number(query.limit) : undefined, + }); + } + + async findById(id: string): Promise { + const wagon = await this.wagonRepo.findOne({ where: { id } }); + if (!wagon) throw new NotFoundException(`Wagon ${id} not found`); + return wagon; + } + + async update(id: string, dto: UpdateWagonDto): Promise { + const wagon = await this.findById(id); + Object.assign(wagon, dto); + return this.wagonRepo.save(wagon); + } + + async remove(id: string): Promise { + const wagon = await this.findById(id); + await this.wagonRepo.remove(wagon); + } + + async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { + const wagon = await this.findById(wagonId); + if (wagon.status === 'ASSIGNED') { + throw new ConflictException('Wagon already assigned to a train'); + } + + const train = await this.trainRepo.findOne({ where: { id: dto.trainId } }); + if (!train) throw new NotFoundException('Train not found'); + + let sequence: number | null = dto.sequenceNumber ?? null; + if (sequence === null) { + const maxSeq = await this.wagonRepo + .createQueryBuilder('w') + .select('MAX(w.sequenceNumber)', 'max') + .where('w.trainId = :trainId', { trainId: train.id }) + .getRawOne(); + sequence = (maxSeq?.max ?? 0) + 1; + } + + wagon.trainId = train.id; + wagon.sequenceNumber = sequence; + wagon.status = 'ASSIGNED'; + return this.wagonRepo.save(wagon); + } + + async unassignFromTrain(wagonId: string): Promise { + const wagon = await this.findById(wagonId); + wagon.trainId = null; + wagon.sequenceNumber = null; + wagon.status = 'AVAILABLE'; + return this.wagonRepo.save(wagon); + } + + async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + for (let i = 0; i < dto.wagonIds.length; i++) { + await queryRunner.manager.update(Wagon, dto.wagonIds[i], { sequenceNumber: i + 1 }); + } + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } +} diff --git a/apps/edr-freight-api/src/scripts/create-freight-schema.js b/apps/edr-freight-api/src/scripts/create-freight-schema.js new file mode 100644 index 000000000..60d98616c --- /dev/null +++ b/apps/edr-freight-api/src/scripts/create-freight-schema.js @@ -0,0 +1,25 @@ +const { Client } = require('pg'); + +(async function createSchema(){ + const client = new Client({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: '', + database: 'edr_freight', + }); + + try { + console.log('Connecting to Postgres...'); + await client.connect(); + console.log('Creating schema freight if not exists...'); + await client.query('CREATE SCHEMA IF NOT EXISTS freight'); + console.log('Schema ensured.'); + await client.end(); + process.exit(0); + } catch (err) { + console.error('Failed to create schema:', err); + try { await client.end(); } catch {} + process.exit(1); + } +})(); diff --git a/apps/edr-freight-api/src/scripts/create-freight-schema.ts b/apps/edr-freight-api/src/scripts/create-freight-schema.ts new file mode 100644 index 000000000..5f66d2e76 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/create-freight-schema.ts @@ -0,0 +1,27 @@ +import { Client } from 'pg'; + +async function createSchema() { + const client = new Client({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: '', + database: 'edr_freight', + }); + + try { + console.log('Connecting to Postgres...'); + await client.connect(); + console.log('Creating schema freight if not exists...'); + await client.query('CREATE SCHEMA IF NOT EXISTS freight'); + console.log('Schema ensured.'); + await client.end(); + process.exit(0); + } catch (err) { + console.error('Failed to create schema:', err); + try { await client.end(); } catch {} + process.exit(1); + } +} + +createSchema(); diff --git a/apps/edr-freight-api/src/scripts/run-migrations.ts b/apps/edr-freight-api/src/scripts/run-migrations.ts new file mode 100644 index 000000000..b5cb23078 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/run-migrations.ts @@ -0,0 +1,21 @@ +import { AppDataSource } from '../data-source'; + +async function runMigrations() { + try { + console.log('Initializing datasource...'); + await AppDataSource.initialize(); + console.log('Datasource initialized. Running migrations...'); + const migrations = await AppDataSource.runMigrations(); + console.log(`Applied ${migrations.length} migrations.`); + await AppDataSource.destroy(); + process.exit(0); + } catch (err) { + console.error('Migration run failed:', err); + try { + await AppDataSource.destroy(); + } catch {} + process.exit(1); + } +} + +runMigrations(); diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts new file mode 100644 index 000000000..edf19adbb --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -0,0 +1,343 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { randomUUID } from "crypto"; +import { DataSource } from "typeorm"; + +import { BookingContainer } from "../modules/bookings/entities/booking-container.entity"; +import { Booking } from "../modules/bookings/entities/booking.entity"; +import { + Company, + CompanyStatus, + CompanyType, +} from "../modules/companies/entities/company.entity"; +import { Locomotive } from "../modules/locomotives/entities/locomotive.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; +import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; + +const SEED_FLAG = "SEED_DEMO_BOOKINGS"; + +const SERVICE_TYPE_CODE = "RAIL_CONTAINER"; +const COMPANY_EMAIL = "train-scheduling-demo@edr.local"; +const COMPANY_TIN = "1234567890"; + +const YARDS = [ + { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + }, +]; + +const CONTAINER_TYPES = [ + { code: "20FT", label: "20FT", sizeFt: 20 }, + { code: "40FT", label: "40FT", sizeFt: 40 }, +]; + +const DEMO_BOOKINGS = [ + { + reference: "BKG-CONT-001", + containerCode: "40FT", + quantity: 20, + totalWeightTons: 500, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-002", + containerCode: "20FT", + quantity: 10, + totalWeightTons: 300, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-003", + containerCode: "40FT", + quantity: 15, + totalWeightTons: 450, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-007", + containerCode: "20FT", + quantity: 6, + totalWeightTons: 180, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-008", + containerCode: "40FT", + quantity: 4, + totalWeightTons: 120, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-009", + containerCode: "20FT", + quantity: 5, + totalWeightTons: 110, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-004", + containerCode: "40FT", + quantity: 12, + totalWeightTons: 360, + originCode: "ADDIS_ABABA", + destinationCode: "DIRE_DAWA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-005", + containerCode: "20FT", + quantity: 8, + totalWeightTons: 160, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-21T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-006", + containerCode: "40FT", + quantity: 80, + totalWeightTons: 3600, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, +]; + +@Injectable() +export class DemoBookingsSeeder { + private readonly logger = new Logger(DemoBookingsSeeder.name); + + constructor(private readonly dataSource: DataSource) { } + + async run() { + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + if (!shouldSeed) { + this.logger.log( + `Skipping demo booking seed because ${SEED_FLAG} is not enabled`, + ); + return; + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WagonType).upsert( + { + code: "NW5", + name: "Flat Wagon", + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ["CONTAINER"], + isActive: true, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Locomotive).upsert( + [ + { + code: "LOC-001", + name: "Demo Locomotive 1", + locomotiveType: 'ELECTRIC', + maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, + status: "AVAILABLE", + }, + { + code: "LOC-002", + name: "Demo Locomotive 2", + locomotiveType: 'DIESEL', + maxPullWeightTons: 2500, + maxTrainLengthMeters: 760, + status: "AVAILABLE", + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: "Rail Container Service", + description: "Temporary service type for train scheduling demos", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: "Train Scheduling Demo Customer", + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: COMPANY_TIN, + vatNumber: "1234567890", + fanNumber: "1234567890123456", + country: "Ethiopia", + address: "Demo Address", + phone: "251900000001", + email: COMPANY_EMAIL, + website: null, + contactPersonName: "Train Scheduling", + contactPersonPhone: "251900000001", + generalManagerName: "Demo Manager", + generalManagerEmail: COMPANY_EMAIL, + generalManagerPhone: "251900000001", + }, + { conflictPaths: { tin: true } }, + ); + + const [serviceType, company, yards, containerTypes] = await Promise.all([ + manager + .getRepository(ServiceType) + .findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager + .getRepository(Company) + .findOneByOrFail({ tin: COMPANY_TIN }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [ + containerType.code, + containerType, + ]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get( + demoBooking.containerCode, + ); + + if (!origin || !destination || !containerType) { + throw new Error( + `demo_booking_seed_dependency_missing:${demoBooking.reference}`, + ); + } + + const vgmPerUnitTons = + demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + companyId: company.id, + status: demoBooking.status, + scheduledDate: new Date(demoBooking.scheduledDate), + totalAmount: 0, + paymentStatus: demoBooking.paymentStatus, + contractType: "NEW", + serviceTypeId: serviceType.id, + equipmentReturn: "WITHOUT_RETURN", + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: "IMPORT", + freightType: "CONTAINER", + cargoTypeId: null, + cargoFreeText: null, + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + paymentCurrency: "USD", + allowConsolidation: false, + priorityScore: 0, + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager + .getRepository(BookingContainer) + .delete({ bookingId: booking.id }); + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70), + weightLimitRuleId: null, + isOverweight: demoBooking.totalWeightTons > 70, + overweightExcessTons: + demoBooking.totalWeightTons > 70 + ? demoBooking.totalWeightTons - 70 + : null, + }); + } + }); + + this.logger.log("Seeded demo train scheduling data"); + } +} diff --git a/apps/edr-freight-api/src/seed/demo-users.seeder.ts b/apps/edr-freight-api/src/seed/demo-users.seeder.ts new file mode 100644 index 000000000..8d886e3aa --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-users.seeder.ts @@ -0,0 +1,213 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; +import { + Employee, + Organization, + Permission, + Role, + RolePermission, + User, + UserCredential, + UserRole, +} from "@tria-plc/iamapi-common"; +import { DataSource } from "typeorm"; + +const SEED_FLAG = "SEED_DEMO_USERS"; + +const DEMO_ORG_KEY = "demo_iam"; +const DEMO_ORG_NAME = { en: "Demo IAM" }; + +const DEMO_PERMISSIONS = [ + { key: "can:demo:user1", name: { en: "Can access demo user1" } }, + { key: "can:demo:user2", name: { en: "Can access demo user2" } }, +]; + +const DEMO_ROLES = [ + { key: "demo_user1", name: { en: "Demo User1" } }, + { key: "demo_user2", name: { en: "Demo User2" } }, +]; + +const DEMO_USERS = [ + { + email: "user@gmail.com", + username: "user", + name: { en: "Demo User 1" }, + roleKey: "demo_user1", + }, + { + email: "user2@gmail.com", + username: "user2", + name: { en: "Demo User 2" }, + roleKey: "demo_user2", + }, +]; + +@Injectable() +export class DemoUsersSeeder { + private readonly logger = new Logger(DemoUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + if (!shouldSeed) { + this.logger.log(`Skipping demo user seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + const organizationRepository = manager.getRepository(Organization); + const employeeRepository = manager.getRepository(Employee); + const permissionRepository = manager.getRepository(Permission); + const roleRepository = manager.getRepository(Role); + const rolePermissionRepository = manager.getRepository(RolePermission); + const userRepository = manager.getRepository(User); + const userCredentialRepository = manager.getRepository(UserCredential); + const userRoleRepository = manager.getRepository(UserRole); + + await organizationRepository.upsert( + { + key: DEMO_ORG_KEY, + name: DEMO_ORG_NAME, + // status defaults to ACTIVE in IAM entity + isGovernmentOrganization: true, + }, + { conflictPaths: { key: true } }, + ); + + const organization = await organizationRepository.findOne({ + where: { key: DEMO_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) { + throw new Error("demo_org_seed_failed"); + } + + await permissionRepository.upsert(DEMO_PERMISSIONS, { + conflictPaths: { key: true }, + }); + + await roleRepository.upsert(DEMO_ROLES, { + conflictPaths: { key: true }, + }); + + const roles = await roleRepository.find({ where: DEMO_ROLES.map((r) => ({ key: r.key })) }); + const permissions = await permissionRepository.find({ + where: DEMO_PERMISSIONS.map((p) => ({ key: p.key })), + }); + + const roleByKey = new Map(roles.map((r) => [r.key, r])); + const permissionByKey = new Map(permissions.map((p) => [p.key, p])); + + const superAdminRole = await roleRepository.findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + const rolePermissionsToUpsert = [ + { + roleId: roleByKey.get("demo_user1")!.id, + permissionId: permissionByKey.get("can:demo:user1")!.id, + }, + { + roleId: roleByKey.get("demo_user2")!.id, + permissionId: permissionByKey.get("can:demo:user2")!.id, + }, + ...(superAdminRole + ? ([ + { + roleId: superAdminRole.id, + permissionId: permissionByKey.get("can:demo:user1")!.id, + }, + { + roleId: superAdminRole.id, + permissionId: permissionByKey.get("can:demo:user2")!.id, + }, + ] as Array<{ roleId: string; permissionId: string }>) + : []), + ]; + + await rolePermissionRepository.upsert(rolePermissionsToUpsert, { + conflictPaths: { roleId: true, permissionId: true }, + }); + + const hashedPassword = await hashPassword("12345678"); + + for (const demoUser of DEMO_USERS) { + const existingUser = await userRepository.findOne({ + where: { email: demoUser.email }, + select: { id: true, email: true }, + }); + + let user = existingUser; + if (!user) { + user = await userRepository.save( + userRepository.create({ + email: demoUser.email, + username: demoUser.username, + name: demoUser.name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } + + // Ensure an active credential exists for login. + const activeCredentialExists = await userCredentialRepository.exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await userCredentialRepository.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + // Login query requires a current employee in an ACTIVE organization. + const employeeExists = await employeeRepository.exists({ + where: { + userId: user.id, + organizationId: organization.id, + isCurrent: true, + }, + }); + + if (!employeeExists) { + await employeeRepository.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: demoUser.name, + }); + } + + const role = roleByKey.get(demoUser.roleKey); + if (!role) { + throw new Error(`missing_role:${demoUser.roleKey}`); + } + + await userRoleRepository.upsert( + { + userId: user.id, + roleId: role.id, + organizationId: organization.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + } + }); + + this.logger.log( + "Seeded demo users + permissions (user@gmail.com, user2@gmail.com; permissions can:demo:user1/can:demo:user2)", + ); + } +} 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..c2705673f --- /dev/null +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -0,0 +1,269 @@ +import { + BOOKING_RULE_ENGINE_PERMISSIONS, + BOOKING_RULE_ENGINE_PERMISSION_KEYS, + ROLE_PERMISSION_PRESETS, +} from './freight-permissions.registry'; + +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, + ...BOOKING_RULE_ENGINE_PERMISSIONS, +]; + +export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry'; + +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_line_staff", + name: { en: "EDR Line Staff" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff], + }, + { + key: "edr_director", + name: { en: "EDR Director" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.director], + }, + { + key: "edr_ceo", + name: { en: "EDR CEO" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo], + }, + { + key: "edr_finance", + name: { en: "EDR Finance" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.finance], + }, + { + key: "edr_marketing", + name: { en: "EDR Marketing" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], + }, + { + key: "edr_org_manager", + name: { en: "EDR Org Manager" }, + permissionKeys: [ + ...BOOKING_RULE_ENGINE_PERMISSION_KEYS, + ...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key), + ...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_POSITION_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), + ...POSITION_TYPE_PERMISSIONS.map((p) => p.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 new file mode 100644 index 000000000..8243ef267 --- /dev/null +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -0,0 +1,208 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + Organization, + OrganizationConfiguration, + Permission, + Role, + RolePermission, +} from "@tria-plc/iamapi-common"; +import { DataSource, EntityManager, In } from "typeorm"; + +import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; +import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; +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"; + +type SeedOrganization = { + id: string; + key: string; +}; + +@Injectable() +export class EdrOrgSeeder { + private readonly logger = new Logger(EdrOrgSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (!this.shouldSeed()) { + this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + const organization = await this.ensureOrganization(manager); + + await this.ensureOrganizationConfiguration(manager, organization.id); + await this.ensureRoles(manager, EDR_FREIGHT_ROLES); + await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); + await this.ensureSuperAdminPermissions(manager); + }); + + 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 }, + }); + + if (!organization) { + const insertResult = await organizationRepository.insert({ + key: EDR_ORG_KEY, + name: EDR_ORG_NAME, + isGovernmentOrganization: true, + }); + + this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`); + + return { + id: insertResult.identifiers[0]?.id as string, + key: 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, + canCreateBranchByItself: true, + canStartReceivingRecord: true, + }, { + conflictPaths: { organizationId: true }, + }); + + this.logger.log( + `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`); + } + + private async ensureSuperAdminPermissions(manager: EntityManager) { + const role = await manager.getRepository(Role).findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + if (!role) { + this.logger.warn( + `Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`, + ); + return; + } + + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) }, + select: { id: true, key: true }, + }); + + if (!permissions.length) { + this.logger.warn('No booking/rule-engine permissions found for super_admin'); + return; + } + + await manager.getRepository(RolePermission).upsert( + permissions.map((permission) => ({ + roleId: role.id, + permissionId: permission.id, + })), + { conflictPaths: { roleId: true, permissionId: true } }, + ); + + this.logger.log( + `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, + ); + } +} diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts new file mode 100644 index 000000000..2ef1f79f0 --- /dev/null +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -0,0 +1,125 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity"; +import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; + +const COMPANY_ONBOARDING_DOCUMENTS = [ + { + code: "company_onboarding_documents_customer", + label: "Customer onboarding documents", + entity: "customer", + }, + { + code: "company_onboarding_documents_forwarder", + label: "Forwarder onboarding documents", + entity: "other", + }, + { + code: "company_onboarding_documents_transporter", + label: "Transporter onboarding documents", + entity: "other", + }, + { + code: "company_onboarding_documents_forwarder_dj", + label: "Djibouti forwarder onboarding documents", + entity: "other", + }, +] as const; + +const COMPANY_ONBOARDING_DESCRIPTION = + "Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers."; + +const COMPANY_ONBOARDING_FIELDS = [ + { + fileKey: "business_license", + fileLabel: "Business License / Trade License", + helpText: "Verified against the government trade system during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder: 1, + }, + { + fileKey: "tin_certificate", + fileLabel: "TIN Certificate", + helpText: "Verified against the TIN registry during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder: 2, + }, + { + fileKey: "national_id_passport", + fileLabel: "National ID / Passport", + helpText: "Verified against the National ID API during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder: 3, + }, +] as const; + +@Injectable() +export class FileUploadSettingsSeeder { + private readonly logger = new Logger(FileUploadSettingsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + const settingRepository = manager.getRepository(FileUploadSetting); + const fieldRepository = manager.getRepository(FileUploadField); + + for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) { + await settingRepository.upsert( + { + code: documentSetting.code, + label: documentSetting.label, + description: COMPANY_ONBOARDING_DESCRIPTION, + entity: documentSetting.entity, + }, + { + conflictPaths: { code: true }, + }, + ); + + const setting = await settingRepository.findOne({ + where: { code: documentSetting.code }, + select: { id: true, code: true }, + }); + + if (!setting) { + throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`); + } + + await fieldRepository.delete({ settingId: setting.id }); + + await fieldRepository.insert( + COMPANY_ONBOARDING_FIELDS.map((field, index) => ({ + settingId: setting.id, + fileKey: field.fileKey, + fileLabel: field.fileLabel, + helpText: field.helpText, + isRequired: field.isRequired, + isMultiple: field.isMultiple, + maxFiles: field.maxFiles, + allowedExtensions: [...field.allowedExtensions], + maxSizeMb: field.maxSizeMb, + displayOrder: field.displayOrder ?? index + 1, + })), + ); + } + }); + + this.logger.log( + "Ensured company onboarding file upload settings for external companies", + ); + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts new file mode 100644 index 000000000..e82032cff --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -0,0 +1,154 @@ +const EDR_FREIGHT_APP_KEY = 'edr_freight_app'; + +export type FreightPermissionSeed = { + id: string; + key: string; + name: { am: string; en: string }; + applicationKey: string; +}; + +export const RULE_ENGINE_RESOURCE_SLUGS = [ + 'cargo-types', + 'container-types', + 'wagon-types', + 'service-types', + 'yards', + 'shipping-lines', + 'weight-limit-rules', + 'surcharge-types', + 'priority-rules', + 'rates', + 'approval-rules', +] as const; + +export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; + +const slugToResourceKey = (slug: RuleEngineResourceSlug): string => + slug.replace(/-/g, '_'); + +const perm = ( + id: string, + key: string, + en: string, +): FreightPermissionSeed => ({ + id, + key, + name: { am: en, en }, + applicationKey: EDR_FREIGHT_APP_KEY, +}); + +export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ + perm('a1000001-0001-4000-8000-000000000001', 'edr_freight_app:bookings:view', 'View bookings'), + perm('a1000001-0001-4000-8000-000000000002', 'edr_freight_app:bookings:staff_accept', 'Accept booking intake'), + perm('a1000001-0001-4000-8000-000000000003', 'edr_freight_app:bookings:request_changes', 'Request booking changes'), + perm('a1000001-0001-4000-8000-000000000004', 'edr_freight_app:bookings:reject', 'Reject booking submission'), + perm('a1000001-0001-4000-8000-000000000005', 'edr_freight_app:bookings:approve_line_staff', 'Approve as line staff'), + perm('a1000001-0001-4000-8000-000000000006', 'edr_freight_app:bookings:approve_director', 'Approve as director'), + perm('a1000001-0001-4000-8000-000000000007', 'edr_freight_app:bookings:approve_ceo', 'Approve as CEO'), + perm('a1000001-0001-4000-8000-000000000008', 'edr_freight_app:bookings:reject_approval', 'Reject at approval step'), + perm('a1000001-0001-4000-8000-000000000009', 'edr_freight_app:bookings:generate_contract', 'Generate contract'), + perm('a1000001-0001-4000-8000-00000000000a', 'edr_freight_app:bookings:sign_staff', 'Staff contract signature'), + perm('a1000001-0001-4000-8000-00000000000b', 'edr_freight_app:bookings:payment_pnr', 'Generate PNR'), + perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'), + perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), + perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), +]; + +const RULE_ENGINE_PERMISSION_IDS: Record = { + 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' }, + 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' }, + 'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' }, + 'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' }, + yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' }, + 'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' }, + 'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' }, + 'surcharge-types': { view: 'b2000001-0001-4000-8000-00000000000d', manage: 'b2000001-0001-4000-8000-00000000000e' }, + 'priority-rules': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' }, + rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' }, + 'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' }, +}; + +export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( + (slug) => { + const resource = slugToResourceKey(slug); + const ids = RULE_ENGINE_PERMISSION_IDS[slug]; + return [ + perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), + perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`), + ]; + }, +); + +export const BOOKING_RULE_ENGINE_PERMISSIONS = [ + ...BOOKING_PERMISSIONS, + ...RULE_ENGINE_PERMISSIONS, +]; + +export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( + (p) => p.key, +); + +export const FREIGHT_PERMS = { + bookings: { + view: 'edr_freight_app:bookings:view', + staffAccept: 'edr_freight_app:bookings:staff_accept', + requestChanges: 'edr_freight_app:bookings:request_changes', + reject: 'edr_freight_app:bookings:reject', + approveLineStaff: 'edr_freight_app:bookings:approve_line_staff', + approveDirector: 'edr_freight_app:bookings:approve_director', + approveCeo: 'edr_freight_app:bookings:approve_ceo', + rejectApproval: 'edr_freight_app:bookings:reject_approval', + generateContract: 'edr_freight_app:bookings:generate_contract', + signStaff: 'edr_freight_app:bookings:sign_staff', + operations: 'edr_freight_app:bookings:operations', + cancel: 'edr_freight_app:bookings:cancel', + }, + ruleEngine: { + view: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, + manage: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, + }, +} as const; + +const allRuleEngineViewKeys = () => + RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s)); + +export const ROLE_PERMISSION_PRESETS = { + lineStaff: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.staffAccept, + FREIGHT_PERMS.bookings.requestChanges, + FREIGHT_PERMS.bookings.reject, + FREIGHT_PERMS.bookings.approveLineStaff, + FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.bookings.cancel, + ...allRuleEngineViewKeys(), + ], + director: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.approveDirector, + FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.bookings.generateContract, + ...allRuleEngineViewKeys(), + ], + ceo: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.approveCeo, + FREIGHT_PERMS.bookings.rejectApproval, + ...allRuleEngineViewKeys(), + ], + finance: [FREIGHT_PERMS.bookings.view], + marketing: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.generateContract, + FREIGHT_PERMS.bookings.signStaff, + ], + orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], +} as const; + +export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ + key: p.key, + label: p.name.en, + module: p.key.includes(':bookings:') ? 'bookings' : 'rule_engine', +})); diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts new file mode 100644 index 000000000..06b9afa94 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -0,0 +1,127 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { hashPassword } from '@tria-plc/api-common/utils/argon'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, +} from '@tria-plc/iamapi-common'; +import { DataSource } from 'typeorm'; + +const SEED_FLAG = 'SEED_FREIGHT_STAFF'; +const EDR_ORG_KEY = 'edr_freight'; + +const STAFF_USERS = [ + { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, +] as const; + +@Injectable() +export class FreightStaffUsersSeeder { + private readonly logger = new Logger(FreightStaffUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping freight staff seed because ${SEED_FLAG} is not enabled`); + return; + } + + const password = + process.env.DEFAULT_PASSWORD?.trim() || '12345678'; + + await this.dataSource.transaction(async (manager) => { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) { + throw new Error(`missing_organization:${EDR_ORG_KEY}`); + } + + const roleRepository = manager.getRepository(Role); + const userRepository = manager.getRepository(User); + const userCredentialRepository = manager.getRepository(UserCredential); + const userRoleRepository = manager.getRepository(UserRole); + const employeeRepository = manager.getRepository(Employee); + + const hashedPassword = await hashPassword(password); + + for (const staff of STAFF_USERS) { + const role = await roleRepository.findOne({ + where: { key: staff.roleKey }, + select: { id: true, key: true }, + }); + + if (!role) { + throw new Error(`missing_role:${staff.roleKey}`); + } + + let user = await userRepository.findOne({ + where: { email: staff.email }, + select: { id: true, email: true }, + }); + + if (!user) { + user = await userRepository.save( + userRepository.create({ + email: staff.email, + username: staff.username, + name: { en: staff.username }, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + this.logger.log(`Seeded freight staff user ${staff.email}`); + } + + const activeCredentialExists = await userCredentialRepository.exists({ + where: { userId: user.id, isActive: true }, + }); + + if (!activeCredentialExists) { + await userCredentialRepository.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + await userRoleRepository.upsert( + { + userId: user.id, + roleId: role.id, + organizationId: organization.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + const employeeExists = await employeeRepository.exists({ + where: { + userId: user.id, + organizationId: organization.id, + isCurrent: true, + }, + }); + + if (!employeeExists) { + await employeeRepository.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: { en: staff.username }, + }); + } + } + }); + + this.logger.log('Ensured freight staff users (linestaff@, director@, ceo@)'); + } +} diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts new file mode 100644 index 000000000..fb8fbf2e6 --- /dev/null +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -0,0 +1,802 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity"; +import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; +import { PriorityRule } from "../modules/rule-engine/entities/priority-rule.entity"; +import { Rate } from "../modules/rule-engine/entities/rate.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity"; +import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity"; +import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; + +const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001"; +const CEO_USER_ID = "00000000-0000-0000-0000-000000000002"; + +@Injectable() +export class PricingDataSeeder { + private readonly logger = new Logger(PricingDataSeeder.name); + + constructor(private readonly dataSource: DataSource) { } + + async run(): Promise { + await this.dataSource.transaction(async (manager) => { + const ctRepo = manager.getRepository(ContainerType); + const stRepo = manager.getRepository(ServiceType); + const yRepo = manager.getRepository(Yard); + const slRepo = manager.getRepository(ShippingLine); + const wlRepo = manager.getRepository(WeightLimitRule); + const prRepo = manager.getRepository(PriorityRule); + const rRepo = manager.getRepository(Rate); + + await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo); + await this.seedWeightLimits(wlRepo, ctRepo); + await this.seedPriorityRules(prRepo); + const containerTypes = await ctRepo.find(); + const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct])); + + const rates = await this.seedRates(rRepo, ctByCode); + const ratesByType = new Map(); + for (const r of rates) { + const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`; + if (!ratesByType.has(key)) ratesByType.set(key, []); + ratesByType.get(key)!.push(r); + } + + await this.seedSurchargeTypes(manager, ratesByType); + + const yards = await yRepo.find(); + const yardByCode = new Map(yards.map((y) => [y.code, y])); + const serviceTypes = await stRepo.find(); + const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); + const shippingLines = await slRepo.find(); + const slByCode = new Map(shippingLines.map((sl) => [sl.code, sl])); + const cargoTypes = await manager.getRepository(CargoType).find(); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + + await this.seedDraftBookings( + ctByCode, + yardByCode, + stByCode, + slByCode, + cargoByCode, + ); + }); + + this.logger.log("Seeded pricing data"); + } + + private async upsertReferenceData( + manager: any, + ctRepo: any, + stRepo: any, + yRepo: any, + slRepo: any, + ): Promise { + await yRepo.upsert( + [ + { + code: "DJIBOUTI", + label: "Djibouti", + country: "Djibouti", + displayOrder: 1, + isActive: true, + }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + isActive: true, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + isActive: true, + }, + { + code: "MODJO", + label: "Modjo", + country: "Ethiopia", + displayOrder: 4, + isActive: true, + }, + ], + { conflictPaths: { code: true } }, + ); + + await ctRepo.upsert( + [ + { + code: "20FT", + label: "20FT Standard", + sizeFt: 20, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 1, + }, + { + code: "40FT", + label: "40FT Standard", + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 2, + }, + { + code: "20FT_REEFER", + label: "20FT Reefer", + sizeFt: 20, + wagonsPerUnit: 1, + isReefer: true, + isOpenTop: false, + isActive: true, + displayOrder: 3, + }, + { + code: "40FT_REEFER", + label: "40FT Reefer", + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: true, + isOpenTop: false, + isActive: true, + displayOrder: 4, + }, + ], + { conflictPaths: { code: true } }, + ); + + await stRepo.upsert( + [ + { + code: "RAIL_CONTAINER", + serviceName: "Rail Container Service", + description: "Standard rail container transport", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }, + { + code: "RAIL_FORWARDING", + serviceName: "Rail Forwarding Service", + description: "Rail transport with first/last mile and customs", + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: true, + priorityBonusPoints: 100, + isActive: true, + displayOrder: 2, + }, + { + code: "RAIL_BULK", + serviceName: "Rail Bulk Transport", + description: "Bulk commodity rail transport", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 50, + isActive: true, + displayOrder: 3, + }, + ], + { conflictPaths: { code: true } }, + ); + + await slRepo.upsert( + [ + { + code: "MAERSK", + label: "Maersk Line", + mappedToCode: "MAERSK", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "MSC", + label: "MSC", + mappedToCode: "MSC", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "CMA_CGM", + label: "CMA CGM", + mappedToCode: "CMA_CGM", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "COSCO", + label: "COSCO Shipping", + mappedToCode: "COSCO", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "OTHER", + label: "Other Line", + mappedToCode: null, + showExtraFeeNotice: false, + isActive: true, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(CargoType).upsert( + [ + { + code: "GRAIN", + cargoTypeName: "Grain / Cereals", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 1, + }, + { + code: "FERTILIZER", + cargoTypeName: "Fertilizer", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 2, + }, + { + code: "CEMENT", + cargoTypeName: "Cement / Clinker", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 3, + }, + { + code: "STEEL", + cargoTypeName: "Steel / Rebar", + requiresDirectorApproval: true, + isActive: true, + displayOrder: 4, + }, + { + code: "MACHINERY", + cargoTypeName: "Heavy Machinery", + requiresDirectorApproval: true, + isActive: true, + displayOrder: 5, + }, + { + code: "OTHER_BULK", + cargoTypeName: "Other Bulk Cargo", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 6, + }, + ], + { conflictPaths: { code: true } }, + ); + } + + private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { + await wlRepo.createQueryBuilder().delete().execute(); + const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); + const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); + const base = new Date("2026-01-01"); + await wlRepo.insert([ + { + containerTypeId: twenty.id, + tradeDirection: "IMPORT", + maxVgmTons: 26, + effectiveFrom: base, + }, + { + containerTypeId: twenty.id, + tradeDirection: "EXPORT", + maxVgmTons: 26, + effectiveFrom: base, + }, + { + containerTypeId: forty.id, + tradeDirection: "IMPORT", + maxVgmTons: 28, + effectiveFrom: base, + }, + { + containerTypeId: forty.id, + tradeDirection: "EXPORT", + maxVgmTons: 28, + effectiveFrom: base, + }, + ]); + this.logger.log("Seeded weight limit rules"); + } + + private async seedPriorityRules(prRepo: any): Promise { + const existing = await prRepo.find({ + where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }], + }); + for (const r of existing) { + await prRepo.remove(r); + } + await prRepo.save([ + prRepo.create({ + code: "USD_PRIORITY", + label: "USD Payment Priority", + score: 200, + conditionCurrency: "USD", + isActive: true, + }), + prRepo.create({ + code: "STANDARD_PRIORITY", + label: "Standard Priority", + score: 50, + conditionCurrency: null, + isActive: true, + }), + ]); + this.logger.log("Seeded priority rules"); + } + + private async seedRates( + rRepo: any, + ctByCode: Map, + ): Promise { + const effectiveFrom = new Date("2026-01-01"); + const now = new Date(); + // await rRepo.createQueryBuilder().delete().execute(); + + const rateData = [ + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 800, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 1200, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 45000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 67000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 600, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 900, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 34000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 50000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 20000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 30000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: null, + currency: "USD", + rateValue: 1000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 56000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: null, + currency: "USD", + rateValue: 750, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 42000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: null, + currency: "ETB", + rateValue: 25000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "BULK_IMPORT", + containerTypeId: null, + currency: "USD", + rateValue: 50, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_IMPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 2800, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_EXPORT", + containerTypeId: null, + currency: "USD", + rateValue: 40, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_EXPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 2200, + rateUnit: "PER_TON", + }, + { + rateType: "OVERWEIGHT_PER_TON", + containerTypeId: null, + currency: "USD", + rateValue: 25, + rateUnit: "PER_TON", + }, + { + rateType: "OVERWEIGHT_PER_TON", + containerTypeId: null, + currency: "ETB", + rateValue: 1400, + rateUnit: "PER_TON", + }, + { + rateType: "HAZARD_SURCHARGE", + containerTypeId: null, + currency: "USD", + rateValue: 150, + rateUnit: "FLAT", + }, + { + rateType: "HAZARD_SURCHARGE", + containerTypeId: null, + currency: "ETB", + rateValue: 8500, + rateUnit: "FLAT", + }, + { + rateType: "REEFER_SURCHARGE", + containerTypeId: null, + currency: "USD", + rateValue: 200, + rateUnit: "FLAT", + }, + { + rateType: "REEFER_SURCHARGE", + containerTypeId: null, + currency: "ETB", + rateValue: 11000, + rateUnit: "FLAT", + }, + { + rateType: "DOUBLE_HANDLING", + containerTypeId: null, + currency: "USD", + rateValue: 100, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "DOUBLE_HANDLING", + containerTypeId: null, + currency: "ETB", + rateValue: 5500, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "LASHING", + containerTypeId: null, + currency: "USD", + rateValue: 50, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "LASHING", + containerTypeId: null, + currency: "ETB", + rateValue: 2800, + rateUnit: "PER_CONTAINER", + }, + ]; + + const entities = rateData.map((d) => + rRepo.create({ + ...d, + status: "LIVE", + proposedByStaffId: STAFF_USER_ID, + approvedByCeoId: CEO_USER_ID, + approvedAt: now, + effectiveFrom, + }), + ); + return rRepo.save(entities); + } + + private async seedSurchargeTypes( + manager: any, + ratesByType: Map, + ): Promise { + const surRepo = manager.getRepository(SurchargeType); + const bcmRepo = manager.getRepository(BookingCargoModifier); + await bcmRepo.createQueryBuilder().delete().execute(); + const findRate = (rateType: string, currency: string) => { + const key = `${rateType}|${currency}|`; + const rates = ratesByType.get(key); + return rates?.[0]; + }; + + const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD"); + const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB"); + const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD"); + const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB"); + const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD"); + const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB"); + const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD"); + const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB"); + const consolidRateUsd = findRate("LASHING", "USD"); + const consolidRateEtb = findRate("LASHING", "ETB"); + + await surRepo.createQueryBuilder().delete().execute(); + await surRepo.save([ + surRepo.create({ + code: "HAZARDOUS_CARGO", + label: "Hazardous Cargo", + triggerCondition: "CARGO_FLAG_HAZARDOUS", + rateId: hazardRateUsd?.id ?? hazardRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "REEFER_CARGO", + label: "Reefer Cargo", + triggerCondition: "CARGO_FLAG_REEFER", + rateId: reeferRateUsd?.id ?? reeferRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "OVERWEIGHT_CARGO", + label: "Overweight Cargo", + triggerCondition: "VGM_EXCEEDS_LIMIT", + rateId: overweightRateUsd?.id ?? overweightRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "SHIPPING_LINE_FEE", + label: "Shipping Line Fee", + triggerCondition: "SHIPPING_LINE_MAPPED", + rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "CONSOLIDATION_FEE", + label: "Consolidation Fee", + triggerCondition: "CONSOLIDATION_ENABLED", + rateId: consolidRateUsd?.id ?? consolidRateEtb?.id, + isActive: true, + }), + ]); + this.logger.log("Seeded surcharge types"); + } + + private async seedDraftBookings( + ctByCode: Map, + yardByCode: Map, + stByCode: Map, + slByCode: Map, + cargoByCode: Map, + ): Promise { + const djibouti = yardByCode.get("DJIBOUTI")!; + const addis = yardByCode.get("ADDIS_ABABA")!; + const railContainer = stByCode.get("RAIL_CONTAINER")!; + const railBulk = stByCode.get("RAIL_BULK")!; + const maersk = slByCode.get("MAERSK")!; + const grain = cargoByCode.get("GRAIN")!; + const twenty = ctByCode.get("20FT")!; + const forty = ctByCode.get("40FT")!; + const twentyReefer = ctByCode.get("20FT_REEFER")!; + + const drafts = [ + { + reference: "BKG-PRICE-001", + description: "Standard 20FT container import — base rail only", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 250, + containers: [ + { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 25 }, + ], + expectedBaseRate: 800, + expectedSurcharges: [], + }, + { + reference: "BKG-PRICE-002", + description: "40FT container import + hazardous surcharge", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: true, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 135, + containers: [ + { containerTypeId: forty.id, quantity: 5, vgmPerUnitTons: 27 }, + ], + expectedBaseRate: 1200, + expectedSurcharges: ["HAZARDOUS_CARGO"], + }, + { + reference: "BKG-PRICE-003", + description: "20FT container import + shipping line (ETB)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "ETB", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: maersk.id, + cargoTypeId: null, + cargoTotalWeightVgm: 480, + containers: [ + { containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 }, + ], + expectedBaseRate: 45000, + expectedSurcharges: ["SHIPPING_LINE_FEE"], + }, + { + reference: "BKG-PRICE-004", + description: "40FT container import + consolidation (USD)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: true, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 224, + containers: [ + { containerTypeId: forty.id, quantity: 8, vgmPerUnitTons: 28 }, + ], + expectedBaseRate: 1200, + expectedSurcharges: ["CONSOLIDATION_FEE"], + }, + { + reference: "BKG-PRICE-005", + description: "Bulk import — grain", + freightType: "BULK" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railBulk.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: grain.id, + cargoTotalWeightVgm: 500, + containers: [], + expectedBaseRate: 50, + expectedSurcharges: [], + }, + { + reference: "BKG-PRICE-006", + description: "20FT reefer container import + reefer surcharge", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 75, + containers: [ + { containerTypeId: twentyReefer.id, quantity: 3, vgmPerUnitTons: 25 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["REEFER_CARGO"], + }, + { + reference: "BKG-PRICE-007", + description: "20FT container import + overweight (30t > 26t limit)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 300, + containers: [ + { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 30 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["OVERWEIGHT_CARGO"], + }, + ]; + + this.logger.log(`Seeded ${drafts.length} DRAFT bookings for pricing`); + } +} diff --git a/apps/edr-freight-api/test-minio-upload.js b/apps/edr-freight-api/test-minio-upload.js new file mode 100644 index 000000000..4b793b504 --- /dev/null +++ b/apps/edr-freight-api/test-minio-upload.js @@ -0,0 +1,45 @@ +const { Client } = require('minio'); + +const config = { + endPoint: 'minio-dev.smart.aaca.gov.et', + port: 443, + useSSL: true, + accessKey: 'f2f22b0ea929cebd5567ed0c71ec351b', + secretKey: 'xxHnjRsb90suQZZdOtEcXJXls4nj0A2anMetb1kY', + bucket: 'fhc', +}; + +const filePath = '/home/marshal/Desktop/EDR/bash/download.jpeg'; +const objectName = `test-upload-${Date.now()}.jpeg`; + +console.log('Testing MinIO upload...'); +console.log('Endpoint:', `${config.useSSL ? 'https' : 'http'}://${config.endPoint}:${config.port}`); +console.log('Bucket:', config.bucket); +console.log('File:', filePath); +console.log('Object:', objectName); +console.log(''); + +const client = new Client(config); + +const fs = require('fs'); + +try { + const fileBuffer = fs.readFileSync(filePath); + console.log('File size:', fileBuffer.length, 'bytes'); + + client.putObject(config.bucket, objectName, fileBuffer, fileBuffer.length, { 'Content-Type': 'image/jpeg' }) + .then(() => { + const url = `${config.useSSL ? 'https' : 'http'}://${config.endPoint}:${config.port}/${config.bucket}/${objectName}`; + console.log('✓ Upload successful!'); + console.log('URL:', url); + }) + .catch(err => { + console.error('✗ Upload failed:', err.message); + if (err.code === 'InvalidAccessKeyId') { + console.error('The access key does not exist on the MinIO server.'); + console.error('Contact your MinIO administrator for valid credentials.'); + } + }); +} catch (err) { + console.error('Error reading file:', err.message); +} diff --git a/apps/edr-freight-api/tsconfig.json b/apps/edr-freight-api/tsconfig.json index e8cec7548..467c474ee 100644 --- a/apps/edr-freight-api/tsconfig.json +++ b/apps/edr-freight-api/tsconfig.json @@ -6,7 +6,9 @@ "rootDir": "./src", "noEmit": false, "incremental": true, - "tsBuildInfoFile": "./.tsbuildinfo" + "tsBuildInfoFile": "./.tsbuildinfo", + "module": "node16", + "moduleResolution": "node16" }, "include": ["src"] } diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index e0099a3fc..cbbdd289f 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1 +1,2 @@ VITE_API_URL=http://localhost:3001 +VITE_BASE_API_URL=http://localhost:3001 diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css new file mode 100644 index 000000000..8d40838ea --- /dev/null +++ b/apps/edr-freight-web/backoffice/index.css @@ -0,0 +1,18 @@ +@import "tailwindcss"; +@import "@edr/ui-common/theme.css" layer(theme); + +:root { + --freight-brand: #15803d; + --freight-brand-dark: #166534; + --freight-brand-light: #22c55e; + --freight-brand-muted: #f0fdf4; + --freight-brand-border: #bbf7d0; + --freight-brand-ring: rgb(21 128 61 / 0.2); +} + +html, +body, +#root { + height: 100%; + overflow: hidden; +} diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index f0ad134f1..13e522996 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -12,27 +12,40 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@tria-plc/iamui-common": "1.0.3", "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", - "@tanstack/react-query": "^5.59.0", + "@mantine/core": "^9.3.0", + "@mantine/hooks": "^9.3.0", + "@tabler/icons-react": "^3.44.0", + "@hello-pangea/dnd": "^18.0.1", + "@tanstack/react-query": "^5.100.11", + "@tria-plc/iamui-common": "1.1.2", "axios": "^1.7.7", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "libphonenumber-js": "^1.12.24", + "lucide-react": "^1.14.0", + "radix-ui": "^1.4.3", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-hot-toast": "^2.6.0", "react-router-dom": "^6.27.0", + "recharts": "^3.8.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", "zustand": "^5.0.0" }, "devDependencies": { "@edr/eslint-config": "workspace:*", "@edr/tsconfig": "workspace:*", + "@tailwindcss/vite": "^4.3.0", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", "jsdom": "^25.0.1", "postcss": "^8.4.47", - "tailwindcss": "^3.4.13", + "tailwindcss": "^4.3.0", "typescript": "^5.5.4", "vite": "^5.4.8", "vitest": "^2.1.2" 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/public/assets/logo.svg b/apps/edr-freight-web/backoffice/public/assets/logo.svg new file mode 100644 index 000000000..377b70438 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/assets/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/edr-freight-web/backoffice/public/assets/smart-office-logo.svg b/apps/edr-freight-web/backoffice/public/assets/smart-office-logo.svg new file mode 100644 index 000000000..97e5bf786 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/assets/smart-office-logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f3e631ec2..a1807599b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,33 +1,305 @@ +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { + Boxes, + FileText, + LayoutDashboard, + Network, + Paperclip, + Settings, + SlidersHorizontal, + Train, + Truck, + Container, + Package, + //TrainTrack, +} 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 BookingContractPage from "./pages/bookings/BookingContractPage"; +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 UsersPage from "./pages/dashboard/user-management/UsersPage"; +import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; +import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +import TrainsPage from "./pages/trains/TrainsPage"; import { - useNavigate, - useLocation, - Routes, - Route, - Navigate, -} from "react-router-dom"; -import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; - -import DashboardPage from "./pages/dashboard/DashboardPage"; - -const sidebarItems: SidebarItem[] = [{ label: "Dashboard", href: "/" }]; - -const App = () => { - const navigate = useNavigate(); - const location = useLocation(); - - return ( - - - } /> - } /> - - - ); -}; - -export default App; + CargoesCrudPage, + ContainersCrudPage, + LocomotivesCrudPage, + TrainMasterDataPage, + WagonTypesCrudPage, + WagonsCrudPage, +} from "./pages/fleet/FleetCrudPages"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import RoutesPage from "./pages/fleet/RoutesPage"; + +const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ + { + title: "Main menu", + mutedTitle: true, + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + ...demoItems, + ], + }, + { + title: "Operations", + items: [ + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling", + icon: , + }, + ], + }, + { + title: "Fleet Management", + items: [ + { + label: "Routes", + href: "/dashboard/routes", + icon: , + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + }, + { + label: "Trains", + href: "/dashboard/trains", + icon: , + }, + { + label: "Wagon types", + href: "/dashboard/wagon-types", + icon: , + }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + }, + { + label: "Containers", + href: "/dashboard/containers", + icon: , + }, + { + label: "Cargoes", + href: "/dashboard/cargoes", + icon: , + }, + ], + }, + { + title: "Administration", + items: [ + { + 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: "File settings", + href: "/dashboard/file-settings", + icon: , + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-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"), + }, + ], + }, +]; + +const hasPermission = ( + user: ReturnType["user"], + key: string, +) => { + 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[] = []; + + const sidebarSections = buildSidebarSections(demoItems); + const displayName = user?.name?.en || user?.username || user?.email || "User"; + + return ( + + + + ); +}; + +const App = () => { + const { user, loading } = useAuth(); + + if (loading) { + return ; + } + + if (!user) { + return ( + + } /> + } /> + + ); + } + + return ( + + } /> + } /> + + }> + } /> + + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + + } /> + } /> + + } + /> + } /> + + } + /> + } /> + + } + /> + } /> + + } /> + } /> + + } + /> + } + /> + + + } /> + + ); +}; + +export default App; diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx new file mode 100644 index 000000000..66834c9f5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -0,0 +1,145 @@ +import { + createContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { getMeRequest, loginRequest, verifyMfaRequest } from "./api"; +import { + AUTH_TOKEN_COOKIE, + AUTH_USER_COOKIE, + clearSessionCookies, + getCookie, + setCookie, +} from "./cookies"; +import { applyTokens } from "./http"; +import type { AuthTokens, AuthUser } from "./types"; + +interface LoginPayload { + email: string; + password: string; +} + +interface VerifyMfaPayload { + email: string; + otp: string; +} + +export interface AuthContextValue { + user: AuthUser | null; + loading: boolean; + login: (payload: LoginPayload) => Promise<{ mfaRequired: boolean }>; + verifyMfa: (payload: VerifyMfaPayload) => Promise; + logout: () => void; +} + +export const AuthContext = createContext(null); + +const persistUser = (user: AuthUser | null) => { + if (user) { + setCookie(AUTH_USER_COOKIE, JSON.stringify(user)); + return; + } + + clearSessionCookies(); +}; + +const bootstrapCachedUser = (): AuthUser | null => { + const serialized = getCookie(AUTH_USER_COOKIE); + if (!serialized) { + return null; + } + + try { + return JSON.parse(serialized) as AuthUser; + } catch { + return null; + } +}; + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [user, setUser] = useState(() => bootstrapCachedUser()); + const [loading, setLoading] = useState(true); + const mfaEmailRef = useRef(null); + + const loadCurrentUser = async () => { + const currentUser = await getMeRequest(); + setUser(currentUser); + persistUser(currentUser); + }; + + useEffect(() => { + ["auth-token", "refresh-token"].forEach((name) => { + const value = getCookie(name); + if (value === "undefined" || value === "null" || value === "") { + clearSessionCookies(); + } + }); + + const bootstrap = async () => { + const token = getCookie(AUTH_TOKEN_COOKIE); + if (!token) { + setLoading(false); + return; + } + + try { + await loadCurrentUser(); + } catch { + clearSessionCookies(); + setUser(null); + } finally { + setLoading(false); + } + }; + + void bootstrap(); + }, []); + + const value = useMemo( + () => ({ + user, + loading, + login: async ({ email, password }) => { + const result = await loginRequest({ email, password }); + + if (result.mfaRequired) { + mfaEmailRef.current = email; + return { mfaRequired: true }; + } + + const tokens = result as AuthTokens; + applyTokens(tokens); + await loadCurrentUser(); + mfaEmailRef.current = null; + return { mfaRequired: false }; + }, + verifyMfa: async ({ email, otp }) => { + const identifier = mfaEmailRef.current ?? email; + const tokens = await verifyMfaRequest({ email: identifier, otp }); + applyTokens(tokens); + await loadCurrentUser(); + mfaEmailRef.current = null; + }, + logout: () => { + const preservedTheme = window.localStorage.getItem("edr-theme"); + + clearSessionCookies(); + window.localStorage.clear(); + + if (preservedTheme === "dark" || preservedTheme === "light") { + window.localStorage.setItem("edr-theme", preservedTheme); + } + + setUser(null); + window.location.replace("/auth"); + }, + }), + [loading, user], + ); + + return {children}; +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/api.ts b/apps/edr-freight-web/backoffice/src/auth/api.ts new file mode 100644 index 000000000..f72e72949 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/api.ts @@ -0,0 +1,23 @@ +import { api } from "./http"; +import type { AuthTokens, AuthUser, LoginResponse } from "./types"; + +export const loginRequest = async (payload: { + email: string; + password: string; +}) => { + const response = await api.post("/auth/login", payload); + return response.data; +}; + +export const verifyMfaRequest = async (payload: { + email: string; + otp: string; +}) => { + const response = await api.post("/auth/mfa-verify", payload); + return response.data; +}; + +export const getMeRequest = async () => { + const response = await api.get("/me"); + return response.data; +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/cookies.ts b/apps/edr-freight-web/backoffice/src/auth/cookies.ts new file mode 100644 index 000000000..0292f7f38 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/cookies.ts @@ -0,0 +1,38 @@ +const DEFAULT_PATH = "/"; +const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7; + +export const AUTH_TOKEN_COOKIE = "auth-token"; +export const REFRESH_TOKEN_COOKIE = "refresh-token"; +export const AUTH_USER_COOKIE = "auth-user"; + +export const AUTH_COOKIE_MAX_AGE = SEVEN_DAYS_IN_SECONDS; + +export const getCookie = (name: string) => { + const match = document.cookie + .split("; ") + .find((entry) => entry.startsWith(`${name}=`)); + + return match ? decodeURIComponent(match.split("=").slice(1).join("=")) : null; +}; + +export const setCookie = ( + name: string, + value: string, + maxAge = AUTH_COOKIE_MAX_AGE, +) => { + document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; path=${DEFAULT_PATH}; SameSite=Lax`; +}; + +export const clearCookie = (name: string) => { + document.cookie = `${name}=; Max-Age=0; path=${DEFAULT_PATH}`; +}; + +export const clearSessionCookies = () => { + [ + AUTH_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, + AUTH_USER_COOKIE, + "current-position-id", + "selected-position-id", + ].forEach(clearCookie); +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts new file mode 100644 index 000000000..115a0d9d1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -0,0 +1,99 @@ +import axios from "axios"; + +import { + AUTH_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, + clearSessionCookies, + getCookie, + setCookie, +} from "./cookies"; +import type { AuthTokens } from "./types"; + +type RetriableRequest = { + _retry?: boolean; + headers?: Record; + url?: string; +}; + +const api = axios.create({ + baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`, + withCredentials: true, +}); + +let refreshPromise: Promise | null = null; + +const applyTokens = ({ token, refreshToken }: AuthTokens) => { + setCookie(AUTH_TOKEN_COOKIE, token); + setCookie(REFRESH_TOKEN_COOKIE, refreshToken); +}; + +api.interceptors.request.use((config) => { + const token = getCookie(AUTH_TOKEN_COOKIE); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}); + +api.interceptors.response.use( + (response) => { + if ( + response.data && + typeof response.data === "object" && + "success" in response.data && + "data" in response.data + ) { + response.data = response.data.data; + } + + return response; + }, + async (error) => { + const originalRequest = error.config as RetriableRequest | undefined; + + if ( + error.response?.status !== 401 || + !originalRequest || + originalRequest._retry || + originalRequest.url?.includes("/auth/login") || + originalRequest.url?.includes("/auth/mfa-verify") || + originalRequest.url?.includes("/auth/refresh-token") + ) { + return Promise.reject(error); + } + + const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); + if (!refreshToken) { + clearSessionCookies(); + return Promise.reject(error); + } + + originalRequest._retry = true; + + try { + refreshPromise ??= api + .post("/auth/refresh-token", { refreshToken }) + .then((response) => response.data) + .finally(() => { + refreshPromise = null; + }); + + const tokens = await refreshPromise; + applyTokens(tokens); + originalRequest.headers = { + ...originalRequest.headers, + Authorization: `Bearer ${tokens.token}`, + }; + + return api(originalRequest); + } catch (refreshError) { + clearSessionCookies(); + window.location.replace("/auth"); + return Promise.reject(refreshError); + } + }, +); + +export { api, applyTokens }; diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts new file mode 100644 index 000000000..41742039c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -0,0 +1,64 @@ +interface LocaleText { + en?: string; + am?: string; +} + +interface AuthRole { + id?: string; + key?: string; +} + +interface AuthPermission { + id?: string; + key?: string; +} + +interface AuthEmployeePosition { + id?: string; + employeePositionId?: string; + name?: LocaleText; + key?: string; + isDelegate?: boolean; + parentPositionId?: string | null; + permissions?: AuthPermission[]; +} + +interface AuthEmployeeRecord { + id?: string; + organizationId?: string; + unitId?: string; + name?: LocaleText; + positions?: AuthEmployeePosition[]; +} + +export interface AuthUser { + id?: string; + email?: string; + username?: string; + phoneNumber?: string; + name?: LocaleText; + roles?: AuthRole[]; + permissions?: AuthPermission[]; + /** Flat keys from GET /api/me (roles + position permissions). */ + permissionKeys?: string[]; + isSuperAdmin?: boolean; + employee?: AuthEmployeeRecord[]; + hasSetPassword?: boolean; + status?: string; +} + +export interface AuthTokens { + token: string; + refreshToken: string; +} + +export interface LoginResponse extends Partial { + mfaRequired?: boolean; +} + +// Additional types for Matrix form test +export interface User { + id: string; + name: string; + role: "ADMIN" | "MANAGER" | "CHIEF_EXECUTIVE"; +} diff --git a/apps/edr-freight-web/backoffice/src/auth/useAuth.ts b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts new file mode 100644 index 000000000..74a29cf7e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts @@ -0,0 +1,15 @@ +import { useContext } from "react"; + +import { AuthContext } from "./AuthProvider"; + +export const useAuth = () => { + const context = useContext(AuthContext); + + if (!context) { + throw new Error("useAuth must be used within AuthProvider"); + } + + return context; +}; + + diff --git a/apps/edr-freight-web/backoffice/src/components/FeaturePlaceholder.tsx b/apps/edr-freight-web/backoffice/src/components/FeaturePlaceholder.tsx new file mode 100644 index 000000000..56807deba --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/FeaturePlaceholder.tsx @@ -0,0 +1,25 @@ +interface FeaturePlaceholderProps { + title: string; + description: string; +} + +const FeaturePlaceholder = ({ + title, + description, +}: FeaturePlaceholderProps) => { + return ( +
+
+

+ EDR Freight Backoffice +

+

{title}

+

+ {description} +

+
+
+ ); +}; + +export default FeaturePlaceholder; diff --git a/apps/edr-freight-web/backoffice/src/components/LoadingScreen.tsx b/apps/edr-freight-web/backoffice/src/components/LoadingScreen.tsx new file mode 100644 index 000000000..aedae2f7c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/LoadingScreen.tsx @@ -0,0 +1,14 @@ +const LoadingScreen = () => { + return ( +
+
+

+ EDR Freight Backoffice +

+

Loading...

+
+
+ ); +}; + +export default LoadingScreen; diff --git a/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx b/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx new file mode 100644 index 000000000..f18389f9e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx @@ -0,0 +1,330 @@ +// components/baselineRatematrix/RateMatrixForm.tsx +import React, { useState, useCallback } from 'react'; +// import { useForm } from 'react-hook-form'; +// import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Loader2, Save, Send, Shield, AlertTriangle } from 'lucide-react'; +import { RateTypeSection } from './RateTypeSection'; +import { ConfirmationDialog } from './ConfirmationDialog'; +import { ValidationSummary } from './ValidationSummary'; +import { LoadingScreen } from '@/ui/LoadingScreen'; +import { useRateMatrixAuth } from '@/auth/hooks/useAuth'; +import { useReferenceData } from '@/hooks/useReferenceData'; +import { queryKeys } from '@/constants/queryKeys'; +import { API_URLS } from '@/constants/apiUrls'; +import { + RATE_TYPES, + RATE_TYPE_LABELS, + REQUIRED_RATE_TYPES +} from '@/constants/rateMatrixConstants'; +import { rateMatrixRulesEngine } from '../../ruleEngine/rateMatrixRules'; +import type { RateEntry } from './types'; + +const formSchema = z.object({ + matrixName: z.string().min(1, 'Matrix name is required').max(200), + effectiveDate: z.string().min(1, 'Effective date is required'), + expiryDate: z.string().optional(), + currency: z.string().min(1, 'Currency is required'), +}); + +type FormData = z.infer; + +const createInitialSections = (): RateEntry[] => { + return REQUIRED_RATE_TYPES.map(rateType => ({ + rateType, + entries: [{ + validFrom: '', + validTo: '', + }], + })); +}; + +export function RateMatrixForm() { + const [rateSections, setRateSections] = useState(createInitialSections()); + const [showConfirmation, setShowConfirmation] = useState(false); + const [savedMatrixId, setSavedMatrixId] = useState(null); + const [validationErrors, setValidationErrors] = useState([]); + + const { isDirector } = useRateMatrixAuth(); + const { data: referenceData, isLoading: isLoadingReference } = useReferenceData(); + const queryClient = useQueryClient(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + matrixName: '', + effectiveDate: '', + expiryDate: '', + currency: 'USD', + }, + }); + + // Save draft mutation + const saveDraftMutation = useMutation({ + mutationFn: async (data: FormData & { rateSections: RateEntry[] }) => { + const response = await fetch(API_URLS.RATE_MATRIX.DRAFT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!response.ok) throw new Error('Failed to save draft'); + return response.json(); + }, + onSuccess: (data) => { + setSavedMatrixId(data.id); + queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all }); + toast.success('Draft saved successfully'); + }, + onError: (error) => { + toast.error('Failed to save draft'); + }, + }); + + // Submit for approval mutation + const submitMutation = useMutation({ + mutationFn: async (matrixId: string) => { + const response = await fetch(API_URLS.RATE_MATRIX.SUBMIT(matrixId), { + method: 'POST', + }); + if (!response.ok) throw new Error('Failed to submit'); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all }); + toast.success('Rate matrix submitted for executive approval and locked!'); + setShowConfirmation(false); + }, + onError: (error) => { + toast.error('Failed to submit for approval'); + setShowConfirmation(false); + }, + }); + + const handleValidate = useCallback(() => { + const validation = rateMatrixRulesEngine.validate(rateSections); + setValidationErrors([...validation.errors, ...validation.warnings]); + + if (validation.isValid) { + toast.success('All validations passed!'); + } + }, [rateSections]); + + const handleSaveDraft = async () => { + const formData = form.getValues(); + await saveDraftMutation.mutateAsync({ + ...formData, + rateSections, + }); + }; + + const handleSubmitClick = async () => { + const isFormValid = await form.trigger(); + if (!isFormValid) return; + + const validation = rateMatrixRulesEngine.validate(rateSections); + setValidationErrors([...validation.errors, ...validation.warnings]); + + if (!validation.isValid) { + toast.error('Please fix validation errors before submitting'); + return; + } + + setShowConfirmation(true); + }; + + const handleConfirmSubmit = async () => { + const formData = form.getValues(); + + try { + let matrixId = savedMatrixId; + + if (!matrixId) { + const draftResult = await saveDraftMutation.mutateAsync({ + ...formData, + rateSections, + }); + matrixId = draftResult.id; + } + + await submitMutation.mutateAsync(matrixId!); + } catch (error) { + // Error handling done in mutations + } + }; + + if (isLoadingReference) { + return ; + } + + if (!isDirector) { + return ( +
+ + + Access Denied + + Only Directors can access the rate matrix registration. + + +
+ ); + } + + return ( +
+ {/* Header */} +
+

+ Baseline Rate Matrix Registration +

+

+ Submit a comprehensive rate matrix for executive approval +

+
+ + {/* Director Warning */} + + + Director Notice + + Once submitted, this matrix will be locked pending Chief Executive approval. + No edits can be made by any user until authorization is granted. + + + +
e.preventDefault()}> + {/* Matrix Metadata */} + + + Matrix Information + + +
+
+ + + {form.formState.errors.matrixName && ( +

+ {form.formState.errors.matrixName.message} +

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + {/* Rate Type Sections */} +
+ {rateSections.map((section, index) => ( + { + const newSections = [...rateSections]; + newSections[index] = updatedSection; + setRateSections(newSections); + }} + referenceData={referenceData} + /> + ))} +
+ + {/* Validation Errors */} + {validationErrors.length > 0 && ( +
+ +
+ )} + + {/* Form Actions */} +
+ + + + + +
+
+ + {/* Confirmation Dialog */} + +
+ ); +} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx new file mode 100644 index 000000000..25b3816b7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx @@ -0,0 +1,217 @@ +import { useMemo, useState } from "react"; +import { Check, ShieldCheck } from "lucide-react"; +import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core"; + +import { BookingConfirmDialog } from "./BookingConfirmDialog"; +import { useAuth } from "@/auth/useAuth"; +import { formatApprovalProgress } from "@/features/bookings/approval-progress"; +import { + buildApproveActionForStep, + canActOnApprovalStep, + getNextPendingApprovalStep, +} from "@/features/bookings/booking-actions.config"; +import type { useBookingMutations } from "@/hooks/bookings/useBookings"; +import type { BookingApprovalStep, BookingDetail } from "@/types/booking"; +import { SectionCard } from "./detail/SectionCard"; + +type Mutations = ReturnType; + +interface ApprovalStepsCardProps { + booking: BookingDetail; + mutations: Mutations; +} + +/** Approval chain with inline approve on the current pending step. */ +export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) { + const { user } = useAuth(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [pendingStep, setPendingStep] = useState(null); + + const steps = useMemo( + () => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder), + [booking.approvalSteps], + ); + + const nextPending = getNextPendingApprovalStep(steps); + const summary = formatApprovalProgress(booking.status, steps); + const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null; + + const openApprove = (step: BookingApprovalStep) => { + setPendingStep(step); + setConfirmOpen(true); + }; + + const closeApprove = () => { + setConfirmOpen(false); + setPendingStep(null); + }; + + const runApprove = () => { + if (!pendingStep) return; + mutations.approveStep.mutate( + { stepId: pendingStep.id, requiredRole: pendingStep.requiredRole }, + { onSuccess: () => closeApprove() }, + ); + }; + + const subtitle = + summary.detail || + (nextPending + ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` + : steps.length + ? "All steps complete" + : "Accept submission to begin"); + + return ( + <> + + {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} + + } + > + + {subtitle} + + + {steps.length === 0 ? ( + + Use Accept for approval in staff actions to instantiate steps. + + ) : ( + + {steps.map((step) => ( + + ))} + + )} + + + { + if (!open) closeApprove(); + else setConfirmOpen(true); + }} + action={pendingAction} + reference={booking.reference} + inputValue="" + onInputChange={() => {}} + onConfirm={runApprove} + isPending={mutations.approveStep.isPending} + /> + + ); +} + +function StepRow({ + step, + steps, + user, + isNext, + isPending, + onApprove, +}: { + step: BookingApprovalStep; + steps: BookingApprovalStep[]; + user: ReturnType["user"]; + isNext: boolean; + isPending: boolean; + onApprove: (step: BookingApprovalStep) => void; +}) { + const canApprove = canActOnApprovalStep(user, step, steps); + const statusColor = + step.status === "APPROVED" + ? "green" + : step.status === "REJECTED" + ? "red" + : isNext + ? "green" + : "gray"; + + return ( + + + + {step.stepOrder} + + + + {step.requiredRole} + + {step.remarks && ( + + {step.remarks} + + )} + + + + {canApprove && ( + + )} + + {step.status} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx new file mode 100644 index 000000000..49950d97b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -0,0 +1,220 @@ +import { useNavigate } from "react-router-dom"; +import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react"; +import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core"; + +import { BookingConfirmDialog } from "./BookingConfirmDialog"; +import { useBookingActionDialog } from "./useBookingActionDialog"; +import { useAuth } from "@/auth/useAuth"; +import { + getNextPendingApprovalStep, + isContractNavAction, + listRowHasActions, + type BookingActionContext, +} from "@/features/bookings/booking-actions.config"; +import type { BookingListRow } from "@/types/booking"; + +interface BookingActionsMenuProps { + row: BookingListRow; + /** Compact table cell vs. larger detail toolbar */ + variant?: "table" | "toolbar"; + className?: string; + /** Suppresses table row navigation after menu/dialog close (click-through). */ + onSuppressRowClick?: () => void; +} + +export function BookingActionsMenu({ + row, + variant = "table", + onSuppressRowClick, +}: BookingActionsMenuProps) { + const navigate = useNavigate(); + const { user } = useAuth(); + const context: BookingActionContext = { + status: row.status, + paymentCurrency: row.paymentCurrency, + reference: row.reference, + approvalSteps: row.approvalSteps, + }; + + const flow = useBookingActionDialog(row.id, context); + const { actions, pendingAction, mutations } = flow; + + const goToContract = () => + navigate(`/dashboard/booking-requests/${row.id}/contract`); + + const handleAction = (action: (typeof actions)[number]) => { + onSuppressRowClick?.(); + if (isContractNavAction(action.id)) { + goToContract(); + } else { + flow.openAction(action); + } + }; + + const hasMenu = listRowHasActions(row, user); + const primary = actions.find((a) => a.primary) ?? actions[0]; + + if (!hasMenu && variant === "table") { + return ( + navigate(`/dashboard/booking-requests/${row.id}`)} + aria-label="View booking" + > + + + ); + } + + // Toolbar: lay every action out as a button row. + if (variant === "toolbar" && actions.length > 0) { + return ( + <> + + {actions.map((action) => { + const Icon = action.icon; + const destructive = action.variant === "destructive"; + return ( + + ); + })} + + + + ); + } + + return ( + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {variant === "table" && primary && ( + + )} + + + + + + + + + + + {row.reference} + + + {actions.map((action) => { + const Icon = action.icon; + return ( + } + onClick={() => handleAction(action)} + > + {action.label} + + ); + })} + {actions.length > 0 && } + } + onClick={() => { + onSuppressRowClick?.(); + navigate(`/dashboard/booking-requests/${row.id}`); + }} + > + Open full details + + + + + + + ); +} + +function ActionDialog({ + flow, + pendingAction, + onSuppressRowClick, +}: { + flow: ReturnType; + pendingAction: ReturnType["pendingAction"]; + onSuppressRowClick?: () => void; +}) { + return ( + { + if (!open) onSuppressRowClick?.(); + flow.setDialogOpen(open); + }} + action={pendingAction} + reference={flow.mergedContext.reference} + inputValue={flow.inputValue} + onInputChange={flow.setInputValue} + selectedFile={flow.selectedFile} + onFileChange={flow.setSelectedFile} + onConfirm={() => { + onSuppressRowClick?.(); + flow.runAction(); + }} + isPending={flow.mutations.isPending || flow.detailLoading} + confirmDisabled={flow.confirmDisabled} + extra={ + flow.detailLoading ? ( + + Loading approval steps… + + ) : pendingAction?.id === "approve" && + !getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( + + No pending approval step. Refresh the page after staff accept, or reject the + booking. + + ) : null + } + /> + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx new file mode 100644 index 000000000..e451f2d2c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -0,0 +1,123 @@ +import { Download, Zap, FileText, Clock } from "lucide-react"; +import { Stack, Text, Button } from "@mantine/core"; + +import type { BookingDetail } from "@/types/booking"; +import { BookingActionsMenu } from "./BookingActionsMenu"; +import { SectionCard } from "./detail/SectionCard"; +import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; +import type { useBookingMutations } from "@/hooks/bookings/useBookings"; + +type Mutations = ReturnType; + +interface BookingActionsToolbarProps { + booking: BookingDetail; + mutations: Mutations; +} + +/** Detail-page actions: primary toolbar + downloads. */ +export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { + const row = toBookingListRow(booking); + const { status } = booking; + + const downloadBlob = async (fn: () => Promise, filename: string) => { + const blob = await fn(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }; + + if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") { + return null; + } + + if (status === "CHANGES_REQUESTED") { + return ( + + + + No staff actions until resubmit. + + {booking.latestChangeRequestNote && ( + + {booking.latestChangeRequestNote} + + )} + + + ); + } + + if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) { + return ( + + + Monitor until the customer or system advances status. + + + ); + } + + if ( + ["FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS"].includes( + status, + ) + ) { + return ( + + + + + Payment is completed by the customer. The booking status updates + automatically once payment is confirmed, then moves to Operations. + + {status === "FULLY_EXECUTED" && ( + + )} + + + + ); + } + + return ( + + + + + Confirm each step before it is applied. + + + + + + {status === "CONTRACT_READY" && ( + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx new file mode 100644 index 000000000..be051371c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx @@ -0,0 +1,29 @@ +import { formatApprovalProgress } from "@/features/bookings/approval-progress"; +import type { BookingListRow } from "@/types/booking"; +import { cn } from "@/lib/utils"; + +interface BookingApprovalProgressCellProps { + row: BookingListRow; +} + +export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCellProps) { + const summary = formatApprovalProgress(row.status, row.approvalSteps); + + return ( +
+

+ {summary.label} +

+ {summary.detail ? ( +

+ {summary.detail} +

+ ) : null} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx new file mode 100644 index 000000000..3eae09b49 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -0,0 +1,162 @@ +import type { ReactNode } from "react"; +import { + Modal, + Group, + Stack, + Text, + Box, + Button, + Textarea, + FileInput, +} from "@mantine/core"; + +import type { BookingActionDef } from "@/features/bookings/booking-actions.config"; + +interface BookingConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + action: BookingActionDef | null; + reference?: string; + inputValue: string; + onInputChange: (value: string) => void; + selectedFile?: File | null; + onFileChange?: (file: File | null) => void; + onConfirm: () => void; + isPending: boolean; + confirmDisabled?: boolean; + extra?: ReactNode; +} + +export function BookingConfirmDialog({ + open, + onOpenChange, + action, + reference, + inputValue, + onInputChange, + selectedFile = null, + onFileChange, + onConfirm, + isPending, + confirmDisabled = false, + extra, +}: BookingConfirmDialogProps) { + if (!action || !action.confirmTitle) return null; + + const Icon = action.icon; + const needsTextInput = action.input === "note" || action.input === "reason"; + const needsFileInput = action.input === "file"; + const inputMissing = + (needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile); + const isDestructive = action.variant === "destructive"; + const accent = isDestructive ? "red" : "green"; + + return ( + onOpenChange(false)} + withCloseButton={false} + centered + radius="md" + size="md" + padding={0} + title={null} + > + {/* Header */} + + + + + + + + {action.confirmTitle} + + {reference && ( + + {reference} + + )} + + + {action.confirmDescription && ( + + {action.confirmDescription} + + )} + + + {/* Body */} + + {needsTextInput && ( +