diff --git a/sql/migration.sql b/sql/migration.sql new file mode 100644 index 0000000..1944fe8 --- /dev/null +++ b/sql/migration.sql @@ -0,0 +1,637 @@ +-- ============================================================================ +-- 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.5) ---------- +-- category_id is NULLABLE and DERIVED automatically from the first linked +-- supplier (supplier_materials insert, first-link-wins). 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, + category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT, + 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) +-- ============================================================================ + +-- Derive materials.category_id from the FIRST linked supplier (first-link-wins). +-- Idempotent: only touches rows where category_id IS NULL. +CREATE OR REPLACE FUNCTION fn_derive_material_category() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE materials + SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id), + updated_at = now() + WHERE id = NEW.material_id + AND category_id IS NULL; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_supplier_materials_derive_category + AFTER INSERT ON supplier_materials + FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category(); + +-- 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. + +COMMIT;