# 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*
