//////////////////////////////////////////////////////////////////////////// // 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 (and to the admin / // procurement manager — the same person — who can also receive and // monitor all projects): 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.