"""Schema bootstrap for the units-dashboard tables. The migration.sql provided with this project does NOT contain the three tables introduced by the "NEW v2" units-dashboard contracts (category_reference_units, unit_conversions, standard_conversions). This module creates them with ``CREATE TABLE IF NOT EXISTS`` so the endpoints work end-to-end against an existing database. It never touches any table defined by migration.sql — the core schema is left exactly as the migration/seed created it. """ import logging from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine logger = logging.getLogger(__name__) _DASHBOARD_DDL = [ # One reference unit per category (units.html «واحد مرجع هر دسته») """ CREATE TABLE IF NOT EXISTS 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: 1 from_unit = factor × to_unit. # UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct). """ CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS uq_unit_conversion ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit) """, # Global conversions, applies to all categories. """ CREATE TABLE IF NOT EXISTS 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) ) """, ] async def ensure_dashboard_tables(engine: AsyncEngine) -> None: """Create (if missing) the units-dashboard tables. Safe to call every startup.""" try: async with engine.begin() as conn: for ddl in _DASHBOARD_DDL: await conn.execute(text(ddl)) except Exception: # pragma: no cover - DB unavailable at startup logger.exception("Could not ensure units-dashboard tables — are they already created?") # The migration.sql from this repo does not contain a file-attachment table — # the original design kept invoice/proforma files on local disk with a # metadata sidecar. `quote_attachments` replaces that: metadata only, bytes # live in MinIO object storage. Multi-file per quote per kind (no unique). _ATTACHMENT_DDL = [ """ CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_quote_attachments_quote_id ON quote_attachments (quote_id) """, # The set_updated_at() function comes from migration.sql (already loaded # before the API starts); only the per-table trigger is added here. # NOTE: one statement per string — asyncpg rejects multi-statement # prepared statements. """ DROP TRIGGER IF EXISTS trg_quote_attachments_updated_at ON quote_attachments """, """ CREATE TRIGGER trg_quote_attachments_updated_at BEFORE UPDATE ON quote_attachments FOR EACH ROW EXECUTE FUNCTION set_updated_at() """, ] async def ensure_attachment_table(engine: AsyncEngine) -> None: """Create (if missing) the quote_attachments table. Safe to call every startup.""" try: async with engine.begin() as conn: for ddl in _ATTACHMENT_DDL: await conn.execute(text(ddl)) except Exception: # pragma: no cover - DB unavailable at startup logger.exception("Could not ensure quote_attachments table — is the schema ready?") # The `topic` column on materials is used by the frontend to keep the # (Persian) topic of a material; older volumes created before this column # existed need an ALTER TABLE. Idempotent — safe to call every startup. _MATERIAL_TOPIC_DDL = [ """ ALTER TABLE materials ADD COLUMN IF NOT EXISTS topic TEXT """, ] async def ensure_material_topic_column(engine: AsyncEngine) -> None: """Add the materials.topic column if missing (existing volumes).""" try: async with engine.begin() as conn: for ddl in _MATERIAL_TOPIC_DDL: await conn.execute(text(ddl)) except Exception: # pragma: no cover - DB unavailable at startup logger.exception("Could not ensure materials.topic column — is the schema ready?") # Multi-project quotes: a quote can be about several projects at once # (the wizard assigns line items to multiple projects). `quotes.project_id` # stays as the "primary" project for legacy queries. _PROJECTS_DDL = [ """ CREATE TABLE IF NOT EXISTS 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) ) """, # Per-material project allocation (هر قلم → چند پروژه) """ CREATE TABLE IF NOT EXISTS 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) ) """, # Backfill for quotes created before per-material projects existed: # every line item inherits its quote's projects (previous display behavior). """ INSERT INTO line_item_projects (line_item_id, project_id) SELECT li.id, qp.project_id FROM line_items li JOIN quote_projects qp ON qp.quote_id = li.quote_id ON CONFLICT DO NOTHING """, ] async def ensure_quote_projects(engine: AsyncEngine) -> None: """Create the quote_projects + line_item_projects junctions if missing.""" try: async with engine.begin() as conn: for ddl in _PROJECTS_DDL: await conn.execute(text(ddl)) except Exception: # pragma: no cover - DB unavailable at startup logger.exception("Could not ensure quote_projects/line_item_projects — is the schema ready?") # --------------------------------------------------------------------------- # v2.6 — inventory domain + users + arrival flow (idempotent upgrades) # The old runtime `suppliers.categories` JSONB is DROPPED here (v2.5/2.6 # replaces it with the suppliers.category_id FK from migration.sql). # --------------------------------------------------------------------------- _V26_DDL = [ # PG has no CREATE TYPE IF NOT EXISTS — guard with DO blocks. """ DO $$ BEGIN CREATE TYPE inventory_transaction_reason AS ENUM ( 'PURCHASE', 'WITHDRAWAL', 'RETURN', 'TRANSFER_IN', 'TRANSFER_OUT', 'DAMAGE', 'WASTE', 'LOSS', 'FOUND', 'STOCK_COUNT'); EXCEPTION WHEN duplicate_object THEN NULL; END $$ """, """ DO $$ BEGIN CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin'); EXCEPTION WHEN duplicate_object THEN NULL; END $$ """, """ CREATE TABLE IF NOT EXISTS 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() ) """, """ ALTER TABLE projects ADD COLUMN IF NOT EXISTS pm_user_id UUID REFERENCES users(id) ON DELETE SET NULL """, """ ALTER TABLE item_decisions ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL """, """ CREATE TABLE IF NOT EXISTS 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() ) """, """ CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS uq_inventory_stock ON inventory ( warehouse_id, material_id, COALESCE(brand_id, '00000000-0000-0000-0000-000000000000'), specs_hash ) """, """ CREATE INDEX IF NOT EXISTS idx_inventory_warehouse_material ON inventory (warehouse_id, material_id) """, """ CREATE TABLE IF NOT EXISTS 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) ) """, """ CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_inventory_tx_inventory_date ON inventory_transactions (inventory_id, transaction_date) """, """ CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date) """, # suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it. """ ALTER TABLE suppliers DROP COLUMN IF EXISTS categories """, # item_receipts becomes 1:N (partial deliveries): drop the UNIQUE, add confirmed_by. """ ALTER TABLE item_receipts DROP CONSTRAINT IF EXISTS item_receipts_item_decision_id_key """, """ ALTER TABLE item_receipts ADD COLUMN IF NOT EXISTS confirmed_by UUID REFERENCES users(id) ON DELETE RESTRICT """, # materials: global name unique (v2.5) — drop the old composite unique, add the new. """ ALTER TABLE materials DROP CONSTRAINT IF EXISTS uq_material_category_name """, """ CREATE UNIQUE INDEX IF NOT EXISTS uq_material_name ON materials(name) """, # suppliers.category_id FK (v2.5) — for volumes that predate migration.sql v2.6. """ ALTER TABLE suppliers ADD COLUMN IF NOT EXISTS category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT """, """ CREATE INDEX IF NOT EXISTS idx_suppliers_category_id ON suppliers(category_id) """, # PM → projects is 1:N (a PM may manage several projects). No UNIQUE on # pm_user_id — just a lookup index for "projects of this PM" queries. """ CREATE INDEX IF NOT EXISTS idx_projects_pm_user_id ON projects(pm_user_id) """, # supplier_materials (v2.5) + its triggers (functions are CREATE OR REPLACE below). """ CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_supplier_materials_material ON supplier_materials(material_id) """, ] # The 8 FIXED topic categories (category IS the topic in v2.6). # Slugs/names match the shipped inventory dash and its seed_stack.py. TOPIC_CATEGORIES: list[dict[str, str]] = [ {"slug": "mechanical-installations-pipes", "name": "تأسیسات مکانیکی و آبی"}, {"slug": "electrical-equipment-supplies", "name": "تجهیزات و لوازم برقی"}, {"slug": "doors-windows-glass", "name": "درب، پنجره و شیشه"}, {"slug": "metals-steel-sections", "name": "فلزات و مقاطع فولادی"}, {"slug": "construction-materials", "name": "مصالح ساختمانی"}, {"slug": "construction-chemicals", "name": "مواد شیمیایی ساختمانی"}, {"slug": "finishing-interior-decoration", "name": "نازک‌کاری و دکوراسیون داخلی"}, {"slug": "construction-tools-equipment", "name": "ابزار و تجهیزات کارگاهی"}, ] # Base units (idempotent seed — mirrors the fork's seed_stack.py units). BASE_UNITS: list[dict] = [ {"code": "kg", "name_fa": "کیلوگرم", "dimension": "mass", "si_factor": 1, "is_base": True}, {"code": "piece", "name_fa": "عدد", "dimension": "count", "si_factor": 1, "is_base": True}, {"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True}, {"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False}, {"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False}, ] async def ensure_v26(engine: AsyncEngine) -> None: """Idempotent v2.6 upgrade: inventory domain + users + arrival flow + v2.5 deltas. Every statement runs in its OWN transaction: one failure (e.g. duplicate material names blocking uq_material_name on an old volume) can never roll back the rest of the upgrade — otherwise `item_receipts.confirmed_by` and friends would stay missing and the arrival flow would crash with 500s. """ async def run_statement(ddl: str, params: dict | None = None) -> None: try: async with engine.begin() as conn: await conn.execute(text(ddl), params or {}) except Exception: # pragma: no cover - pre-existing data conflicts first_line = ddl.splitlines()[0][:100] if ddl.splitlines() else ddl[:100] logger.exception("v2.6 statement skipped (continuing): %s", first_line) for ddl in _V26_DDL: await run_statement(ddl) # Seed the 8 fixed topic categories (ON CONFLICT keeps existing rows). for cat in TOPIC_CATEGORIES: await run_statement( "INSERT INTO material_categories (slug, name) VALUES (:slug, :name) ON CONFLICT (slug) DO NOTHING", {"slug": cat["slug"], "name": cat["name"]}, ) # Seed base units (idempotent) so materials/line items can be created. for u in BASE_UNITS: await run_statement( "INSERT INTO units (code, name_fa, dimension, si_factor, is_base) " "VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING", u, ) # v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync). await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials") await run_statement(""" CREATE TRIGGER trg_supplier_materials_derive_category AFTER INSERT ON supplier_materials FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category() """) await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions") await run_statement(""" 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() """)