-- ============================================================================ -- 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 entities. name is GLOBALLY unique (uq_material_name). -- topic is a legacy display column; category_id (v2.8) is the dashboard's -- Category→Material cascade link — backfilled from topic at API startup -- (init_db.ensure_dashboard_schema; NULL = «بدون دسته»). CREATE TABLE materials ( id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, name TEXT NOT NULL, topic TEXT, -- LEGACY display column category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL, 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); -- ============================================================================ -- MVP DASHBOARD INDEXES (v2.8 — aggregation queries in dashboard_service) -- (Fresh installs; existing volumes get the same set idempotently via -- init_db.ensure_dashboard_schema.) -- ============================================================================ CREATE INDEX idx_materials_category ON materials(category_id); CREATE INDEX idx_warehouses_project ON warehouses(project_id); CREATE INDEX idx_inventory_tx_date ON inventory_transactions(transaction_date); CREATE INDEX idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date); CREATE INDEX idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL; CREATE INDEX idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL; CREATE INDEX idx_quotes_status ON quotes(status); CREATE INDEX idx_item_decisions_project ON item_decisions(project_id); CREATE INDEX idx_item_receipts_received ON item_receipts(received_at); CREATE INDEX idx_inventory_material_brand ON inventory(material_id, brand_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 '{}' ); -- ---------- BALE BOT DRAFTS (quote extraction & review sync) ---------- CREATE TABLE IF NOT EXISTS bot_drafts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), chat_id BIGINT NOT NULL, kind VARCHAR(16) NOT NULL, payload JSONB NOT NULL DEFAULT '{}'::jsonb, missing JSONB, status VARCHAR(16) NOT NULL DEFAULT 'active', source VARCHAR(16), review_message_id BIGINT, last_bale_update_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts(chat_id); CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts(chat_id, status); -- ---------- 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(); (revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')) COMMIT; -- ---------- PANEL ACCESS (جایگزین مدل نقش) ---------- -- تخصیص پنل + سطح دسترسی به ازای هر کاربر و عضویت M:N پروژه‌ای CREATE TABLE IF NOT EXISTS user_panels ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, panel TEXT NOT NULL CHECK (panel IN ('procurement', 'inventory', 'pm')), level TEXT NOT NULL CHECK (level IN ('full', 'limited')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (user_id, panel) ); CREATE TABLE IF NOT EXISTS user_project_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (user_id, project_id) ); COMMIT;