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