Conflict resolved in ChangeInventoryPage.tsx (canSubmit): kept both the WITHDRAWAL branch (prod-v9: delivered-to validation) and the PROJECT_TRANSFER branch (prod-v8: inter-project transfer validation). All other files auto-merged; both feature sets fully preserved.
663 lines
29 KiB
PL/PgSQL
663 lines
29 KiB
PL/PgSQL
-- ============================================================================
|
||
-- PROCUREMENT + INVENTORY SCHEMA v2.6 — PostgreSQL Migration
|
||
-- Integrated ER: Material → Supplier → Category + PM arrival flow + inventory
|
||
-- ============================================================================
|
||
|
||
BEGIN;
|
||
|
||
-- ============================================================================
|
||
-- EXTENSIONS
|
||
-- ============================================================================
|
||
|
||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- for gen_random_uuid()
|
||
|
||
-- ============================================================================
|
||
-- ENUMS
|
||
-- ============================================================================
|
||
|
||
CREATE TYPE quote_status AS ENUM (
|
||
'offered',
|
||
'accepted',
|
||
'arrived',
|
||
'rejected',
|
||
'expired'
|
||
);
|
||
|
||
CREATE TYPE termsheet_status AS ENUM (
|
||
'offered',
|
||
'accepted',
|
||
'rejected'
|
||
);
|
||
|
||
CREATE TYPE material_status AS ENUM (
|
||
'active',
|
||
'discontinued',
|
||
'draft'
|
||
);
|
||
|
||
CREATE TYPE attribute_data_type AS ENUM (
|
||
'number',
|
||
'integer',
|
||
'string',
|
||
'enum',
|
||
'boolean',
|
||
'date'
|
||
);
|
||
|
||
CREATE TYPE discount_type AS ENUM (
|
||
'none',
|
||
'percent',
|
||
'fixed'
|
||
);
|
||
|
||
CREATE TYPE item_decision_status AS ENUM (
|
||
'pending',
|
||
'chosen',
|
||
'rejected'
|
||
);
|
||
|
||
CREATE TYPE quote_event_type AS ENUM (
|
||
'offered',
|
||
'accepted',
|
||
'rejected',
|
||
'arrived',
|
||
'note',
|
||
'edited',
|
||
'expired'
|
||
);
|
||
|
||
-- The exact 10 supported inventory transaction types (no generic OTHER).
|
||
CREATE TYPE inventory_transaction_reason AS ENUM (
|
||
'PURCHASE',
|
||
'WITHDRAWAL',
|
||
'RETURN',
|
||
'TRANSFER_IN',
|
||
'TRANSFER_OUT',
|
||
'DAMAGE',
|
||
'WASTE',
|
||
'LOSS',
|
||
'FOUND',
|
||
'STOCK_COUNT'
|
||
);
|
||
|
||
-- Casdoor-backed roles. NULL = provisioned but not yet assigned.
|
||
CREATE TYPE user_role AS ENUM (
|
||
'pm',
|
||
'procurement_manager',
|
||
'admin'
|
||
);
|
||
|
||
-- ============================================================================
|
||
-- USERS & IDENTITY
|
||
-- ============================================================================
|
||
|
||
CREATE TABLE users (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
casdoor_id TEXT NOT NULL UNIQUE,
|
||
first_name TEXT NOT NULL,
|
||
last_name TEXT NOT NULL,
|
||
phone TEXT,
|
||
role user_role,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- ============================================================================
|
||
-- CATALOG DOMAIN
|
||
-- ============================================================================
|
||
|
||
-- ---------- UNITS ----------
|
||
CREATE TABLE units (
|
||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||
code VARCHAR NOT NULL UNIQUE,
|
||
name_fa VARCHAR,
|
||
dimension VARCHAR, -- length, mass, pressure, area, volume, voltage, count, ratio, time, currency
|
||
si_factor DECIMAL NOT NULL DEFAULT 1.0,
|
||
is_base BOOLEAN NOT NULL DEFAULT FALSE
|
||
);
|
||
|
||
-- ---------- BRANDS ----------
|
||
CREATE TABLE brands (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
country TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
CONSTRAINT uq_brands_name_country UNIQUE (name, country)
|
||
);
|
||
|
||
-- ---------- MATERIAL CATEGORIES (the 8 FIXED topic categories) ----------
|
||
-- Seeded by init_db / seed_stack with slugs:
|
||
-- mechanical-installations-pipes, electrical-equipment-supplies,
|
||
-- doors-windows-glass, metals-steel-sections, construction-materials,
|
||
-- construction-chemicals, finishing-interior-decoration,
|
||
-- construction-tools-equipment
|
||
-- The app NEVER auto-creates categories.
|
||
CREATE TABLE material_categories (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
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()
|
||
);
|
||
|
||
-- ---------- ATTRIBUTE DEFINITIONS (Global Attribute Dictionary) ----------
|
||
CREATE TABLE attribute_definitions (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
attribute_key TEXT NOT NULL UNIQUE,
|
||
label TEXT NOT NULL,
|
||
data_type attribute_data_type NOT NULL,
|
||
unit_id INTEGER REFERENCES units(id) ON DELETE RESTRICT,
|
||
enum_values JSONB, -- Allowed options for enum types
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- ---------- CATEGORY ATTRIBUTES (Junction) ----------
|
||
CREATE TABLE category_attributes (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
|
||
attribute_definition_id UUID NOT NULL REFERENCES attribute_definitions(id) ON DELETE CASCADE,
|
||
is_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||
is_filterable BOOLEAN NOT NULL DEFAULT FALSE,
|
||
conditional_rules JSONB, -- UI Wizard rules
|
||
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
|
||
);
|
||
|
||
-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
|
||
-- Materials are global, category-free entities. name is GLOBALLY
|
||
-- unique (uq_material_name). topic is a legacy display column no longer
|
||
-- written by the app.
|
||
CREATE TABLE materials (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
topic TEXT, -- LEGACY display column
|
||
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||
status material_status NOT NULL DEFAULT 'active',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
CONSTRAINT uq_material_name UNIQUE (name)
|
||
);
|
||
|
||
-- ---------- BRAND MATERIALS (M:N Junction) ----------
|
||
CREATE TABLE brand_materials (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
|
||
catalog_code TEXT,
|
||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
CONSTRAINT uq_brand_material UNIQUE (brand_id, material_id)
|
||
);
|
||
|
||
-- ---------- MATERIAL UNITS (Packaging / Unit Overrides) ----------
|
||
CREATE TABLE material_units (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
|
||
brand_material_id UUID REFERENCES brand_materials(id) ON DELETE CASCADE, -- NULL = generic fallback
|
||
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||
conversion_factor NUMERIC(18,6) NOT NULL, -- Multiplier → canonical_unit_id
|
||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||
CONSTRAINT uq_material_unit UNIQUE (material_id, brand_material_id, unit_id)
|
||
);
|
||
|
||
-- ---------- MATERIAL ATTRIBUTE VALUES (Typed EAV) ----------
|
||
CREATE TABLE material_attribute_values (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
|
||
attribute_definition_id UUID NOT NULL REFERENCES attribute_definitions(id) ON DELETE RESTRICT,
|
||
value_number NUMERIC(18,6),
|
||
value_string TEXT,
|
||
value_boolean BOOLEAN,
|
||
value_date DATE,
|
||
CONSTRAINT uq_material_attr_value UNIQUE (material_id, attribute_definition_id)
|
||
);
|
||
|
||
-- ============================================================================
|
||
-- QUOTATION & PROCUREMENT DOMAIN
|
||
-- ============================================================================
|
||
|
||
-- ---------- SUPPLIERS (1 supplier → exactly 1 category) ----------
|
||
-- The old suppliers.categories JSONB (array of category names, added at
|
||
-- runtime by init_db) is DROPPED in v2.6 — replaced by category_id.
|
||
CREATE TABLE suppliers (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE RESTRICT,
|
||
location TEXT,
|
||
phone TEXT,
|
||
contact_person TEXT,
|
||
tax_id TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_suppliers_category_id ON suppliers(category_id);
|
||
|
||
-- ---------- SUPPLIER MATERIALS (NEW — the direct supplier↔material edge) ----------
|
||
-- Auto-created whenever a quote line item is saved; managed via
|
||
-- /suppliers/{id}/materials and /materials/{id}/suppliers.
|
||
CREATE TABLE supplier_materials (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
|
||
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
|
||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
last_quoted_price NUMERIC(18,4),
|
||
last_quoted_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
CONSTRAINT uq_supplier_material UNIQUE (supplier_id, material_id)
|
||
);
|
||
|
||
CREATE INDEX idx_supplier_materials_material ON supplier_materials(material_id);
|
||
|
||
-- ---------- PROJECTS (1 project → 1 PM) ----------
|
||
CREATE TABLE projects (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
description TEXT,
|
||
start_date DATE,
|
||
pm_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- ---------- QUOTES ----------
|
||
CREATE TABLE quotes (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE RESTRICT,
|
||
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||
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, -- TRUE = formal; FALSE = estimate/draft
|
||
notes TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- ---------- QUOTE EVENTS (Audit / History Log) ----------
|
||
CREATE TABLE quote_events (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||
event_type quote_event_type NOT NULL,
|
||
payload JSONB NOT NULL DEFAULT '{}',
|
||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
actor TEXT
|
||
);
|
||
|
||
-- ---------- LINE ITEMS ----------
|
||
CREATE TABLE line_items (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
|
||
brand_material_id UUID REFERENCES brand_materials(id) ON DELETE SET NULL,
|
||
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||
quantity NUMERIC(18,4) NOT NULL,
|
||
notes TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- ---------- LINE ITEM SPECS (Historical Snapshot) ----------
|
||
CREATE TABLE line_item_specs (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
line_item_id UUID NOT NULL REFERENCES line_items(id) ON DELETE CASCADE,
|
||
attribute_definition_id UUID NOT NULL REFERENCES attribute_definitions(id) ON DELETE RESTRICT,
|
||
value_number NUMERIC(18,6),
|
||
value_string TEXT,
|
||
value_boolean BOOLEAN,
|
||
value_date DATE,
|
||
unit_id_at_time INTEGER REFERENCES units(id) ON DELETE SET NULL,
|
||
CONSTRAINT uq_lineitem_spec UNIQUE (line_item_id, attribute_definition_id)
|
||
);
|
||
|
||
-- ---------- TERMSHEETS ----------
|
||
CREATE TABLE termsheets (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||
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()
|
||
);
|
||
|
||
-- ---------- TERMSHEET SCHEDULE STEPS ----------
|
||
CREATE TABLE termsheet_schedule_steps (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
termsheet_id UUID NOT NULL REFERENCES termsheets(id) ON DELETE CASCADE,
|
||
step_no SMALLINT NOT NULL,
|
||
due_date DATE NOT NULL,
|
||
amount NUMERIC(18,2) NOT NULL,
|
||
note TEXT,
|
||
CONSTRAINT uq_termsheet_step UNIQUE (termsheet_id, step_no)
|
||
);
|
||
|
||
-- ---------- ITEM TERMSHEETS (Line Item Pricing in a Termsheet) ----------
|
||
CREATE TABLE item_termsheets (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
line_item_id UUID NOT NULL REFERENCES line_items(id) ON DELETE CASCADE,
|
||
termsheet_id UUID NOT NULL REFERENCES termsheets(id) ON DELETE CASCADE,
|
||
unit_price NUMERIC(18,4) NOT NULL,
|
||
min_order_qty NUMERIC(18,4),
|
||
CONSTRAINT uq_item_termsheet UNIQUE (line_item_id, termsheet_id)
|
||
);
|
||
|
||
-- Convenience VIEW: total_price = unit_price × line_items.quantity
|
||
CREATE VIEW item_termsheets_with_total AS
|
||
SELECT
|
||
it.id,
|
||
it.line_item_id,
|
||
it.termsheet_id,
|
||
it.unit_price,
|
||
it.min_order_qty,
|
||
(it.unit_price * li.quantity)::NUMERIC(18,2) AS total_price
|
||
FROM item_termsheets it
|
||
JOIN line_items li ON li.id = it.line_item_id;
|
||
|
||
-- ---------- ITEM DECISIONS (1:1 with line_items) ----------
|
||
-- project_id: the SINGLE purchased project, picked by the procurement
|
||
-- manager when the item is chosen (auto-filled when the offer had one
|
||
-- project; validated against line_item_projects). Warehouses of this
|
||
-- project are the only valid arrival destinations.
|
||
CREATE TABLE item_decisions (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
line_item_id UUID NOT NULL UNIQUE REFERENCES line_items(id) ON DELETE CASCADE,
|
||
chosen_item_termsheet_id UUID REFERENCES item_termsheets(id) ON DELETE SET NULL,
|
||
status item_decision_status NOT NULL DEFAULT 'pending',
|
||
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||
adjusted_unit_price NUMERIC(18,4),
|
||
adjusted_notes TEXT,
|
||
decided_at TIMESTAMPTZ
|
||
);
|
||
|
||
-- ---------- ITEM RECEIPTS (1 item : N receipts — partial deliveries) ----------
|
||
-- The 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.
|
||
CREATE TABLE item_receipts (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
item_decision_id UUID NOT NULL REFERENCES item_decisions(id) ON DELETE CASCADE,
|
||
confirmed_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
received_qty NUMERIC(18,4) NOT NULL,
|
||
on_time BOOLEAN,
|
||
quality_note TEXT
|
||
);
|
||
|
||
CREATE INDEX idx_item_receipts_decision ON item_receipts(item_decision_id);
|
||
|
||
-- ============================================================================
|
||
-- INVENTORY DOMAIN (fork's shipped model, with fixes)
|
||
-- ============================================================================
|
||
|
||
-- ---------- WAREHOUSES (1 warehouse → exactly 1 project) ----------
|
||
CREATE TABLE warehouses (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||
name TEXT NOT NULL,
|
||
description TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- ---------- ITEM RECEIPT ALLOCATIONS (NEW — PM's split into warehouses) ----------
|
||
-- App rule: SUM(quantity) = item_receipts.received_qty and every
|
||
-- warehouse.project_id = item_decisions.project_id.
|
||
CREATE TABLE item_receipt_allocations (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
receipt_id UUID NOT NULL REFERENCES item_receipts(id) ON DELETE CASCADE,
|
||
warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
|
||
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
|
||
CONSTRAINT uq_receipt_warehouse UNIQUE (receipt_id, warehouse_id)
|
||
);
|
||
|
||
-- ---------- INVENTORY (current stock snapshot) ----------
|
||
-- FIX (vs fork): brand_id is nullable, so the unique index uses COALESCE —
|
||
-- a plain unique index would allow duplicate stock rows for unbranded
|
||
-- materials (Postgres treats NULLs as distinct).
|
||
-- specs_hash = SHA256 of sorted canonical specs JSON ('default' when empty),
|
||
-- computed server-side on every write.
|
||
CREATE TABLE inventory (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE CASCADE,
|
||
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
|
||
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||
quantity NUMERIC(18,6) NOT NULL DEFAULT 0 CHECK (quantity >= 0),
|
||
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||
specs JSONB NOT NULL DEFAULT '{}',
|
||
specs_hash TEXT NOT NULL DEFAULT 'default',
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE UNIQUE INDEX uq_inventory_stock ON inventory (
|
||
warehouse_id,
|
||
material_id,
|
||
COALESCE(brand_id, '00000000-0000-0000-0000-000000000000'),
|
||
specs_hash
|
||
);
|
||
|
||
CREATE INDEX idx_inventory_warehouse_material ON inventory (warehouse_id, material_id);
|
||
|
||
-- ---------- INVENTORY TRANSACTIONS (full chronological ledger) ----------
|
||
-- quantity is SIGNED: positive = increase, negative = decrease.
|
||
-- STOCK_COUNT: recorded_qty/physical_qty + quantity = difference.
|
||
-- A transfer = two linked rows (TRANSFER_OUT + TRANSFER_IN) sharing
|
||
-- transfer_pair_id.
|
||
-- receipt_allocation_id links an auto-posted PURCHASE back to its
|
||
-- allocation — UNIQUE prevents double-posting the same delivery.
|
||
CREATE TABLE inventory_transactions (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
inventory_id UUID NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
|
||
warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
|
||
receipt_allocation_id UUID REFERENCES item_receipt_allocations(id) ON DELETE SET NULL UNIQUE,
|
||
transaction_type inventory_transaction_reason NOT NULL,
|
||
quantity NUMERIC(18,6) NOT NULL,
|
||
recorded_qty NUMERIC(18,6),
|
||
physical_qty NUMERIC(18,6),
|
||
transfer_pair_id UUID,
|
||
performed_by TEXT,
|
||
delivered_to TEXT,
|
||
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
note TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_inventory_tx_inventory_date ON inventory_transactions (inventory_id, transaction_date);
|
||
CREATE INDEX idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date);
|
||
|
||
-- ============================================================================
|
||
-- INDEXES (additional performance indexes beyond PKs / UNIQUEs)
|
||
-- ============================================================================
|
||
|
||
CREATE INDEX idx_quotes_supplier_status ON quotes(supplier_id, status);
|
||
CREATE INDEX idx_quotes_project ON quotes(project_id);
|
||
CREATE INDEX idx_quote_events_quote_time ON quote_events(quote_id, occurred_at);
|
||
CREATE INDEX idx_line_items_quote ON line_items(quote_id);
|
||
CREATE INDEX idx_line_items_material ON line_items(material_id);
|
||
CREATE INDEX idx_termsheets_quote ON termsheets(quote_id);
|
||
CREATE INDEX idx_item_termsheets_termsheet ON item_termsheets(termsheet_id);
|
||
CREATE INDEX idx_brand_materials_material ON brand_materials(material_id);
|
||
CREATE INDEX idx_material_attr_values_def ON material_attribute_values(attribute_definition_id);
|
||
CREATE INDEX idx_category_attrs_category ON category_attributes(category_id);
|
||
CREATE INDEX idx_category_attrs_attr_def ON category_attributes(attribute_definition_id);
|
||
CREATE INDEX idx_line_item_specs_attr_def ON line_item_specs(attribute_definition_id);
|
||
|
||
-- ============================================================================
|
||
-- QUOTE ATTACHMENTS (invoice / proforma files — bytes live in MinIO)
|
||
-- ============================================================================
|
||
|
||
CREATE TABLE quote_attachments (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||
kind TEXT NOT NULL CHECK (kind IN ('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 DEFAULT now(),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_quote_attachments_quote_id ON quote_attachments(quote_id);
|
||
|
||
-- ============================================================================
|
||
-- RUNTIME TABLES (created idempotently at API startup by init_db.py;
|
||
-- defined here too so fresh installs have them from the start)
|
||
-- ============================================================================
|
||
|
||
-- M:N quotes ↔ projects (an offer can be about several projects)
|
||
CREATE TABLE quote_projects (
|
||
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (quote_id, project_id)
|
||
);
|
||
|
||
-- M:N line_items ↔ projects (offer-side allocation; the PURCHASED project
|
||
-- is the single item_decisions.project_id)
|
||
CREATE TABLE line_item_projects (
|
||
line_item_id UUID NOT NULL REFERENCES line_items(id) ON DELETE CASCADE,
|
||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (line_item_id, project_id)
|
||
);
|
||
|
||
-- One reference unit per category (units dashboard)
|
||
CREATE TABLE category_reference_units (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE,
|
||
ref_unit TEXT NOT NULL,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- Brand-scoped / category-scoped conversions
|
||
CREATE TABLE unit_conversions (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
|
||
brand TEXT,
|
||
from_unit TEXT NOT NULL,
|
||
to_unit TEXT NOT NULL,
|
||
factor NUMERIC(18,6) NOT NULL,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE UNIQUE INDEX uq_unit_conversion
|
||
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit);
|
||
|
||
-- Global conversions, applies to all categories
|
||
CREATE TABLE standard_conversions (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
from_unit TEXT NOT NULL,
|
||
to_unit TEXT NOT NULL,
|
||
factor NUMERIC(18,6) NOT NULL,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
CONSTRAINT uq_standard_conversion UNIQUE (from_unit, to_unit)
|
||
);
|
||
|
||
-- Inter-project material transfers
|
||
CREATE TABLE project_transfers (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
|
||
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
|
||
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
|
||
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
|
||
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
|
||
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||
specs_hash TEXT NOT NULL DEFAULT 'default',
|
||
note TEXT,
|
||
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
|
||
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
|
||
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
transferred_by TEXT,
|
||
received_at TIMESTAMPTZ,
|
||
received_by TEXT,
|
||
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
|
||
receipt_allocations JSONB,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_project_transfers_from ON project_transfers(from_project_id, status);
|
||
CREATE INDEX idx_project_transfers_to ON project_transfers(to_project_id, status);
|
||
|
||
|
||
-- ============================================================================
|
||
-- UPDATED_AT TRIGGER (auto-set updated_at on row modification)
|
||
-- ============================================================================
|
||
|
||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||
RETURNS TRIGGER AS $$
|
||
BEGIN
|
||
NEW.updated_at = now();
|
||
RETURN NEW;
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
CREATE TRIGGER trg_material_categories_updated_at
|
||
BEFORE UPDATE ON material_categories
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TRIGGER trg_materials_updated_at
|
||
BEFORE UPDATE ON materials
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TRIGGER trg_quotes_updated_at
|
||
BEFORE UPDATE ON quotes
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TRIGGER trg_quote_attachments_updated_at
|
||
BEFORE UPDATE ON quote_attachments
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TRIGGER trg_project_transfers_updated_at
|
||
BEFORE UPDATE ON project_transfers
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
-- ============================================================================
|
||
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
||
-- ============================================================================
|
||
|
||
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
|
||
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
|
||
-- inserts decisions directly as '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;
|
||
|
||
CREATE TRIGGER trg_item_decisions_sync_price
|
||
AFTER INSERT OR UPDATE OF status ON item_decisions
|
||
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price();
|
||
|
||
-- NOTE: inventory.quantity is maintained by the SERVICE layer
|
||
-- (inventory_service.apply_transaction) with SELECT ... FOR UPDATE row
|
||
-- locks — NOT a trigger — so the ledger and the snapshot can never be
|
||
-- double-applied. Arrival auto-posting (POST /pm/receipts) goes through
|
||
-- the same service path and is idempotent via receipt_allocation_id.
|
||
|
||
-- ---------- AUDIT LOG (general activity trail, v2.7) ----------
|
||
CREATE TABLE IF NOT EXISTS audit_log (
|
||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
actor TEXT,
|
||
action TEXT NOT NULL,
|
||
entity TEXT NOT NULL,
|
||
entity_id TEXT,
|
||
details JSONB NOT NULL DEFAULT '{}'
|
||
);
|
||
|
||
COMMIT;
|