feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:14 +03:30
parent c8a9b66395
commit 170aed66ea
67 changed files with 9774 additions and 0 deletions
+385
View File
@@ -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?")