Files
ERP/docs/new erd.txt

525 lines
19 KiB
Plaintext

// ============================================================================
// 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.