////////////////////////////////////////////////////////////////////////////
// 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 <Casdoor JWT>; 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.
