49 KiB
ITMLS — Full Database Design
Booking & Configuration Domain
Schema:
freightBase columns on every table (viaBaseEntity):id UUID PK,created_at TIMESTAMPTZ,updated_at TIMESTAMPTZPrinciple: Every business rule lives as a config-table row — not hard-coded in application logic.
Table of Contents
- Design Philosophy
- Entity Relationship Diagram
- Configuration Tables
- 3.1
service_types - 3.2
container_types - 3.3
cargo_types - 3.4
yards - 3.5
shipping_lines - 3.6
weight_limit_rules - 3.7
rates - 3.8
surcharge_types - 3.9
priority_rules - 3.10
approval_rules
- 3.1
- Core Booking Tables
- 4.1
booking - 4.2
booking_container - 4.3
booking_cargo_modifier
- 4.1
- Supporting Tables
- Business Rule Traceability
- Key Formulas
- 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 TIMESTAMPTZfromBaseEntity.
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 formincludes_last_mile = true→ renders mandatory Delivery Address field + prompts Container Return selectionpriority_bonus_points→ feeds directly intobooking.priority_scoreformula
✅ Current code status:
service-type.entity.tsmatches 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-triggersREEFERsurcharge viasurcharge_typesis_open_top = true→ can driveLASHINGsurcharge (viasurcharge_typesconfig)
⚠️ Gap — current
container-type.entity.ts:
Current field Issue size_codeVARCHAR(20)Rename to codedescriptionVARCHAR(100)Rename to label; upgrade to VARCHAR(100) ✓containers_per_wagonINTReplace 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 toapproval_rulesto determine which approval chain to instantiateshow_free_text_box = true→ renderscargo_free_textinput 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.tsmatches 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) orEXPORT(origin Ethiopia, destination Djibouti)origin.country = destination.country→ intercity/domestic corridor — drivesINTERCITY_*rate type selection
⚠️ Gap — no
yardsentity exists in the current codebase. The currentbookingentity storesorigin_station VARCHAR(255)anddestination_station VARCHAR(255)as free strings. Action required: CreateYardentity + migration; replace both booking columns withorigin_yard_id UUID FKanddestination_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 lookupsshow_extra_fee_notice = true→ quotation document explicitly states additional fee to customer (PIL rule, US-02 Step 4B-ii)
⚠️ Gap — no
shipping_linesentity exists in the current codebase. Action required: CreateShippingLineentity + migration; addshipping_line_id UUID NULL FK → shipping_linestobooking.
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 againstbooking_container.total_vgm_tons; excess drives overweight surcharge calculationeffective_from/effective_to→ allows rule rotation without deleting historical data; query withWHERE 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.tsdiverges:
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 ENUMRemove — exceeded action is always "apply surcharge via surcharge_types"surcharge_id UUID FK → surchargesRemove — surcharge linking goes through surcharge_types, not directly from weight rule— Add: effective_from DATE NOT NULL— Add: effective_to DATE NULLis_active BOOLEANRemove — replaced by effective_to IS NULLlogic
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 querySUPERSEDEDrows are never deleted — they backbooking_rate_snapshotfor historical accuracy
⚠️ Gap — no
ratesentity exists in the current codebase. The currentsurcharge.entity.tsstores arateNUMERIC field directly on the surcharge — this is a partial, non-scalable substitute. Action required: CreateRateentity; 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.tsandsurcharge.entity.tsdiverge significantly:
- Current design has a two-level hierarchy:
surcharge_types(category) →surcharges(instance with rate). The target collapses this into a singlesurcharge_typestable with a directrate_id FK → rates.- Current
SurchargeTypeis missing:trigger_condition,rate_id.- Current
Surchargeentity (fee_name,calculation_method,rate,currency,apply_to_rail,apply_to_first_mile,apply_to_last_mile) is replaced entirely by theratestable +surcharge_types.rate_id FK.- Action required: Refactor
surcharge_typesto addtrigger_conditionandrate_id FK; removesurchargestable; migrate rate data toratestable.
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.tsdiverges:
Current field Issue priority_type ENUMReplace with code VARCHAR(40) UNIQUE— free text code is more flexible and matches the designrule_name VARCHAR(255)Rename to label VARCHAR(100)bonus_points INTRename to score INTactivation_condition TEXTRemove — replaced by condition_currency VARCHAR(5) NULL(structured, queryable)description TEXTRemove — 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_rulesentity exists in the current codebase. Action required: CreateApprovalRuleentity + 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.tshas 9 fields to remove, 7 fields to add, and 5 FKs to introduce:Remove:
origin_station VARCHARdestination_station VARCHARservice_type VARCHARfreight_type VARCHARfreight_subtype VARCHARcontainers JSONBfirst_mile_enabled BOOLEANlast_mile_enabled BOOLEANis_refrigerated BOOLEANAdd:
origin_yard_id UUID FK → yardsdestination_yard_id UUID FK → yardsservice_type_id UUID FK → service_typescargo_type_id UUID FK → cargo_typescargo_free_text VARCHAR(200) NULLshipping_line_id UUID NULL FK → shipping_linespnr_code VARCHAR(50) NULLcustomer_signed_at TIMESTAMPTZ NULLfully_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_containertable does not exist in the current codebase. The currentbookingentity storescontainers JSONB— a non-queryable, non-validated array. Action required: CreateBookingContainerentity + migration; removecontainersfrombooking.
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_modifiertable does not exist in the current codebase. Action required: CreateBookingCargoModifierentity + 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_steptable does not exist in the current codebase. Action required: CreateBookingApprovalStepentity + 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_snapshottable does not exist in the current codebase. Action required: CreateBookingRateSnapshotentity + 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