Files
edr-platform/ITMLS_DB_Design.md
2026-05-30 10:27:59 +03:00

1003 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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