docs: add ERD diagrams (v2.4/v2.5/v2.6) and material-supplier-category migration plan
This commit is contained in:
+670
@@ -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 <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.
|
||||||
+300
@@ -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}
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user