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.
|
||||
Reference in New Issue
Block a user