Files
ERP/sql/migration.sql
T

640 lines
28 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ============================================================================
-- 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,
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)
);
-- ============================================================================
-- 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();
-- ============================================================================
-- 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 '{}'
);
-- ---------- UNIFIED PROJECTS (PM & PROCUREMENT SHARED SCHEMA) ----------
ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_end DATE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_start DATE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_end DATE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
COMMIT;