673 lines
25 KiB
Plaintext
673 lines
25 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 (and to the admin /
|
|
// procurement manager — the same person — who can also receive and
|
|
// monitor all projects): proc manager purchases →
|
|
// item appears on PM dash as "on the way" → PM confirms arrival and
|
|
// splits the goods into warehouses (free count, typically 3) →
|
|
// inventory posts PURCHASE automatically → proc dash updates.
|
|
// Partial deliveries allowed: 1 item : N receipts (three derived
|
|
// states: on-the-way / partially received / arrived).
|
|
// 6. Auth: Casdoor (OIDC + PKCE). Backend validates the Casdoor JWT;
|
|
// role/project come from the users table + projects.pm_user_id.
|
|
// X-User-Role / X-Project-Id headers are a DEV-ONLY fallback.
|
|
// 7. Roles: pm | procurement_manager | admin (admin = read-only structure
|
|
// management for the inventory dash).
|
|
// 8. No hard deletes after confirmation — undo is a reversing transaction.
|
|
// 9. Stock quantity: stored snapshot maintained by the service layer with
|
|
// row locks (the fork's apply_transaction); ledger is the history.
|
|
// 10. Existing data is disposable — fresh reset + seed (seed_stack.py).
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// ENUMS
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
enum quote_status {
|
|
offered
|
|
accepted
|
|
arrived
|
|
rejected
|
|
expired
|
|
}
|
|
|
|
enum termsheet_status {
|
|
offered
|
|
accepted
|
|
rejected
|
|
}
|
|
|
|
enum material_status {
|
|
active
|
|
discontinued
|
|
draft
|
|
}
|
|
|
|
enum attribute_data_type {
|
|
number
|
|
integer
|
|
string
|
|
enum
|
|
boolean
|
|
date
|
|
}
|
|
|
|
enum discount_type {
|
|
none
|
|
percent
|
|
fixed
|
|
}
|
|
|
|
enum item_decision_status {
|
|
pending
|
|
chosen
|
|
rejected
|
|
}
|
|
|
|
enum quote_event_type {
|
|
offered
|
|
accepted
|
|
rejected
|
|
arrived
|
|
note
|
|
edited
|
|
expired
|
|
}
|
|
|
|
// The EXACT 10 supported transaction types (no generic OTHER/adjustment).
|
|
// Signed quantity convention: PURCHASE/RETURN/FOUND/TRANSFER_IN store
|
|
// positive quantity; WITHDRAWAL/DAMAGE/WASTE/LOSS/TRANSFER_OUT store
|
|
// negative quantity; STOCK_COUNT stores the difference (recorded→physical).
|
|
enum inventory_transaction_reason {
|
|
PURCHASE
|
|
WITHDRAWAL
|
|
RETURN
|
|
TRANSFER_IN
|
|
TRANSFER_OUT
|
|
DAMAGE
|
|
WASTE
|
|
LOSS
|
|
FOUND
|
|
STOCK_COUNT
|
|
}
|
|
|
|
enum user_role {
|
|
pm
|
|
procurement_manager
|
|
admin
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// USERS & IDENTITY (Casdoor-backed)
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
// casdoor_id = Casdoor user "sub" claim (unique). Profile fields are
|
|
// refreshed from Casdoor claims on every login. role is stored HERE
|
|
// (our DB is the authorization source of truth, not Casdoor).
|
|
// New users are auto-provisioned with role NULL until assigned.
|
|
Table users {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
casdoor_id text [not null, unique]
|
|
first_name text [not null]
|
|
last_name text [not null]
|
|
phone text
|
|
role user_role [null, note: 'NULL until assigned — backend denies role-gated routes for NULL']
|
|
created_at timestamptz [not null, default: `now()`]
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// CATALOG
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
Table units {
|
|
id integer [pk]
|
|
code varchar [not null, unique]
|
|
name_fa varchar
|
|
dimension varchar
|
|
si_factor decimal [not null, default: 1.0]
|
|
is_base boolean [default: false]
|
|
}
|
|
|
|
Table brands {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
name text [not null]
|
|
country text
|
|
created_at timestamptz [not null, default: `now()`]
|
|
|
|
indexes {
|
|
(name, country) [unique]
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// THE 8 FIXED TOPIC CATEGORIES (v2.6 — category IS the topic)
|
|
// mechanical-installations-pipes تأسیسات مکانیکی و آبی
|
|
// electrical-equipment-supplies تجهیزات و لوازم برقی
|
|
// doors-windows-glass درب، پنجره و شیشه
|
|
// metals-steel-sections فلزات و مقاطع فولادی
|
|
// construction-materials مصالح ساختمانی
|
|
// construction-chemicals مواد شیمیایی ساختمانی
|
|
// finishing-interior-decoration نازککاری و دکوراسیون داخلی
|
|
// construction-tools-equipment ابزار و تجهیزات کارگاهی
|
|
// Seeded once; the app NEVER auto-creates categories.
|
|
// ==========================================================================
|
|
Table material_categories {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
slug text [not null, unique]
|
|
name text [not null]
|
|
description text
|
|
is_active boolean [not null, default: true]
|
|
created_at timestamptz [not null, default: `now()`]
|
|
updated_at timestamptz [not null, default: `now()`]
|
|
}
|
|
|
|
Table attribute_definitions {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
attribute_key text [not null, unique]
|
|
label text [not null]
|
|
data_type attribute_data_type [not null]
|
|
unit_id integer [ref: > units.id]
|
|
enum_values jsonb
|
|
created_at timestamptz [not null, default: `now()`]
|
|
}
|
|
|
|
Table category_attributes {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
category_id uuid [not null, ref: > material_categories.id]
|
|
attribute_definition_id uuid [not null, ref: > attribute_definitions.id]
|
|
is_required boolean [not null, default: false]
|
|
is_filterable boolean [not null, default: false]
|
|
conditional_rules jsonb
|
|
sort_order smallint [not null, default: 0]
|
|
|
|
indexes {
|
|
(category_id, attribute_definition_id) [unique]
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Materials — GLOBAL catalog entities (v2.5/v2.6)
|
|
// name is GLOBALLY unique (uq_material_name). category_id is NULLABLE and
|
|
// DERIVED: set automatically when the material is first linked to a
|
|
// supplier (supplier_materials insert) — first link wins, never overwritten.
|
|
// A material never linked to a supplier stays category_id = NULL
|
|
// ("uncategorized" bucket) and remains fully usable in quotes.
|
|
// topic is a LEGACY column kept for old rows; the frontend derives the
|
|
// topic display from the category slug (category IS the topic in v2.6).
|
|
// --------------------------------------------------------------------------
|
|
Table materials {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
category_id uuid [ref: > material_categories.id, note: 'NULLABLE — derived from first linked supplier']
|
|
name text [not null, note: 'GLOBALLY unique']
|
|
topic text [note: 'LEGACY display column — no longer written']
|
|
canonical_unit_id integer [not null, ref: > units.id]
|
|
status material_status [not null, default: 'active']
|
|
created_at timestamptz [not null, default: `now()`]
|
|
updated_at timestamptz [not null, default: `now()`]
|
|
|
|
indexes {
|
|
name [unique, name: 'uq_material_name']
|
|
}
|
|
}
|
|
|
|
Table brand_materials {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
brand_id uuid [not null, ref: > brands.id]
|
|
material_id uuid [not null, ref: > materials.id]
|
|
catalog_code text
|
|
is_active boolean [not null, default: true]
|
|
created_at timestamptz [not null, default: `now()`]
|
|
|
|
indexes {
|
|
(brand_id, material_id) [unique]
|
|
}
|
|
}
|
|
|
|
Table material_units {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
material_id uuid [not null, ref: > materials.id]
|
|
brand_material_id uuid [ref: > brand_materials.id, note: 'NULL = generic material fallback']
|
|
unit_id integer [not null, ref: > units.id]
|
|
conversion_factor decimal(18,6) [not null, note: 'Multiplier to canonical_unit_id']
|
|
is_default boolean [not null, default: false]
|
|
|
|
indexes {
|
|
(material_id, brand_material_id, unit_id) [unique]
|
|
}
|
|
}
|
|
|
|
Table material_attribute_values {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
material_id uuid [not null, ref: > materials.id]
|
|
attribute_definition_id uuid [not null, ref: > attribute_definitions.id]
|
|
value_number decimal(18,6)
|
|
value_string text
|
|
value_boolean boolean
|
|
value_date date
|
|
|
|
indexes {
|
|
(material_id, attribute_definition_id) [unique]
|
|
}
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// SUPPLIERS
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
// 1 supplier → exactly 1 category (one of the 8). The old suppliers
|
|
// categories JSONB (array of names) is DROPPED.
|
|
Table suppliers {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
name text [not null]
|
|
category_id uuid [not null, ref: > material_categories.id, note: 'A supplier deals in exactly one category']
|
|
location text
|
|
phone text
|
|
contact_person text
|
|
tax_id text
|
|
created_at timestamptz [not null, default: `now()`]
|
|
|
|
indexes {
|
|
category_id
|
|
}
|
|
}
|
|
|
|
// Catalog-layer M:N supplier ↔ material link (the direct edge of the new
|
|
// flow: material → supplier → derived category).
|
|
// Rows are auto-created whenever a quote line item is saved
|
|
// (quote_service / line_items router) and manageably via
|
|
// GET/POST /suppliers/{id}/materials, DELETE /suppliers/{id}/materials/{id},
|
|
// GET /materials/{id}/suppliers.
|
|
// last_quoted_price / last_quoted_at auto-sync when an item decision
|
|
// becomes 'chosen' (trigger trg_item_decisions_sync_price).
|
|
Table supplier_materials {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
supplier_id uuid [not null, ref: > suppliers.id, ondelete CASCADE]
|
|
material_id uuid [not null, ref: > materials.id, ondelete CASCADE]
|
|
is_active boolean [not null, default: true]
|
|
last_quoted_price decimal(18,4) [note: 'Denormalized — auto-synced on decision chosen']
|
|
last_quoted_at timestamptz
|
|
created_at timestamptz [not null, default: `now()`]
|
|
|
|
indexes {
|
|
(supplier_id, material_id) [unique, name: 'uq_supplier_material']
|
|
material_id
|
|
}
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// PROJECTS
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
// pm_user_id: the ONE project manager responsible for this project
|
|
// (1 PM per project). Assigned by the procurement manager.
|
|
Table projects {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
name text [not null]
|
|
description text
|
|
start_date date
|
|
pm_user_id uuid [ref: > users.id, note: 'The single PM of this project (confirms arrivals)']
|
|
created_at timestamptz [not null, default: `now()`]
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// QUOTATIONS
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
Table quotes {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
supplier_id uuid [not null, ref: > suppliers.id]
|
|
project_id uuid [ref: > projects.id]
|
|
quote_date timestamptz [not null, default: `now()`]
|
|
expiry_date date
|
|
accepted_date timestamptz
|
|
arrived_date timestamptz
|
|
status quote_status [not null, default: 'offered']
|
|
is_official boolean [not null, default: true]
|
|
notes text
|
|
created_at timestamptz [not null, default: `now()`]
|
|
updated_at timestamptz [not null, default: `now()`]
|
|
}
|
|
|
|
Table quote_events {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
quote_id uuid [not null, ref: > quotes.id]
|
|
event_type quote_event_type [not null]
|
|
payload jsonb [not null, default: '{}']
|
|
occurred_at timestamptz [not null, default: `now()`]
|
|
actor text [note: 'Free-text label (legacy) — may upgrade to users FK later']
|
|
}
|
|
|
|
Table line_items {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
quote_id uuid [not null, ref: > quotes.id]
|
|
material_id uuid [not null, ref: > materials.id, note: 'GLOBAL material reference']
|
|
brand_material_id uuid [ref: > brand_materials.id]
|
|
unit_id integer [not null, ref: > units.id]
|
|
quantity decimal(18,4) [not null, note: 'Ordered quantity']
|
|
notes text
|
|
created_at timestamptz [not null, default: `now()`]
|
|
}
|
|
|
|
Table line_item_specs {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
line_item_id uuid [not null, ref: > line_items.id]
|
|
attribute_definition_id uuid [not null, ref: > attribute_definitions.id]
|
|
value_number decimal(18,6)
|
|
value_string text
|
|
value_boolean boolean
|
|
value_date date
|
|
unit_id_at_time integer [ref: > units.id]
|
|
|
|
indexes {
|
|
(line_item_id, attribute_definition_id) [unique]
|
|
}
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// TERMSHEETS
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
Table termsheets {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
quote_id uuid [not null, ref: > quotes.id]
|
|
label text [not null]
|
|
status termsheet_status [not null, default: 'offered']
|
|
discount_type discount_type [not null, default: 'none']
|
|
discount_value decimal(18,4) [not null, default: 0]
|
|
created_at timestamptz [not null, default: `now()`]
|
|
}
|
|
|
|
Table termsheet_schedule_steps {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
termsheet_id uuid [not null, ref: > termsheets.id]
|
|
step_no smallint [not null]
|
|
due_date date [not null]
|
|
amount decimal(18,2) [not null]
|
|
note text
|
|
|
|
indexes {
|
|
(termsheet_id, step_no) [unique]
|
|
}
|
|
}
|
|
|
|
Table item_termsheets {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
line_item_id uuid [not null, ref: > line_items.id]
|
|
termsheet_id uuid [not null, ref: > termsheets.id]
|
|
unit_price decimal(18,4) [not null]
|
|
min_order_qty decimal(18,4)
|
|
total_price decimal(18,2) [note: 'NOT stored — computed in view item_termsheets_with_total']
|
|
|
|
indexes {
|
|
(line_item_id, termsheet_id) [unique]
|
|
}
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// ITEM DECISIONS & RECEIPTS (purchase + PM arrival flow)
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
// project_id: the SINGLE purchased project. Picked by the procurement
|
|
// manager when the item is chosen (auto-filled when the offer had exactly
|
|
// one project; validated against line_item_projects). Warehouses of this
|
|
// project are the only valid arrival destinations.
|
|
Table item_decisions {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
line_item_id uuid [not null, unique, ref: - line_items.id]
|
|
chosen_item_termsheet_id uuid [ref: > item_termsheets.id]
|
|
status item_decision_status [not null, default: 'pending']
|
|
project_id uuid [ref: > projects.id, note: 'The ONE purchased project (set at purchase by proc manager)']
|
|
adjusted_unit_price decimal(18,4)
|
|
adjusted_notes text
|
|
decided_at timestamptz
|
|
}
|
|
|
|
// 1 item : N receipts (PARTIAL DELIVERIES — UNIQUE on item_decision_id is
|
|
// REMOVED in v2.6). A receipt means "the PM confirmed this delivery".
|
|
// Received qty is in the line item's unit; inventory posting converts to
|
|
// the inventory row's unit.
|
|
// Derived arrival state (no column): sum of receipts vs line_items.quantity
|
|
// = 0 → on-the-way
|
|
// < ordered → partially received
|
|
// >= ordered → arrived (quote.status flips to 'arrived' when ALL
|
|
// chosen items are fully received)
|
|
Table item_receipts {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
item_decision_id uuid [not null, ref: > item_decisions.id, note: 'NOT unique — partial deliveries']
|
|
confirmed_by uuid [not null, ref: > users.id, note: 'The PM who confirmed this delivery']
|
|
received_at timestamptz [not null, default: `now()`]
|
|
received_qty decimal(18,4) [not null]
|
|
on_time boolean
|
|
quality_note text
|
|
|
|
indexes {
|
|
item_decision_id
|
|
}
|
|
}
|
|
|
|
// The PM's split of ONE receipt across warehouses (typically 3).
|
|
// App rule: SUM(quantity) = item_receipts.received_qty and every
|
|
// warehouse.project_id = item_decisions.project_id.
|
|
Table item_receipt_allocations {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
receipt_id uuid [not null, ref: > item_receipts.id, ondelete CASCADE]
|
|
warehouse_id uuid [not null, ref: > warehouses.id, ondelete RESTRICT]
|
|
quantity decimal(18,6) [not null, note: '> 0 — in the receipt line-item unit']
|
|
|
|
indexes {
|
|
(receipt_id, warehouse_id) [unique]
|
|
}
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// WAREHOUSES & INVENTORY
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
// 1 warehouse → exactly 1 project. Receipts route to warehouses of the
|
|
// purchased project.
|
|
Table warehouses {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
project_id uuid [not null, ref: > projects.id, ondelete CASCADE]
|
|
name text [not null]
|
|
description text
|
|
created_at timestamptz [not null, default: `now()`]
|
|
}
|
|
|
|
// Current stock snapshot, unique per (warehouse, material, brand, specs).
|
|
// brand_id is NULLABLE → the unique index MUST use COALESCE so NULL means
|
|
// ONE concrete value ('no brand'); a plain unique index would allow
|
|
// duplicate stock rows for unbranded materials (Postgres NULLs are
|
|
// distinct). specs_hash = SHA256 of the sorted canonical specs JSON
|
|
// ('default' when empty) — computed SERVER-SIDE on every write.
|
|
// unit_id: the unit of quantity for this row (canonical unit for
|
|
// auto-posted purchases; PM manual entries may pick any unit — the service
|
|
// enforces one unit per row).
|
|
Table inventory {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
warehouse_id uuid [not null, ref: > warehouses.id, ondelete CASCADE]
|
|
material_id uuid [not null, ref: > materials.id, ondelete RESTRICT]
|
|
brand_id uuid [ref: > brands.id, ondelete SET NULL]
|
|
quantity decimal(18,6) [not null, default: 0, note: 'CHECK (quantity >= 0)']
|
|
unit_id integer [not null, ref: > units.id]
|
|
specs jsonb [not null, default: '{}']
|
|
specs_hash text [not null, default: 'default']
|
|
updated_at timestamptz [not null, default: `now()`]
|
|
|
|
indexes {
|
|
(warehouse_id, material_id, COALESCE(brand_id, '00000000-0000-0000-0000-000000000000'), specs_hash) [unique, name: 'uq_inventory_stock']
|
|
(warehouse_id, material_id)
|
|
}
|
|
}
|
|
|
|
// Full chronological ledger. quantity is SIGNED (see enum note).
|
|
// STOCK_COUNT: recorded_qty/physical_qty hold the before/counted values
|
|
// and quantity = difference. A transfer = two linked rows
|
|
// (TRANSFER_OUT in source, TRANSFER_IN in destination) sharing
|
|
// transfer_pair_id. performed_by = user display name (or FK later).
|
|
// receipt_allocation_id links an auto-posted PURCHASE back to its
|
|
// allocation — UNIQUE, so the same delivery can never post twice.
|
|
Table inventory_transactions {
|
|
id uuid [pk, default: `gen_random_uuid()`]
|
|
inventory_id uuid [not null, ref: > inventory.id, ondelete CASCADE]
|
|
warehouse_id uuid [not null, ref: > warehouses.id, ondelete RESTRICT]
|
|
receipt_allocation_id uuid [ref: > item_receipt_allocations.id, ondelete SET NULL, note: 'UNIQUE — idempotent arrival posting']
|
|
transaction_type inventory_transaction_reason [not null]
|
|
quantity decimal(18,6) [not null, note: 'SIGNED — positive in, negative out']
|
|
recorded_qty decimal(18,6) [note: 'STOCK_COUNT only']
|
|
physical_qty decimal(18,6) [note: 'STOCK_COUNT only']
|
|
transfer_pair_id uuid [note: 'Links TRANSFER_OUT ↔ TRANSFER_IN rows']
|
|
performed_by text [note: 'User display name (dev placeholder — users FK later)']
|
|
transaction_date timestamptz [not null, default: `now()`]
|
|
note text
|
|
created_at timestamptz [not null, default: `now()`]
|
|
|
|
indexes {
|
|
receipt_allocation_id [unique]
|
|
(inventory_id, transaction_date)
|
|
(warehouse_id, transaction_date)
|
|
}
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// RUNTIME TABLES (created idempotently at API startup)
|
|
////////////////////////////////////////////////////////////////////////////
|
|
|
|
// M:N quotes ↔ projects (an offer can be about several projects)
|
|
Table quote_projects {
|
|
quote_id uuid [pk, ref: > quotes.id]
|
|
project_id uuid [pk, ref: > projects.id]
|
|
}
|
|
|
|
// M:N line_items ↔ projects (offer-side allocation; the PURCHASED project
|
|
// is the single item_decisions.project_id)
|
|
Table line_item_projects {
|
|
line_item_id uuid [pk, ref: > line_items.id]
|
|
project_id uuid [pk, ref: > projects.id]
|
|
}
|
|
|
|
// Invoice / proforma metadata — bytes in MinIO
|
|
Table quote_attachments {
|
|
id uuid [pk]
|
|
quote_id uuid [not null, ref: > quotes.id]
|
|
kind text [not null, note: 'invoice | proforma']
|
|
file_name text [not null]
|
|
mime_type text [not null]
|
|
size_bytes bigint [not null]
|
|
object_key text [not null]
|
|
uploaded_at timestamptz [not null]
|
|
created_at timestamptz [not null]
|
|
updated_at timestamptz [not null]
|
|
}
|
|
|
|
// One reference unit per category (units dashboard)
|
|
Table category_reference_units {
|
|
id uuid [pk]
|
|
category_id uuid [not null, unique, ref: > material_categories.id]
|
|
ref_unit text [not null]
|
|
updated_at timestamptz [not null]
|
|
}
|
|
|
|
Table unit_conversions {
|
|
id uuid [pk]
|
|
category_id uuid [not null, ref: > material_categories.id]
|
|
brand text
|
|
from_unit text [not null]
|
|
to_unit text [not null]
|
|
factor decimal(18,6) [not null]
|
|
updated_at timestamptz [not null]
|
|
}
|
|
|
|
Table standard_conversions {
|
|
id uuid [pk]
|
|
from_unit text [not null]
|
|
to_unit text [not null]
|
|
factor decimal(18,6) [not null]
|
|
updated_at timestamptz [not null]
|
|
|
|
indexes {
|
|
(from_unit, to_unit) [unique]
|
|
}
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// TRIGGERS (automatic behavior)
|
|
////////////////////////////////////////////////////////////////////////////
|
|
//
|
|
// 1) set_updated_at() — existing: material_categories, materials, quotes,
|
|
// quote_attachments.
|
|
//
|
|
// 2) fn_derive_material_category() — AFTER INSERT ON supplier_materials:
|
|
// materials.category_id = supplier.category_id IF NULL (first-link-wins,
|
|
// idempotent, bumps materials.updated_at). Service applies the same rule;
|
|
// trigger is defense-in-depth for direct SQL.
|
|
//
|
|
// 3) fn_sync_last_quoted_price() — AFTER INSERT OR UPDATE OF status ON
|
|
// item_decisions WHERE status='chosen' AND chosen_item_termsheet_id NOT
|
|
// NULL: supplier_materials.last_quoted_price = COALESCE(adjusted_unit_price,
|
|
// chosen termsheet unit_price); last_quoted_at = now().
|
|
//
|
|
// 4) Stock quantity is maintained by the SERVICE (inventory_service.
|
|
// apply_transaction) with SELECT ... FOR UPDATE row locks — NOT a
|
|
// trigger — because the fork's shipped code already implements the
|
|
// find-or-create/decrement logic there and both must not double-apply.
|
|
// The ledger (inventory_transactions) is the history; inventory.quantity
|
|
// is the atomic snapshot.
|
|
//
|
|
// 5) Arrival auto-posting goes through the same service path: POST
|
|
// /pm/receipts → insert receipt + allocations → for each allocation
|
|
// insert an inventory_transactions row with transaction_type=PURCHASE,
|
|
// quantity converted to the inventory row's unit, receipt_allocation_id
|
|
// = allocation id (UNIQUE prevents double-posting).
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// API CONTRACT NOTES (for the inventory dash port)
|
|
////////////////////////////////////////////////////////////////////////////
|
|
// Endpoints the shipped dash already calls (shapes must stay stable):
|
|
// GET /projects, /material-categories, /units, /brands
|
|
// GET /materials?expand=category,brands,canonical_unit
|
|
// GET /inventory/warehouses
|
|
// GET /inventory (filters: project_id, category_id, warehouse_id, material_id)
|
|
// GET /inventory/transactions (filters: project_id, warehouse_id, transaction_type, date_from/to)
|
|
// POST /inventory/transactions (manual PM entries)
|
|
// GET /inventory/{id}/transactions
|
|
// GET /inventory/meta/types
|
|
// GET /inventory/materials/options?category_id=&warehouse_id=
|
|
// POST /inventory/warehouses, PATCH/DELETE /inventory/warehouses/{id}
|
|
// All responses use the {data, meta} envelope.
|
|
// New for the PM arrival flow:
|
|
// GET /pm/on-the-way → chosen items not fully received (three states)
|
|
// POST /pm/receipts → confirm delivery: receipt + allocations
|
|
// GET /pm/receipts → delivery history for the PM's project
|
|
// Auth: Authorization: Bearer <Casdoor JWT>; role from users.role;
|
|
// PM's project from projects.pm_user_id. Dev fallback: X-User-Role /
|
|
// X-Project-Id / X-User-Name-B64 headers.
|