mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
1003 lines
49 KiB
Markdown
1003 lines
49 KiB
Markdown
# 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*
|