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