feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
"""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)
|
||||
""",
|
||||
# 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."""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
for ddl in _V26_DDL:
|
||||
await conn.execute(text(ddl))
|
||||
# Seed the 8 fixed topic categories (ON CONFLICT keeps existing rows).
|
||||
for cat in TOPIC_CATEGORIES:
|
||||
await conn.execute(
|
||||
text(
|
||||
"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 conn.execute(
|
||||
text(
|
||||
"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 conn.execute(text("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials"))
|
||||
await conn.execute(text("""
|
||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
||||
AFTER INSERT ON supplier_materials
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
|
||||
"""))
|
||||
await conn.execute(text("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions"))
|
||||
await conn.execute(text("""
|
||||
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()
|
||||
"""))
|
||||
except Exception: # pragma: no cover - DB unavailable at startup
|
||||
logger.exception("Could not ensure v2.6 schema — is the database ready?")
|
||||
Reference in New Issue
Block a user