Files
ERP/backend/app/db/init_db.py
T
MMD 0ea0322967 Revert 253c922: restore the PM/RBAC work to the integration
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
2026-10-01 15:24:56 +03:30

517 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")