Files
ERP/docs/NewERD.txt
T

671 lines
24 KiB
Plaintext

////////////////////////////////////////////////////////////////////////////
// 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.