Reverting the production revert so the pm branch can merge cleanly on top of the restored RBAC base. Conflicts against newer integration work resolved by combining: - App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside the supervisor gating (no-project block screen, panel scoping) - LoginPage: returnUrl navigation kept, supervisor routing added - migration.sql: bot_drafts table kept, unified-projects ALTERs restored
517 lines
24 KiB
Python
517 lines
24 KiB
Python
"""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 «واحد مرجع هر دسته»)
|
||
# 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 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', 'supervisor');
|
||
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,
|
||
delivered_to 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)
|
||
""",
|
||
"""
|
||
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
|
||
""",
|
||
# 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},
|
||
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
|
||
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
|
||
]
|
||
|
||
# تبدیلهای ثابت جهانی («تبدیلهای ثابت — همه متریالها») — ضرایب استاندارد و
|
||
# فراگیر که در هر استارتآپ بهصورت idempotent ذخیره میشوند. کلید یکتا:
|
||
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
|
||
# قالبی که فرانتاند در standard_conversions مینویسد.
|
||
STANDARD_CONVERSIONS: list[dict] = [
|
||
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
|
||
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
|
||
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
|
||
]
|
||
|
||
|
||
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,
|
||
)
|
||
# Seed the global fixed conversions (idempotent — unique key from/to unit).
|
||
for c in STANDARD_CONVERSIONS:
|
||
await run_statement(
|
||
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
|
||
"VALUES (:from_unit, :to_unit, :factor) "
|
||
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
||
c,
|
||
)
|
||
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
|
||
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
||
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
|
||
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
|
||
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
|
||
|
||
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()
|
||
""")
|
||
|
||
|
||
async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
|
||
"""v2.7 follow-up: the category taxonomy is reduced to a supplier label.
|
||
|
||
These three tables backed features that were never populated (0 rows on every
|
||
volume): per-category reference units and conversion factors (the
|
||
per-reference-unit price line) and the category -> attribute mapping. Dropping
|
||
them is idempotent and loses no data. material_categories itself stays — every
|
||
supplier carries one as its label.
|
||
"""
|
||
async with engine.begin() as conn:
|
||
await conn.execute(text(
|
||
"DROP TABLE IF EXISTS category_attributes, category_reference_units, unit_conversions CASCADE"
|
||
))
|
||
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
|
||
|
||
|
||
async def ensure_audit_table(engine: AsyncEngine) -> None:
|
||
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
||
ddl = """
|
||
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 '{}'
|
||
)
|
||
"""
|
||
indexes = (
|
||
"CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity, entity_id)",
|
||
"CREATE INDEX IF NOT EXISTS idx_audit_log_occurred ON audit_log(occurred_at DESC)",
|
||
)
|
||
async with engine.begin() as conn:
|
||
await conn.execute(text(ddl))
|
||
for stmt in indexes:
|
||
await conn.execute(text(stmt))
|
||
logger.info("audit_log table ensured")
|
||
|
||
|
||
async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
|
||
"""Ensure bot_drafts table and indexes exist for Bale bot quote sync."""
|
||
ddl = """
|
||
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),
|
||
source_message_id BIGINT,
|
||
review_message_id BIGINT,
|
||
last_bale_update_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
)
|
||
"""
|
||
indexes = (
|
||
"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)",
|
||
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ",
|
||
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT",
|
||
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts(review_message_id)",
|
||
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts(source_message_id)",
|
||
)
|
||
async with engine.begin() as conn:
|
||
await conn.execute(text(ddl))
|
||
for stmt in indexes:
|
||
await conn.execute(text(stmt))
|
||
logger.info("bot_drafts table ensured")
|
||
|
||
|
||
async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
|
||
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
|
||
ddl = """
|
||
CREATE TABLE IF NOT EXISTS 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()
|
||
)
|
||
"""
|
||
indexes = (
|
||
"CREATE INDEX IF NOT EXISTS idx_project_transfers_from ON project_transfers(from_project_id, status)",
|
||
"CREATE INDEX IF NOT EXISTS idx_project_transfers_to ON project_transfers(to_project_id, status)",
|
||
"DROP TRIGGER IF EXISTS trg_project_transfers_updated_at ON project_transfers",
|
||
"""
|
||
CREATE TRIGGER trg_project_transfers_updated_at
|
||
BEFORE UPDATE ON project_transfers
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
|
||
""",
|
||
)
|
||
async with engine.begin() as conn:
|
||
await conn.execute(text(ddl))
|
||
for stmt in indexes:
|
||
await conn.execute(text(stmt))
|
||
logger.info("project_transfers table ensured")
|
||
|