525 lines
19 KiB
Plaintext
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.
|