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

49 KiB
Raw Blame History

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
  2. Entity Relationship Diagram
  3. Configuration Tables
  4. Core Booking Tables
  5. Supporting Tables
  6. Business Rule Traceability
  7. Key Formulas
  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):

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.countrytrade_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' DRAFTPENDING_APPROVALLIVESUPERSEDED
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:

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):

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):

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):

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):

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