diff --git a/docs/NewERD.txt b/docs/NewERD.txt new file mode 100644 index 0000000..9c90055 --- /dev/null +++ b/docs/NewERD.txt @@ -0,0 +1,670 @@ +//////////////////////////////////////////////////////////////////////////// +// PROCUREMENT + INVENTORY SYSTEM — INTEGRATED ER v2.6 (FINAL) +// Material → Supplier → Category + PM Arrival Flow + Inventory Ledger +//////////////////////////////////////////////////////////////////////////// +// +// MERGED FROM: +// - new erd.txt (procurement v2.5: global materials, supplier categories) +// - NewERD.txt (inventory domain: warehouses, inventory, transactions) +// - the software team's shipped inventory-dashboard fork (endpoint shapes, +// 10 transaction types, seed model: categories = the 8 topic categories) +// +// LOCKED DECISIONS (confirmed with the product owner): +// 1. material_categories = EXACTLY the 8 topic categories below (fixed set). +// Fine products (میلگرد فولادی، سیمان تیپ ۲، ...) are MATERIALS. +// 2. Materials are GLOBAL: name globally unique, category_id nullable and +// DERIVED AUTOMATICALLY from the first linked supplier (first-link-wins). +// 3. 1 supplier → 1 category (one of the 8); 1 category → N suppliers. +// 4. Offers may span several projects per line item (line_item_projects), +// but a PURCHASED item has exactly ONE project, picked by the +// procurement manager at purchase → item_decisions.project_id. +// 5. Arrival confirmation belongs to the PM: proc manager purchases → +// item appears on PM dash as "on the way" → PM confirms arrival and +// splits the goods into warehouses (free count, typically 3) → +// inventory posts PURCHASE automatically → proc dash updates. +// Partial deliveries allowed: 1 item : N receipts (three derived +// states: on-the-way / partially received / arrived). +// 6. Auth: Casdoor (OIDC + PKCE). Backend validates the Casdoor JWT; +// role/project come from the users table + projects.pm_user_id. +// X-User-Role / X-Project-Id headers are a DEV-ONLY fallback. +// 7. Roles: pm | procurement_manager | admin (admin = read-only structure +// management for the inventory dash). +// 8. No hard deletes after confirmation — undo is a reversing transaction. +// 9. Stock quantity: stored snapshot maintained by the service layer with +// row locks (the fork's apply_transaction); ledger is the history. +// 10. Existing data is disposable — fresh reset + seed (seed_stack.py). + + +//////////////////////////////////////////////////////////////////////////// +// 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 +} + +// The EXACT 10 supported transaction types (no generic OTHER/adjustment). +// Signed quantity convention: PURCHASE/RETURN/FOUND/TRANSFER_IN store +// positive quantity; WITHDRAWAL/DAMAGE/WASTE/LOSS/TRANSFER_OUT store +// negative quantity; STOCK_COUNT stores the difference (recorded→physical). +enum inventory_transaction_reason { + PURCHASE + WITHDRAWAL + RETURN + TRANSFER_IN + TRANSFER_OUT + DAMAGE + WASTE + LOSS + FOUND + STOCK_COUNT +} + +enum user_role { + pm + procurement_manager + admin +} + + +//////////////////////////////////////////////////////////////////////////// +// USERS & IDENTITY (Casdoor-backed) +//////////////////////////////////////////////////////////////////////////// + +// casdoor_id = Casdoor user "sub" claim (unique). Profile fields are +// refreshed from Casdoor claims on every login. role is stored HERE +// (our DB is the authorization source of truth, not Casdoor). +// New users are auto-provisioned with role NULL until assigned. +Table users { + id uuid [pk, default: `gen_random_uuid()`] + casdoor_id text [not null, unique] + first_name text [not null] + last_name text [not null] + phone text + role user_role [null, note: 'NULL until assigned — backend denies role-gated routes for NULL'] + created_at timestamptz [not null, default: `now()`] +} + + +//////////////////////////////////////////////////////////////////////////// +// CATALOG +//////////////////////////////////////////////////////////////////////////// + +Table units { + id integer [pk] + code varchar [not null, unique] + name_fa varchar + dimension varchar + si_factor decimal [not null, default: 1.0] + 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] + } +} + +// ========================================================================== +// THE 8 FIXED TOPIC CATEGORIES (v2.6 — category IS the topic) +// mechanical-installations-pipes تأسیسات مکانیکی و آبی +// electrical-equipment-supplies تجهیزات و لوازم برقی +// doors-windows-glass درب، پنجره و شیشه +// metals-steel-sections فلزات و مقاطع فولادی +// construction-materials مصالح ساختمانی +// construction-chemicals مواد شیمیایی ساختمانی +// finishing-interior-decoration نازک‌کاری و دکوراسیون داخلی +// construction-tools-equipment ابزار و تجهیزات کارگاهی +// Seeded once; the app NEVER auto-creates categories. +// ========================================================================== +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()`] +} + +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 + created_at timestamptz [not null, default: `now()`] +} + +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 + sort_order smallint [not null, default: 0] + + indexes { + (category_id, attribute_definition_id) [unique] + } +} + +// -------------------------------------------------------------------------- +// Materials — GLOBAL catalog entities (v2.5/v2.6) +// name is GLOBALLY unique (uq_material_name). category_id is NULLABLE and +// DERIVED: set automatically when the material is first linked to a +// supplier (supplier_materials insert) — first link wins, never overwritten. +// A material never linked to a supplier stays category_id = NULL +// ("uncategorized" bucket) and remains fully usable in quotes. +// topic is a LEGACY column kept for old rows; the frontend derives the +// topic display from the category slug (category IS the topic in v2.6). +// -------------------------------------------------------------------------- +Table materials { + id uuid [pk, default: `gen_random_uuid()`] + category_id uuid [ref: > material_categories.id, note: 'NULLABLE — derived from first linked supplier'] + name text [not null, note: 'GLOBALLY unique'] + topic text [note: 'LEGACY display column — no longer written'] + 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'] + } +} + +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] + } +} + +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'] + unit_id integer [not null, ref: > units.id] + conversion_factor decimal(18,6) [not null, note: 'Multiplier to canonical_unit_id'] + is_default boolean [not null, default: false] + + indexes { + (material_id, brand_material_id, unit_id) [unique] + } +} + +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 decimal(18,6) + value_string text + value_boolean boolean + value_date date + + indexes { + (material_id, attribute_definition_id) [unique] + } +} + + +//////////////////////////////////////////////////////////////////////////// +// SUPPLIERS +//////////////////////////////////////////////////////////////////////////// + +// 1 supplier → exactly 1 category (one of the 8). The old suppliers +// categories JSONB (array of names) is DROPPED. +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'] + location text + phone text + contact_person text + tax_id text + created_at timestamptz [not null, default: `now()`] + + indexes { + category_id + } +} + +// Catalog-layer M:N supplier ↔ material link (the direct edge of the new +// flow: material → supplier → derived category). +// Rows are auto-created whenever a quote line item is saved +// (quote_service / line_items router) and manageably via +// GET/POST /suppliers/{id}/materials, DELETE /suppliers/{id}/materials/{id}, +// GET /materials/{id}/suppliers. +// last_quoted_price / last_quoted_at auto-sync when an item decision +// becomes 'chosen' (trigger trg_item_decisions_sync_price). +Table supplier_materials { + id uuid [pk, default: `gen_random_uuid()`] + supplier_id uuid [not null, ref: > suppliers.id, ondelete CASCADE] + material_id uuid [not null, ref: > materials.id, ondelete CASCADE] + is_active boolean [not null, default: true] + last_quoted_price decimal(18,4) [note: 'Denormalized — auto-synced on decision chosen'] + last_quoted_at timestamptz + created_at timestamptz [not null, default: `now()`] + + indexes { + (supplier_id, material_id) [unique, name: 'uq_supplier_material'] + material_id + } +} + + +//////////////////////////////////////////////////////////////////////////// +// PROJECTS +//////////////////////////////////////////////////////////////////////////// + +// pm_user_id: the ONE project manager responsible for this project +// (1 PM per project). Assigned by the procurement manager. +Table projects { + id uuid [pk, default: `gen_random_uuid()`] + name text [not null] + description text + start_date date + pm_user_id uuid [ref: > users.id, note: 'The single PM of this project (confirms arrivals)'] + created_at timestamptz [not null, default: `now()`] +} + + +//////////////////////////////////////////////////////////////////////////// +// QUOTATIONS +//////////////////////////////////////////////////////////////////////////// + +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] + 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 [note: 'Free-text label (legacy) — may upgrade to users FK later'] +} + +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'] + brand_material_id uuid [ref: > brand_materials.id] + unit_id integer [not null, ref: > units.id] + quantity decimal(18,4) [not null, note: 'Ordered quantity'] + notes text + created_at timestamptz [not null, default: `now()`] +} + +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 decimal(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] + } +} + + +//////////////////////////////////////////////////////////////////////////// +// TERMSHEETS +//////////////////////////////////////////////////////////////////////////// + +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 decimal(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 decimal(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 decimal(18,4) [not null] + min_order_qty decimal(18,4) + total_price decimal(18,2) [note: 'NOT stored — computed in view item_termsheets_with_total'] + + indexes { + (line_item_id, termsheet_id) [unique] + } +} + + +//////////////////////////////////////////////////////////////////////////// +// ITEM DECISIONS & RECEIPTS (purchase + PM arrival flow) +//////////////////////////////////////////////////////////////////////////// + +// project_id: the SINGLE purchased project. Picked by the procurement +// manager when the item is chosen (auto-filled when the offer had exactly +// one project; validated against line_item_projects). Warehouses of this +// project are the only valid arrival destinations. +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'] + project_id uuid [ref: > projects.id, note: 'The ONE purchased project (set at purchase by proc manager)'] + adjusted_unit_price decimal(18,4) + adjusted_notes text + decided_at timestamptz +} + +// 1 item : N receipts (PARTIAL DELIVERIES — UNIQUE on item_decision_id is +// REMOVED in v2.6). A receipt means "the PM confirmed this delivery". +// Received qty is in the line item's unit; inventory posting converts to +// the inventory row's unit. +// Derived arrival state (no column): sum of receipts vs line_items.quantity +// = 0 → on-the-way +// < ordered → partially received +// >= ordered → arrived (quote.status flips to 'arrived' when ALL +// chosen items are fully received) +Table item_receipts { + id uuid [pk, default: `gen_random_uuid()`] + item_decision_id uuid [not null, ref: > item_decisions.id, note: 'NOT unique — partial deliveries'] + confirmed_by uuid [not null, ref: > users.id, note: 'The PM who confirmed this delivery'] + received_at timestamptz [not null, default: `now()`] + received_qty decimal(18,4) [not null] + on_time boolean + quality_note text + + indexes { + item_decision_id + } +} + +// The PM's split of ONE receipt across warehouses (typically 3). +// App rule: SUM(quantity) = item_receipts.received_qty and every +// warehouse.project_id = item_decisions.project_id. +Table item_receipt_allocations { + id uuid [pk, default: `gen_random_uuid()`] + receipt_id uuid [not null, ref: > item_receipts.id, ondelete CASCADE] + warehouse_id uuid [not null, ref: > warehouses.id, ondelete RESTRICT] + quantity decimal(18,6) [not null, note: '> 0 — in the receipt line-item unit'] + + indexes { + (receipt_id, warehouse_id) [unique] + } +} + + +//////////////////////////////////////////////////////////////////////////// +// WAREHOUSES & INVENTORY +//////////////////////////////////////////////////////////////////////////// + +// 1 warehouse → exactly 1 project. Receipts route to warehouses of the +// purchased project. +Table warehouses { + id uuid [pk, default: `gen_random_uuid()`] + project_id uuid [not null, ref: > projects.id, ondelete CASCADE] + name text [not null] + description text + created_at timestamptz [not null, default: `now()`] +} + +// Current stock snapshot, unique per (warehouse, material, brand, specs). +// brand_id is NULLABLE → the unique index MUST use COALESCE so NULL means +// ONE concrete value ('no brand'); a plain unique index would allow +// duplicate stock rows for unbranded materials (Postgres NULLs are +// distinct). specs_hash = SHA256 of the sorted canonical specs JSON +// ('default' when empty) — computed SERVER-SIDE on every write. +// unit_id: the unit of quantity for this row (canonical unit for +// auto-posted purchases; PM manual entries may pick any unit — the service +// enforces one unit per row). +Table inventory { + id uuid [pk, default: `gen_random_uuid()`] + warehouse_id uuid [not null, ref: > warehouses.id, ondelete CASCADE] + material_id uuid [not null, ref: > materials.id, ondelete RESTRICT] + brand_id uuid [ref: > brands.id, ondelete SET NULL] + quantity decimal(18,6) [not null, default: 0, note: 'CHECK (quantity >= 0)'] + unit_id integer [not null, ref: > units.id] + specs jsonb [not null, default: '{}'] + specs_hash text [not null, default: 'default'] + updated_at timestamptz [not null, default: `now()`] + + indexes { + (warehouse_id, material_id, COALESCE(brand_id, '00000000-0000-0000-0000-000000000000'), specs_hash) [unique, name: 'uq_inventory_stock'] + (warehouse_id, material_id) + } +} + +// Full chronological ledger. quantity is SIGNED (see enum note). +// STOCK_COUNT: recorded_qty/physical_qty hold the before/counted values +// and quantity = difference. A transfer = two linked rows +// (TRANSFER_OUT in source, TRANSFER_IN in destination) sharing +// transfer_pair_id. performed_by = user display name (or FK later). +// receipt_allocation_id links an auto-posted PURCHASE back to its +// allocation — UNIQUE, so the same delivery can never post twice. +Table inventory_transactions { + id uuid [pk, default: `gen_random_uuid()`] + inventory_id uuid [not null, ref: > inventory.id, ondelete CASCADE] + warehouse_id uuid [not null, ref: > warehouses.id, ondelete RESTRICT] + receipt_allocation_id uuid [ref: > item_receipt_allocations.id, ondelete SET NULL, note: 'UNIQUE — idempotent arrival posting'] + transaction_type inventory_transaction_reason [not null] + quantity decimal(18,6) [not null, note: 'SIGNED — positive in, negative out'] + recorded_qty decimal(18,6) [note: 'STOCK_COUNT only'] + physical_qty decimal(18,6) [note: 'STOCK_COUNT only'] + transfer_pair_id uuid [note: 'Links TRANSFER_OUT ↔ TRANSFER_IN rows'] + performed_by text [note: 'User display name (dev placeholder — users FK later)'] + transaction_date timestamptz [not null, default: `now()`] + note text + created_at timestamptz [not null, default: `now()`] + + indexes { + receipt_allocation_id [unique] + (inventory_id, transaction_date) + (warehouse_id, transaction_date) + } +} + + +//////////////////////////////////////////////////////////////////////////// +// RUNTIME TABLES (created idempotently at API startup) +//////////////////////////////////////////////////////////////////////////// + +// M:N quotes ↔ projects (an offer 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 (offer-side allocation; the PURCHASED project +// is the single item_decisions.project_id) +Table line_item_projects { + line_item_id uuid [pk, ref: > line_items.id] + project_id uuid [pk, ref: > projects.id] +} + +// Invoice / proforma metadata — bytes 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] +} + +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 decimal(18,6) [not null] + updated_at timestamptz [not null] +} + +Table standard_conversions { + id uuid [pk] + from_unit text [not null] + to_unit text [not null] + factor decimal(18,6) [not null] + updated_at timestamptz [not null] + + indexes { + (from_unit, to_unit) [unique] + } +} + + +//////////////////////////////////////////////////////////////////////////// +// TRIGGERS (automatic behavior) +//////////////////////////////////////////////////////////////////////////// +// +// 1) set_updated_at() — existing: material_categories, materials, quotes, +// quote_attachments. +// +// 2) fn_derive_material_category() — AFTER INSERT ON supplier_materials: +// materials.category_id = supplier.category_id IF NULL (first-link-wins, +// idempotent, bumps materials.updated_at). Service applies the same rule; +// trigger is defense-in-depth for direct SQL. +// +// 3) fn_sync_last_quoted_price() — AFTER INSERT OR UPDATE OF status ON +// item_decisions WHERE status='chosen' AND chosen_item_termsheet_id NOT +// NULL: supplier_materials.last_quoted_price = COALESCE(adjusted_unit_price, +// chosen termsheet unit_price); last_quoted_at = now(). +// +// 4) Stock quantity is maintained by the SERVICE (inventory_service. +// apply_transaction) with SELECT ... FOR UPDATE row locks — NOT a +// trigger — because the fork's shipped code already implements the +// find-or-create/decrement logic there and both must not double-apply. +// The ledger (inventory_transactions) is the history; inventory.quantity +// is the atomic snapshot. +// +// 5) Arrival auto-posting goes through the same service path: POST +// /pm/receipts → insert receipt + allocations → for each allocation +// insert an inventory_transactions row with transaction_type=PURCHASE, +// quantity converted to the inventory row's unit, receipt_allocation_id +// = allocation id (UNIQUE prevents double-posting). + + +//////////////////////////////////////////////////////////////////////////// +// API CONTRACT NOTES (for the inventory dash port) +//////////////////////////////////////////////////////////////////////////// +// Endpoints the shipped dash already calls (shapes must stay stable): +// GET /projects, /material-categories, /units, /brands +// GET /materials?expand=category,brands,canonical_unit +// GET /inventory/warehouses +// GET /inventory (filters: project_id, category_id, warehouse_id, material_id) +// GET /inventory/transactions (filters: project_id, warehouse_id, transaction_type, date_from/to) +// POST /inventory/transactions (manual PM entries) +// GET /inventory/{id}/transactions +// GET /inventory/meta/types +// GET /inventory/materials/options?category_id=&warehouse_id= +// POST /inventory/warehouses, PATCH/DELETE /inventory/warehouses/{id} +// All responses use the {data, meta} envelope. +// New for the PM arrival flow: +// GET /pm/on-the-way → chosen items not fully received (three states) +// POST /pm/receipts → confirm delivery: receipt + allocations +// GET /pm/receipts → delivery history for the PM's project +// Auth: Authorization: Bearer ; role from users.role; +// PM's project from projects.pm_user_id. Dev fallback: X-User-Role / +// X-Project-Id / X-User-Name-B64 headers. diff --git a/docs/erd.txt b/docs/erd.txt new file mode 100644 index 0000000..bd7d5a6 --- /dev/null +++ b/docs/erd.txt @@ -0,0 +1,300 @@ +// ============================================================================ +// PROCUREMENT SCHEMA v2.4 — Final Enterprise Architecture +// ============================================================================ + +// ---------- 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] +} +} + +// Abstract Base Products / Materials +Table materials { +id uuid [pk, default: `gen_random_uuid()`] +category_id uuid [not null, ref: > material_categories.id] +name text [not null] +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 { +(category_id, name) [unique] +} +} + +// 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 +// ============================================================================ + +Table suppliers { +id uuid [pk, default: `gen_random_uuid()`] +name text [not null] +location text +phone text +contact_person text +tax_id text +created_at timestamptz [not null, default: `now()`] +} + +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] +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] +} +} + +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} \ No newline at end of file diff --git a/docs/material-supplier-category-migration-plan.md b/docs/material-supplier-category-migration-plan.md new file mode 100644 index 0000000..3f581c4 --- /dev/null +++ b/docs/material-supplier-category-migration-plan.md @@ -0,0 +1,274 @@ +# Migration Plan: Material → Supplier → Category Redesign + +**Schema:** Procurement Schema v2.4 +**Change owner:** [fill in] +**Status:** Ready for engineering review + +--- + +## 1. Objective + +Today, a material can only exist inside a category, and a category is the only path that connects a material to a supplier: + +``` +material → category → supplier +``` + +We are flipping this so materials are **global, catalog-wide entities** that connect to a supplier first. Category is *derived automatically* from the supplier, not chosen by a category-first hierarchy: + +``` +material → supplier → category +``` + +Confirmed business rules driving this design: + +1. **Material names are globally unique** across the entire catalog — not just unique within a category. +2. **A supplier deals in exactly one category** (1 supplier → 1 category; 1 category → N suppliers). A supplier is "known" for that category. +3. **Category assignment must be fully automatic** — no manual/admin step required after a material is linked to a supplier. + +--- + +## 2. Schema changes — summary table + +| # | Table | Change | Type | +|---|-------|--------|------| +| 1 | `materials` | `category_id` becomes nullable | Alter column | +| 2 | `materials` | Unique index changes from `(category_id, name)` → `name` | Alter index | +| 3 | `suppliers` | Add `category_id` (not null, FK to `material_categories.id`) | New column | +| 4 | `suppliers` | Add index on `category_id` | New index | +| 5 | — | New table `supplier_materials` | New table | +| 6 | — | Trigger: auto-derive `materials.category_id` on first supplier link | New trigger | +| 7 | — | (Recommended, separate decision) sync job/trigger for `supplier_materials.last_quoted_price` | New trigger/job | + +Nothing else in the schema changes. `category_attributes` and the EAV machinery (`material_attribute_values`, `attribute_definitions`) are **untouched** — they continue to key off `category_id`, which now just gets populated automatically instead of being set at material-creation time. + +--- + +## 3. Detailed DBML changes + +### 3.1 `materials` — loosen the category requirement + +```dbml +Table materials { + id uuid [pk, default: `gen_random_uuid()`] + category_id uuid [ref: > material_categories.id, note: 'Nullable — populated automatically once linked to a supplier'] + name text [not null] + 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] + } +} +``` + +**Removed:** `not null` on `category_id`, and the composite `(category_id, name)` unique index. +**Added:** bare `name [unique]`. + +⚠️ **Pre-migration check required** — see §5.1. A bare unique index on `name` will fail to apply if any duplicate material names currently exist across different categories. + +### 3.2 `suppliers` — add the category tag + +```dbml +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'] + location text + phone text + contact_person text + tax_id text + created_at timestamptz [not null, default: `now()`] + + indexes { + category_id + } +} +``` + +The index on `category_id` supports "list all suppliers in category X" lookups and category-based reporting. + +⚠️ **Pre-migration check required** — see §5.2. `not null` can't be applied until every existing supplier row has a category assigned. + +### 3.3 New table: `supplier_materials` + +The catalog-layer edge that currently doesn't exist — what a supplier actually sells, independent of any quote. + +```dbml +// M:N Supplier <-> Material catalog junction +Table supplier_materials { + id uuid [pk, default: `gen_random_uuid()`] + supplier_id uuid [not null, ref: > suppliers.id] + material_id uuid [not null, ref: > materials.id] + is_active boolean [not null, default: true] + last_quoted_price "numeric(18,4)" [note: 'Denormalized — see §3.5 sync mechanism'] + created_at timestamptz [not null, default: `now()`] + + indexes { + (supplier_id, material_id) [unique] + material_id + } +} +``` + +Two indexes, two different access patterns: +- `(supplier_id, material_id)` unique — "show this supplier's catalog," and prevents duplicate links. +- Standalone `material_id` — "which suppliers carry this material" (price comparison UI). Without this, that query has to scan the whole table. + +### 3.4 Trigger: automatic category derivation + +Fires the moment a material is linked to a supplier for the first time. If the material has no category yet, it inherits the supplier's category. Idempotent — only touches rows where `category_id IS NULL`, so it never overwrites a manually-set or already-derived category. + +```sql +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; + +CREATE TRIGGER trg_supplier_materials_derive_category +AFTER INSERT ON supplier_materials +FOR EACH ROW +EXECUTE FUNCTION fn_derive_material_category(); +``` + +Because `suppliers.category_id` is `not null`, this trigger can never pull a null category — it will always resolve to something as long as the supplier row is valid. + +### 3.5 `last_quoted_price` sync — needs a team decision + +`supplier_materials.last_quoted_price` is denormalized, and nothing in the schema keeps it in sync automatically yet. This wasn't part of the original ask, but it's a loose end worth closing now rather than discovering later. **The team needs to pick the trigger event** — the most natural candidate is when a line item's decision is finalized: + +```sql +CREATE OR REPLACE FUNCTION fn_sync_last_quoted_price() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status = 'chosen' THEN + UPDATE supplier_materials sm + SET last_quoted_price = COALESCE(NEW.adjusted_unit_price, it.unit_price) + 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; + +CREATE TRIGGER trg_item_decisions_sync_price +AFTER UPDATE OF status ON item_decisions +FOR EACH ROW +EXECUTE FUNCTION fn_sync_last_quoted_price(); +``` + +**Open question for the team:** should this fire on `item_decisions.status = 'chosen'` (as above — reflects the price actually decided on), or on `quotes.status = 'accepted'` (reflects the whole quote being accepted, before line-level decisions are made)? These can produce different values if a decision is later adjusted. Pick one source of truth and confirm before implementing. + +--- + +## 4. What is explicitly NOT changing + +- `category_attributes`, `attribute_definitions`, `material_attribute_values` — the EAV system stays exactly as-is. It already keys off `material_id` directly with no FK to category, so a material sitting with `category_id = NULL` for a while doesn't break anything at the data layer — it just means the UI won't prompt for category-specific spec fields until `category_id` gets populated by the trigger. +- `brand_materials`, `material_units` — untouched. +- `quotes`, `line_items`, `termsheets`, `item_decisions`, `item_receipts` — untouched. `supplier_materials` is a catalog-layer table; it doesn't replace or interact with the transactional quote flow. + +--- + +## 5. Migration steps (in order) + +### 5.1 Pre-check: duplicate material names + +Run this against production **before** touching the `materials` unique index: + +```sql +SELECT name, COUNT(*) AS occurrences +FROM materials +GROUP BY name +HAVING COUNT(*) > 1; +``` + +If this returns any rows, resolve them (rename, merge, or archive duplicates) before proceeding — the new `name [unique]` index will fail to create otherwise. + +### 5.2 Pre-check / backfill: supplier categories + +Every existing supplier needs a `category_id` before the column can be made `not null`. + +```sql +-- 1. Add as nullable first +ALTER TABLE suppliers ADD COLUMN category_id uuid REFERENCES material_categories(id); + +-- 2. Backfill (manual, or scripted from existing quote/line_item history — +-- e.g. infer from the most common category among a supplier's past line items) + +-- 3. Verify zero nulls remain +SELECT id, name FROM suppliers WHERE category_id IS NULL; + +-- 4. Only once that returns zero rows: +ALTER TABLE suppliers ALTER COLUMN category_id SET NOT NULL; +CREATE INDEX idx_suppliers_category_id ON suppliers(category_id); +``` + +### 5.3 Apply `materials` changes + +```sql +ALTER TABLE materials ALTER COLUMN category_id DROP NOT NULL; +DROP INDEX IF EXISTS materials_category_id_name_idx; -- adjust to actual constraint name +CREATE UNIQUE INDEX materials_name_idx ON materials(name); +``` + +### 5.4 Create `supplier_materials` + +Create table and both indexes as in §3.3. + +### 5.5 Create the category-derivation trigger + +Deploy the function and trigger from §3.4. + +### 5.6 Decide and implement `last_quoted_price` sync + +Resolve the open question in §3.5, then deploy. + +### 5.7 Backfill existing materials (optional, recommended) + +Materials that already have a `category_id` from the old flow don't need anything — the trigger only acts on nulls. But if you want `supplier_materials` populated retroactively from quote history so the "which suppliers carry this material" UI works immediately instead of only going forward, backfill it from `line_items`: + +```sql +INSERT INTO supplier_materials (supplier_id, material_id, is_active) +SELECT DISTINCT q.supplier_id, li.material_id, true +FROM line_items li +JOIN quotes q ON q.id = li.quote_id +ON CONFLICT (supplier_id, material_id) DO NOTHING; +``` + +--- + +## 6. Open decisions for the team + +| Question | Why it matters | +|---|---| +| Can a supplier ever legitimately sell more than one category (e.g. a general distributor)? | Current design hard-enforces 1 supplier = 1 category. If this happens in practice, the workaround is separate supplier records per category — confirm this is acceptable before locking it in. | +| Which event syncs `last_quoted_price` — `item_decisions.status = 'chosen'` or `quotes.status = 'accepted'`? | Determines what "last quoted price" actually means to users; needs one source of truth. | +| Do we backfill `supplier_materials` from historical `line_items` (§5.7)? | Affects whether "supplier catalog" and "which suppliers carry X" features work on day one or only going forward. | +| What happens to materials that never get linked to any supplier? | They stay permanently uncategorized (`category_id = NULL`) by design — confirm reporting/spend-by-category views handle a NULL bucket gracefully. | + +--- + +## 7. Testing checklist + +- [ ] Inserting a material with no `category_id` succeeds. +- [ ] Inserting a duplicate material `name` (any category) fails with a unique violation. +- [ ] Inserting a supplier with no `category_id` fails (not null violation). +- [ ] Linking a material to a supplier via `supplier_materials` auto-populates `materials.category_id` to match the supplier's category. +- [ ] Linking a material to a **second** supplier (different category) does **not** overwrite an already-populated `category_id`. +- [ ] `category_attributes`-driven UI still correctly shows spec fields once `category_id` is populated. +- [ ] Reverse lookup ("suppliers carrying material X") uses the `material_id` index and performs well at scale. +- [ ] `last_quoted_price` updates correctly once the chosen sync trigger fires (per §3.5 decision). diff --git a/docs/new erd.txt b/docs/new erd.txt new file mode 100644 index 0000000..85fffc6 --- /dev/null +++ b/docs/new erd.txt @@ -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.