docs: add ERD diagrams (v2.4/v2.5/v2.6) and material-supplier-category migration plan
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
// ============================================================================
|
||||
// PROCUREMENT SCHEMA v2.5 — Material → Supplier → Category Architecture
|
||||
// ============================================================================
|
||||
//
|
||||
// CHANGE FROM v2.4 (the material -> category -> supplier model):
|
||||
//
|
||||
// OLD: material -> category -> supplier
|
||||
// (a material could only exist inside a category, and category was
|
||||
// the only path that connected it to a supplier)
|
||||
//
|
||||
// NEW: material -> supplier -> category
|
||||
// (materials are GLOBAL, catalog-wide entities with globally unique
|
||||
// names; they link directly to suppliers; the material's category
|
||||
// is DERIVED AUTOMATICALLY from the supplier — first link wins)
|
||||
//
|
||||
// Business rules enforced by this schema:
|
||||
// 1. materials.name is globally unique across the entire catalog.
|
||||
// 2. A supplier deals in exactly ONE category:
|
||||
// 1 supplier -> 1 category, 1 category -> N suppliers.
|
||||
// 3. Category assignment is fully automatic — never a manual step:
|
||||
// - linking a material to a supplier (supplier_materials) sets
|
||||
// materials.category_id to the supplier's category IF it is NULL
|
||||
// (first link wins; later links never overwrite).
|
||||
// - supplier_materials rows are created automatically whenever a
|
||||
// quote line item is saved (quote_service / line_items router).
|
||||
// 4. last_quoted_price / last_quoted_at on supplier_materials are
|
||||
// denormalized and auto-synced when an item_decision becomes 'chosen'.
|
||||
//
|
||||
// DOCUMENTED RULES (accepted trade-offs — see plan):
|
||||
// - A material linked to suppliers of different categories keeps the
|
||||
// FIRST category forever.
|
||||
// - Deleting a supplier_materials link does NOT clear the category.
|
||||
// - Changing a supplier's category does NOT retroactively re-categorize
|
||||
// its materials.
|
||||
// - A material never linked to any supplier stays category_id = NULL
|
||||
// ("uncategorized" bucket) and remains fully usable in quotes.
|
||||
// - A supplier genuinely selling across categories gets one supplier
|
||||
// record per category.
|
||||
|
||||
// ---------- ENUMS ----------
|
||||
|
||||
Enum quote_status {
|
||||
offered
|
||||
accepted
|
||||
arrived
|
||||
rejected
|
||||
expired
|
||||
}
|
||||
|
||||
Enum termsheet_status {
|
||||
offered
|
||||
accepted
|
||||
rejected
|
||||
}
|
||||
|
||||
Enum material_status {
|
||||
active
|
||||
discontinued
|
||||
draft
|
||||
}
|
||||
|
||||
Enum attribute_data_type {
|
||||
number
|
||||
integer
|
||||
string
|
||||
enum
|
||||
boolean
|
||||
date
|
||||
}
|
||||
|
||||
Enum discount_type {
|
||||
none
|
||||
percent
|
||||
fixed
|
||||
}
|
||||
|
||||
Enum item_decision_status {
|
||||
pending
|
||||
chosen
|
||||
rejected
|
||||
}
|
||||
|
||||
Enum quote_event_type {
|
||||
offered
|
||||
accepted
|
||||
rejected
|
||||
arrived
|
||||
note
|
||||
edited
|
||||
expired
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CATALOG DOMAIN
|
||||
// ============================================================================
|
||||
|
||||
Table units {
|
||||
id integer [pk]
|
||||
code varchar [not null, unique]
|
||||
name_fa varchar
|
||||
dimension varchar [note: 'length, mass, pressure, area, volume, voltage, count, ratio, time, currency']
|
||||
si_factor decimal [not null, default: 1.0, note: 'Universal multiply factor to convert to base SI unit within same dimension']
|
||||
is_base boolean [default: false]
|
||||
}
|
||||
|
||||
Table brands {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [not null]
|
||||
country text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(name, country) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
Table material_categories {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
slug text [not null, unique]
|
||||
name text [not null]
|
||||
description text
|
||||
is_active boolean [not null, default: true]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
// Global Attribute Dictionary
|
||||
Table attribute_definitions {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
attribute_key text [not null, unique]
|
||||
label text [not null]
|
||||
data_type attribute_data_type [not null]
|
||||
unit_id integer [ref: > units.id]
|
||||
enum_values jsonb [note: 'Allowed options for enum types']
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
// Category-Attribute Junction (Scopes global attributes & sets category rules)
|
||||
Table category_attributes {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
category_id uuid [not null, ref: > material_categories.id]
|
||||
attribute_definition_id uuid [not null, ref: > attribute_definitions.id]
|
||||
is_required boolean [not null, default: false]
|
||||
is_filterable boolean [not null, default: false]
|
||||
conditional_rules jsonb [note: 'UI Wizard rules e.g. {"when": {"is_colored": true}, "then": {"required": ["color"]}}']
|
||||
sort_order smallint [not null, default: 0]
|
||||
|
||||
indexes {
|
||||
(category_id, attribute_definition_id) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Materials — GLOBAL catalog entities (v2.5)
|
||||
// ----------------------------------------------------------------------------
|
||||
// category_id is now NULLABLE and DERIVED: it is populated automatically by
|
||||
// the trigger/service when the material is first linked to a supplier
|
||||
// (supplier_materials insert), inheriting that supplier's category.
|
||||
// A material may sit with category_id = NULL indefinitely — it is a valid,
|
||||
// global, reusable catalog row.
|
||||
Table materials {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
category_id uuid [ref: > material_categories.id, note: 'NULLABLE — derived automatically from the first linked supplier (first-link-wins)']
|
||||
name text [not null, note: 'GLOBALLY unique across the entire catalog']
|
||||
topic text [note: 'LEGACY display column (Persian topic) — no longer written by the app; frontend derives topic from the category slug']
|
||||
canonical_unit_id integer [not null, ref: > units.id]
|
||||
status material_status [not null, default: `'active'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
name [unique, name: 'uq_material_name', note: 'REPLACES the old (category_id, name) composite unique — global uniqueness']
|
||||
}
|
||||
}
|
||||
|
||||
// M:N Brand <-> Material Junction
|
||||
Table brand_materials {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
brand_id uuid [not null, ref: > brands.id]
|
||||
material_id uuid [not null, ref: > materials.id]
|
||||
catalog_code text
|
||||
is_active boolean [not null, default: true]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(brand_id, material_id) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
// Material & Brand Specific Packaging / Unit Overrides
|
||||
Table material_units {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
material_id uuid [not null, ref: > materials.id]
|
||||
brand_material_id uuid [ref: > brand_materials.id, note: 'NULL = generic material fallback; set = brand-specific packaging override']
|
||||
unit_id integer [not null, ref: > units.id]
|
||||
conversion_factor "numeric(18,6)" [not null, note: 'Multiplier to convert to canonical_unit_id']
|
||||
is_default boolean [not null, default: false]
|
||||
|
||||
indexes {
|
||||
(material_id, brand_material_id, unit_id) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
// Typed Specs Source of Truth (EAV)
|
||||
Table material_attribute_values {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
material_id uuid [not null, ref: > materials.id]
|
||||
attribute_definition_id uuid [not null, ref: > attribute_definitions.id]
|
||||
value_number "numeric(18,6)"
|
||||
value_string text
|
||||
value_boolean boolean
|
||||
value_date date
|
||||
|
||||
indexes {
|
||||
(material_id, attribute_definition_id) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUOTATION & PROCUREMENT DOMAIN
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Suppliers — v2.5: exactly ONE category each (1 supplier -> 1 category)
|
||||
// ----------------------------------------------------------------------------
|
||||
// The old suppliers.categories JSONB (array of category NAMES, added at
|
||||
// runtime by init_db.py) is DROPPED and replaced by this category_id FK.
|
||||
// A supplier is "known under" its category (its major product offering).
|
||||
Table suppliers {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [not null]
|
||||
category_id uuid [not null, ref: > material_categories.id, note: 'A supplier deals in exactly one category (1 category -> N suppliers)']
|
||||
location text
|
||||
phone text
|
||||
contact_person text
|
||||
tax_id text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
category_id [name: 'idx_suppliers_category_id', note: '"list all suppliers in category X" + category-based reporting']
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// supplier_materials — NEW (v2.5): the catalog-layer M:N supplier <-> material
|
||||
// link that did not exist before. This is the edge that makes the new flow
|
||||
// work: material -> supplier (this table) -> category (derived).
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rows are created automatically when a quote line item is saved
|
||||
// (quote_service._insert_children / replace_quote_children / line_items
|
||||
// router), and can also be managed explicitly via
|
||||
// GET/POST /suppliers/{id}/materials
|
||||
// DELETE /suppliers/{id}/materials/{material_id}
|
||||
// GET /materials/{id}/suppliers (price comparison data source)
|
||||
//
|
||||
// last_quoted_price / last_quoted_at are DENORMALIZED and auto-synced by
|
||||
// trigger trg_item_decisions_sync_price when an item_decision becomes
|
||||
// 'chosen' (see TRIGGERS section below).
|
||||
Table supplier_materials {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
supplier_id uuid [not null, ref: > suppliers.id, note: 'CASCADE on supplier delete']
|
||||
material_id uuid [not null, ref: > materials.id, note: 'CASCADE on material delete']
|
||||
is_active boolean [not null, default: true]
|
||||
last_quoted_price "numeric(18,4)" [note: 'Denormalized — auto-synced on item decision "chosen"']
|
||||
last_quoted_at timestamptz [note: 'When last_quoted_price was last synced']
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(supplier_id, material_id) [unique, name: 'uq_supplier_material', note: '"this supplier\'s catalog" + prevents duplicate links']
|
||||
material_id [name: 'idx_supplier_materials_material', note: '"which suppliers carry this material" (price comparison UI)']
|
||||
}
|
||||
}
|
||||
|
||||
Table projects {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [not null]
|
||||
description text
|
||||
start_date date
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
Table quotes {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
supplier_id uuid [not null, ref: > suppliers.id]
|
||||
project_id uuid [ref: > projects.id]
|
||||
quote_date timestamptz [not null, default: `now()`]
|
||||
expiry_date date
|
||||
accepted_date timestamptz
|
||||
arrived_date timestamptz
|
||||
status quote_status [not null, default: `'offered'`]
|
||||
is_official boolean [not null, default: true, note: 'True = formal binding quote; False = informal price estimate / draft']
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
Table quote_events {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
quote_id uuid [not null, ref: > quotes.id]
|
||||
event_type quote_event_type [not null]
|
||||
payload jsonb [not null, default: `'{}'`]
|
||||
occurred_at timestamptz [not null, default: `now()`]
|
||||
actor text
|
||||
}
|
||||
|
||||
Table line_items {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
quote_id uuid [not null, ref: > quotes.id]
|
||||
material_id uuid [not null, ref: > materials.id, note: 'GLOBAL material reference — no category involved at this layer']
|
||||
brand_material_id uuid [ref: > brand_materials.id, note: 'Optional: set when a specific branded product is quoted']
|
||||
unit_id integer [not null, ref: > units.id]
|
||||
quantity "numeric(18,4)" [not null]
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
// Historical Snapshot: Freezes material specs at quote generation time
|
||||
Table line_item_specs {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
line_item_id uuid [not null, ref: > line_items.id]
|
||||
attribute_definition_id uuid [not null, ref: > attribute_definitions.id]
|
||||
value_number "numeric(18,6)"
|
||||
value_string text
|
||||
value_boolean boolean
|
||||
value_date date
|
||||
unit_id_at_time integer [ref: > units.id]
|
||||
|
||||
indexes {
|
||||
(line_item_id, attribute_definition_id) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
Table termsheets {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
quote_id uuid [not null, ref: > quotes.id]
|
||||
label text [not null]
|
||||
status termsheet_status [not null, default: `'offered'`]
|
||||
discount_type discount_type [not null, default: `'none'`]
|
||||
discount_value "numeric(18,4)" [not null, default: 0]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
Table termsheet_schedule_steps {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
termsheet_id uuid [not null, ref: > termsheets.id]
|
||||
step_no smallint [not null]
|
||||
due_date date [not null]
|
||||
amount "numeric(18,2)" [not null]
|
||||
note text
|
||||
|
||||
indexes {
|
||||
(termsheet_id, step_no) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
Table item_termsheets {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
line_item_id uuid [not null, ref: > line_items.id]
|
||||
termsheet_id uuid [not null, ref: > termsheets.id]
|
||||
unit_price "numeric(18,4)" [not null]
|
||||
min_order_qty "numeric(18,4)"
|
||||
total_price "numeric(18,2)" [note: 'Calculated at query/view time: unit_price * line_items.quantity']
|
||||
|
||||
indexes {
|
||||
(line_item_id, termsheet_id) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
// item_decisions.status='chosen' drives the supplier_materials price sync
|
||||
// (trigger trg_item_decisions_sync_price — see TRIGGERS section).
|
||||
Table item_decisions {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
line_item_id uuid [not null, unique, ref: - line_items.id]
|
||||
chosen_item_termsheet_id uuid [ref: > item_termsheets.id]
|
||||
status item_decision_status [not null, default: `'pending'`]
|
||||
adjusted_unit_price "numeric(18,4)"
|
||||
adjusted_notes text
|
||||
decided_at timestamptz
|
||||
}
|
||||
|
||||
Table item_receipts {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
item_decision_id uuid [not null, unique, ref: - item_decisions.id]
|
||||
received_at timestamptz [not null, default: `now()`]
|
||||
received_qty "numeric(18,4)" [not null]
|
||||
on_time boolean
|
||||
quality_note text
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RUNTIME TABLES (created idempotently at API startup by init_db.py)
|
||||
// Present in the live database but absent from the v2.4 ERD. Included here
|
||||
// so the ERD matches reality. NONE of these change in v2.5 except the
|
||||
// suppliers.categories column, which is DROPPED.
|
||||
// ============================================================================
|
||||
|
||||
// M:N quotes <-> projects junction (a quote can be about several projects)
|
||||
Table quote_projects {
|
||||
quote_id uuid [pk, ref: > quotes.id]
|
||||
project_id uuid [pk, ref: > projects.id]
|
||||
}
|
||||
|
||||
// M:N line_items <-> projects junction (per-material project allocation)
|
||||
Table line_item_projects {
|
||||
line_item_id uuid [pk, ref: > line_items.id]
|
||||
project_id uuid [pk, ref: > projects.id]
|
||||
}
|
||||
|
||||
// Invoice / proforma metadata — bytes live in MinIO
|
||||
Table quote_attachments {
|
||||
id uuid [pk]
|
||||
quote_id uuid [not null, ref: > quotes.id]
|
||||
kind text [not null, note: 'invoice | proforma']
|
||||
file_name text [not null]
|
||||
mime_type text [not null]
|
||||
size_bytes bigint [not null]
|
||||
object_key text [not null]
|
||||
uploaded_at timestamptz [not null]
|
||||
created_at timestamptz [not null]
|
||||
updated_at timestamptz [not null]
|
||||
}
|
||||
|
||||
// One reference unit per category (units dashboard)
|
||||
Table category_reference_units {
|
||||
id uuid [pk]
|
||||
category_id uuid [not null, unique, ref: > material_categories.id]
|
||||
ref_unit text [not null]
|
||||
updated_at timestamptz [not null]
|
||||
}
|
||||
|
||||
// Brand-scoped / category-scoped conversions
|
||||
Table unit_conversions {
|
||||
id uuid [pk]
|
||||
category_id uuid [not null, ref: > material_categories.id]
|
||||
brand text
|
||||
from_unit text [not null]
|
||||
to_unit text [not null]
|
||||
factor "numeric(18,6)" [not null]
|
||||
updated_at timestamptz [not null]
|
||||
}
|
||||
|
||||
// Global conversions, applies to all categories
|
||||
Table standard_conversions {
|
||||
id uuid [pk]
|
||||
from_unit text [not null]
|
||||
to_unit text [not null]
|
||||
factor "numeric(18,6)" [not null]
|
||||
updated_at timestamptz [not null]
|
||||
|
||||
indexes {
|
||||
(from_unit, to_unit) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TRIGGERS (automatic behavior — no manual steps anywhere)
|
||||
// ============================================================================
|
||||
//
|
||||
// 1) set_updated_at() — existing: auto-updates updated_at on
|
||||
// material_categories, materials, quotes, quote_attachments.
|
||||
//
|
||||
// 2) fn_derive_material_category() + trg_supplier_materials_derive_category
|
||||
// AFTER INSERT ON supplier_materials FOR EACH ROW:
|
||||
// if materials.category_id IS NULL, set it to the linked supplier's
|
||||
// category_id (first link wins). Idempotent — never overwrites an
|
||||
// existing category. Also bumps materials.updated_at.
|
||||
// Backend service link_supplier_material() applies the same rule in the
|
||||
// application layer (the only write path); the trigger is defense-in-depth
|
||||
// so even direct SQL follows the rule.
|
||||
//
|
||||
// CREATE OR REPLACE FUNCTION fn_derive_material_category()
|
||||
// RETURNS TRIGGER AS $$
|
||||
// BEGIN
|
||||
// UPDATE materials
|
||||
// SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id),
|
||||
// updated_at = now()
|
||||
// WHERE id = NEW.material_id
|
||||
// AND category_id IS NULL;
|
||||
// RETURN NEW;
|
||||
// END;
|
||||
// $$ LANGUAGE plpgsql;
|
||||
//
|
||||
// 3) fn_sync_last_quoted_price() + trg_item_decisions_sync_price
|
||||
// AFTER INSERT OR UPDATE OF status ON item_decisions FOR EACH ROW
|
||||
// WHEN (NEW.status = 'chosen' AND NEW.chosen_item_termsheet_id IS NOT NULL):
|
||||
// updates supplier_materials.last_quoted_price =
|
||||
// COALESCE(NEW.adjusted_unit_price, chosen termsheet unit_price)
|
||||
// and last_quoted_at = now() for (quote.supplier_id, line_item.material_id).
|
||||
// Fires on BOTH insert and update because the accept-quote flow inserts
|
||||
// decisions directly with status 'chosen'.
|
||||
//
|
||||
// CREATE OR REPLACE FUNCTION fn_sync_last_quoted_price()
|
||||
// RETURNS TRIGGER AS $$
|
||||
// BEGIN
|
||||
// IF NEW.status = 'chosen' AND NEW.chosen_item_termsheet_id IS NOT NULL THEN
|
||||
// UPDATE supplier_materials sm
|
||||
// SET last_quoted_price = COALESCE(NEW.adjusted_unit_price, it.unit_price),
|
||||
// last_quoted_at = now()
|
||||
// FROM item_termsheets it
|
||||
// JOIN line_items li ON li.id = it.line_item_id
|
||||
// JOIN quotes q ON q.id = li.quote_id
|
||||
// WHERE it.id = NEW.chosen_item_termsheet_id
|
||||
// AND sm.supplier_id = q.supplier_id
|
||||
// AND sm.material_id = li.material_id;
|
||||
// END IF;
|
||||
// RETURN NEW;
|
||||
// END;
|
||||
// $$ LANGUAGE plpgsql;
|
||||
|
||||
// ============================================================================
|
||||
// SUMMARY OF v2.4 -> v2.5 CHANGES
|
||||
// ============================================================================
|
||||
// 1. materials.category_id: NOT NULL -> NULLABLE (derived automatically).
|
||||
// 2. materials unique index: (category_id, name) -> name (global uniqueness).
|
||||
// 3. suppliers: + category_id NOT NULL FK -> material_categories.id + index.
|
||||
// 4. suppliers.categories JSONB: DROPPED (replaced by #3).
|
||||
// 5. NEW table supplier_materials (the direct material <-> supplier edge).
|
||||
// 6. NEW trigger: auto-derive materials.category_id on first supplier link.
|
||||
// 7. NEW trigger: auto-sync last_quoted_price/last_quoted_at on item decision
|
||||
// 'chosen' (insert OR update).
|
||||
// 8. materials.topic: kept as a legacy column but no longer written — the
|
||||
// frontend derives the 8-topic grouping from the category slug.
|
||||
// 9. Everything else (EAV, brands, units, quotes chain, runtime tables)
|
||||
// unchanged.
|
||||
Reference in New Issue
Block a user