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
+6
View File
@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
uploads
.env
tests
+23
View File
@@ -0,0 +1,23 @@
# Backend image — FastAPI + SQLAlchemy async (asyncpg)
# The PostgreSQL schema is managed by the postgres service (migration.sql +
# seed.sql init scripts); this image only runs the API.
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /srv/backend
# Dependencies first (layer caching)
# PIP_INDEX_URL overridable (e.g. a China mirror) — default is official PyPI.
ARG PIP_INDEX_URL=https://pypi.org/simple
COPY requirements.txt .
RUN pip install --no-cache-dir -i ${PIP_INDEX_URL} -r requirements.txt
# Application
COPY app ./app
COPY tools ./tools
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+136
View File
@@ -0,0 +1,136 @@
"""Casdoor JWT authentication — the v2.6 identity source.
- Verifies the Bearer token against Casdoor's JWKS (issuer = CASDOOR_ENDPOINT).
- Auto-provisions the `users` row from the token claims on first login
(role stays NULL until assigned by an admin / seed).
- Resolves the caller's role (users.role) and, for PMs, their single
project (projects.pm_user_id).
- Dev fallback: when no Authorization header is present, endpoints that
use `get_optional_user` degrade gracefully; the inventory router keeps
its header-based placeholder mode for local testing.
Dependencies (pyjwt[crypto]):
pip install pyjwt[crypto]
"""
import logging
from dataclasses import dataclass
import jwt
from fastapi import Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.engine import get_db
from app.models.procurement import Project, User
from app.utils.errors import ForbiddenError, ValidationError
logger = logging.getLogger("procurement.auth")
_JWKS_CACHE: dict | None = None
def _jwks_url() -> str:
return settings.CASDOOR_JWKS_URL or f"{settings.CASDOOR_ENDPOINT}/api/public_jwks"
async def _get_jwks() -> dict:
"""Fetch (and cache) Casdoor's public JWKS."""
global _JWKS_CACHE
if _JWKS_CACHE is not None:
return _JWKS_CACHE
import httpx
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(_jwks_url())
resp.raise_for_status()
_JWKS_CACHE = resp.json()
return _JWKS_CACHE
async def _verify_token(token: str) -> dict:
"""Validate the Casdoor JWT and return its claims."""
jwks = await _get_jwks()
unverified = jwt.decode(token, options={"verify_signature": False})
kid = unverified.get("kid")
key = next((k for k in jwks.get("keys", []) if k.get("kid") == kid), None)
if key is None:
raise ValidationError("امضای توکن نامعتبر است", field="authorization")
issuer = settings.CASDOOR_ENDPOINT
audience = settings.CASDOOR_AUDIENCE or settings.CASDOOR_CLIENT_ID
try:
return jwt.decode(
token,
key,
algorithms=["RS256", "RS384", "RS512"],
issuer=issuer,
audience=audience,
)
except jwt.PyJWTError as exc:
logger.warning("JWT verification failed: %s", exc)
raise ValidationError("توکن نامعتبر یا منقضی شده است", field="authorization")
@dataclass
class UserContext:
user: User
role: str | None
project_id: object | None # uuid.UUID | None
display_name: str
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
"""Resolve the caller from the Casdoor Bearer token, or None when absent/invalid.
Never raises on a missing token — callers decide their own fallback
(e.g. the inventory router's dev headers).
"""
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
return None
token = auth.split(" ", 1)[1].strip()
if not token:
return None
claims = await _verify_token(token)
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
if not casdoor_id:
raise ValidationError("توکن فاقد شناسه کاربر است", field="authorization")
# Auto-provision on first login (role stays NULL until assigned).
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
if user is None:
user = User(
casdoor_id=casdoor_id,
first_name=str(claims.get("firstname") or claims.get("name") or "کاربر"),
last_name=str(claims.get("lastname") or ""),
phone=str(claims.get("phone") or "") or None,
)
db.add(user)
await db.flush()
project_id = None
if user.role == "pm":
project = await db.scalar(select(Project).where(Project.pm_user_id == user.id))
project_id = project.id if project else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
return UserContext(user=user, role=user.role, project_id=project_id, display_name=display_name)
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext:
"""Require a valid Casdoor token (403 when missing or unprovisioned)."""
ctx = await get_optional_user(request, db)
if ctx is None:
raise ForbiddenError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
return ctx
def require_role(*roles: str):
"""Dependency factory: require the caller to hold one of the given roles."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role not in roles:
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx
return _dep
+56
View File
@@ -0,0 +1,56 @@
"""Application configuration.
All settings can be overridden with environment variables prefixed with
``PROCUREMENT_`` (e.g. ``PROCUREMENT_DATABASE_URL=...``) or a local ``.env`` file.
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="PROCUREMENT_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# PostgreSQL DSN — the schema (migration.sql + seed.sql) must already exist.
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
# Echo SQL statements to the console (dev aid).
DEBUG_SQL: bool = False
# MinIO object storage — invoice / proforma attachments (S3-compatible).
# Endpoint is the compose service name (`minio:9000`) inside Docker.
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_BUCKET: str = "procurement-files"
MINIO_SECURE: bool = False
# Background auto-expiry of offered quotes whose expiry_date has passed.
AUTO_EXPIRE_ENABLED: bool = True
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
# Pagination limits (contract: default 20, max 100).
DEFAULT_PAGE_SIZE: int = 20
MAX_PAGE_SIZE: int = 100
# CORS (dev-friendly default; the frontend is a static site).
CORS_ORIGINS: list[str] = ["*"]
# Casdoor (OIDC) — JWT verification against the issuer's JWKS.
CASDOOR_ENDPOINT: str = "http://localhost:8080"
CASDOOR_CLIENT_ID: str = "inventory-app-client"
CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026"
CASDOOR_ORGANIZATION: str = "inventory"
CASDOOR_APP_NAME: str = "inventory-app"
CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks
CASDOOR_AUDIENCE: str = "inventory-app-client" # expected token aud (falls back to client_id)
# Inventory dev fallback (only when no Authorization header is sent).
INVENTORY_DEFAULT_ROLE: str = "pm"
INVENTORY_DEFAULT_PROJECT_ID: str = ""
settings = Settings()
View File
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Declarative base for all ORM models."""
+29
View File
@@ -0,0 +1,29 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG_SQL,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency: yields a session, committing on success.
Services that need multi-statement atomicity use ``async with db.begin()``
blocks (those commit/rollback explicitly); a plain commit here persists
the single-statement writes of the CRUD routers.
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+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?")
+167
View File
@@ -0,0 +1,167 @@
"""FastAPI application assembly.
- Routes: all routers under /api/v1
- Errors: contract envelope {"error": {"code", "message", "field"}}
- Lifespan: ensure units-dashboard tables (IF NOT EXISTS) + background
auto-expiry scheduler for offered quotes past their expiry_date.
"""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from app.config import settings
from app.db.engine import AsyncSessionLocal, engine
from app.db.init_db import (
ensure_attachment_table,
ensure_dashboard_tables,
ensure_material_topic_column,
ensure_quote_projects,
ensure_v26,
)
from app.routers import ROUTERS
from app.services.quote_service import auto_expire_quotes
from app.utils.errors import AppError
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
logger = logging.getLogger("procurement")
API_V1_PREFIX = "/api/v1"
async def _auto_expire_loop() -> None:
"""Background job: expire quotes with expiry_date < today AND status='offered'."""
while True:
await asyncio.sleep(settings.AUTO_EXPIRE_INTERVAL_SECONDS)
try:
async with AsyncSessionLocal() as session:
await auto_expire_quotes(session)
except Exception:
logger.exception("Auto-expiry pass failed")
@asynccontextmanager
async def lifespan(_app: FastAPI):
await ensure_dashboard_tables(engine)
await ensure_attachment_table(engine)
await ensure_material_topic_column(engine)
await ensure_quote_projects(engine)
await ensure_v26(engine)
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
try:
yield
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await engine.dispose()
app = FastAPI(
title="Procurement API",
version="2.4.0",
description="Backend for the procurement system — schema v2.4 (expert-opinion contracts P0/P1/P2)",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# Exception handlers — contract error envelope
# ---------------------------------------------------------------------------
@app.exception_handler(AppError)
async def app_error_handler(_request: Request, exc: AppError):
return JSONResponse(status_code=exc.status_code, content=exc.body)
# ترجمه‌ی پیام‌های اعتبارسنجی pydantic به فارسی — الگوهای رایج
_VALIDATION_FA: list[tuple[str, str]] = [
("field required", "فیلد الزامی خالی است"),
("input should be a valid uuid", "شناسه‌ی ارسال‌شده معتبر نیست"),
("input should be a valid datetime", "تاریخ و ساعت واردشده معتبر نیست"),
("input should be a valid date", "تاریخ واردشده معتبر نیست"),
("input should be a valid integer", "عدد صحیح وارد کنید"),
("input should be a valid number", "عدد معتبر وارد کنید"),
("input should be a valid boolean", "مقدار درست/نادرست (بولی) وارد کنید"),
("input should be greater than 0", "مقدار باید بزرگ‌تر از صفر باشد"),
("input should be greater than or equal to", "مقدار از حداقل مجاز کوچک‌تر است"),
("input should be less than or equal to", "مقدار از حداکثر مجاز بزرگ‌تر است"),
("string should have at least 1 character", "متن نباید خالی باشد"),
("string too short", "مقدار خیلی کوتاه است"),
("string too long", "مقدار خیلی طولانی است"),
("value error", "مقدار واردشده نامعتبر است"),
]
def _fa_validation_msg(msg: str) -> str:
lower = msg.lower()
for en, fa in _VALIDATION_FA:
if en in lower:
return fa
return msg
@app.exception_handler(RequestValidationError)
async def validation_error_handler(_request: Request, exc: RequestValidationError):
first = exc.errors()[0]
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
field = ".".join(loc) if loc else None
return JSONResponse(
status_code=400,
content={
"error": {
"code": "VALIDATION_ERROR",
"message": _fa_validation_msg(first.get("msg", "داده نامعتبر است")),
"field": field,
}
},
)
@app.exception_handler(IntegrityError)
async def integrity_error_handler(_request: Request, exc: IntegrityError):
# Services pre-check FKs and duplicates, so an IntegrityError here almost
# always means a unique-constraint violation → 409 CONFLICT.
logger.warning("IntegrityError: %s", exc.orig)
return JSONResponse(
status_code=409,
content={"error": {"code": "CONFLICT", "message": "تعارض داده — رکورد تکراری یا ارجاع نامعتبر"}},
)
@app.exception_handler(Exception)
async def unhandled_error_handler(_request: Request, exc: Exception):
logger.exception("Unhandled error: %s", exc)
return JSONResponse(
status_code=500,
content={"error": {"code": "INTERNAL_ERROR", "message": "خطای داخلی سرور"}},
)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
for router in ROUTERS:
app.include_router(router, prefix=API_V1_PREFIX)
@app.get("/health", tags=["system"])
async def health():
return {"status": "ok", "service": "procurement-api"}
+62
View File
@@ -0,0 +1,62 @@
"""ORM models — import order matters only for registration in Base.metadata."""
from app.db.base import Base
from app.models.catalog import (
AttributeDefinition,
Brand,
BrandMaterial,
CategoryAttribute,
CategoryReferenceUnit,
Material,
MaterialAttributeValue,
MaterialCategory,
MaterialUnit,
StandardConversion,
Unit,
UnitConversion,
)
from app.models.files import QuoteAttachment
from app.models.procurement import (
ItemDecision,
ItemReceipt,
ItemTermsheet,
ItemTermsheetsWithTotal,
LineItem,
LineItemSpec,
Project,
Quote,
QuoteEvent,
QuoteProjects,
Supplier,
Termsheet,
TermsheetScheduleStep,
)
__all__ = [
"Base",
"Unit",
"Brand",
"MaterialCategory",
"AttributeDefinition",
"CategoryAttribute",
"Material",
"BrandMaterial",
"MaterialUnit",
"MaterialAttributeValue",
"CategoryReferenceUnit",
"UnitConversion",
"StandardConversion",
"Supplier",
"Project",
"Quote",
"QuoteEvent",
"QuoteProjects",
"LineItem",
"LineItemSpec",
"Termsheet",
"TermsheetScheduleStep",
"ItemTermsheet",
"ItemTermsheetsWithTotal",
"ItemDecision",
"ItemReceipt",
"QuoteAttachment",
]
+261
View File
@@ -0,0 +1,261 @@
"""SQLAlchemy models for the CATALOG domain — mirrors migration.sql exactly.
Column types, nullability, server defaults, FKs, unique constraints and
indexes all follow the DDL in `data base/migration.sql`. No table is
created through SQLAlchemy metadata; the schema already exists in Postgres.
"""
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
Date,
DateTime,
Enum,
ForeignKey,
Identity,
Integer,
Numeric,
SmallInteger,
String,
Text,
UniqueConstraint,
Uuid,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
attribute_data_type_enum = Enum(
"number", "integer", "string", "enum", "boolean", "date",
name="attribute_data_type",
native_enum=True,
)
material_status_enum = Enum(
"active", "discontinued", "draft",
name="material_status",
native_enum=True,
)
def _uuid_pk() -> Mapped[uuid.UUID]:
"""UUID primary key, server-generated via gen_random_uuid()."""
return mapped_column(Uuid(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
def _timestamptz() -> Mapped[datetime]:
return mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class Unit(Base):
"""units — the only non-UUID PK in the system (INTEGER IDENTITY)."""
__tablename__ = "units"
id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True)
code: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
name_fa: Mapped[str | None] = mapped_column(String)
dimension: Mapped[str | None] = mapped_column(String) # length, mass, pressure, ...
si_factor: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False, server_default=text("1.0"))
is_base: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
class Brand(Base):
__tablename__ = "brands"
__table_args__ = (UniqueConstraint("name", "country", name="uq_brands_name_country"),)
id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False)
country: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = _timestamptz()
class MaterialCategory(Base):
__tablename__ = "material_categories"
# eager_defaults: fetch onupdate=func.now() via RETURNING during flush so
# updated_at is never expired post-flush (lazy refresh raises
# MissingGreenlet in async code).
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
slug: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class AttributeDefinition(Base):
"""Global attribute dictionary (EAV definition side)."""
__tablename__ = "attribute_definitions"
id: Mapped[uuid.UUID] = _uuid_pk()
attribute_key: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
label: Mapped[str] = mapped_column(Text, nullable=False)
data_type: Mapped[str] = mapped_column(attribute_data_type_enum, nullable=False)
unit_id: Mapped[int | None] = mapped_column(ForeignKey("units.id", ondelete="RESTRICT"))
enum_values: Mapped[list | None] = mapped_column(JSONB) # allowed options for enum types
created_at: Mapped[datetime] = _timestamptz()
class CategoryAttribute(Base):
"""Category-attribute junction (scopes global attributes & sets category rules)."""
__tablename__ = "category_attributes"
__table_args__ = (UniqueConstraint("category_id", "attribute_definition_id", name="uq_category_attribute"),)
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("material_categories.id", ondelete="CASCADE"), nullable=False
)
attribute_definition_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("attribute_definitions.id", ondelete="CASCADE"), nullable=False
)
is_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
is_filterable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
conditional_rules: Mapped[dict | None] = mapped_column(JSONB)
sort_order: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default=text("0"))
attribute: Mapped["AttributeDefinition"] = relationship(lazy="raise")
class Material(Base):
__tablename__ = "materials"
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived.
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="RESTRICT")
)
name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[str] = mapped_column(material_status_enum, nullable=False, server_default=text("'active'"))
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True
)
class BrandMaterial(Base):
"""M:N Brand <-> Material junction."""
__tablename__ = "brand_materials"
__table_args__ = (UniqueConstraint("brand_id", "material_id", name="uq_brand_material"),)
id: Mapped[uuid.UUID] = _uuid_pk()
brand_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("brands.id", ondelete="CASCADE"), nullable=False)
material_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("materials.id", ondelete="CASCADE"), nullable=False)
catalog_code: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
created_at: Mapped[datetime] = _timestamptz()
brand: Mapped["Brand"] = relationship(lazy="raise")
material: Mapped["Material"] = relationship(lazy="raise")
class MaterialUnit(Base):
"""Packaging / unit overrides per material (and optionally per brand)."""
__tablename__ = "material_units"
__table_args__ = (
UniqueConstraint("material_id", "brand_material_id", "unit_id", name="uq_material_unit"),
)
id: Mapped[uuid.UUID] = _uuid_pk()
material_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("materials.id", ondelete="CASCADE"), nullable=False)
brand_material_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("brand_materials.id", ondelete="CASCADE")
) # NULL = generic material fallback
unit_id: Mapped[int] = mapped_column(ForeignKey("units.id", ondelete="RESTRICT"), nullable=False)
conversion_factor: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
class MaterialAttributeValue(Base):
"""Typed EAV values for a material."""
__tablename__ = "material_attribute_values"
__table_args__ = (UniqueConstraint("material_id", "attribute_definition_id", name="uq_material_attr_value"),)
id: Mapped[uuid.UUID] = _uuid_pk()
material_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("materials.id", ondelete="CASCADE"), nullable=False)
attribute_definition_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("attribute_definitions.id", ondelete="RESTRICT"), nullable=False
)
value_number: Mapped[Decimal | None] = mapped_column(Numeric(18, 6))
value_string: Mapped[str | None] = mapped_column(Text)
value_boolean: Mapped[bool | None] = mapped_column(Boolean)
value_date: Mapped[date | None] = mapped_column(Date)
material: Mapped["Material"] = relationship(back_populates="attribute_values", lazy="raise")
definition: Mapped["AttributeDefinition"] = relationship(lazy="raise")
# ---------------------------------------------------------------------------
# Units-dashboard tables (NEW v2 contracts) — created IF NOT EXISTS at startup
# by app.db.init_db.ensure_dashboard_tables.
# ---------------------------------------------------------------------------
class CategoryReferenceUnit(Base):
__tablename__ = "category_reference_units"
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("material_categories.id", ondelete="CASCADE"), nullable=False, unique=True
)
ref_unit: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class UnitConversion(Base):
__tablename__ = "unit_conversions"
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("material_categories.id", ondelete="CASCADE"), nullable=False
)
brand: Mapped[str | None] = mapped_column(Text)
from_unit: Mapped[str] = mapped_column(Text, nullable=False)
to_unit: Mapped[str] = mapped_column(Text, nullable=False)
factor: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class StandardConversion(Base):
__tablename__ = "standard_conversions"
__table_args__ = (UniqueConstraint("from_unit", "to_unit", name="uq_standard_conversion"),)
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
from_unit: Mapped[str] = mapped_column(Text, nullable=False)
to_unit: Mapped[str] = mapped_column(Text, nullable=False)
factor: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+52
View File
@@ -0,0 +1,52 @@
"""ORM model for invoice / proforma attachments (table `quote_attachments`).
File bytes live in MinIO object storage; this table is the metadata record
(``object_key`` points at the object inside the PROCUREMENT_MINIO_BUCKET).
Multiple attachments per quote per kind are allowed (multi-file contract,
no unique constraint on (quote_id, kind)).
"""
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, CheckConstraint, DateTime, ForeignKey, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.catalog import _timestamptz, _uuid_pk
from app.utils.serialization import to_iso
class QuoteAttachment(Base):
__tablename__ = "quote_attachments"
__mapper_args__ = {"eager_defaults": True} # fetch onupdate=func.now() via RETURNING
__table_args__ = (
CheckConstraint("kind IN ('invoice', 'proforma')", name="ck_quote_attachments_kind"),
)
id: Mapped[uuid.UUID] = _uuid_pk()
quote_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False)
kind: Mapped[str] = mapped_column(Text, nullable=False) # "invoice" | "proforma"
file_name: Mapped[str] = mapped_column(Text, nullable=False)
mime_type: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
object_key: Mapped[str] = mapped_column(Text, nullable=False) # MinIO key inside the bucket
uploaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
quote: Mapped["Quote"] = relationship(lazy="raise")
def to_dict(self) -> dict:
return {
"id": str(self.id),
"quote_id": str(self.quote_id),
"kind": self.kind,
"file_name": self.file_name,
"mime_type": self.mime_type,
"size_bytes": self.size_bytes,
"uploaded_at": to_iso(self.uploaded_at),
}
+178
View File
@@ -0,0 +1,178 @@
"""SQLAlchemy models for the INVENTORY domain (warehouses, inventory, transactions).
Merged from the software team's shipped inventory-dashboard fork with the
v2.6 fixes:
- unique stock index uses COALESCE(brand_id, sentinel) so NULL brand is ONE
concrete value (a plain unique index would allow duplicate unbranded rows).
- schema is created by sql/migration.sql (fresh) / init_db.ensure_v26
(existing volumes) — NOT through SQLAlchemy metadata.
"""
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Numeric,
Text,
UniqueConstraint,
Uuid,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.catalog import _timestamptz, _uuid_pk
inventory_transaction_reason_enum = Enum(
"PURCHASE",
"WITHDRAWAL",
"RETURN",
"TRANSFER_IN",
"TRANSFER_OUT",
"DAMAGE",
"WASTE",
"LOSS",
"FOUND",
"STOCK_COUNT",
name="inventory_transaction_reason",
native_enum=True,
)
# The 10 supported transaction types. No OTHER / generic adjustment.
INVENTORY_TRANSACTION_TYPES = (
"PURCHASE",
"WITHDRAWAL",
"RETURN",
"TRANSFER_IN",
"TRANSFER_OUT",
"DAMAGE",
"WASTE",
"LOSS",
"FOUND",
"STOCK_COUNT",
)
# Types that INCREASE inventory quantity.
INCREASING_TYPES = frozenset({"PURCHASE", "RETURN", "FOUND", "TRANSFER_IN"})
# Types that DECREASE inventory quantity.
DECREASING_TYPES = frozenset({"WITHDRAWAL", "DAMAGE", "WASTE", "LOSS", "TRANSFER_OUT"})
class Warehouse(Base):
"""warehouses — storage location bound to exactly one project."""
__tablename__ = "warehouses"
id: Mapped[uuid.UUID] = _uuid_pk()
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = _timestamptz()
project: Mapped["Project"] = relationship(lazy="raise")
class InventoryItem(Base):
"""inventory — current stock snapshot, unique per (warehouse, material, brand, specs)."""
__tablename__ = "inventory"
# The DB unique index (uq_inventory_stock) uses COALESCE on brand_id —
# this Table-level constraint is only a model mirror for tooling; the
# real constraint lives in migration.sql / init_db.ensure_v26.
__mapper_args__ = {"eager_defaults": True} # fetch onupdate=func.now() via RETURNING
id: Mapped[uuid.UUID] = _uuid_pk()
warehouse_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("warehouses.id", ondelete="CASCADE"), nullable=False
)
material_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
)
brand_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("brands.id", ondelete="SET NULL")
)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False, server_default=text("0"))
unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
# Canonical technical specifications (sorted JSON). specs_hash is the
# SHA256 of the sorted canonical JSON (or 'default' when empty).
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
warehouse: Mapped["Warehouse"] = relationship(lazy="raise")
material: Mapped["Material"] = relationship(lazy="raise")
brand: Mapped["Brand | None"] = relationship(lazy="raise")
unit: Mapped["Unit"] = relationship(lazy="raise")
transactions: Mapped[list["InventoryTransaction"]] = relationship(
back_populates="inventory", lazy="raise", passive_deletes=True,
order_by="InventoryTransaction.transaction_date",
)
class InventoryTransaction(Base):
"""inventory_transactions — full chronological ledger of stock changes.
quantity is SIGNED: positive = increase (PURCHASE/RETURN/FOUND/TRANSFER_IN),
negative = decrease (WITHDRAWAL/DAMAGE/WASTE/LOSS/TRANSFER_OUT).
A transfer is recorded as two linked rows (TRANSFER_OUT in source,
TRANSFER_IN in destination) sharing the same transfer_pair_id.
receipt_allocation_id links an auto-posted PURCHASE back to its
item_receipt_allocation — UNIQUE, so the same delivery never posts twice.
"""
__tablename__ = "inventory_transactions"
__mapper_args__ = {"eager_defaults": True} # fetch server_default now() via RETURNING
id: Mapped[uuid.UUID] = _uuid_pk()
inventory_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("inventory.id", ondelete="CASCADE"), nullable=False
)
warehouse_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
)
receipt_allocation_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("item_receipt_allocations.id", ondelete="SET NULL"), unique=True
)
transaction_type: Mapped[str] = mapped_column(
inventory_transaction_reason_enum, nullable=False
)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
# STOCK_COUNT only: quantity before the count (recorded) and the counted
# value (physical). NULL for every other type.
recorded_qty: Mapped[Decimal | None] = mapped_column(Numeric(18, 6))
physical_qty: Mapped[Decimal | None] = mapped_column(Numeric(18, 6))
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
transaction_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = _timestamptz()
inventory: Mapped["InventoryItem"] = relationship(back_populates="transactions", lazy="raise")
warehouse: Mapped["Warehouse"] = relationship(lazy="raise")
# Re-export for convenience (mirrors the ERD enum name)
__all__ = [
"Warehouse",
"InventoryItem",
"InventoryTransaction",
"inventory_transaction_reason_enum",
"INVENTORY_TRANSACTION_TYPES",
"INCREASING_TYPES",
"DECREASING_TYPES",
]
+404
View File
@@ -0,0 +1,404 @@
"""SQLAlchemy models for the QUOTATION & PROCUREMENT domain — mirrors migration.sql exactly."""
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
Column,
Date,
DateTime,
Enum,
ForeignKey,
Numeric,
SmallInteger,
Table,
Text,
Uuid,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
user_role_enum = Enum(
"pm", "procurement_manager", "admin",
name="user_role", native_enum=True,
)
quote_status_enum = Enum(
"offered", "accepted", "arrived", "rejected", "expired",
name="quote_status", native_enum=True,
)
termsheet_status_enum = Enum(
"offered", "accepted", "rejected",
name="termsheet_status", native_enum=True,
)
discount_type_enum = Enum(
"none", "percent", "fixed",
name="discount_type", native_enum=True,
)
item_decision_status_enum = Enum(
"pending", "chosen", "rejected",
name="item_decision_status", native_enum=True,
)
quote_event_type_enum = Enum(
"offered", "accepted", "rejected", "arrived", "note", "edited", "expired",
name="quote_event_type", native_enum=True,
)
class User(Base):
"""users — Casdoor-backed identity. role is OUR authorization source."""
__tablename__ = "users"
id: Mapped[uuid.UUID] = _uuid_pk()
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
first_name: Mapped[str] = mapped_column(Text, nullable=False)
last_name: Mapped[str] = mapped_column(Text, nullable=False)
phone: Mapped[str | None] = mapped_column(Text)
role: Mapped[str | None] = mapped_column(user_role_enum)
created_at: Mapped[datetime] = _timestamptz()
class Supplier(Base):
__tablename__ = "suppliers"
id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False)
# v2.6: 1 supplier → exactly 1 category (one of the 8 fixed topic
# categories). The old runtime JSONB `categories` column is dropped.
category_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("material_categories.id", ondelete="RESTRICT"), nullable=False
)
location: Mapped[str | None] = mapped_column(Text)
phone: Mapped[str | None] = mapped_column(Text)
contact_person: Mapped[str | None] = mapped_column(Text)
tax_id: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = _timestamptz()
category: Mapped["MaterialCategory"] = relationship(lazy="raise")
class SupplierMaterial(Base):
"""supplier_materials — catalog-layer M:N supplier ↔ material link (v2.5).
Auto-created whenever a quote line item is saved; also managed via
/suppliers/{id}/materials. Inserting a row derives materials.category_id
(first-link-wins) via service + DB trigger.
"""
__tablename__ = "supplier_materials"
__table_args__ = (UniqueConstraint("supplier_id", "material_id", name="uq_supplier_material"),)
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
supplier_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("suppliers.id", ondelete="CASCADE"), nullable=False
)
material_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("materials.id", ondelete="CASCADE"), nullable=False
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
last_quoted_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 4))
last_quoted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = _timestamptz()
supplier: Mapped["Supplier"] = relationship(lazy="raise")
material: Mapped["Material"] = relationship(lazy="raise")
class QuoteProjects(Base):
"""M:N quotes ↔ projects junction (a quote can be about several projects)."""
__tablename__ = "quote_projects"
quote_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("quotes.id", ondelete="CASCADE"), primary_key=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True
)
project: Mapped["Project"] = relationship(lazy="raise")
class LineItemProjects(Base):
"""M:N line_items ↔ projects junction — which projects each material is allocated to."""
__tablename__ = "line_item_projects"
line_item_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("line_items.id", ondelete="CASCADE"), primary_key=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True
)
project: Mapped["Project"] = relationship(lazy="raise")
class Project(Base):
__tablename__ = "projects"
id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
start_date: Mapped[date | None] = mapped_column(Date)
# v2.6: the single PM responsible for this project (confirms arrivals).
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
created_at: Mapped[datetime] = _timestamptz()
class Quote(Base):
__tablename__ = "quotes"
__mapper_args__ = {"eager_defaults": True} # fetch onupdate=func.now() via RETURNING
id: Mapped[uuid.UUID] = _uuid_pk()
supplier_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("suppliers.id", ondelete="RESTRICT"), nullable=False
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="SET NULL")
)
quote_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
expiry_date: Mapped[date | None] = mapped_column(Date)
accepted_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
arrived_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(quote_status_enum, nullable=False, server_default=text("'offered'"))
is_official: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
supplier: Mapped["Supplier"] = relationship(foreign_keys=[supplier_id], lazy="raise")
project: Mapped["Project | None"] = relationship(foreign_keys=[project_id], lazy="raise")
line_items: Mapped[list["LineItem"]] = relationship(
back_populates="quote", lazy="raise", passive_deletes=True, order_by="LineItem.created_at"
)
termsheets: Mapped[list["Termsheet"]] = relationship(
back_populates="quote", lazy="raise", passive_deletes=True, order_by="Termsheet.created_at"
)
events: Mapped[list["QuoteEvent"]] = relationship(
back_populates="quote", lazy="raise", passive_deletes=True, order_by="QuoteEvent.occurred_at"
)
class QuoteEvent(Base):
__tablename__ = "quote_events"
id: Mapped[uuid.UUID] = _uuid_pk()
quote_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False)
event_type: Mapped[str] = mapped_column(quote_event_type_enum, nullable=False)
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
actor: Mapped[str | None] = mapped_column(Text)
quote: Mapped["Quote"] = relationship(back_populates="events", lazy="raise")
class LineItem(Base):
__tablename__ = "line_items"
id: Mapped[uuid.UUID] = _uuid_pk()
quote_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False)
material_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False)
brand_material_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("brand_materials.id", ondelete="SET NULL")
) # optional; app-layer check ensures it belongs to material_id
unit_id: Mapped[int] = mapped_column(ForeignKey("units.id", ondelete="RESTRICT"), nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = _timestamptz()
quote: Mapped["Quote"] = relationship(back_populates="line_items", lazy="raise")
material: Mapped["Material"] = relationship(lazy="raise")
unit: Mapped["Unit"] = relationship(lazy="raise")
specs: Mapped[list["LineItemSpec"]] = relationship(
back_populates="line_item", lazy="raise", passive_deletes=True
)
decision: Mapped["ItemDecision | None"] = relationship(
back_populates="line_item", uselist=False, lazy="raise", passive_deletes=True
)
item_termsheets: Mapped[list["ItemTermsheet"]] = relationship(
back_populates="line_item", lazy="raise", passive_deletes=True
)
class LineItemSpec(Base):
"""Historical snapshot — freezes material specs at quote generation time."""
__tablename__ = "line_item_specs"
__table_args__ = (UniqueConstraint("line_item_id", "attribute_definition_id", name="uq_lineitem_spec"),)
id: Mapped[uuid.UUID] = _uuid_pk()
line_item_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("line_items.id", ondelete="CASCADE"), nullable=False)
attribute_definition_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("attribute_definitions.id", ondelete="RESTRICT"), nullable=False
)
value_number: Mapped[Decimal | None] = mapped_column(Numeric(18, 6))
value_string: Mapped[str | None] = mapped_column(Text)
value_boolean: Mapped[bool | None] = mapped_column(Boolean)
value_date: Mapped[date | None] = mapped_column(Date)
unit_id_at_time: Mapped[int | None] = mapped_column(ForeignKey("units.id", ondelete="SET NULL"))
line_item: Mapped["LineItem"] = relationship(back_populates="specs", lazy="raise")
class Termsheet(Base):
__tablename__ = "termsheets"
id: Mapped[uuid.UUID] = _uuid_pk()
quote_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False)
label: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(termsheet_status_enum, nullable=False, server_default=text("'offered'"))
discount_type: Mapped[str] = mapped_column(discount_type_enum, nullable=False, server_default=text("'none'"))
discount_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False, server_default=text("0"))
created_at: Mapped[datetime] = _timestamptz()
quote: Mapped["Quote"] = relationship(back_populates="termsheets", lazy="raise")
schedule_steps: Mapped[list["TermsheetScheduleStep"]] = relationship(
back_populates="termsheet", lazy="raise", passive_deletes=True, order_by="TermsheetScheduleStep.step_no"
)
item_termsheets: Mapped[list["ItemTermsheet"]] = relationship(
back_populates="termsheet", lazy="raise", passive_deletes=True
)
class TermsheetScheduleStep(Base):
__tablename__ = "termsheet_schedule_steps"
__table_args__ = (UniqueConstraint("termsheet_id", "step_no", name="uq_termsheet_step"),)
id: Mapped[uuid.UUID] = _uuid_pk()
termsheet_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("termsheets.id", ondelete="CASCADE"), nullable=False)
step_no: Mapped[int] = mapped_column(SmallInteger, nullable=False)
due_date: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
note: Mapped[str | None] = mapped_column(Text)
termsheet: Mapped["Termsheet"] = relationship(back_populates="schedule_steps", lazy="raise")
class ItemTermsheet(Base):
"""Line-item pricing inside a termsheet."""
__tablename__ = "item_termsheets"
__table_args__ = (UniqueConstraint("line_item_id", "termsheet_id", name="uq_item_termsheet"),)
id: Mapped[uuid.UUID] = _uuid_pk()
line_item_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("line_items.id", ondelete="CASCADE"), nullable=False)
termsheet_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("termsheets.id", ondelete="CASCADE"), nullable=False)
unit_price: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
min_order_qty: Mapped[Decimal | None] = mapped_column(Numeric(18, 4))
line_item: Mapped["LineItem"] = relationship(back_populates="item_termsheets", lazy="raise")
termsheet: Mapped["Termsheet"] = relationship(back_populates="item_termsheets", lazy="raise")
class ItemTermsheetsWithTotal(Base):
"""Read-only mapping of the VIEW item_termsheets_with_total.
total_price is NEVER stored — it is computed by the view as
unit_price × line_items.quantity.
"""
__table__ = Table(
"item_termsheets_with_total",
Base.metadata,
Column("id", Uuid(as_uuid=True), primary_key=True),
Column("line_item_id", Uuid(as_uuid=True)),
Column("termsheet_id", Uuid(as_uuid=True)),
Column("unit_price", Numeric(18, 4)),
Column("min_order_qty", Numeric(18, 4)),
Column("total_price", Numeric(18, 2)),
)
class ItemDecision(Base):
"""1:1 with line_items (UNIQUE on line_item_id).
project_id: the SINGLE purchased project, picked by the procurement
manager at purchase (auto-filled when the offer had one project;
validated against line_item_projects). Warehouses of this project are
the only valid arrival destinations.
"""
__tablename__ = "item_decisions"
id: Mapped[uuid.UUID] = _uuid_pk()
line_item_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("line_items.id", ondelete="CASCADE"), nullable=False, unique=True
)
chosen_item_termsheet_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("item_termsheets.id", ondelete="SET NULL")
)
status: Mapped[str] = mapped_column(item_decision_status_enum, nullable=False, server_default=text("'pending'"))
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="SET NULL")
)
adjusted_unit_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 4))
adjusted_notes: Mapped[str | None] = mapped_column(Text)
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
line_item: Mapped["LineItem"] = relationship(back_populates="decision", lazy="raise")
project: Mapped["Project | None"] = relationship(lazy="raise")
receipt: Mapped["ItemReceipt | None"] = relationship(
back_populates="decision", uselist=False, lazy="raise", passive_deletes=True
)
class ItemReceipt(Base):
"""1 item : N receipts (PARTIAL DELIVERIES — no UNIQUE on item_decision_id).
A receipt means "the PM confirmed this delivery". received_qty is in the
line item's unit; inventory posting converts to the inventory row's unit.
"""
__tablename__ = "item_receipts"
id: Mapped[uuid.UUID] = _uuid_pk()
item_decision_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("item_decisions.id", ondelete="CASCADE"), nullable=False
)
confirmed_by: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
received_qty: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
on_time: Mapped[bool | None] = mapped_column(Boolean)
quality_note: Mapped[str | None] = mapped_column(Text)
decision: Mapped["ItemDecision"] = relationship(back_populates="receipt", lazy="raise")
allocations: Mapped[list["ItemReceiptAllocation"]] = relationship(
back_populates="receipt", lazy="raise", passive_deletes=True
)
class ItemReceiptAllocation(Base):
"""item_receipt_allocations — the PM's split of one receipt across
warehouses (typically 3). SUM(quantity) must equal the receipt's
received_qty; every warehouse belongs to the decision's project.
"""
__tablename__ = "item_receipt_allocations"
__table_args__ = (UniqueConstraint("receipt_id", "warehouse_id", name="uq_receipt_warehouse"),)
id: Mapped[uuid.UUID] = _uuid_pk()
receipt_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("item_receipts.id", ondelete="CASCADE"), nullable=False
)
warehouse_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
receipt: Mapped["ItemReceipt"] = relationship(back_populates="allocations", lazy="raise")
warehouse: Mapped["Warehouse"] = relationship(lazy="raise")
+58
View File
@@ -0,0 +1,58 @@
"""All API routers, re-exported for main.py assembly."""
from app.routers import (
attribute_definitions,
brand_materials,
brands,
category_attributes,
category_ref_units,
files,
inventory,
item_decisions,
item_receipts,
item_termsheets,
line_item_specs,
line_items,
material_categories,
materials,
pm,
projects,
quote_events,
quotes,
schedule_steps,
standard_conversions,
supplier_materials,
suppliers,
termsheets,
unit_conversions,
units,
users,
)
ROUTERS = [
units.router,
brands.router,
material_categories.router,
attribute_definitions.router,
category_attributes.router,
materials.router,
brand_materials.router,
suppliers.router,
supplier_materials.router,
projects.router,
quotes.router,
line_items.router,
line_item_specs.router,
termsheets.router,
schedule_steps.router,
item_termsheets.router,
item_decisions.router,
item_receipts.router,
quote_events.router,
files.router,
category_ref_units.router,
unit_conversions.router,
standard_conversions.router,
inventory.router,
pm.router,
users.router,
]
@@ -0,0 +1,158 @@
"""Attribute definitions (global attribute dictionary) CRUD — P2 set."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
from app.models.procurement import LineItemSpec
from app.schemas.catalog import AttributeDefinitionCreate, AttributeDefinitionUpdate
from app.services.expand_service import unit_to_dict
from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
d = {
"id": str(ad.id),
"attribute_key": ad.attribute_key,
"label": ad.label,
"data_type": ad.data_type,
"unit_id": ad.unit_id,
"enum_values": ad.enum_values,
"created_at": to_iso(ad.created_at),
}
if unit is not None:
d["unit"] = unit_to_dict(unit)
return d
@router.get("")
async def list_attribute_definitions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
q: str | None = None,
data_type: str | None = None,
unit_id: int | None = None,
sort: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(AttributeDefinition)
if q:
stmt = stmt.where(
or_(
AttributeDefinition.attribute_key.ilike(f"%{q}%"),
AttributeDefinition.label.ilike(f"%{q}%"),
)
)
if data_type:
stmt = stmt.where(AttributeDefinition.data_type == data_type)
if unit_id is not None:
stmt = stmt.where(AttributeDefinition.unit_id == unit_id)
stmt = sort_query(stmt, AttributeDefinition, sort)
items, meta = await paginate(stmt, page, page_size, db)
units = {}
if expand and "unit" in {t.strip() for t in expand.split(",")}:
unit_ids = {ad.unit_id for ad in items if ad.unit_id is not None}
if unit_ids:
rows = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
units = {u.id: u for u in rows}
return {
"data": [attr_def_to_dict(ad, units.get(ad.unit_id)) for ad in items],
"meta": meta,
}
@router.post("", status_code=201)
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
if duplicate is not None:
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
if payload.data_type == "enum" and not payload.enum_values:
raise ValidationError("برای data_type=enum باید enum_values مشخص شود", field="enum_values")
ad = AttributeDefinition(
attribute_key=payload.attribute_key,
label=payload.label,
data_type=payload.data_type,
unit_id=payload.unit_id,
enum_values=payload.enum_values,
)
db.add(ad)
await db.flush()
return {"data": attr_def_to_dict(ad)}
@router.get("/{definition_id}")
async def get_attribute_definition(
definition_id, expand: str | None = None, db: AsyncSession = Depends(get_db)
):
ad = await db.get(AttributeDefinition, definition_id)
if ad is None:
raise NotFoundError("تعریف ویژگی یافت نشد")
unit = None
if expand and "unit" in {t.strip() for t in expand.split(",")} and ad.unit_id is not None:
unit = await db.get(Unit, ad.unit_id)
return {"data": attr_def_to_dict(ad, unit)}
@router.patch("/{definition_id}")
async def update_attribute_definition(
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
):
ad = await db.get(AttributeDefinition, definition_id)
if ad is None:
raise NotFoundError("تعریف ویژگی یافت نشد")
if payload.data_type is not None and payload.data_type != ad.data_type:
# Changing data_type with existing values is a hard block (P2-9).
values = await db.scalar(
select(func.count()).select_from(MaterialAttributeValue).where(
MaterialAttributeValue.attribute_definition_id == definition_id
)
)
specs = await db.scalar(
select(func.count()).select_from(LineItemSpec).where(
LineItemSpec.attribute_definition_id == definition_id
)
)
if values or specs:
raise ConflictError("به دلیل وجود مقادیر ثبت‌شده، تغییر data_type امکان‌پذیر نیست", field="data_type")
ad.data_type = payload.data_type
if payload.data_type == "enum" and not payload.enum_values:
raise ValidationError("برای data_type=enum باید enum_values مشخص شود", field="enum_values")
if payload.label is not None:
ad.label = payload.label
if payload.unit_id is not None:
ad.unit_id = payload.unit_id
if payload.enum_values is not None:
ad.enum_values = payload.enum_values
await db.flush()
return {"data": attr_def_to_dict(ad)}
@router.delete("/{definition_id}")
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
ad = await db.get(AttributeDefinition, definition_id)
if ad is None:
raise NotFoundError("تعریف ویژگی یافت نشد")
# ON DELETE RESTRICT from material_attribute_values / line_item_specs — block if values exist.
values = await db.scalar(
select(func.count()).select_from(MaterialAttributeValue).where(
MaterialAttributeValue.attribute_definition_id == definition_id
)
)
specs = await db.scalar(
select(func.count()).select_from(LineItemSpec).where(
LineItemSpec.attribute_definition_id == definition_id
)
)
if values or specs:
raise ConflictError("این ویژگی دارای مقادیر ثبت‌شده است و قابل حذف نیست")
await db.delete(ad) # category_attributes cascade via DDL
await db.flush()
return {"data": {"id": str(definition_id), "deleted": True}}
+135
View File
@@ -0,0 +1,135 @@
"""Brand-material M:N junction CRUD. (brand_id, material_id) is UNIQUE."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material
from app.models.procurement import LineItem
from app.schemas.catalog import BrandMaterialCreate, BrandMaterialUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(bm.id),
"brand_id": str(bm.brand_id),
"material_id": str(bm.material_id),
"catalog_code": bm.catalog_code,
"is_active": bm.is_active,
"created_at": to_iso(bm.created_at),
}
if "brand" in expand and hasattr(bm, "_brand"):
d["brand"] = {"id": str(bm._brand.id), "name": bm._brand.name, "country": bm._brand.country}
if "material" in expand and hasattr(bm, "_material"):
d["material"] = {"id": str(bm._material.id), "name": bm._material.name}
return d
@router.get("")
async def list_brand_materials(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
brand_id=None,
material_id=None,
is_active: bool | None = None,
sort: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(BrandMaterial)
if brand_id:
stmt = stmt.where(BrandMaterial.brand_id == brand_id)
if material_id:
stmt = stmt.where(BrandMaterial.material_id == material_id)
if is_active is not None:
stmt = stmt.where(BrandMaterial.is_active == is_active)
stmt = sort_query(stmt, BrandMaterial, sort)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "brand" in tokens or "material" in tokens:
brand_ids = {bm.brand_id for bm in items}
material_ids = {bm.material_id for bm in items}
brands = {}
materials = {}
if brand_ids:
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
if material_ids:
materials = {
m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars()
}
for bm in items:
bm._brand = brands.get(bm.brand_id)
bm._material = materials.get(bm.material_id)
return {"data": [bm_to_dict(bm, tokens) for bm in items], "meta": meta}
@router.post("", status_code=201)
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
if await db.get(Brand, payload.brand_id) is None:
raise NotFoundError("برند یافت نشد", field="brand_id")
if await db.get(Material, payload.material_id) is None:
raise NotFoundError("متریال یافت نشد", field="material_id")
duplicate = await db.scalar(
select(BrandMaterial.id).where(
BrandMaterial.brand_id == payload.brand_id, BrandMaterial.material_id == payload.material_id
)
)
if duplicate is not None:
raise ConflictError("این ارتباط برند-متریال قبلاً ثبت شده است")
bm = BrandMaterial(
brand_id=payload.brand_id,
material_id=payload.material_id,
catalog_code=payload.catalog_code,
is_active=payload.is_active,
)
db.add(bm)
await db.flush()
return {"data": bm_to_dict(bm)}
@router.get("/{bm_id}")
async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession = Depends(get_db)):
bm = await db.get(BrandMaterial, bm_id)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "brand" in tokens:
bm._brand = await db.get(Brand, bm.brand_id)
if "material" in tokens:
bm._material = await db.get(Material, bm.material_id)
return {"data": bm_to_dict(bm, tokens)}
@router.patch("/{bm_id}")
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
bm = await db.get(BrandMaterial, bm_id)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
if payload.catalog_code is not None:
bm.catalog_code = payload.catalog_code
if payload.is_active is not None:
bm.is_active = payload.is_active
await db.flush()
return {"data": bm_to_dict(bm)}
@router.delete("/{bm_id}")
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
bm = await db.get(BrandMaterial, bm_id)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
# Recommendation (P2-16): block if line items reference it (SET NULL would lose context).
referenced = await db.scalar(
select(func.count()).select_from(LineItem).where(LineItem.brand_material_id == bm_id)
)
if referenced:
raise ConflictError("این ارتباط توسط خطوط آیتم استفاده شده و قابل حذف نیست")
await db.delete(bm)
await db.flush()
return {"data": {"id": str(bm_id), "deleted": True}}
+94
View File
@@ -0,0 +1,94 @@
"""Brands CRUD. (name, country) is UNIQUE (uq_brands_name_country)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial
from app.schemas.catalog import BrandCreate, BrandUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/brands", tags=["brands"])
def brand_to_dict(brand: Brand) -> dict:
return {
"id": str(brand.id),
"name": brand.name,
"country": brand.country,
"created_at": to_iso(brand.created_at),
}
@router.get("")
async def list_brands(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
q: str | None = None,
country: str | None = None,
sort: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(Brand)
if q:
stmt = stmt.where(Brand.name.ilike(f"%{q}%"))
if country:
stmt = stmt.where(Brand.country == country)
stmt = sort_query(stmt, Brand, sort)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [brand_to_dict(b) for b in items], "meta": meta}
@router.post("", status_code=201)
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
if any(b.country == payload.country for b in rows):
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
brand = Brand(name=payload.name, country=payload.country)
db.add(brand)
await db.flush()
return {"data": brand_to_dict(brand)}
@router.get("/{brand_id}")
async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
brand = await db.get(Brand, brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد")
return {"data": brand_to_dict(brand)}
@router.patch("/{brand_id}")
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
brand = await db.get(Brand, brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد")
new_name = payload.name if payload.name is not None else brand.name
new_country = payload.country if payload.country is not None else brand.country
rows = (await db.execute(select(Brand).where(Brand.name == new_name, Brand.id != brand_id))).scalars().all()
if any(b.country == new_country for b in rows):
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
if payload.name is not None:
brand.name = payload.name
if payload.country is not None:
brand.country = payload.country
await db.flush()
return {"data": brand_to_dict(brand)}
@router.delete("/{brand_id}")
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
brand = await db.get(Brand, brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد")
# Recommendation (b) from P2-4: block instead of silently cascading associations.
linked = await db.scalar(
select(func.count()).select_from(BrandMaterial).where(BrandMaterial.brand_id == brand_id)
)
if linked:
raise ConflictError(f"این برند با {linked} متریال مرتبط است و قابل حذف نیست")
await db.delete(brand)
await db.flush()
return {"data": {"id": str(brand_id), "deleted": True}}
+136
View File
@@ -0,0 +1,136 @@
"""Category attributes — scoped attribute definitions per category (P2 set).
Identified by composite key {category_id}/{attribute_definition_id}.
The nested `attribute` object is ALWAYS included (not expand-based).
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
router = APIRouter(
prefix="/material-categories/{category_id}/attributes",
tags=["category-attributes"],
)
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
return {
"id": str(ca.id),
"category_id": str(ca.category_id),
"attribute_definition_id": str(ca.attribute_definition_id),
"is_required": ca.is_required,
"is_filterable": ca.is_filterable,
"conditional_rules": ca.conditional_rules,
"sort_order": ca.sort_order,
"attribute": {
"attribute_key": attribute.attribute_key,
"label": attribute.label,
"data_type": attribute.data_type,
"unit_id": attribute.unit_id,
"enum_values": attribute.enum_values,
},
}
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
return category
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
return ca_to_dict(ca, attribute)
@router.get("")
async def list_category_attributes(
category_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
await _category_or_404(db, category_id)
stmt = (
select(CategoryAttribute)
.where(CategoryAttribute.category_id == category_id)
.order_by(CategoryAttribute.sort_order.asc())
)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
@router.post("", status_code=201)
async def create_category_attribute(
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
):
await _category_or_404(db, category_id)
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
duplicate = await db.scalar(
select(CategoryAttribute.id).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
)
)
if duplicate is not None:
raise ConflictError("این ویژگی قبلاً به این دسته‌بندی اضافه شده است", field="attribute_definition_id")
ca = CategoryAttribute(
category_id=category_id,
attribute_definition_id=payload.attribute_definition_id,
is_required=payload.is_required,
is_filterable=payload.is_filterable,
conditional_rules=payload.conditional_rules,
sort_order=payload.sort_order,
)
db.add(ca)
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.patch("/{attribute_definition_id}")
async def update_category_attribute(
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
if payload.is_required is not None:
ca.is_required = payload.is_required
if payload.is_filterable is not None:
ca.is_filterable = payload.is_filterable
if payload.conditional_rules is not None:
ca.conditional_rules = payload.conditional_rules
if payload.sort_order is not None:
ca.sort_order = payload.sort_order
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.delete("/{attribute_definition_id}")
async def delete_category_attribute(
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
await db.delete(ca)
await db.flush()
return {"data": {"deleted": True}}
+69
View File
@@ -0,0 +1,69 @@
"""Category reference units — units.html dashboard (P2-26/27).
One reference unit per category (UNIQUE category_id); PUT is an upsert.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
from app.schemas.catalog import CategoryReferenceUnitPut
from app.services.expand_service import category_brief
from app.utils.errors import NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(cru.id),
"category_id": str(cru.category_id),
"ref_unit": cru.ref_unit,
"updated_at": to_iso(cru.updated_at),
}
if "category" in expand and hasattr(cru, "_category"):
d["category"] = category_brief(cru._category)
return d
@router.get("")
async def list_category_reference_units(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(CategoryReferenceUnit)
if category_id:
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {cru.category_id for cru in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for cru in items:
cru._category = cats.get(cru.category_id)
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
@router.put("/{category_id}")
async def upsert_category_reference_unit(
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
if cru is None:
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
db.add(cru)
else:
cru.ref_unit = payload.ref_unit
await db.flush()
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
+151
View File
@@ -0,0 +1,151 @@
"""Invoice / proforma attachments — multipart upload, list, download, delete.
Multi-file contract (P1-28..33 extended): a quote may hold several
attachments per kind; every POST creates a new one.
- POST /quotes/{quote_id}/invoice → 201, new attachment metadata
- GET /quotes/{quote_id}/invoice → list of attachment metadata
- GET /quotes/{quote_id}/invoice/{file_id} → file bytes
- DELETE /quotes/{quote_id}/invoice/{file_id} → delete one attachment
The same four endpoints exist under /proforma. Bytes live in MinIO
(PROCUREMENT_MINIO_BUCKET); the `quote_attachments` table holds metadata.
Both invoice and proforma uploads are allowed in every quote status (the
user can attach the invoice from the edit page at any time).
"""
import uuid
from fastapi import APIRouter, Depends, File, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.procurement import Quote
from app.services import file_service
from app.utils.errors import NotFoundError, ValidationError
router = APIRouter(prefix="/quotes", tags=["files"])
ALLOWED_MIME_TYPES = {
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
}
ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
# حداکثر حجم مجاز هر فایل — ۵ مگابایت
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
return quote
def _validate_upload(file: UploadFile) -> str:
mime = (file.content_type or "").lower()
if mime not in ALLOWED_MIME_TYPES:
raise ValidationError("فرمت فایل مجاز نیست — فقط تصویر یا PDF", field="file")
name = (file.filename or "").lower()
if name and not any(name.endswith(ext) for ext in ALLOWED_EXTENSIONS):
raise ValidationError("پسوند فایل مجاز نیست", field="file")
return mime
async def _attachment_or_404(
db: AsyncSession, quote_id: uuid.UUID, attachment_id: uuid.UUID, kind: str
) -> file_service.QuoteAttachment:
"""Load an attachment and ensure it belongs to the given quote/kind."""
attachment = await file_service.get(db, attachment_id)
if attachment.quote_id != quote_id or attachment.kind != kind:
raise NotFoundError("فایل پیوست یافت نشد")
return attachment
# ---------------------------------------------------------------------------
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش)
# ---------------------------------------------------------------------------
@router.post("/{quote_id}/invoice", status_code=201)
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
mime = _validate_upload(file)
content = await file.read()
if len(content) > MAX_FILE_SIZE_BYTES:
raise ValidationError("حجم فایل از حد مجاز (۵ مگابایت) بیشتر است", field="file")
stored = await file_service.create(db, quote_id, "invoice", file.filename or "invoice", content, mime)
return {"data": stored.to_dict()}
@router.get("/{quote_id}/invoice")
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "invoice")
return {"data": [a.to_dict() for a in attachments]}
@router.get("/{quote_id}/invoice/{attachment_id}")
async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
content = await file_service.read_bytes(attachment)
return Response(
content=content,
media_type=attachment.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{attachment.file_name}"',
"X-File-Id": str(attachment.id),
},
)
@router.delete("/{quote_id}/invoice/{attachment_id}")
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
await file_service.remove(db, attachment)
return {"data": {"deleted": True}}
# ---------------------------------------------------------------------------
# Proforma — no status restriction
# ---------------------------------------------------------------------------
@router.post("/{quote_id}/proforma", status_code=201)
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
mime = _validate_upload(file)
content = await file.read()
if len(content) > MAX_FILE_SIZE_BYTES:
raise ValidationError("حجم فایل از حد مجاز (۵ مگابایت) بیشتر است", field="file")
stored = await file_service.create(db, quote_id, "proforma", file.filename or "proforma", content, mime)
return {"data": stored.to_dict()}
@router.get("/{quote_id}/proforma")
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "proforma")
return {"data": [a.to_dict() for a in attachments]}
@router.get("/{quote_id}/proforma/{attachment_id}")
async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
content = await file_service.read_bytes(attachment)
return Response(
content=content,
media_type=attachment.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{attachment.file_name}"',
"X-File-Id": str(attachment.id),
},
)
@router.delete("/{quote_id}/proforma/{attachment_id}")
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
await file_service.remove(db, attachment)
return {"data": {"deleted": True}}
+547
View File
@@ -0,0 +1,547 @@
"""Inventory router — PM (project-scoped, writable) / Admin (read-only, all projects).
Merged from the software team's shipped inventory-dashboard fork.
Authorization (v2.6):
- Primary: Casdoor JWT (Authorization: Bearer) → app.auth.get_current_user →
role from users.role, PM's project from projects.pm_user_id.
- Dev fallback: X-User-Role / X-Project-Id / X-User-Name-B64 headers
(the fork's placeholder mode — only when no Authorization header is sent).
"""
import base64
import uuid
from dataclasses import dataclass
from decimal import Decimal
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user, require_role
from app.config import settings
from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
from app.models.inventory import (
InventoryItem,
InventoryTransaction,
Warehouse,
)
from app.models.procurement import Project
from app.schemas.inventory import (
InventoryTransactionCreate,
WarehouseCreate,
WarehouseUpdate,
)
from app.services.inventory_service import (
apply_transaction,
get_warehouse_or_404,
)
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/inventory", tags=["inventory"])
ROLE_PM = "pm"
ROLE_ADMIN = "admin"
@dataclass
class Scope:
role: str
project_id: uuid.UUID | None
actor: str | None
async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db)) -> Scope:
"""Resolve the caller role/project — Casdoor JWT first, dev headers as fallback."""
# Primary path: Casdoor JWT. Role from users.role; PM project from
# projects.pm_user_id (single PM per project).
if request.headers.get("authorization"):
user: UserContext = await get_optional_user(request, db)
if user is not None and user.role is not None:
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار.
if user.role == "procurement_manager":
return Scope(role="admin", project_id=None, actor=user.display_name)
if user.role not in ("pm", "admin"):
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
if user.role == "pm":
if user.project_id is None:
raise ValidationError(
"کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id"
)
return Scope(role="pm", project_id=user.project_id, actor=user.display_name)
return Scope(role="admin", project_id=None, actor=user.display_name)
# Dev fallback: header-based identity (fork's placeholder mode).
role = (request.headers.get("x-user-role") or settings.INVENTORY_DEFAULT_ROLE or ROLE_PM).strip().lower()
if role == "procurement_manager":
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقط‌خواندنی)
if role not in (ROLE_PM, ROLE_ADMIN):
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
raw_project = request.headers.get("x-project-id") or settings.INVENTORY_DEFAULT_PROJECT_ID
project_id = None
if raw_project:
try:
project_id = uuid.UUID(str(raw_project))
except ValueError:
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
if role == ROLE_PM and project_id is None:
raise ValidationError(
"برای نقش PM هدر X-Project-Id (پروژه مسئول) الزامی است", field="X-Project-Id"
)
actor = request.headers.get("x-user-name")
b64 = request.headers.get("x-user-name-b64")
if b64:
try:
actor = base64.b64decode(b64.encode()).decode("utf-8")
except Exception:
pass
return Scope(role=role, project_id=project_id, actor=actor)
def _require_pm(scope: Scope) -> None:
if scope.role != ROLE_PM:
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
# ---------------------------------------------------------------------------
# Serialization helpers (fork contract — stable shapes)
# ---------------------------------------------------------------------------
def unit_to_dict(unit: Unit | None) -> dict | None:
if unit is None:
return None
return {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa}
def brand_to_dict(brand: Brand | None) -> dict | None:
if brand is None:
return None
return {"id": str(brand.id), "name": brand.name}
def material_to_dict(material: Material, category: MaterialCategory | None = None) -> dict:
return {
"id": str(material.id),
"name": material.name,
"category": {"id": str(category.id), "name": category.name} if category else None,
}
def warehouse_to_dict(wh: Warehouse, project: Project | None = None, items_count: int | None = None) -> dict:
return {
"id": str(wh.id),
"project_id": str(wh.project_id),
"name": wh.name,
"description": wh.description,
"created_at": to_iso(wh.created_at),
"project": {"id": str(project.id), "name": project.name} if project else None,
"items_count": items_count,
}
def inventory_to_dict(item: InventoryItem, wh: Warehouse, material: Material,
category: MaterialCategory | None, brand: Brand | None, unit: Unit | None) -> dict:
return {
"id": str(item.id),
"warehouse": warehouse_to_dict(wh),
"material": material_to_dict(material, category),
"brand": brand_to_dict(brand),
"quantity": str(item.quantity),
"quantity_number": float(item.quantity),
"unit": unit_to_dict(unit),
"specs": item.specs or {},
"updated_at": to_iso(item.updated_at),
}
def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -> dict:
return {
"id": str(tx.id),
"inventory_id": str(tx.inventory_id),
"warehouse_id": str(tx.warehouse_id),
"warehouse": {"id": str(wh.id), "name": wh.name} if wh else None,
"transaction_type": tx.transaction_type,
"quantity": str(tx.quantity),
"quantity_number": float(tx.quantity),
"recorded_qty": float(tx.recorded_qty) if tx.recorded_qty is not None else None,
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
"performed_by": tx.performed_by,
"transaction_date": to_iso(tx.transaction_date),
"note": tx.note,
}
# ---------------------------------------------------------------------------
# Warehouses
# ---------------------------------------------------------------------------
@router.get("/warehouses")
async def list_warehouses(
project_id: uuid.UUID | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(100, ge=1, le=100),
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
stmt = select(Warehouse)
if scope.role == ROLE_PM:
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
stmt = stmt.order_by(Warehouse.name)
items, meta = await paginate(stmt, page, page_size, db)
project_ids = {w.project_id for w in items}
projects = {}
if project_ids:
projects = {
p.id: p for p in (await db.execute(select(Project).where(Project.id.in_(project_ids)))).scalars()
}
counts = {}
if items:
rows = (await db.execute(
select(InventoryItem.warehouse_id, InventoryItem.id).where(
InventoryItem.warehouse_id.in_([w.id for w in items])
)
)).all()
from collections import Counter
counts = Counter(r.warehouse_id for r in rows)
return {
"data": [warehouse_to_dict(w, projects.get(w.project_id), counts.get(w.id, 0)) for w in items],
"meta": meta,
}
@router.post("/warehouses", status_code=201)
async def create_warehouse(
payload: WarehouseCreate,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه)
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
raise ValidationError("PM فقط می‌تواند برای پروژه خودش انبار بسازد", field="project_id")
if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="project_id")
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
db.add(wh)
await db.flush()
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
@router.patch("/warehouses/{warehouse_id}")
async def update_warehouse(
warehouse_id,
payload: WarehouseUpdate,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM:
if scope.project_id != wh.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.name is not None:
wh.name = payload.name
if payload.description is not None:
wh.description = payload.description
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id")
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
raise ForbiddenError("PM فقط می‌تواند انبار را در پروژه خودش جابه‌جا کند", field="project_id")
wh.project_id = payload.project_id
await db.flush()
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
@router.delete("/warehouses/{warehouse_id}")
async def delete_warehouse(
warehouse_id,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and scope.project_id != wh.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
# inventory rows cascade via DDL; transactions are RESTRICTed through
# inventory cascade — block deletion if any stock history exists.
linked = await db.scalar(
select(InventoryTransaction.id).where(InventoryTransaction.warehouse_id == warehouse_id).limit(1)
)
if linked is not None:
raise ForbiddenError("این انبار دارای سابقه تراکنش است و قابل حذف نیست")
await db.delete(wh)
await db.flush()
return {"data": {"id": str(warehouse_id), "deleted": True}}
# ---------------------------------------------------------------------------
# Inventory (current stock) — filters: project (admin), category, warehouse,
# material; quantity sort asc/desc.
# ---------------------------------------------------------------------------
@router.get("")
async def list_inventory(
project_id: uuid.UUID | None = None,
category_id=None,
warehouse_id=None,
material_id=None,
sort: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
stmt = select(InventoryItem)
if scope.role == ROLE_PM:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == scope.project_id
)
elif project_id is not None:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id
)
if category_id is not None:
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
Material.category_id == category_id
)
if warehouse_id is not None:
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
if material_id is not None:
stmt = stmt.where(InventoryItem.material_id == material_id)
stmt = sort_query(stmt, InventoryItem, sort, default="-updated_at")
items, meta = await paginate(stmt, page, page_size, db)
data = await _serialize_inventory_rows(db, items)
return {"data": data, "meta": meta}
async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]) -> list[dict]:
"""Join-related lookups in batch and serialize inventory rows."""
if not items:
return []
wh_ids = {i.warehouse_id for i in items}
mat_ids = {i.material_id for i in items}
brand_ids = {i.brand_id for i in items if i.brand_id}
unit_ids = {i.unit_id for i in items}
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
categories = {}
if materials:
cat_ids = {m.category_id for m in materials.values()}
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
brands = {}
if brand_ids:
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
units = {}
if unit_ids:
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
return [
inventory_to_dict(
i, warehouses[i.warehouse_id], materials[i.material_id],
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
)
for i in items
]
@router.get("/transactions")
async def list_transactions(
project_id: uuid.UUID | None = None,
warehouse_id=None,
transaction_type: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
"""Transaction history with filters (کنترل روزانه / history).
PM is always scoped to his own project. `date_from`/`date_to` are
ISO date strings (YYYY-MM-DD), inclusive on the date part.
"""
stmt = select(InventoryTransaction).join(
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
if scope.role == ROLE_PM:
stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
if warehouse_id is not None:
stmt = stmt.where(InventoryTransaction.warehouse_id == warehouse_id)
if transaction_type is not None:
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
if date_from is not None:
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
if date_to is not None:
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
items, meta = await paginate(stmt, page, page_size, db)
# Enrich: warehouse name + material/brand/specs/unit for the UI rows.
out = []
if items:
inv_ids = {t.inventory_id for t in items}
invs = {i.id: i for i in (await db.execute(
select(InventoryItem).where(InventoryItem.id.in_(inv_ids))
)).scalars()}
mat_ids = {i.material_id for i in invs.values()}
mats = {m.id: m for m in (await db.execute(
select(Material).where(Material.id.in_(mat_ids))
)).scalars()}
brand_ids = {i.brand_id for i in invs.values() if i.brand_id}
brands = {}
if brand_ids:
brands = {b.id: b for b in (await db.execute(
select(Brand).where(Brand.id.in_(brand_ids))
)).scalars()}
unit_ids = {i.unit_id for i in invs.values() if i.unit_id}
units = {}
if unit_ids:
units = {u.id: u for u in (await db.execute(
select(Unit).where(Unit.id.in_(unit_ids))
)).scalars()}
whs = {w.id: w for w in (await db.execute(
select(Warehouse).where(Warehouse.id.in_([t.warehouse_id for t in items]))
)).scalars()}
for t in items:
inv = invs.get(t.inventory_id)
d = transaction_to_dict(t, whs.get(t.warehouse_id))
d["material"] = {
"id": str(inv.material_id) if inv else None,
"name": mats[inv.material_id].name if inv and inv.material_id in mats else None,
} if inv else None
d["brand"] = {"id": str(inv.brand_id), "name": brands[inv.brand_id].name} if inv and inv.brand_id and inv.brand_id in brands else None
d["specs"] = (inv.specs or {}) if inv else {}
d["unit"] = unit_to_dict(units.get(inv.unit_id)) if inv and inv.unit_id in units else None
out.append(d)
return {"data": out, "meta": meta}
@router.get("/{inventory_id}/transactions")
async def inventory_history(
inventory_id,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
"""Chronological transaction history of one inventory record (timeline)."""
item = await db.get(InventoryItem, inventory_id)
if item is None:
raise NotFoundError("موجودی یافت نشد")
wh = await get_warehouse_or_404(db, item.warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
txs = (await db.execute(
select(InventoryTransaction)
.where(InventoryTransaction.inventory_id == inventory_id)
.order_by(InventoryTransaction.transaction_date, InventoryTransaction.created_at)
)).scalars().all()
return {"data": [transaction_to_dict(tx, wh) for tx in txs]}
# ---------------------------------------------------------------------------
# Transactions — creation (PM only)
# ---------------------------------------------------------------------------
@router.post("/transactions", status_code=201)
async def create_transaction(
payload: InventoryTransactionCreate,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
_require_pm(scope)
async with db.begin():
wh = await get_warehouse_or_404(db, payload.warehouse_id)
if wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.dest_warehouse_id is not None:
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
if dest.project_id != scope.project_id:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
txs = await apply_transaction(db, payload, performed_by=scope.actor)
return {"data": [transaction_to_dict(tx) for tx in txs]}
# ---------------------------------------------------------------------------
# Transaction-type helpers for the UI
# ---------------------------------------------------------------------------
@router.get("/meta/types")
async def transaction_types():
"""The exact 10 supported transaction types (no generic adjustment/other)."""
labels = {
"PURCHASE": "خرید",
"WITHDRAWAL": "برداشت",
"RETURN": "برگشت",
"TRANSFER_IN": "انتقال (ورودی)",
"TRANSFER_OUT": "انتقال (خروجی)",
"DAMAGE": "ضایعات",
"WASTE": "اتلاف",
"LOSS": "مفقودی",
"FOUND": "پیدا شده",
"STOCK_COUNT": "شمارش موجودی",
}
return {"data": [{"type": t, "label": labels[t]} for t in (
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
)]}
# ---------------------------------------------------------------------------
# Material selector — filtered by Category + Warehouse (transaction forms)
# ---------------------------------------------------------------------------
@router.get("/materials/options")
async def material_options(
category_id: uuid.UUID,
warehouse_id: uuid.UUID | None = None,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
"""Materials for the transaction form.
Always filtered by category. When `warehouse_id` is given, only materials
that already exist in that warehouse's inventory are returned (so the PM
works with the actual stock of that warehouse); otherwise the full
category list is returned (e.g. first purchase of a new material).
"""
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
if warehouse_id is not None:
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
stmt = stmt.where(
Material.id.in_(
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
)
)
stmt = stmt.order_by(Material.name)
materials = (await db.execute(stmt)).scalars().all()
# Brands per material (from brand_materials catalog + brands actually used in stock)
brand_rows = (await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
)).all()
brands_by_material: dict[str, list[dict]] = {}
for mat_id, b_id, b_name in brand_rows:
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
return {"data": [
{
"id": str(m.id),
"name": m.name,
"brands": brands_by_material.get(str(m.id), []),
}
for m in materials
]}
+138
View File
@@ -0,0 +1,138 @@
"""Item decisions — 1:1 with line items.
State machine (P1 cross-endpoint rules):
pending -> chosen (requires chosen_item_termsheet_id of the SAME line item)
pending -> rejected
chosen/rejected -> pending/anything-else = 409
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemTermsheet, LineItem
from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate
from app.services.expand_service import decision_full
from app.services.quote_service import utcnow
from app.utils.errors import ConflictError, NotFoundError, ValidationError
router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/decision",
tags=["item-decisions"],
)
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
li = await db.scalar(
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
)
if li is None:
raise NotFoundError("خط آیتم یافت نشد")
return li
async def _get_or_create_pending(db: AsyncSession, line_item_id) -> ItemDecision:
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
decision = ItemDecision(line_item_id=line_item_id, status="pending")
db.add(decision)
await db.flush()
return decision
async def _validate_chosen_link(db: AsyncSession, line_item_id, item_termsheet_id) -> None:
"""chosen_item_termsheet_id must reference an item_termsheets row of the SAME line item."""
if item_termsheet_id is None:
raise ValidationError("برای وضعیت chosen، chosen_item_termsheet_id الزامی است", field="chosen_item_termsheet_id")
it = await db.scalar(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
)
if it is None:
raise ValidationError("اتصال قیمت باید متعلق به همین خط آیتم باشد", field="chosen_item_termsheet_id")
@router.get("")
async def get_decision(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
"""Always returns a record — pending is auto-created (P0 contract)."""
await _line_item_or_404(db, quote_id, line_item_id)
decision = await _get_or_create_pending(db, line_item_id)
return {"data": decision_full(decision)}
@router.put("")
async def put_decision(
quote_id, line_item_id, payload: DecisionPut, db: AsyncSession = Depends(get_db)
):
"""UPSERT — pending -> chosen | pending -> rejected."""
created = False
async with db.begin():
await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(
select(ItemDecision).where(ItemDecision.line_item_id == line_item_id).with_for_update()
)
if decision is None:
decision = ItemDecision(line_item_id=line_item_id, status="pending")
db.add(decision)
await db.flush()
created = True
if decision.status != "pending":
raise ConflictError(
f"بازگشت از وضعیت {decision.status} به {payload.status} مجاز نیست"
)
if payload.status == "chosen":
await _validate_chosen_link(db, line_item_id, payload.chosen_item_termsheet_id)
decision.chosen_item_termsheet_id = payload.chosen_item_termsheet_id
decision.adjusted_unit_price = payload.adjusted_unit_price
decision.adjusted_notes = payload.adjusted_notes
else: # rejected
decision.chosen_item_termsheet_id = None
decision.adjusted_unit_price = None
decision.adjusted_notes = payload.adjusted_notes
decision.status = payload.status
decision.decided_at = utcnow()
return {"data": decision_full(decision), "status_code_hint": 201 if created else 200}
@router.patch("/status")
async def patch_decision_status(
quote_id, line_item_id, payload: DecisionStatusUpdate, db: AsyncSession = Depends(get_db)
):
"""Status-only transition; chosen requires chosen_item_termsheet_id already set."""
async with db.begin():
await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(
select(ItemDecision).where(ItemDecision.line_item_id == line_item_id).with_for_update()
)
if decision is None:
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
if decision.status != "pending":
raise ConflictError(
f"بازگشت از وضعیت {decision.status} به {payload.status} مجاز نیست"
)
if payload.status == "chosen" and decision.chosen_item_termsheet_id is None:
raise ValidationError(
"برای انتخاب، ابتدا باید chosen_item_termsheet_id تنظیم شود", field="chosen_item_termsheet_id"
)
decision.status = payload.status
decision.decided_at = utcnow()
return {"data": decision_full(decision)}
@router.patch("")
async def patch_decision(
quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db)
):
"""Update chosen link / adjusted fields — status is NOT changeable here."""
await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
if payload.chosen_item_termsheet_id is not None:
await _validate_chosen_link(db, line_item_id, payload.chosen_item_termsheet_id)
decision.chosen_item_termsheet_id = payload.chosen_item_termsheet_id
if payload.adjusted_unit_price is not None:
decision.adjusted_unit_price = payload.adjusted_unit_price
if payload.adjusted_notes is not None:
decision.adjusted_notes = payload.adjusted_notes
await db.flush()
return {"data": decision_full(decision)}
+83
View File
@@ -0,0 +1,83 @@
"""Item receipts — 1:1 with item_decisions. Quote-level arrival uses separate endpoints."""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
from app.services.expand_service import receipt_full
from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"],
)
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
li = await db.scalar(
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
)
if li is None:
raise NotFoundError("خط آیتم یافت نشد")
return li
async def _decision_or_404(db: AsyncSession, line_item_id) -> ItemDecision:
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
return decision
@router.get("")
async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
raise NotFoundError("رسیدی برای این خط آیتم ثبت نشده است")
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if receipt is None:
raise NotFoundError("رسیدی برای این خط آیتم ثبت نشده است")
return {"data": receipt_full(receipt)}
@router.post("", status_code=201)
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
await _line_item_or_404(db, quote_id, line_item_id)
decision = await _decision_or_404(db, line_item_id)
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if existing is not None:
raise ConflictError("رسید این خط آیتم قبلاً ثبت شده است")
receipt = ItemReceipt(
item_decision_id=decision.id,
received_at=payload.received_at,
received_qty=payload.received_qty,
on_time=payload.on_time,
quality_note=payload.quality_note,
)
db.add(receipt)
await db.flush()
return {"data": receipt_full(receipt)}
@router.patch("")
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if receipt is None:
raise NotFoundError("رسیدی برای این خط آیتم ثبت نشده است")
if payload.received_at is not None:
receipt.received_at = payload.received_at
if payload.received_qty is not None:
receipt.received_qty = payload.received_qty
if payload.on_time is not None:
receipt.on_time = payload.on_time
if payload.quality_note is not None:
receipt.quality_note = payload.quality_note
await db.flush()
return {"data": receipt_full(receipt)}
+136
View File
@@ -0,0 +1,136 @@
"""Item termsheets — pricing links between line items and termsheets.
total_price is COMPUTED (unit_price × quantity via the VIEW
item_termsheets_with_total) and never accepted from the client.
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.procurement import (
ItemDecision,
ItemTermsheet,
ItemTermsheetsWithTotal,
LineItem,
Quote,
Termsheet,
)
from app.schemas.procurement import ItemTermsheetCreate, ItemTermsheetUpdate
from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
tags=["item-termsheets"],
)
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
li = await db.scalar(
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
)
if li is None:
raise NotFoundError("خط آیتم یافت نشد")
return li
def it_to_dict(row: ItemTermsheetsWithTotal | ItemTermsheet, total_price=None) -> dict:
return {
"id": str(row.id),
"line_item_id": str(row.line_item_id),
"termsheet_id": str(row.termsheet_id),
"unit_price": float(row.unit_price),
"min_order_qty": float(row.min_order_qty) if row.min_order_qty is not None else None,
"total_price": float(total_price) if total_price is not None else None,
}
@router.get("")
async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
await _line_item_or_404(db, quote_id, line_item_id)
rows = (
await db.execute(
select(ItemTermsheetsWithTotal).where(ItemTermsheetsWithTotal.line_item_id == line_item_id)
)
).scalars().all()
return {"data": [it_to_dict(r, r.total_price) for r in rows]}
@router.post("", status_code=201)
async def create_item_termsheet(
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
):
async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await db.get(Quote, quote_id)
if quote is None or quote.status != "offered":
raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل افزودن است")
# termsheet must belong to the SAME quote (contract P0-35)
ts = await db.scalar(select(Termsheet).where(Termsheet.id == payload.termsheet_id, Termsheet.quote_id == quote_id))
if ts is None:
raise NotFoundError("ترم‌شیت یافت نشد یا متعلق به این پیشنهاد نیست", field="termsheet_id")
duplicate = await db.scalar(
select(ItemTermsheet.id).where(
ItemTermsheet.line_item_id == line_item_id, ItemTermsheet.termsheet_id == payload.termsheet_id
)
)
if duplicate is not None:
raise ConflictError("این خط آیتم قبلاً به این ترم‌شیت متصل شده است")
it = ItemTermsheet(
line_item_id=line_item_id,
termsheet_id=payload.termsheet_id,
unit_price=payload.unit_price,
min_order_qty=payload.min_order_qty,
)
db.add(it)
await db.flush()
total = it.unit_price * li.quantity
return {"data": it_to_dict(it, total)}
@router.patch("/{item_termsheet_id}")
async def update_item_termsheet(
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await db.get(Quote, quote_id)
if quote is None or quote.status != "offered":
raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل ویرایش است")
it = await db.scalar(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
)
if it is None:
raise NotFoundError("اتصال قیمت یافت نشد")
if payload.unit_price is not None:
it.unit_price = payload.unit_price
if payload.min_order_qty is not None:
it.min_order_qty = payload.min_order_qty
await db.flush()
return {"data": it_to_dict(it, it.unit_price * li.quantity)}
@router.delete("/{item_termsheet_id}")
async def delete_item_termsheet(
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
):
it = await db.scalar(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
)
if it is None:
raise NotFoundError("اتصال قیمت یافت نشد")
quote = await db.get(Quote, quote_id)
if quote is None or quote.status != "offered":
raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل حذف است")
chosen = await db.scalar(
select(ItemDecision.id).where(
ItemDecision.chosen_item_termsheet_id == item_termsheet_id, ItemDecision.status == "chosen"
).limit(1)
)
if chosen:
raise ConflictError("اتصال قیمت انتخاب‌شده قابل حذف نیست")
await db.delete(it)
await db.flush()
return {"data": {"deleted": True}}
+79
View File
@@ -0,0 +1,79 @@
"""Line item specs — frozen EAV snapshot, full-replacement PUT, editable only while offered."""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Unit
from app.models.procurement import LineItem, LineItemSpec, Quote
from app.schemas.procurement import SpecsPutIn
from app.services import material_service
from app.services.eav_service import flatten_eav_values
from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
li = await db.scalar(
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
)
if li is None:
raise NotFoundError("خط آیتم یافت نشد")
return li
async def _flatten(db: AsyncSession, li: LineItem) -> list[dict]:
rows = (
await db.execute(
select(LineItemSpec)
.where(LineItemSpec.line_item_id == li.id)
.order_by(LineItemSpec.attribute_definition_id)
)
).scalars().all()
def_ids = {row.attribute_definition_id for row in rows}
defs = {}
if def_ids:
defs = {
d.id: d
for d in (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars()
}
unit_ids = {d.unit_id for d in defs.values() if d.unit_id is not None}
unit_ids |= {row.unit_id_at_time for row in rows if row.unit_id_at_time is not None}
units = {}
if unit_ids:
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
return flatten_eav_values(rows, defs, units, include_definition_id=True)
@router.get("")
async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
li = await _line_item_or_404(db, quote_id, line_item_id)
return {"data": await _flatten(db, li)}
@router.put("")
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await db.get(Quote, quote_id)
if quote is None or quote.status != "offered":
raise ConflictError("مشخصات فقط در پیشنهادهای offered قابل ویرایش است")
seen: set = set()
for index, spec in enumerate(payload.specs):
if spec.attribute_definition_id in seen:
raise ConflictError("attribute_definition_id در درخواست تکراری است", field=f"specs[{index}]")
seen.add(spec.attribute_definition_id)
validated = await material_service.validate_attributes(db, payload.specs)
existing = (
(await db.execute(select(LineItemSpec).where(LineItemSpec.line_item_id == li.id))).scalars().all()
)
for row in existing:
await db.delete(row)
await db.flush()
for (ad, values), spec in zip(validated, payload.specs):
db.add(LineItemSpec(line_item_id=li.id, attribute_definition_id=ad.id, **values, unit_id_at_time=spec.unit_id_at_time))
return {"data": await _flatten(db, li)}
+138
View File
@@ -0,0 +1,138 @@
"""Line items nested under quotes. Editable only while the quote is offered."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Material, Unit
from app.models.procurement import ItemDecision, LineItem, Quote
from app.schemas.procurement import LineItemCreate, LineItemUpdate
from app.services.expand_service import parse_expand, serialize_line_items
from app.services.material_service import ensure_brand_material_belong
from app.services.supplier_material_service import link_supplier_material
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
return quote
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
li = await db.scalar(
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
)
if li is None:
raise NotFoundError("خط آیتم یافت نشد")
return li
@router.get("")
async def list_line_items(
quote_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
sort: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
await _quote_or_404(db, quote_id)
stmt = select(LineItem).where(LineItem.quote_id == quote_id)
stmt = sort_query(stmt, LineItem, sort, default="created_at")
items, meta = await paginate(stmt, page, page_size, db)
data = await serialize_line_items(db, items, parse_expand(expand))
return {"data": data, "meta": meta}
@router.post("", status_code=201)
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
async with db.begin():
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل افزودن است")
if await db.get(Material, payload.material_id) is None:
raise NotFoundError("متریال یافت نشد", field="material_id")
if await db.get(Unit, payload.unit_id) is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
await ensure_brand_material_belong(db, payload.brand_material_id, payload.material_id)
li = LineItem(
quote_id=quote.id,
material_id=payload.material_id,
brand_material_id=payload.brand_material_id,
unit_id=payload.unit_id,
quantity=payload.quantity,
notes=payload.notes,
)
db.add(li)
await db.flush()
# v2.5: auto-link the material to the quote's supplier (catalog edge).
await link_supplier_material(db, quote.supplier_id, payload.material_id)
# auto-create the 1:1 pending decision (contract P0-22)
db.add(ItemDecision(line_item_id=li.id, status="pending"))
return {"data": (await serialize_line_items(db, [li], set()))[0]}
@router.get("/{line_item_id}")
async def get_line_item(
quote_id, line_item_id, expand: str | None = None, db: AsyncSession = Depends(get_db)
):
li = await _line_item_or_404(db, quote_id, line_item_id)
return {"data": (await serialize_line_items(db, [li], parse_expand(expand)))[0]}
@router.patch("/{line_item_id}")
async def update_line_item(
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل ویرایش است")
new_material_id = payload.material_id if payload.material_id is not None else li.material_id
if payload.material_id is not None:
if await db.get(Material, payload.material_id) is None:
raise NotFoundError("متریال یافت نشد", field="material_id")
if payload.unit_id is not None:
if await db.get(Unit, payload.unit_id) is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
if payload.brand_material_id is not None or payload.material_id is not None:
# If only the material changed, re-validate the EXISTING brand link against it.
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
await ensure_brand_material_belong(db, effective_bm, new_material_id)
if payload.material_id is not None:
li.material_id = payload.material_id
# v2.5: keep the supplier<->material catalog edge in sync.
await link_supplier_material(db, quote.supplier_id, payload.material_id)
if payload.brand_material_id is not None:
li.brand_material_id = payload.brand_material_id
if payload.unit_id is not None:
li.unit_id = payload.unit_id
if payload.quantity is not None:
li.quantity = payload.quantity
if payload.notes is not None:
li.notes = payload.notes
await db.flush()
return {"data": (await serialize_line_items(db, [li], set()))[0]}
@router.delete("/{line_item_id}")
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل حذف است")
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
if decision is not None and decision.status == "chosen":
raise ConflictError("خط آیتم انتخاب‌شده قابل حذف نیست")
await db.delete(li) # specs / links / decision / receipt cascade via DDL
await db.flush()
return {"data": {"id": str(line_item_id), "deleted": True}}
+104
View File
@@ -0,0 +1,104 @@
"""Material categories CRUD. slug is UNIQUE; category_attributes cascade on delete."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
def category_to_dict(category: MaterialCategory) -> dict:
return {
"id": str(category.id),
"slug": category.slug,
"name": category.name,
"description": category.description,
"is_active": category.is_active,
"created_at": to_iso(category.created_at),
"updated_at": to_iso(category.updated_at),
}
@router.get("")
async def list_categories(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
q: str | None = None,
is_active: bool | None = None,
sort: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(MaterialCategory)
if q:
stmt = stmt.where(or_(MaterialCategory.name.ilike(f"%{q}%"), MaterialCategory.slug.ilike(f"%{q}%")))
if is_active is not None:
stmt = stmt.where(MaterialCategory.is_active == is_active)
stmt = sort_query(stmt, MaterialCategory, sort)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [category_to_dict(c) for c in items], "meta": meta}
@router.post("", status_code=201)
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
if duplicate is not None:
raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug")
category = MaterialCategory(
slug=payload.slug,
name=payload.name,
description=payload.description,
is_active=payload.is_active,
)
db.add(category)
await db.flush()
return {"data": category_to_dict(category)}
@router.get("/{category_id}")
async def get_category(category_id, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
return {"data": category_to_dict(category)}
@router.patch("/{category_id}")
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
if payload.slug is not None and payload.slug != category.slug:
duplicate = await db.scalar(
select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug, MaterialCategory.id != category_id)
)
if duplicate is not None:
raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug")
category.slug = payload.slug
if payload.name is not None:
category.name = payload.name
if payload.description is not None:
category.description = payload.description
if payload.is_active is not None:
category.is_active = payload.is_active
await db.flush()
return {"data": category_to_dict(category)}
@router.delete("/{category_id}")
async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
if linked:
raise ConflictError(f"این دسته‌بندی شامل {linked} متریال است و قابل حذف نیست")
await db.delete(category) # category_attributes cascade via DDL
await db.flush()
return {"data": {"id": str(category_id), "deleted": True}}
+385
View File
@@ -0,0 +1,385 @@
"""Materials router — CRITICAL: EAV flattening, M:N brands, dynamic attr_ filters.
GET /materials supports `?attr_{attribute_key}=value` dynamic EAV filters plus
`expand=category,brands,canonical_unit,attributes`. Nested sub-resources
(material_units, attribute-values) live here too (P1-34 / P2-17..22).
"""
from datetime import date
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import (
AttributeDefinition,
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialUnit,
Unit,
)
from app.models.procurement import LineItem
from app.schemas.catalog import (
MaterialAttributeValuesPut,
MaterialCreate,
MaterialUnitCreate,
MaterialUnitUpdate,
MaterialUpdate,
)
from app.services import material_service
from app.services.eav_service import flatten_eav_values
from app.services.expand_service import (
parse_expand,
serialize_material,
serialize_materials,
unit_to_dict,
)
from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"])
def _coerce_filter_value(data_type: str, raw: str):
"""Typed coercion for `attr_{key}=value` query filters."""
try:
if data_type in ("number",):
return float(raw)
if data_type in ("integer",):
return int(raw)
if data_type == "boolean":
return raw.lower() in ("true", "1")
if data_type == "date":
return date.fromisoformat(raw)
return raw # string / enum
except ValueError:
raise ValidationError("مقدار فیلتر برای ویژگی نامعتبر است", field=f"attr_{raw}")
async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
"""Apply every `attr_{key}=value` query param as a typed EAV filter."""
for key, raw_value in request.query_params.multi_items():
if not key.startswith("attr_"):
continue
attr_key = key[len("attr_"):]
definition = await db.scalar(
select(AttributeDefinition).where(AttributeDefinition.attribute_key == attr_key)
)
if definition is None:
raise ValidationError(f"ویژگی ناشناخته است: {attr_key}", field=key)
value = _coerce_filter_value(definition.data_type, raw_value)
column_name = {
"number": "value_number",
"integer": "value_number",
"string": "value_string",
"enum": "value_string",
"boolean": "value_boolean",
"date": "value_date",
}[definition.data_type]
column = getattr(MaterialAttributeValue, column_name)
sub = (
select(1)
.where(
MaterialAttributeValue.material_id == Material.id,
MaterialAttributeValue.attribute_definition_id == definition.id,
column == value,
)
)
stmt = stmt.where(exists(sub))
return stmt
# ---------------------------------------------------------------------------
# Materials CRUD
# ---------------------------------------------------------------------------
@router.get("")
async def list_materials(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand_id=None,
status: str | None = None,
q: str | None = None,
sort: str | None = None,
expand: str | None = None,
request: Request = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(Material)
if category_id:
stmt = stmt.where(Material.category_id == category_id)
if brand_id:
# WHERE EXISTS brand_materials with that brand_id
sub = select(1).where(
BrandMaterial.material_id == Material.id, BrandMaterial.brand_id == brand_id
)
stmt = stmt.where(exists(sub))
if status:
stmt = stmt.where(Material.status == status)
if q:
stmt = stmt.where(Material.name.ilike(f"%{q}%"))
stmt = await _apply_attr_filters(db, stmt, request)
stmt = sort_query(stmt, Material, sort)
items, meta = await paginate(stmt, page, page_size, db)
data = await serialize_materials(db, items, parse_expand(expand))
return {"data": data, "meta": meta}
@router.post("", status_code=201)
async def create_material(
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
):
material = await material_service.create_material(db, payload)
return {"data": await serialize_material(db, material, parse_expand(expand))}
@router.get("/{material_id}")
async def get_material(material_id, expand: str | None = None, db: AsyncSession = Depends(get_db)):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
return {"data": await serialize_material(db, material, parse_expand(expand))}
@router.patch("/{material_id}")
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
material = await material_service.update_material(db, material, payload)
return {"data": await serialize_material(db, material, parse_expand(None))}
@router.delete("/{material_id}")
async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
referenced = await db.scalar(
select(1).where(LineItem.material_id == material_id).limit(1)
)
if referenced:
raise ConflictError("این متریال در خطوط آیتم استفاده شده و قابل حذف نیست")
await db.delete(material) # attribute_values / brand_materials / material_units cascade via DDL
await db.flush()
return {"data": {"id": str(material_id), "deleted": True}}
# ---------------------------------------------------------------------------
# Material units (packaging / conversion overrides) — P1-34, P2-17..20
# ---------------------------------------------------------------------------
def material_unit_to_dict(mu: MaterialUnit, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(mu.id),
"material_id": str(mu.material_id),
"brand_material_id": str(mu.brand_material_id) if mu.brand_material_id else None,
"unit_id": mu.unit_id,
"conversion_factor": float(mu.conversion_factor),
"is_default": mu.is_default,
}
if "unit" in expand and hasattr(mu, "_unit"):
d["unit"] = unit_to_dict(mu._unit)
if "brand_material" in expand and hasattr(mu, "_brand_material"):
bm = mu._brand_material
d["brand_material"] = {
"id": str(bm.id),
"brand_id": str(bm.brand_id),
"material_id": str(bm.material_id),
"catalog_code": bm.catalog_code,
}
return d
async def _material_or_404(db: AsyncSession, material_id) -> Material:
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
return material
def _bm_cond(brand_material_id):
"""PG «IS» فقط با NULL معتبر است؛ برای مقداردار از «==» استفاده می‌شود."""
if brand_material_id is None:
return MaterialUnit.brand_material_id.is_(None)
return MaterialUnit.brand_material_id == brand_material_id
async def _set_default_unit(db: AsyncSession, material_id, brand_material_id, exclude_id) -> None:
"""If is_default=True: demote all other units in the same group."""
await db.execute(
MaterialUnit.__table__.update()
.where(
MaterialUnit.material_id == material_id,
_bm_cond(brand_material_id),
MaterialUnit.id != exclude_id,
)
.values(is_default=False)
)
@router.get("/{material_id}/units")
async def list_material_units(
material_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
brand_material_id=None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
await _material_or_404(db, material_id)
stmt = select(MaterialUnit).where(MaterialUnit.material_id == material_id)
if brand_material_id:
stmt = stmt.where(MaterialUnit.brand_material_id == brand_material_id)
items, meta = await paginate(stmt, page, page_size, db)
tokens = parse_expand(expand)
if "unit" in tokens:
unit_ids = {mu.unit_id for mu in items}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
for mu in items:
mu._unit = units.get(mu.unit_id)
if "brand_material" in tokens:
bm_ids = {mu.brand_material_id for mu in items if mu.brand_material_id}
bms = {}
if bm_ids:
bms = {b.id: b for b in (await db.execute(select(BrandMaterial).where(BrandMaterial.id.in_(bm_ids)))).scalars()}
for mu in items:
mu._brand_material = bms.get(mu.brand_material_id)
return {"data": [material_unit_to_dict(mu, tokens) for mu in items], "meta": meta}
@router.post("/{material_id}/units", status_code=201)
async def create_material_unit(
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
):
async with db.begin():
await _material_or_404(db, material_id)
if await db.get(Unit, payload.unit_id) is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
if payload.brand_material_id is not None:
bm = await db.get(BrandMaterial, payload.brand_material_id)
if bm is None:
raise NotFoundError("برند-متریال یافت نشد", field="brand_material_id")
if bm.material_id != material_id:
raise ConflictError("brand_material_id متعلق به این متریال نیست", field="brand_material_id")
# App-level uniqueness (PG treats NULL brand_material_id as distinct)
duplicate = await db.scalar(
select(MaterialUnit.id).where(
MaterialUnit.material_id == material_id,
MaterialUnit.unit_id == payload.unit_id,
_bm_cond(payload.brand_material_id),
)
)
if duplicate is not None:
raise ConflictError("این واحد قبلاً برای این متریال ثبت شده است")
if payload.is_default:
await _set_default_unit(db, material_id, payload.brand_material_id, exclude_id=None)
mu = MaterialUnit(
material_id=material_id,
brand_material_id=payload.brand_material_id,
unit_id=payload.unit_id,
conversion_factor=payload.conversion_factor,
is_default=payload.is_default,
)
db.add(mu)
await db.flush()
return {"data": material_unit_to_dict(mu)}
@router.patch("/{material_id}/units/{mu_id}")
async def update_material_unit(
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
):
async with db.begin():
mu = await db.scalar(
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
)
if mu is None:
raise NotFoundError("واحد متریال یافت نشد")
if payload.unit_id is not None and payload.unit_id != mu.unit_id:
if await db.get(Unit, payload.unit_id) is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
duplicate = await db.scalar(
select(MaterialUnit.id).where(
MaterialUnit.material_id == material_id,
MaterialUnit.unit_id == payload.unit_id,
_bm_cond(mu.brand_material_id),
MaterialUnit.id != mu.id,
)
)
if duplicate is not None:
raise ConflictError("این واحد قبلاً برای این متریال ثبت شده است")
mu.unit_id = payload.unit_id
if payload.conversion_factor is not None:
mu.conversion_factor = payload.conversion_factor
if payload.is_default is not None:
if payload.is_default:
await _set_default_unit(db, material_id, mu.brand_material_id, exclude_id=mu.id)
mu.is_default = payload.is_default
return {"data": material_unit_to_dict(mu)}
@router.delete("/{material_id}/units/{mu_id}")
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
mu = await db.scalar(
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
)
if mu is None:
raise NotFoundError("واحد متریال یافت نشد")
await db.delete(mu)
await db.flush()
return {"data": {"deleted": True}}
# ---------------------------------------------------------------------------
# Material attribute values (EAV) — P2-21/22
# ---------------------------------------------------------------------------
@router.get("/{material_id}/attribute-values")
async def get_material_attribute_values(
material_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
await _material_or_404(db, material_id)
stmt = (
select(MaterialAttributeValue)
.where(MaterialAttributeValue.material_id == material_id)
.order_by(MaterialAttributeValue.attribute_definition_id)
)
items, meta = await paginate(stmt, page, page_size, db)
def_ids = {row.attribute_definition_id for row in items}
defs = {}
if def_ids:
defs = {
d.id: d
for d in (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars()
}
unit_ids = {d.unit_id for d in defs.values() if d.unit_id is not None}
units = {}
if unit_ids:
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
data = flatten_eav_values(items, defs, units, prefer_unit_id_at_time=False, include_definition_id=True)
return {"data": data, "meta": meta}
@router.put("/{material_id}/attribute-values")
async def put_material_attribute_values(
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
):
"""FULL replacement of a material's EAV values (empty array deletes all)."""
async with db.begin():
await _material_or_404(db, material_id)
validated = await material_service.validate_attributes(db, payload.attributes)
existing = (
(await db.execute(select(MaterialAttributeValue).where(MaterialAttributeValue.material_id == material_id))).scalars().all()
)
for row in existing:
await db.delete(row)
await db.flush()
for ad, values in validated:
db.add(MaterialAttributeValue(material_id=material_id, attribute_definition_id=ad.id, **values))
return {"data": {"updated": len(validated)}}
+432
View File
@@ -0,0 +1,432 @@
"""PM arrival flow (v2.6) — on-the-way list + confirm arrival + warehouse split.
Flow: proc manager purchases (item_decisions.status='chosen' + project_id)
→ item appears on the PM dash as on-the-way → PM confirms each delivery
(POST /pm/receipts) and splits it across warehouses (free count, typically
3) → inventory posts PURCHASE transactions (idempotent via
receipt_allocation_id UNIQUE) → the procurement dashboard flips to arrived
once every chosen item is fully received.
Rules enforced here:
- Only the PM of the item's purchased project can confirm (role 'pm' +
projects.pm_user_id).
- Partial deliveries allowed (1 item : N receipts); three derived states:
on-the-way (sum=0) / partially received / arrived (sum >= ordered).
- SUM(allocations) == received_qty; every warehouse belongs to the
decision's project; quantity converts to the inventory row's unit via the
material's canonical unit (material_units) — inventory rows are stored in
the canonical unit for auto-posted purchases.
- quote.status flips to 'arrived' when ALL chosen items are fully received.
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm +
X-Project-Id) when no Authorization header — same pattern as the inventory
router.
"""
import base64
import uuid
from dataclasses import dataclass
from decimal import Decimal
from fastapi import APIRouter, Depends, Request
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
ItemReceipt,
ItemReceiptAllocation,
LineItem,
Project,
Quote,
)
from app.schemas.procurement import PMReceiptCreate
from app.services.inventory_service import canonical_specs_hash
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
from app.utils.serialization import to_iso
router = APIRouter(prefix="/pm", tags=["pm"])
NULL_BRAND_SENTINEL = "00000000-0000-0000-0000-000000000000"
@dataclass
class PMScope:
user_id: uuid.UUID | None
project_id: uuid.UUID | None
display_name: str
async def pm_scope(request: Request, db: AsyncSession = Depends(get_db)) -> PMScope:
"""Resolve the PM caller — Casdoor JWT first, dev headers as fallback."""
if request.headers.get("authorization"):
ctx: UserContext | None = await get_optional_user(request, db)
if ctx is not None:
if ctx.role != "pm":
raise ForbiddenError("این بخش فقط برای مدیر پروژه است")
if ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return PMScope(user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name)
# Dev fallback: X-User-Role: pm + X-Project-Id
role = (request.headers.get("x-user-role") or "").strip().lower()
if role != "pm":
raise ForbiddenError("این بخش فقط برای مدیر پروژه است — هدر X-User-Role: pm را بفرستید")
raw_project = request.headers.get("x-project-id")
if not raw_project:
raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است")
try:
project_id = uuid.UUID(str(raw_project))
except ValueError:
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
actor = request.headers.get("x-user-name")
b64 = request.headers.get("x-user-name-b64")
if b64:
try:
actor = base64.b64decode(b64.encode()).decode("utf-8")
except Exception:
pass
# Dev fallback needs a real users row (item_receipts.confirmed_by FK).
from app.models.procurement import User
dev = await db.scalar(select(User).where(User.casdoor_id == "dev"))
if dev is None:
dev = User(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm")
db.add(dev)
await db.flush()
return PMScope(user_id=dev.id, project_id=project_id, display_name=actor or "کاربر آزمایشی")
# ---------------------------------------------------------------------------
# Derived arrival state helpers
# ---------------------------------------------------------------------------
async def _receipted_quantities(db: AsyncSession, decision_ids: list) -> dict:
"""decision_id -> sum of confirmed received_qty (in line-item unit)."""
if not decision_ids:
return {}
rows = (
await db.execute(
select(ItemReceipt.item_decision_id, func.sum(ItemReceipt.received_qty))
.where(ItemReceipt.item_decision_id.in_(decision_ids))
.group_by(ItemReceipt.item_decision_id)
)
).all()
return {r[0]: r[1] for r in rows}
def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
received = received or Decimal("0")
if received <= 0:
return "on_the_way"
if received < ordered:
return "partially_received"
return "arrived"
# ---------------------------------------------------------------------------
# GET /pm/on-the-way — chosen items not fully received (PM's project)
# ---------------------------------------------------------------------------
@router.get("/on-the-way")
async def pm_on_the_way(
ctx: PMScope = Depends(pm_scope),
db: AsyncSession = Depends(get_db),
):
if ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
decisions = (
(
await db.execute(
select(ItemDecision, LineItem, Quote, Material)
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
.join(Quote, Quote.id == LineItem.quote_id)
.join(Material, Material.id == LineItem.material_id)
.where(
ItemDecision.status == "chosen",
ItemDecision.project_id == ctx.project_id,
)
.order_by(ItemDecision.decided_at.desc().nulls_last())
)
)
.all()
)
received_map = await _receipted_quantities(db, [d.ItemDecision.id for d in decisions])
# unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
mat_ids = {d.Material.id for d in decisions}
cats = {}
if mat_ids:
rows = (
await db.execute(
select(Material.id, MaterialCategory)
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
.where(Material.id.in_(mat_ids))
)
).all()
cats = {m_id: c for m_id, c in rows}
out = []
for d, li, q, m in decisions:
received = received_map.get(d.id)
out.append({
"item_decision_id": str(d.id),
"line_item_id": str(li.id),
"quote_id": str(q.id),
"quote_status": q.status,
"material": {"id": str(m.id), "name": m.name,
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity),
"received_qty": str(received) if received is not None else "0",
"state": arrival_state(li.quantity, received),
})
return {"data": out}
# ---------------------------------------------------------------------------
# POST /pm/receipts — confirm arrival + split into warehouses + post PURCHASE
# ---------------------------------------------------------------------------
@router.post("/receipts", status_code=201)
async def pm_confirm_receipt(
payload: PMReceiptCreate,
ctx: PMScope = Depends(pm_scope),
db: AsyncSession = Depends(get_db),
):
if ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
async with db.begin():
# --- decision belongs to the PM's project ---
decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen":
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
if decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
li = await db.get(LineItem, decision.line_item_id)
quote = await db.get(Quote, li.quote_id)
material = await db.get(Material, li.material_id)
# --- allocations sum + warehouse ownership ---
total_alloc = sum((a.quantity for a in payload.allocations), Decimal("0"))
if total_alloc != payload.received_qty:
raise ValidationError(
f"مجموع تخصیص انبارها ({total_alloc}) باید با مقدار رسید ({payload.received_qty}) برابر باشد",
field="allocations",
)
wh_ids = {a.warehouse_id for a in payload.allocations}
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
for a in payload.allocations:
wh = warehouses.get(a.warehouse_id)
if wh is None:
raise NotFoundError("انبار یافت نشد", field="allocations.warehouse_id")
if wh.project_id != ctx.project_id:
raise ForbiddenError("انبار انتخاب‌شده متعلق به پروژه شما نیست", field="allocations.warehouse_id")
# --- create the receipt (1:N partial deliveries allowed) ---
receipt = ItemReceipt(
item_decision_id=decision.id,
confirmed_by=ctx.user_id,
received_at=payload.received_at,
received_qty=payload.received_qty,
on_time=payload.on_time,
quality_note=payload.quality_note,
)
db.add(receipt)
await db.flush()
# --- unit conversion: line-item unit -> material canonical unit ---
canonical_unit_id = material.canonical_unit_id
factor = Decimal("1")
if li.unit_id != canonical_unit_id:
from app.models.catalog import MaterialUnit
mu = await db.scalar(
select(MaterialUnit).where(
MaterialUnit.material_id == material.id,
MaterialUnit.unit_id == li.unit_id,
MaterialUnit.brand_material_id.is_(None),
)
)
if mu is None:
raise ValidationError(
"واحد قلم به واحد مرجع متریال قابل تبدیل نیست — ابتدا material_units تعریف کنید",
field="allocations.quantity",
)
factor = mu.conversion_factor
# --- brand + specs for the inventory row ---
brand_id = None
if li.brand_material_id:
bm = await db.get(BrandMaterial, li.brand_material_id)
brand_id = bm.brand_id if bm else None
specs = {}
from app.models.catalog import MaterialAttributeValue
mavs = (
await db.execute(
select(MaterialAttributeValue).where(MaterialAttributeValue.material_id == material.id)
)
).scalars().all()
for mav in mavs:
from app.models.catalog import AttributeDefinition
ad = await db.get(AttributeDefinition, mav.attribute_definition_id)
if ad:
key = ad.attribute_key
if mav.value_string is not None:
specs[key] = mav.value_string
elif mav.value_number is not None:
specs[key] = str(mav.value_number)
elif mav.value_boolean is not None:
specs[key] = mav.value_boolean
elif mav.value_date is not None:
specs[key] = mav.value_date.isoformat()
specs_hash = canonical_specs_hash(specs)
# --- post one PURCHASE transaction per allocation (idempotent) ---
posted = []
for a in payload.allocations:
qty_canonical = a.quantity * factor
inv = await db.scalar(
select(InventoryItem).where(
InventoryItem.warehouse_id == a.warehouse_id,
InventoryItem.material_id == material.id,
InventoryItem.brand_id.is_(None) if brand_id is None else InventoryItem.brand_id == brand_id,
InventoryItem.specs_hash == specs_hash,
).with_for_update()
)
if inv is None:
inv = InventoryItem(
warehouse_id=a.warehouse_id,
material_id=material.id,
brand_id=brand_id,
quantity=Decimal("0"),
unit_id=canonical_unit_id,
specs=specs,
specs_hash=specs_hash,
)
db.add(inv)
await db.flush()
else:
if inv.unit_id != canonical_unit_id:
raise ValidationError(
f"واحد موجودی این ردیف ({inv.unit_id}) با واحد مرجع متریال متفاوت است",
field="allocations.quantity",
)
inv.quantity += qty_canonical
tx = InventoryTransaction(
inventory_id=inv.id,
warehouse_id=a.warehouse_id,
receipt_allocation_id=None, # set after allocation flush below
transaction_type="PURCHASE",
quantity=qty_canonical,
performed_by=ctx.display_name,
note=f"رسید {receipt.id}",
)
db.add(tx)
await db.flush()
posted.append(tx)
# --- link the transactions to the allocations (idempotency key) ---
for a, tx in zip(payload.allocations, posted):
alloc = ItemReceiptAllocation(
receipt_id=receipt.id,
warehouse_id=a.warehouse_id,
quantity=a.quantity,
)
db.add(alloc)
await db.flush()
tx.receipt_allocation_id = alloc.id
# --- auto-flip the quote to arrived when all chosen items are in ---
chosen_ids = (
(
await db.execute(
select(ItemDecision.id).where(
ItemDecision.line_item_id.in_(
select(LineItem.id).where(LineItem.quote_id == quote.id)
),
ItemDecision.status == "chosen",
)
)
)
.scalars()
.all()
)
received_map = await _receipted_quantities(db, chosen_ids)
all_arrived = True
for d_id in chosen_ids:
d = await db.get(ItemDecision, d_id)
li_d = await db.get(LineItem, d.line_item_id)
if arrival_state(li_d.quantity, received_map.get(d_id)) != "arrived":
all_arrived = False
break
if all_arrived and quote.status not in ("arrived", "rejected", "expired"):
quote.status = "arrived"
quote.arrived_date = payload.received_at
return {"data": {
"receipt_id": str(receipt.id),
"state": "arrived",
"posted_transactions": [str(tx.id) for tx in posted],
}}
# ---------------------------------------------------------------------------
# GET /pm/receipts — delivery history for the PM's project
# ---------------------------------------------------------------------------
@router.get("/receipts")
async def pm_receipts(
ctx: PMScope = Depends(pm_scope),
db: AsyncSession = Depends(get_db),
):
if ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
rows = (
(
await db.execute(
select(ItemReceipt, ItemDecision, LineItem, Material)
.join(ItemDecision, ItemDecision.id == ItemReceipt.item_decision_id)
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
.join(Material, Material.id == LineItem.material_id)
.where(ItemDecision.project_id == ctx.project_id)
.order_by(ItemReceipt.received_at.desc())
)
)
.all()
)
receipts = [r.ItemReceipt for r in rows]
alloc_rows = (
(
await db.execute(
select(ItemReceiptAllocation, Warehouse)
.join(Warehouse, Warehouse.id == ItemReceiptAllocation.warehouse_id)
.where(ItemReceiptAllocation.receipt_id.in_([r.id for r in receipts]) if receipts else False)
)
)
.all()
)
allocs_by_receipt: dict = {}
for alloc, wh in alloc_rows:
allocs_by_receipt.setdefault(str(alloc.receipt_id), []).append(
{"warehouse_id": str(wh.id), "warehouse_name": wh.name, "quantity": str(alloc.quantity)}
)
return {"data": [
{
"receipt_id": str(r.ItemReceipt.id),
"line_item_id": str(r.LineItem.id),
"material": {"id": str(r.Material.id), "name": r.Material.name},
"received_at": to_iso(r.ItemReceipt.received_at),
"received_qty": str(r.ItemReceipt.received_qty),
"on_time": r.ItemReceipt.on_time,
"quality_note": r.ItemReceipt.quality_note,
"allocations": allocs_by_receipt.get(str(r.ItemReceipt.id), []),
}
for r in rows
]}
+124
View File
@@ -0,0 +1,124 @@
"""Projects CRUD + per-project stats."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate
from app.services.quote_service import project_stats
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_date, to_iso
router = APIRouter(prefix="/projects", tags=["projects"])
async def _pm_map(db: AsyncSession, projects: list[Project]) -> dict:
"""project_id -> pm brief (v2.6: the single PM of the project)."""
pm_ids = {p.pm_user_id for p in projects if p.pm_user_id}
if not pm_ids:
return {}
users = (await db.execute(select(User).where(User.id.in_(pm_ids)))).scalars().all()
return {u.id: u for u in users}
async def project_to_dict(db: AsyncSession, project: Project) -> dict:
d = {
"id": str(project.id),
"name": project.name,
"description": project.description,
"start_date": to_date(project.start_date),
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
"created_at": to_iso(project.created_at),
}
if project.pm_user_id:
pm = await db.get(User, project.pm_user_id)
if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
return d
@router.get("")
async def list_projects(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
q: str | None = None,
sort: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(Project)
if q:
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
stmt = sort_query(stmt, Project, sort)
items, meta = await paginate(stmt, page, page_size, db)
pm_map = await _pm_map(db, items)
data = []
for p in items:
d = {
"id": str(p.id),
"name": p.name,
"description": p.description,
"start_date": to_date(p.start_date),
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
"created_at": to_iso(p.created_at),
}
pm = pm_map.get(p.pm_user_id)
if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
data.append(d)
return {"data": data, "meta": meta}
@router.post("", status_code=201)
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
project = Project(
name=payload.name,
description=payload.description,
start_date=payload.start_date,
)
db.add(project)
await db.flush()
return {"data": await project_to_dict(db, project)}
@router.get("/{project_id}")
async def get_project(project_id, db: AsyncSession = Depends(get_db)):
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
return {"data": await project_to_dict(db, project)}
@router.patch("/{project_id}")
async def update_project(project_id, payload: ProjectUpdate, db: AsyncSession = Depends(get_db)):
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
if payload.name is not None:
project.name = payload.name
if payload.description is not None:
project.description = payload.description
if payload.start_date is not None:
project.start_date = payload.start_date
await db.flush()
return {"data": await project_to_dict(db, project)}
@router.delete("/{project_id}")
async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
# Recommendation (P2-24): block instead of silently unlinking quotes (SET NULL).
linked = await db.scalar(select(func.count()).select_from(Quote).where(Quote.project_id == project_id))
if linked:
raise ConflictError("این پروژه دارای پیشنهاد است و قابل حذف نیست")
await db.delete(project)
await db.flush()
return {"data": {"id": str(project_id), "deleted": True}}
@router.get("/{project_id}/stats")
async def get_project_stats(project_id, db: AsyncSession = Depends(get_db)):
return {"data": await project_stats(db, project_id)}
+51
View File
@@ -0,0 +1,51 @@
"""Quote events — audit timeline. Manual creation is restricted to event_type=note."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.procurement import Quote, QuoteEvent
from app.schemas.procurement import NoteEventIn
from app.services.expand_service import event_to_dict
from app.utils.errors import NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
@router.get("")
async def list_events(
quote_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
event_type: str | None = None,
sort: str | None = None,
db: AsyncSession = Depends(get_db),
):
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
stmt = select(QuoteEvent).where(QuoteEvent.quote_id == quote_id)
if event_type:
stmt = stmt.where(QuoteEvent.event_type == event_type)
stmt = sort_query(stmt, QuoteEvent, sort, default="occurred_at") # contract default: ASC
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [event_to_dict(e) for e in items], "meta": meta}
@router.post("", status_code=201)
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if payload.event_type != "note":
raise ValidationError("فقط رویدادهای note به صورت دستی ثبت می‌شوند", field="event_type")
event = QuoteEvent(
quote_id=quote.id,
event_type="note",
payload=payload.payload or {},
actor=payload.actor,
)
db.add(event)
await db.flush()
return {"data": event_to_dict(event)}
+270
View File
@@ -0,0 +1,270 @@
"""Quotes router — the dashboard ledger + full lifecycle.
⚠️ Route ordering: GET /quotes/stats is registered BEFORE /quotes/{quote_id}
(contract P0-38), otherwise FastAPI would capture "stats" as a UUID path.
"""
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Material
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet
from app.schemas.procurement import (
AcceptQuoteIn,
ArriveItemsIn,
QuoteChildrenReplaceIn,
QuoteCreate,
QuoteUpdate,
RejectQuoteIn,
)
from app.services import quote_service
from app.services.expand_service import (
decision_brief,
parse_expand,
serialize_quote,
serialize_quote_with_children,
serialize_quotes,
)
from app.utils.errors import NotFoundError
from app.utils.pagination import sort_query
from app.utils.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"])
# --- list filter helpers ---------------------------------------------------
def _quote_category_exists(category_id) -> exists:
return exists(
select(1).where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
)
)
def _quote_search(q: str) -> exists:
like = f"%{q}%"
return or_(
Quote.notes.ilike(like),
exists(select(1).where(Supplier.id == Quote.supplier_id, Supplier.name.ilike(like))),
exists(select(1).where(Project.id == Quote.project_id, Project.name.ilike(like))),
exists(
select(1).where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.name.ilike(like),
)
),
)
# ---------------------------------------------------------------------------
# GET /quotes/stats — MUST be declared before GET /quotes/{quote_id}
# ---------------------------------------------------------------------------
@router.get("/stats")
async def get_quote_stats(
supplier_id=None,
project_id=None,
quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None,
category_id=None,
db: AsyncSession = Depends(get_db),
):
return {
"data": await quote_service.quote_stats(
db,
supplier_id=supplier_id,
project_id=project_id,
quote_date_from=quote_date_from,
quote_date_to=quote_date_to,
category_id=category_id,
)
}
# ---------------------------------------------------------------------------
# List / single
# ---------------------------------------------------------------------------
@router.get("")
async def list_quotes(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str | None = None,
supplier_id=None,
project_id=None,
category_id=None,
quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None,
q: str | None = None,
sort: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(Quote)
if status:
stmt = stmt.where(Quote.status == status)
if supplier_id:
stmt = stmt.where(Quote.supplier_id == supplier_id)
if project_id:
stmt = stmt.where(Quote.project_id == project_id)
if category_id:
stmt = stmt.where(_quote_category_exists(category_id))
if quote_date_from is not None:
stmt = stmt.where(Quote.quote_date >= quote_date_from)
if quote_date_to is not None:
stmt = stmt.where(Quote.quote_date <= quote_date_to)
if q:
stmt = stmt.where(_quote_search(q))
stmt = sort_query(stmt, Quote, sort, default="-quote_date")
items, meta = await _paginate_quotes(db, stmt, page, page_size)
return {"data": await serialize_quotes(db, items, parse_expand(expand)), "meta": meta}
async def _paginate_quotes(db: AsyncSession, stmt, page: int, page_size: int):
total = await db.scalar(select(func.count()).select_from(stmt.subquery()))
items = (await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars().all()
return items, {"total": total or 0, "page": page, "page_size": page_size}
@router.get("/{quote_id}")
async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depends(get_db)):
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
return {"data": await serialize_quote(db, quote, parse_expand(expand))}
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
@router.post("", status_code=201)
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)}
# ---------------------------------------------------------------------------
# Edit (metadata + backward transitions)
# ---------------------------------------------------------------------------
@router.patch("/{quote_id}")
async def update_quote(
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
):
quote = await quote_service.update_quote(db, quote_id, payload, actor)
return {"data": await serialize_quote(db, quote, set())}
@router.patch("/{quote_id}/children")
async def replace_quote_children(
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
):
"""Full replacement of a quote's line items + termsheets (edit modal)."""
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)}
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@router.patch("/{quote_id}/accept")
async def accept_quote(
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
):
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
return {"data": await _accept_response(db, quote)}
async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
"""Contract P0-18 response shape: id/status/accepted_date + decisions + termsheet statuses."""
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
li_ids = [li.id for li in line_items]
decisions: dict = {}
if li_ids:
decisions = {
d.line_item_id: d
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars()
}
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
return {
"id": str(quote.id),
"status": quote.status,
"accepted_date": quote.accepted_date.isoformat() if quote.accepted_date else None,
"line_items": [
{
"id": str(li.id),
"decision": decision_brief(decisions[li.id]) if li.id in decisions else None,
}
for li in line_items
],
"termsheets": [
{"id": str(ts.id), "status": ts.status}
for ts in sorted(termsheets, key=lambda t: t.created_at)
],
}
@router.patch("/{quote_id}/arrive")
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
quote = await quote_service.arrive_quote(db, quote_id, actor)
return {
"data": {
"id": str(quote.id),
"status": quote.status,
"arrived_date": quote.arrived_date.isoformat() if quote.arrived_date else None,
}
}
@router.patch("/{quote_id}/arrive-items")
async def arrive_items(
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
):
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
return {
"data": {
"id": str(quote.id),
"status": quote.status,
"arrived_date": quote.arrived_date.isoformat() if quote.arrived_date else None,
"items": [
{
"line_item_id": str(item.line_item_id),
"received": True,
"received_qty": float(item.received_qty),
}
for item in payload.items
],
}
}
@router.patch("/{quote_id}/reject")
async def reject_quote(
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
):
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
return {"data": {"id": str(quote.id), "status": quote.status}}
@router.patch("/{quote_id}/expire")
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
quote = await quote_service.expire_quote(db, quote_id, actor)
return {"data": {"id": str(quote.id), "status": quote.status}}
# ---------------------------------------------------------------------------
# Delete (only offered / rejected / expired)
# ---------------------------------------------------------------------------
@router.delete("/{quote_id}")
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
result = await quote_service.delete_quote(db, quote_id)
return {"data": result}
+123
View File
@@ -0,0 +1,123 @@
"""Schedule steps nested under termsheets. step_no auto-numbers (MAX+1)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.procurement import Termsheet, TermsheetScheduleStep
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
from app.services.expand_service import schedule_step_to_dict
from app.services.quote_service import next_step_no
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
router = APIRouter(
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
tags=["schedule-steps"],
)
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
ts = await db.scalar(
select(Termsheet).where(Termsheet.id == termsheet_id, Termsheet.quote_id == quote_id)
)
if ts is None:
raise NotFoundError("ترم‌شیت یافت نشد")
return ts
async def _step_or_404(db: AsyncSession, termsheet_id, step_id) -> TermsheetScheduleStep:
step = await db.scalar(
select(TermsheetScheduleStep).where(
TermsheetScheduleStep.id == step_id, TermsheetScheduleStep.termsheet_id == termsheet_id
)
)
if step is None:
raise NotFoundError("مرحله پرداخت یافت نشد")
return step
@router.get("")
async def list_schedule_steps(
quote_id,
termsheet_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
await _termsheet_or_404(db, quote_id, termsheet_id)
stmt = (
select(TermsheetScheduleStep)
.where(TermsheetScheduleStep.termsheet_id == termsheet_id)
.order_by(TermsheetScheduleStep.step_no.asc())
)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [schedule_step_to_dict(s) for s in items], "meta": meta}
@router.post("", status_code=201)
async def create_schedule_step(
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
step_no = payload.step_no
if step_no is None:
step_no = await next_step_no(db, ts.id)
duplicate = await db.scalar(
select(TermsheetScheduleStep.id).where(
TermsheetScheduleStep.termsheet_id == ts.id, TermsheetScheduleStep.step_no == step_no
)
)
if duplicate is not None:
raise ConflictError("شماره مرحله تکراری است", field="step_no")
step = TermsheetScheduleStep(
termsheet_id=ts.id,
step_no=step_no,
due_date=payload.due_date,
amount=payload.amount,
note=payload.note,
)
db.add(step)
await db.flush()
return {"data": schedule_step_to_dict(step)}
@router.patch("/{step_id}")
async def update_schedule_step(
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
):
step = await _step_or_404(db, termsheet_id, step_id)
if payload.step_no is not None and payload.step_no != step.step_no:
duplicate = await db.scalar(
select(TermsheetScheduleStep.id).where(
TermsheetScheduleStep.termsheet_id == termsheet_id,
TermsheetScheduleStep.step_no == payload.step_no,
TermsheetScheduleStep.id != step_id,
)
)
if duplicate is not None:
raise ConflictError("شماره مرحله تکراری است", field="step_no")
step.step_no = payload.step_no
if payload.due_date is not None:
step.due_date = payload.due_date
if payload.amount is not None:
step.amount = payload.amount
if payload.note is not None:
step.note = payload.note
await db.flush()
return {"data": schedule_step_to_dict(step)}
@router.delete("/{step_id}")
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
step = await _step_or_404(db, termsheet_id, step_id)
count = await db.scalar(
select(func.count()).select_from(TermsheetScheduleStep).where(
TermsheetScheduleStep.termsheet_id == termsheet_id
)
)
if count <= 1:
raise ConflictError("آخرین مرحله پرداخت قابل حذف نیست")
await db.delete(step)
await db.flush()
return {"data": {"deleted": True}}
+103
View File
@@ -0,0 +1,103 @@
"""Standard conversions — global, applies to all categories (P2-32..35)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import StandardConversion
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
def sc_to_dict(sc: StandardConversion) -> dict:
return {
"id": str(sc.id),
"from_unit": sc.from_unit,
"to_unit": sc.to_unit,
"factor": float(sc.factor),
"updated_at": to_iso(sc.updated_at),
}
@router.get("")
async def list_standard_conversions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
from_unit: str | None = None,
to_unit: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(StandardConversion)
if from_unit:
stmt = stmt.where(StandardConversion.from_unit == from_unit)
if to_unit:
stmt = stmt.where(StandardConversion.to_unit == to_unit)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [sc_to_dict(sc) for sc in items], "meta": meta}
@router.post("", status_code=201)
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
duplicate = await db.scalar(
select(StandardConversion.id).where(
StandardConversion.from_unit == payload.from_unit,
StandardConversion.to_unit == payload.to_unit,
)
)
if duplicate is not None:
raise ConflictError("این تبدیل استاندارد قبلاً ثبت شده است")
sc = StandardConversion(
from_unit=payload.from_unit, to_unit=payload.to_unit, factor=payload.factor
)
db.add(sc)
await db.flush()
return {"data": sc_to_dict(sc)}
@router.get("/{sc_id}")
async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
sc = await db.get(StandardConversion, sc_id)
if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد")
return {"data": sc_to_dict(sc)}
@router.patch("/{sc_id}")
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
sc = await db.get(StandardConversion, sc_id)
if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد")
new_from = payload.from_unit if payload.from_unit is not None else sc.from_unit
new_to = payload.to_unit if payload.to_unit is not None else sc.to_unit
if payload.from_unit is not None or payload.to_unit is not None:
duplicate = await db.scalar(
select(StandardConversion.id).where(
StandardConversion.from_unit == new_from,
StandardConversion.to_unit == new_to,
StandardConversion.id != sc_id,
)
)
if duplicate is not None:
raise ConflictError("این تبدیل استاندارد قبلاً ثبت شده است")
if payload.from_unit is not None:
sc.from_unit = payload.from_unit
if payload.to_unit is not None:
sc.to_unit = payload.to_unit
if payload.factor is not None:
sc.factor = payload.factor
await db.flush()
return {"data": sc_to_dict(sc)}
@router.delete("/{sc_id}")
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
sc = await db.get(StandardConversion, sc_id)
if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد")
await db.delete(sc)
await db.flush()
return {"data": {"deleted": True}}
+104
View File
@@ -0,0 +1,104 @@
"""Supplier <-> Material catalog links (v2.5).
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
DELETE /suppliers/{id}/materials/{material_id} — unlink.
GET /materials/{id}/suppliers — price-comparison data source.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory
from app.models.procurement import Supplier, SupplierMaterial
from app.schemas.procurement import SupplierMaterialCreate
from app.services.supplier_material_service import link_supplier_material, unlink_supplier_material
from app.utils.errors import NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"])
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
category: MaterialCategory | None = None,
supplier: Supplier | None = None) -> dict:
return {
"id": str(link.id),
"supplier_id": str(link.supplier_id),
"material_id": str(link.material_id),
"is_active": link.is_active,
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
"created_at": to_iso(link.created_at),
"material": {
"id": str(material.id),
"name": material.name,
"category": {"id": str(category.id), "name": category.name} if category else None,
} if material else None,
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
}
@router.get("/suppliers/{supplier_id}/materials")
async def list_supplier_materials(
supplier_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد")
stmt = (
select(SupplierMaterial)
.where(SupplierMaterial.supplier_id == supplier_id)
.order_by(SupplierMaterial.created_at)
)
items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
cat_ids = {m.category_id for m in materials.values() if m.category_id}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material(
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
):
async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id)
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None
return {"data": link_to_dict(link, material, category)}
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id)
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
@router.get("/materials/{material_id}/suppliers")
async def list_material_suppliers(
material_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
stmt = (
select(SupplierMaterial)
.where(SupplierMaterial.material_id == material_id)
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
)
items, meta = await paginate(stmt, page, page_size, db)
sup_ids = {l.supplier_id for l in items}
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
+133
View File
@@ -0,0 +1,133 @@
"""Suppliers CRUD — v2.6: 1 supplier → exactly 1 category (category_id FK)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import MaterialCategory
from app.models.procurement import Quote, Supplier
from app.schemas.procurement import SupplierCreate, SupplierUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
return {
"id": str(supplier.id),
"name": supplier.name,
"category_id": str(supplier.category_id),
"category": {
"id": str(category.id),
"slug": category.slug,
"name": category.name,
} if category else None,
"location": supplier.location,
"phone": supplier.phone,
"contact_person": supplier.contact_person,
"tax_id": supplier.tax_id,
"created_at": to_iso(supplier.created_at),
}
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
return category
@router.get("")
async def list_suppliers(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
q: str | None = None,
category_id=None,
sort: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(Supplier)
if q:
stmt = stmt.where(
or_(
Supplier.name.ilike(f"%{q}%"),
Supplier.location.ilike(f"%{q}%"),
Supplier.phone.ilike(f"%{q}%"),
)
)
if category_id:
stmt = stmt.where(Supplier.category_id == category_id)
stmt = sort_query(stmt, Supplier, sort)
items, meta = await paginate(stmt, page, page_size, db)
cat_ids = {s.category_id for s in items}
cats = {}
if cat_ids:
cats = {
c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()
}
return {"data": [supplier_to_dict(s, cats.get(s.category_id)) for s in items], "meta": meta}
@router.post("", status_code=201)
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
category = await _category_or_404(db, payload.category_id)
supplier = Supplier(
name=payload.name,
category_id=payload.category_id,
location=payload.location,
phone=payload.phone,
contact_person=payload.contact_person,
tax_id=payload.tax_id,
)
db.add(supplier)
await db.flush()
return {"data": supplier_to_dict(supplier, category)}
@router.get("/{supplier_id}")
async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد")
category = await db.get(MaterialCategory, supplier.category_id)
return {"data": supplier_to_dict(supplier, category)}
@router.patch("/{supplier_id}")
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد")
if payload.name is not None:
supplier.name = payload.name
if payload.category_id is not None:
if payload.category_id != supplier.category_id:
await _category_or_404(db, payload.category_id)
supplier.category_id = payload.category_id
if payload.location is not None:
supplier.location = payload.location
if payload.phone is not None:
supplier.phone = payload.phone
if payload.contact_person is not None:
supplier.contact_person = payload.contact_person
if payload.tax_id is not None:
supplier.tax_id = payload.tax_id
await db.flush()
category = await db.get(MaterialCategory, supplier.category_id)
return {"data": supplier_to_dict(supplier, category)}
@router.delete("/{supplier_id}")
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد")
# quotes.supplier_id is NOT NULL RESTRICT — block if any quote exists.
linked = await db.scalar(select(func.count()).select_from(Quote).where(Quote.supplier_id == supplier_id))
if linked:
raise ConflictError("این تأمین‌کننده دارای پیشنهاد است و قابل حذف نیست")
await db.delete(supplier) # supplier_materials cascade via DDL
await db.flush()
return {"data": {"id": str(supplier_id), "deleted": True}}
+160
View File
@@ -0,0 +1,160 @@
"""Termsheets nested under quotes. Unlimited per quote (constraint is per line item)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.procurement import (
ItemDecision,
ItemTermsheet,
Quote,
Termsheet,
TermsheetScheduleStep,
)
from app.schemas.procurement import (
TermsheetCreate,
TermsheetStatusUpdate,
TermsheetUpdate,
)
from app.services.expand_service import (
parse_expand,
serialize_termsheet,
serialize_termsheets,
)
from app.services.quote_service import _add_event, next_step_no
from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
return quote
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
ts = await db.scalar(
select(Termsheet).where(Termsheet.id == termsheet_id, Termsheet.quote_id == quote_id)
)
if ts is None:
raise NotFoundError("ترم‌شیت یافت نشد")
return ts
async def _insert_steps(db: AsyncSession, termsheet_id, steps) -> None:
"""Insert steps with auto-numbered step_no (MAX+1) when omitted."""
next_no = await next_step_no(db, termsheet_id)
for index, step in enumerate(steps):
step_no = step.step_no if step.step_no is not None else next_no + index
db.add(
TermsheetScheduleStep(
termsheet_id=termsheet_id,
step_no=step_no,
due_date=step.due_date,
amount=step.amount,
note=step.note,
)
)
@router.get("")
async def list_termsheets(
quote_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str | None = None,
sort: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
await _quote_or_404(db, quote_id)
stmt = select(Termsheet).where(Termsheet.quote_id == quote_id)
if status:
stmt = stmt.where(Termsheet.status == status)
stmt = sort_query(stmt, Termsheet, sort)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": await serialize_termsheets(db, items, parse_expand(expand)), "meta": meta}
@router.post("", status_code=201)
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
async with db.begin():
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError("ترم‌شیت فقط در پیشنهادهای offered قابل افزودن است")
if not payload.schedule_steps:
raise ValidationError("ترم‌شیت باید حداقل یک مرحله پرداخت داشته باشد", field="schedule_steps")
ts = Termsheet(
quote_id=quote.id,
label=payload.label,
status="offered",
discount_type=payload.discount_type,
discount_value=payload.discount_value,
)
db.add(ts)
await db.flush()
await _insert_steps(db, ts.id, payload.schedule_steps)
return {"data": await serialize_termsheet(db, ts, set())}
@router.get("/{termsheet_id}")
async def get_termsheet(
quote_id, termsheet_id, expand: str | None = None, db: AsyncSession = Depends(get_db)
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
return {"data": await serialize_termsheet(db, ts, parse_expand(expand))}
@router.patch("/{termsheet_id}")
async def update_termsheet(
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
if ts.status != "offered":
raise ConflictError("ترم‌شیت‌های پذیرفته‌شده یا ردشده قابل ویرایش نیستند")
if payload.label is not None:
ts.label = payload.label
if payload.discount_type is not None:
ts.discount_type = payload.discount_type
if payload.discount_value is not None:
ts.discount_value = payload.discount_value
await db.flush()
return {"data": await serialize_termsheet(db, ts, set())}
@router.patch("/{termsheet_id}/status")
async def update_termsheet_status(
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
):
async with db.begin():
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
if ts.status != "offered":
raise ConflictError("فقط ترم‌شیت‌های offered تغییر وضعیت می‌دهند")
ts.status = payload.status # offered -> accepted | offered -> rejected (multiple OK)
_add_event(db, quote_id, "edited", {"action": "termsheet_status", "termsheet_id": str(ts.id), "status": ts.status})
return {"data": await serialize_termsheet(db, ts, set())}
@router.delete("/{termsheet_id}")
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError("ترم‌شیت فقط در پیشنهادهای offered قابل حذف است")
count = await db.scalar(select(func.count()).select_from(Termsheet).where(Termsheet.quote_id == quote_id))
if count <= 1:
raise ConflictError("آخرین ترم‌شیت پیشنهاد قابل حذف نیست")
chosen = await db.scalar(
select(ItemDecision.id)
.join(ItemTermsheet, ItemDecision.chosen_item_termsheet_id == ItemTermsheet.id)
.where(ItemTermsheet.termsheet_id == termsheet_id, ItemDecision.status == "chosen")
.limit(1)
)
if chosen:
raise ConflictError("ترم‌شیتی که قلم انتخاب‌شده دارد قابل حذف نیست")
await db.delete(ts) # steps + item_termsheets cascade via DDL
await db.flush()
return {"data": {"deleted": True}}
+146
View File
@@ -0,0 +1,146 @@
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
Model: {category_id, brand?, from_unit, to_unit, factor}
1 from_unit = factor × to_unit
brand empty = all brands of the category; brand set = that brand only.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import MaterialCategory, UnitConversion
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
from app.services.expand_service import category_brief
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(uc.id),
"category_id": str(uc.category_id),
"brand": uc.brand,
"from_unit": uc.from_unit,
"to_unit": uc.to_unit,
"factor": float(uc.factor),
"updated_at": to_iso(uc.updated_at),
}
if "category" in expand and hasattr(uc, "_category"):
d["category"] = category_brief(uc._category)
return d
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
where = (
UnitConversion.category_id == category_id,
UnitConversion.from_unit == from_unit,
UnitConversion.to_unit == to_unit,
)
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده می‌شود
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
if uc_or_none is not None:
where = where + (UnitConversion.id != uc_or_none.id,)
return where
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
duplicate = await db.scalar(
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
)
if duplicate is not None:
raise ConflictError("این تبدیل قبلاً ثبت شده است")
@router.get("")
async def list_unit_conversions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand: str | None = None,
from_unit: str | None = None,
to_unit: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(UnitConversion)
if category_id:
stmt = stmt.where(UnitConversion.category_id == category_id)
if brand:
stmt = stmt.where(UnitConversion.brand == brand)
if from_unit:
stmt = stmt.where(UnitConversion.from_unit == from_unit)
if to_unit:
stmt = stmt.where(UnitConversion.to_unit == to_unit)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {uc.category_id for uc in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for uc in items:
uc._category = cats.get(uc.category_id)
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
@router.post("", status_code=201)
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, payload.category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
uc = UnitConversion(
category_id=payload.category_id,
brand=payload.brand,
from_unit=payload.from_unit,
to_unit=payload.to_unit,
factor=payload.factor,
)
db.add(uc)
await db.flush()
return {"data": uc_to_dict(uc)}
@router.get("/{uc_id}")
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
return {"data": uc_to_dict(uc)}
@router.patch("/{uc_id}")
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
new_brand = payload.brand if payload.brand is not None else uc.brand
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
if payload.brand is not None:
uc.brand = payload.brand
if payload.from_unit is not None:
uc.from_unit = payload.from_unit
if payload.to_unit is not None:
uc.to_unit = payload.to_unit
if payload.factor is not None:
uc.factor = payload.factor
await db.flush()
return {"data": uc_to_dict(uc)}
@router.delete("/{uc_id}")
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
await db.delete(uc)
await db.flush()
return {"data": {"deleted": True}}
+120
View File
@@ -0,0 +1,120 @@
"""Units CRUD. units.id is the ONLY integer PK in the system (IDENTITY)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
from app.models.procurement import LineItem, LineItemSpec
from app.schemas.catalog import UnitCreate, UnitUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/units", tags=["units"])
def unit_full_to_dict(unit: Unit) -> dict:
"""Full unit shape (P0-1): includes dimension, si_factor, is_base."""
return {
"id": unit.id,
"code": unit.code,
"name_fa": unit.name_fa,
"dimension": unit.dimension,
"si_factor": float(unit.si_factor),
"is_base": unit.is_base,
}
@router.get("")
async def list_units(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
q: str | None = None,
dimension: str | None = None,
sort: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(Unit)
if q:
stmt = stmt.where(or_(Unit.code.ilike(f"%{q}%"), Unit.name_fa.ilike(f"%{q}%")))
if dimension:
stmt = stmt.where(Unit.dimension == dimension)
stmt = sort_query(stmt, Unit, sort, default="id")
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [unit_full_to_dict(u) for u in items], "meta": meta}
@router.post("", status_code=201)
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
if duplicate is not None:
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
unit = Unit(
code=payload.code,
name_fa=payload.name_fa,
dimension=payload.dimension,
si_factor=payload.si_factor,
is_base=payload.is_base,
)
db.add(unit)
await db.flush()
return {"data": unit_full_to_dict(unit)}
@router.get("/{unit_id}")
async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
unit = await db.get(Unit, unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد")
return {"data": unit_full_to_dict(unit)}
@router.patch("/{unit_id}")
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
unit = await db.get(Unit, unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد")
if payload.code is not None and payload.code != unit.code:
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code, Unit.id != unit_id))
if duplicate is not None:
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
unit.code = payload.code
if payload.name_fa is not None:
unit.name_fa = payload.name_fa
if payload.dimension is not None:
unit.dimension = payload.dimension
if payload.si_factor is not None:
unit.si_factor = payload.si_factor
if payload.is_base is not None:
unit.is_base = payload.is_base
await db.flush()
return {"data": unit_full_to_dict(unit)}
@router.delete("/{unit_id}")
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
unit = await db.get(Unit, unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد")
refs: dict[str, int] = {
"attribute_definitions": await db.scalar(
select(func.count()).select_from(AttributeDefinition).where(AttributeDefinition.unit_id == unit_id)
),
"materials": await db.scalar(
select(func.count()).select_from(Material).where(Material.canonical_unit_id == unit_id)
),
"material_units": await db.scalar(
select(func.count()).select_from(MaterialUnit).where(MaterialUnit.unit_id == unit_id)
),
"line_items": await db.scalar(select(func.count()).select_from(LineItem).where(LineItem.unit_id == unit_id)),
"line_item_specs": await db.scalar(
select(func.count()).select_from(LineItemSpec).where(LineItemSpec.unit_id_at_time == unit_id)
),
}
total = sum(refs.values())
if total:
raise ConflictError(f"این واحد در {total} رکورد دیگر استفاده شده و قابل حذف نیست")
await db.delete(unit)
await db.flush()
return {"data": {"id": unit_id, "deleted": True}}
+60
View File
@@ -0,0 +1,60 @@
"""Users — profile + role assignment (v2.6, Casdoor-backed).
GET /users/me — the caller's profile: role, PM's project, display name.
PATCH /users/{id} — assign role (admin only).
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role
from app.db.engine import get_db
from app.models.procurement import Project, User
from app.schemas.procurement import UserRoleUpdate
from app.utils.errors import NotFoundError
from app.utils.serialization import to_iso
router = APIRouter(prefix="/users", tags=["users"])
USER_ROLE_LABELS = {
"pm": "مدیر پروژه",
"procurement_manager": "مدیر تدارکات",
"admin": "ادمین",
}
def user_to_dict(user: User, project: Project | None = None) -> dict:
return {
"id": str(user.id),
"casdoor_id": user.casdoor_id,
"first_name": user.first_name,
"last_name": user.last_name,
"phone": user.phone,
"role": user.role,
"role_label": USER_ROLE_LABELS.get(user.role) if user.role else None,
"project_id": str(project.id) if project else None,
"project_name": project.name if project else None,
"created_at": to_iso(user.created_at),
}
@router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await db.scalar(select(Project).where(Project.pm_user_id == ctx.user.id)) if ctx.role == "pm" else None
return {"data": user_to_dict(ctx.user, project)}
@router.patch("/{user_id}")
async def update_user_role(
user_id,
payload: UserRoleUpdate,
_: UserContext = Depends(require_role("admin")),
db: AsyncSession = Depends(get_db),
):
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
user.role = payload.role
await db.flush()
project = await db.scalar(select(Project).where(Project.pm_user_id == user.id)) if user.role == "pm" else None
return {"data": user_to_dict(user, project)}
View File
+166
View File
@@ -0,0 +1,166 @@
"""Pydantic v2 request schemas — catalog domain."""
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import Field
from app.schemas.common import APIModel
MaterialStatus = Literal["active", "discontinued", "draft"]
AttributeDataType = Literal["number", "integer", "string", "enum", "boolean", "date"]
class UnitCreate(APIModel):
code: str = Field(min_length=1, max_length=255)
name_fa: str | None = None
dimension: str | None = None
si_factor: Decimal = Field(default=Decimal("1.0"), gt=0)
is_base: bool = False
class UnitUpdate(APIModel):
code: str | None = Field(default=None, min_length=1, max_length=255)
name_fa: str | None = None
dimension: str | None = None
si_factor: Decimal | None = Field(default=None, gt=0)
is_base: bool | None = None
class BrandCreate(APIModel):
name: str = Field(min_length=1)
country: str | None = None
class BrandUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
country: str | None = None
class MaterialCategoryCreate(APIModel):
slug: str = Field(min_length=1)
name: str = Field(min_length=1)
description: str | None = None
is_active: bool = True
class MaterialCategoryUpdate(APIModel):
slug: str | None = Field(default=None, min_length=1)
name: str | None = Field(default=None, min_length=1)
description: str | None = None
is_active: bool | None = None
class AttributeDefinitionCreate(APIModel):
attribute_key: str = Field(min_length=1)
label: str = Field(min_length=1)
data_type: AttributeDataType
unit_id: int | None = None
enum_values: list | None = None
class AttributeDefinitionUpdate(APIModel):
label: str | None = Field(default=None, min_length=1)
data_type: AttributeDataType | None = None
unit_id: int | None = None
enum_values: list | None = None
class CategoryAttributeCreate(APIModel):
attribute_definition_id: UUID
is_required: bool = False
is_filterable: bool = False
conditional_rules: dict | None = None
sort_order: int = 0
class CategoryAttributeUpdate(APIModel):
is_required: bool | None = None
is_filterable: bool | None = None
conditional_rules: dict | None = None
sort_order: int | None = None
class MaterialAttributeIn(APIModel):
"""One EAV value. `value` is coerced against attribute_definitions.data_type."""
attribute_definition_id: UUID
value: int | float | str | bool | None = None
class MaterialCreate(APIModel):
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
name: str = Field(min_length=1)
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: int
status: MaterialStatus = "active"
brand_ids: list[UUID] = Field(default_factory=list)
attributes: list[MaterialAttributeIn] = Field(default_factory=list)
class MaterialUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
topic: str | None = None
canonical_unit_id: int | None = None
status: MaterialStatus | None = None
class BrandMaterialCreate(APIModel):
brand_id: UUID
material_id: UUID
catalog_code: str | None = None
is_active: bool = True
class BrandMaterialUpdate(APIModel):
# brand_id / material_id are immutable (UNIQUE pair) — not accepted here.
catalog_code: str | None = None
is_active: bool | None = None
class MaterialUnitCreate(APIModel):
brand_material_id: UUID | None = None
unit_id: int
conversion_factor: Decimal = Field(gt=0)
is_default: bool = False
class MaterialUnitUpdate(APIModel):
unit_id: int | None = None
conversion_factor: Decimal | None = Field(default=None, gt=0)
is_default: bool | None = None
class MaterialAttributeValuesPut(APIModel):
attributes: list[MaterialAttributeIn] = Field(default_factory=list)
class CategoryReferenceUnitPut(APIModel):
ref_unit: str = Field(min_length=1)
class UnitConversionCreate(APIModel):
category_id: UUID
brand: str | None = None
from_unit: str = Field(min_length=1)
to_unit: str = Field(min_length=1)
factor: Decimal = Field(gt=0)
class UnitConversionUpdate(APIModel):
brand: str | None = None
from_unit: str | None = Field(default=None, min_length=1)
to_unit: str | None = Field(default=None, min_length=1)
factor: Decimal | None = Field(default=None, gt=0)
class StandardConversionCreate(APIModel):
from_unit: str = Field(min_length=1)
to_unit: str = Field(min_length=1)
factor: Decimal = Field(gt=0)
class StandardConversionUpdate(APIModel):
from_unit: str | None = Field(default=None, min_length=1)
to_unit: str | None = Field(default=None, min_length=1)
factor: Decimal | None = Field(default=None, gt=0)
+18
View File
@@ -0,0 +1,18 @@
"""Shared schema building blocks (pagination meta, error envelope)."""
from pydantic import BaseModel, ConfigDict
class Meta(BaseModel):
total: int
page: int
page_size: int
class ErrorBody(BaseModel):
error: dict
class APIModel(BaseModel):
"""Base for request schemas — Pydantic v2, permissive on extra fields."""
model_config = ConfigDict(str_strip_whitespace=True)
+1
View File
@@ -0,0 +1 @@
"""Pydantic schemas for file endpoints — request side is a multipart UploadFile."""
+78
View File
@@ -0,0 +1,78 @@
"""Pydantic v2 request schemas — inventory domain (fork's contract, unchanged)."""
from datetime import datetime
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import Field, model_validator
from app.models.inventory import INVENTORY_TRANSACTION_TYPES
from app.schemas.common import APIModel
TransactionType = Literal[
"PURCHASE",
"WITHDRAWAL",
"RETURN",
"TRANSFER_IN",
"TRANSFER_OUT",
"DAMAGE",
"WASTE",
"LOSS",
"FOUND",
"STOCK_COUNT",
]
class WarehouseCreate(APIModel):
project_id: UUID
name: str = Field(min_length=1, max_length=255)
description: str | None = None
class WarehouseUpdate(APIModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
project_id: UUID | None = None
class InventoryTransactionCreate(APIModel):
"""One inventory operation.
- For all types except STOCK_COUNT: `quantity` is the amount to add/remove.
- STOCK_COUNT uses `physical_qty` (the counted quantity); the difference
against the recorded quantity is computed and applied by the service.
- TRANSFER_OUT requires `dest_warehouse_id`; the service creates the linked
TRANSFER_IN row in the destination warehouse (transfer_pair_id).
- `specs` is a dict of technical specifications (dynamic multi-spec entries).
- `transaction_date` defaults to server now() when omitted.
"""
transaction_type: TransactionType
warehouse_id: UUID
dest_warehouse_id: UUID | None = None
material_id: UUID
brand_id: UUID | None = None
quantity: Decimal | None = Field(default=None, gt=0)
physical_qty: Decimal | None = Field(default=None, ge=0)
unit_id: int | None = None
specs: dict | None = None
note: str | None = None
transaction_date: datetime | None = None
@model_validator(mode="after")
def validate_type_rules(self):
t = self.transaction_type
if t == "STOCK_COUNT":
if self.physical_qty is None:
raise ValueError("برای شمارش موجودی، تعداد شمارش‌شده (physical_qty) الزامی است")
else:
if self.quantity is None:
raise ValueError("تعداد (quantity) الزامی است")
if t == "TRANSFER_OUT":
if self.dest_warehouse_id is None:
raise ValueError("برای انتقال خروجی، انبار مقصد (dest_warehouse_id) الزامی است")
if self.dest_warehouse_id == self.warehouse_id:
raise ValueError("انبار مبدأ و مقصد نمی‌توانند یکسان باشند")
if t not in INVENTORY_TRANSACTION_TYPES:
raise ValueError(f"نوع تراکنش نامعتبر است: {t}")
return self
+313
View File
@@ -0,0 +1,313 @@
"""Pydantic v2 request schemas — quotation & procurement domain."""
from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import Field
from app.schemas.common import APIModel
QuoteStatus = Literal["offered", "accepted", "arrived", "rejected", "expired"]
TermsheetStatus = Literal["offered", "accepted", "rejected"]
DiscountType = Literal["none", "percent", "fixed"]
DecisionStatus = Literal["pending", "chosen", "rejected"]
# ---------------------------------------------------------------------------
# Suppliers / projects
# ---------------------------------------------------------------------------
class SupplierCreate(APIModel):
name: str = Field(min_length=1)
category_id: UUID # v2.6: 1 supplier → exactly 1 category (required)
location: str | None = None
phone: str | None = None
contact_person: str | None = None
tax_id: str | None = None
class SupplierUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
category_id: UUID | None = None
location: str | None = None
phone: str | None = None
contact_person: str | None = None
tax_id: str | None = None
class SupplierMaterialCreate(APIModel):
"""Link a global material to a supplier (derives materials.category_id)."""
material_id: UUID
class ProjectCreate(APIModel):
name: str = Field(min_length=1)
description: str | None = None
start_date: date | None = None
class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
description: str | None = None
start_date: date | None = None
# ---------------------------------------------------------------------------
# Quotes
# ---------------------------------------------------------------------------
class QuoteLineItemIn(APIModel):
temp_id: str = Field(min_length=1)
material_id: UUID
brand_material_id: UUID | None = None
unit_id: int
quantity: Decimal = Field(gt=0)
notes: str | None = None
project_ids: list[UUID] | None = None # پروژه‌های تخصیص‌یافته به این قلم
class ScheduleStepIn(APIModel):
step_no: int | None = Field(default=None, ge=1)
due_date: date
amount: Decimal
note: str | None = None
class TermsheetIn(APIModel):
temp_id: str = Field(min_length=1)
label: str = Field(min_length=1)
discount_type: DiscountType = "none"
discount_value: Decimal = Decimal("0")
schedule_steps: list[ScheduleStepIn] = Field(default_factory=list)
class ItemTermsheetLinkIn(APIModel):
line_item_temp_id: str = Field(min_length=1)
termsheet_temp_id: str = Field(min_length=1)
unit_price: Decimal = Field(gt=0)
min_order_qty: Decimal | None = Field(default=None, gt=0)
class QuoteCreate(APIModel):
supplier_id: UUID
project_id: UUID | None = None
project_ids: list[UUID] = Field(default_factory=list) # چند پروژه برای یک پیشنهاد
quote_date: datetime | None = None
expiry_date: date | None = None
is_official: bool = True
notes: str | None = None
# اقلام/شرایط پرداخت/قیمت‌ها می‌توانند خالی باشند — کاربر ممکن است بخواهد
# پیشنهادی ناقص را ذخیره کند (task: عدم اجبار تکمیل فیلدها)
line_items: list[QuoteLineItemIn] = Field(default_factory=list)
termsheets: list[TermsheetIn] = Field(default_factory=list)
item_termsheets: list[ItemTermsheetLinkIn] = Field(default_factory=list)
class QuoteChildrenReplaceIn(APIModel):
"""Full replacement of a quote's line items + termsheets (edit modal)."""
line_items: list[QuoteLineItemIn] = Field(default_factory=list)
termsheets: list[TermsheetIn] = Field(default_factory=list)
item_termsheets: list[ItemTermsheetLinkIn] = Field(default_factory=list)
class QuoteUpdate(APIModel):
supplier_id: UUID | None = None
project_id: UUID | None = None
project_ids: list[UUID] | None = None
quote_date: datetime | None = None
expiry_date: date | None = None
is_official: bool | None = None
notes: str | None = None
status: QuoteStatus | None = None
class AcceptTermsheetIn(APIModel):
id: UUID | None = None
label: str | None = Field(default=None, min_length=1)
discount_type: DiscountType = "none"
discount_value: Decimal = Decimal("0")
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class ItemDecisionIn(APIModel):
line_item_id: UUID
status: Literal["chosen", "rejected"]
accepted_termsheet_index: int | None = Field(default=None, ge=0)
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
class ItemProjectsIn(APIModel):
line_item_id: UUID
project_ids: list[UUID] = Field(default_factory=list)
class AcceptQuoteIn(APIModel):
accepted_termsheets: list[AcceptTermsheetIn] = Field(min_length=1)
item_decisions: list[ItemDecisionIn] = Field(min_length=1)
item_projects: list[ItemProjectsIn] = Field(default_factory=list) # تخصیص پروژه به هر قلم
class ArriveItemIn(APIModel):
line_item_id: UUID
received_qty: Decimal = Field(gt=0)
received_at: datetime | None = None
on_time: bool | None = None
quality_note: str | None = None
class ArriveItemsIn(APIModel):
items: list[ArriveItemIn] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# v2.6 — PM arrival confirmation (item_decisions.project_id + allocations)
# ---------------------------------------------------------------------------
class ReceiptAllocationIn(APIModel):
warehouse_id: UUID
quantity: Decimal = Field(gt=0)
class PMReceiptCreate(APIModel):
"""PM confirms a delivery: receipt + warehouse split (free count).
Allocations must sum exactly to received_qty and every warehouse must
belong to item_decisions.project_id.
"""
item_decision_id: UUID
received_qty: Decimal = Field(gt=0)
allocations: list[ReceiptAllocationIn] = Field(min_length=1)
received_at: datetime | None = None
on_time: bool | None = None
quality_note: str | None = None
class UserRoleUpdate(APIModel):
role: Literal["pm", "procurement_manager", "admin"]
class RejectQuoteIn(APIModel):
reason: str | None = None
class NoteEventIn(APIModel):
event_type: Literal["note"] = "note"
payload: dict = Field(default_factory=dict)
actor: str | None = None
# ---------------------------------------------------------------------------
# Line items / specs
# ---------------------------------------------------------------------------
class LineItemCreate(APIModel):
material_id: UUID
brand_material_id: UUID | None = None
unit_id: int
quantity: Decimal = Field(gt=0)
notes: str | None = None
class LineItemUpdate(APIModel):
material_id: UUID | None = None
brand_material_id: UUID | None = None
unit_id: int | None = None
quantity: Decimal | None = Field(default=None, gt=0)
notes: str | None = None
class SpecIn(APIModel):
attribute_definition_id: UUID
value: int | float | str | bool | None = None
unit_id_at_time: int | None = None
class SpecsPutIn(APIModel):
specs: list[SpecIn] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Termsheets / schedule steps
# ---------------------------------------------------------------------------
class TermsheetCreate(APIModel):
label: str = Field(min_length=1)
discount_type: DiscountType = "none"
discount_value: Decimal = Decimal("0")
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class TermsheetUpdate(APIModel):
label: str | None = Field(default=None, min_length=1)
discount_type: DiscountType | None = None
discount_value: Decimal | None = None
class TermsheetStatusUpdate(APIModel):
status: Literal["accepted", "rejected"]
class ScheduleStepCreate(APIModel):
step_no: int | None = Field(default=None, ge=1)
due_date: date
amount: Decimal
note: str | None = None
class ScheduleStepUpdate(APIModel):
step_no: int | None = Field(default=None, ge=1)
due_date: date | None = None
amount: Decimal | None = None
note: str | None = None
# ---------------------------------------------------------------------------
# Item termsheets / decisions / receipts
# ---------------------------------------------------------------------------
class ItemTermsheetCreate(APIModel):
termsheet_id: UUID
unit_price: Decimal = Field(gt=0)
min_order_qty: Decimal | None = Field(default=None, gt=0)
class ItemTermsheetUpdate(APIModel):
unit_price: Decimal | None = Field(default=None, gt=0)
min_order_qty: Decimal | None = Field(default=None, gt=0)
class DecisionPut(APIModel):
chosen_item_termsheet_id: UUID | None = None
status: Literal["chosen", "rejected"]
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
class DecisionStatusUpdate(APIModel):
status: Literal["chosen", "rejected"]
class DecisionPatch(APIModel):
chosen_item_termsheet_id: UUID | None = None
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
class ReceiptCreate(APIModel):
received_at: datetime | None = None
received_qty: Decimal = Field(gt=0)
on_time: bool | None = None
quality_note: str | None = None
class ReceiptUpdate(APIModel):
received_at: datetime | None = None
received_qty: Decimal | None = Field(default=None, gt=0)
on_time: bool | None = None
quality_note: str | None = None
View File
+74
View File
@@ -0,0 +1,74 @@
"""Shared EAV flattening utility.
Every endpoint that returns materials (``material_attribute_values``) or
line items (``line_item_specs``) with expand uses this single function to
produce the contract shape:
[{"attribute_key", "label", "data_type", "value", "unit"}]
value extraction:
number/integer -> value_number (integer cast for `integer`)
string/enum -> value_string
boolean -> value_boolean
date -> value_date (ISO string)
unit resolution:
prefer row.unit_id_at_time when available (prefer_unit_id_at_time=True),
else fall back to attribute_definitions.unit_id.
"""
from app.models.catalog import AttributeDefinition, MaterialAttributeValue
from app.models.procurement import LineItemSpec
_EAVRow = MaterialAttributeValue | LineItemSpec
def _extract_value(ad: AttributeDefinition, row: _EAVRow):
data_type = ad.data_type
if data_type in ("number", "integer"):
if row.value_number is None:
return None
return int(row.value_number) if data_type == "integer" else float(row.value_number)
if data_type in ("string", "enum"):
return row.value_string
if data_type == "boolean":
return row.value_boolean
if data_type == "date":
return row.value_date.isoformat() if row.value_date is not None else None
return None
def _resolve_unit(row: _EAVRow, ad: AttributeDefinition, unit_map: dict, prefer_unit_id_at_time: bool):
unit_id = None
if prefer_unit_id_at_time and hasattr(row, "unit_id_at_time") and row.unit_id_at_time is not None:
unit_id = row.unit_id_at_time
elif ad.unit_id is not None:
unit_id = ad.unit_id
unit = unit_map.get(unit_id) if unit_id is not None else None
if unit is None:
return None
return {"id": unit.id, "code": unit.code}
def flatten_eav_values(
rows: list[_EAVRow],
attr_defs_map: dict,
unit_map: dict,
prefer_unit_id_at_time: bool = True,
include_definition_id: bool = False,
include_data_type: bool = True,
) -> list[dict]:
"""Flatten typed EAV rows into the contract's `value`/`unit` shape."""
result: list[dict] = []
for row in rows:
ad = attr_defs_map[row.attribute_definition_id]
item: dict = {}
if include_definition_id:
item["attribute_definition_id"] = str(row.attribute_definition_id)
item["attribute_key"] = ad.attribute_key
item["label"] = ad.label
if include_data_type:
item["data_type"] = ad.data_type
item["value"] = _extract_value(ad, row)
item["unit"] = _resolve_unit(row, ad, unit_map, prefer_unit_id_at_time)
result.append(item)
return result
+657
View File
@@ -0,0 +1,657 @@
"""Expand-aware serialization.
Given a set of expand tokens, serializers resolve FK IDs into the nested
object shapes defined by the contracts (expert-opinion-p0/p1/p2).
All list serializers batch-load their dependencies (no N+1): one round of
queries per related table, then assembly from in-memory maps.
"""
import uuid
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import (
AttributeDefinition,
Brand,
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialCategory,
Unit,
)
from app.models.procurement import (
ItemDecision,
ItemReceipt,
ItemTermsheet,
LineItem,
LineItemProjects,
LineItemSpec,
Project,
Quote,
QuoteEvent,
QuoteProjects,
Supplier,
Termsheet,
TermsheetScheduleStep,
)
from app.services.eav_service import flatten_eav_values
from app.utils.serialization import to_date, to_iso
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def parse_expand(expand: str | None) -> set[str]:
if not expand:
return set()
return {token.strip() for token in expand.split(",") if token.strip()}
def unit_to_dict(unit: Unit | None) -> dict | None:
if unit is None:
return None
return {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa}
def category_brief(category: MaterialCategory | None) -> dict | None:
if category is None:
return None
return {"id": str(category.id), "slug": category.slug, "name": category.name}
def supplier_brief(supplier: Supplier | None) -> dict | None:
if supplier is None:
return None
return {"id": str(supplier.id), "name": supplier.name, "location": supplier.location}
def project_brief(project: Project | None) -> dict | None:
if project is None:
return None
return {"id": str(project.id), "name": project.name}
def decision_brief(d: ItemDecision) -> dict:
"""Shape used inside expanded line items (P0-14)."""
return {
"id": str(d.id),
"status": d.status,
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
"decided_at": to_iso(d.decided_at),
}
async def _attach_quote_projects(db: AsyncSession, quotes: list[Quote], out: dict) -> None:
"""Batch-load quote_projects junction rows and attach a `projects` array."""
quote_ids = [q.id for q in quotes]
qp_rows = (
(
await db.execute(
select(QuoteProjects).where(QuoteProjects.quote_id.in_(quote_ids))
)
)
.scalars()
.all()
) if quote_ids else []
proj_ids = {r.project_id for r in qp_rows}
proj_map: dict = {}
if proj_ids:
projs = (await db.execute(select(Project).where(Project.id.in_(proj_ids)))).scalars().all()
proj_map = {p.id: p for p in projs}
projects_by_quote: dict = {}
for r in qp_rows:
projects_by_quote.setdefault(r.quote_id, []).append(proj_map[r.project_id])
for q in quotes:
out[q.id]["projects"] = [
project_brief(p) for p in projects_by_quote.get(q.id, []) if p is not None
]
def decision_full(d: ItemDecision) -> dict:
"""Shape used by the decision endpoints (P1-21)."""
return {
"id": str(d.id),
"line_item_id": str(d.line_item_id),
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
"status": d.status,
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
"adjusted_notes": d.adjusted_notes,
"decided_at": to_iso(d.decided_at),
}
def receipt_brief(r: ItemReceipt) -> dict:
"""Shape used inside expanded line items (P0-14)."""
return {
"id": str(r.id),
"received_qty": float(r.received_qty),
"received_at": to_iso(r.received_at),
"on_time": r.on_time,
}
def receipt_full(r: ItemReceipt) -> dict:
"""Shape used by the receipt endpoints (P1-25)."""
return {
"id": str(r.id),
"item_decision_id": str(r.item_decision_id),
"received_at": to_iso(r.received_at),
"received_qty": float(r.received_qty),
"on_time": r.on_time,
"quality_note": r.quality_note,
}
def event_to_dict(e: QuoteEvent) -> dict:
return {
"id": str(e.id),
"quote_id": str(e.quote_id),
"event_type": e.event_type,
"payload": e.payload,
"occurred_at": to_iso(e.occurred_at),
"actor": e.actor,
}
def schedule_step_to_dict(s: TermsheetScheduleStep) -> dict:
return {
"id": str(s.id),
"step_no": s.step_no,
"due_date": to_date(s.due_date),
"amount": float(s.amount),
"note": s.note,
}
# ---------------------------------------------------------------------------
# Quote (flat)
# ---------------------------------------------------------------------------
def quote_flat(q: Quote) -> dict:
d = {
"id": str(q.id),
"supplier_id": str(q.supplier_id),
"project_id": str(q.project_id) if q.project_id else None,
"quote_date": to_iso(q.quote_date),
"expiry_date": to_date(q.expiry_date),
"accepted_date": to_iso(q.accepted_date),
"arrived_date": to_iso(q.arrived_date),
"status": q.status,
"is_official": q.is_official,
"notes": q.notes,
"created_at": to_iso(q.created_at),
"updated_at": to_iso(q.updated_at),
}
# Lazy auto-expiry flag (contract P0-14): never changes the stored status.
if q.status == "offered" and q.expiry_date is not None and q.expiry_date < date.today():
d["is_expired"] = True
return d
# ---------------------------------------------------------------------------
# Materials
# ---------------------------------------------------------------------------
async def _load_material_maps(db: AsyncSession, materials: list[Material], expand: set[str]) -> dict:
material_ids = [m.id for m in materials]
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
if "category" in expand:
cat_ids = {m.category_id for m in materials}
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
maps["categories"] = {c.id: c for c in rows}
unit_ids: set[int] = set()
if "canonical_unit" in expand:
unit_ids |= {m.canonical_unit_id for m in materials}
if "brands" in expand:
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
by_material: dict[uuid.UUID, list[BrandMaterial]] = {}
brand_ids: set[uuid.UUID] = set()
for bm in bms:
by_material.setdefault(bm.material_id, []).append(bm)
brand_ids.add(bm.brand_id)
maps["material_brands"] = by_material
if brand_ids:
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
maps["brands"] = {b.id: b for b in brands}
if "attributes" in expand:
mavs = (
await db.execute(
select(MaterialAttributeValue)
.where(MaterialAttributeValue.material_id.in_(material_ids))
.order_by(MaterialAttributeValue.attribute_definition_id)
)
).scalars().all()
maps["mavs"] = mavs
def_ids = {row.attribute_definition_id for row in mavs}
if def_ids:
defs = (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars().all()
maps["attr_defs"] = {d.id: d for d in defs}
unit_ids |= {d.unit_id for d in defs if d.unit_id is not None}
if unit_ids:
units = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
maps["units"] = {u.id: u for u in units}
return maps
def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
d = {
"id": str(m.id),
"name": m.name,
"topic": m.topic,
"status": m.status,
"created_at": to_iso(m.created_at),
"updated_at": to_iso(m.updated_at),
}
if "category" in expand:
d["category"] = category_brief(maps["categories"].get(m.category_id))
else:
d["category_id"] = str(m.category_id)
if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else:
d["canonical_unit_id"] = m.canonical_unit_id
if "brands" in expand:
d["brands"] = [
{
"id": str(bm.brand_id),
"name": maps["brands"][bm.brand_id].name,
"catalog_code": bm.catalog_code,
"is_active": bm.is_active,
}
for bm in maps["material_brands"].get(m.id, [])
if bm.brand_id in maps["brands"]
]
if "attributes" in expand:
rows = [row for row in maps["mavs"] if row.material_id == m.id]
d["attributes"] = flatten_eav_values(rows, maps["attr_defs"], maps["units"], prefer_unit_id_at_time=False)
return d
async def serialize_materials(db: AsyncSession, materials: list[Material], expand: set[str]) -> list[dict]:
if not materials:
return []
maps = await _load_material_maps(db, materials, expand)
return [_material_to_dict(m, expand, maps) for m in materials]
async def serialize_material(db: AsyncSession, material: Material, expand: set[str]) -> dict:
return (await serialize_materials(db, [material], expand))[0]
# ---------------------------------------------------------------------------
# Line items
# ---------------------------------------------------------------------------
class _LineItemData:
"""Batch-loaded dependency maps for a set of line items."""
def __init__(self):
self.materials: dict[uuid.UUID, Material] = {}
self.categories: dict[uuid.UUID, MaterialCategory] = {}
self.units: dict[int, Unit] = {}
self.brands: dict[uuid.UUID, Brand] = {}
self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {}
self.specs_by_line: dict[uuid.UUID, list[LineItemSpec]] = {}
self.attr_defs: dict[uuid.UUID, AttributeDefinition] = {}
self.decisions: dict[uuid.UUID, ItemDecision] = {}
self.receipts_by_decision: dict[uuid.UUID, ItemReceipt] = {}
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
data = _LineItemData()
li_ids = [li.id for li in line_items]
unit_ids: set[int] = {li.unit_id for li in line_items}
if "material" in expand:
material_ids = {li.material_id for li in line_items}
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
data.materials = {m.id: m for m in materials}
cat_ids = {m.category_id for m in materials}
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
data.categories = {c.id: c for c in cats}
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
brand_ids: set[uuid.UUID] = set()
for bm in bms:
data.material_brands.setdefault(bm.material_id, []).append(bm)
brand_ids.add(bm.brand_id)
if brand_ids:
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
data.brands = {b.id: b for b in brands}
# Pricing always needs item_termsheets in expanded mode.
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
for it in its:
data.item_termsheets_by_line.setdefault(it.line_item_id, []).append(it)
data.item_termsheets_by_id[it.id] = it
# Per-material project allocation — always attached in expanded mode.
lip_rows = (
(await db.execute(select(LineItemProjects).where(LineItemProjects.line_item_id.in_(li_ids)))).scalars().all()
) if li_ids else []
proj_ids = {r.project_id for r in lip_rows}
proj_map: dict[uuid.UUID, Project] = {}
if proj_ids:
projs = (await db.execute(select(Project).where(Project.id.in_(proj_ids)))).scalars().all()
proj_map = {p.id: p for p in projs}
for r in lip_rows:
if r.project_id in proj_map:
data.projects_by_line.setdefault(r.line_item_id, []).append(proj_map[r.project_id])
if "specs" in expand:
specs = (
await db.execute(
select(LineItemSpec)
.where(LineItemSpec.line_item_id.in_(li_ids))
.order_by(LineItemSpec.attribute_definition_id)
)
).scalars().all()
for spec in specs:
data.specs_by_line.setdefault(spec.line_item_id, []).append(spec)
def_ids = {spec.attribute_definition_id for spec in specs}
if def_ids:
defs = (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars().all()
data.attr_defs = {d.id: d for d in defs}
unit_ids |= {d.unit_id for d in defs if d.unit_id is not None}
unit_ids |= {spec.unit_id_at_time for spec in specs if spec.unit_id_at_time is not None}
if "decision" in expand or "receipt" in expand:
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
data.decisions = {d.line_item_id: d for d in decisions}
dec_ids = [d.id for d in decisions]
if dec_ids:
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
data.receipts_by_decision = {r.item_decision_id: r for r in receipts}
if unit_ids:
units = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
data.units = {u.id: u for u in units}
return data
def _line_item_flat(li: LineItem) -> dict:
return {
"id": str(li.id),
"quote_id": str(li.quote_id),
"material_id": str(li.material_id),
"brand_material_id": str(li.brand_material_id) if li.brand_material_id else None,
"unit_id": li.unit_id,
"quantity": float(li.quantity),
"notes": li.notes,
"created_at": to_iso(li.created_at),
}
def _price_source(li: LineItem, data: _LineItemData) -> ItemTermsheet | None:
"""unit_price source: the item's chosen termsheet link, else first link (P0-14)."""
decision = data.decisions.get(li.id)
if decision is not None and decision.chosen_item_termsheet_id is not None:
chosen = data.item_termsheets_by_id.get(decision.chosen_item_termsheet_id)
if chosen is not None:
return chosen
links = data.item_termsheets_by_line.get(li.id, [])
return links[0] if links else None
def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, include_termsheet_ids: bool) -> dict:
d: dict = {"id": str(li.id)}
if "material" in expand:
m = data.materials.get(li.material_id)
if m is not None:
d["material"] = {
"id": str(m.id),
"name": m.name,
"category": category_brief(data.categories.get(m.category_id)),
"brands": [
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
for bm in data.material_brands.get(m.id, [])
if bm.brand_id in data.brands
],
}
else:
d["material_id"] = str(li.material_id)
else:
d["material_id"] = str(li.material_id)
d["brand_material_id"] = str(li.brand_material_id) if li.brand_material_id else None
if "unit" in expand:
d["unit"] = unit_to_dict(data.units.get(li.unit_id))
else:
d["unit_id"] = li.unit_id
d["quantity"] = float(li.quantity)
price_it = _price_source(li, data)
d["unit_price"] = float(price_it.unit_price) if price_it is not None else None
d["total_price"] = float(price_it.unit_price * li.quantity) if price_it is not None else None
d["projects"] = [project_brief(p) for p in data.projects_by_line.get(li.id, [])]
if "specs" in expand:
d["specs"] = flatten_eav_values(
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
)
decision = data.decisions.get(li.id)
if "decision" in expand:
d["decision"] = decision_brief(decision) if decision is not None else None
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
# termsheet_ids در جلوی برنامه، id ترم‌شیت واقعی هم ارسال می‌شود.
if decision is not None and decision.chosen_item_termsheet_id is not None:
chosen_it = data.item_termsheets_by_id.get(decision.chosen_item_termsheet_id)
if chosen_it is not None:
d["accepted_termsheet_id"] = str(chosen_it.termsheet_id)
if "receipt" in expand:
r = data.receipts_by_decision.get(decision.id) if decision is not None else None
d["receipt"] = receipt_brief(r) if r is not None else None
if include_termsheet_ids:
d["termsheet_ids"] = [str(it.termsheet_id) for it in data.item_termsheets_by_line.get(li.id, [])]
return d
async def serialize_line_items(
db: AsyncSession, line_items: list[LineItem], expand: set[str], include_termsheet_ids: bool = False
) -> list[dict]:
if not line_items:
return []
if not expand:
return [_line_item_flat(li) for li in line_items]
data = await _load_line_item_data(db, line_items, expand)
return [_line_item_expanded(li, expand, data, include_termsheet_ids) for li in line_items]
# ---------------------------------------------------------------------------
# Termsheets
# ---------------------------------------------------------------------------
def _termsheet_flat(ts: Termsheet) -> dict:
return {
"id": str(ts.id),
"quote_id": str(ts.quote_id),
"label": ts.label,
"status": ts.status,
"discount_type": ts.discount_type,
"discount_value": float(ts.discount_value),
"created_at": to_iso(ts.created_at),
}
def _termsheet_quote_shape(ts: Termsheet, steps: list[TermsheetScheduleStep]) -> dict:
"""Shape inside expanded quotes (P0-14): no quote_id/created_at, steps nested."""
return {
"id": str(ts.id),
"label": ts.label,
"status": ts.status,
"discount_type": ts.discount_type,
"discount_value": float(ts.discount_value),
"schedule_steps": [schedule_step_to_dict(s) for s in steps],
}
async def serialize_termsheets(db: AsyncSession, termsheets: list[Termsheet], expand: set[str]) -> list[dict]:
if not termsheets:
return []
result = [_termsheet_flat(ts) for ts in termsheets]
ts_ids = [ts.id for ts in termsheets]
if "schedule_steps" in expand:
steps = (
await db.execute(
select(TermsheetScheduleStep)
.where(TermsheetScheduleStep.termsheet_id.in_(ts_ids))
.order_by(TermsheetScheduleStep.step_no)
)
).scalars().all()
grouped: dict[uuid.UUID, list[TermsheetScheduleStep]] = {}
for s in steps:
grouped.setdefault(s.termsheet_id, []).append(s)
for ts in termsheets:
for item in result:
if item["id"] == str(ts.id):
item["schedule_steps"] = [schedule_step_to_dict(s) for s in grouped.get(ts.id, [])]
break
if "line_items" in expand:
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.termsheet_id.in_(ts_ids)))).scalars().all()
li_ids = [it.line_item_id for it in its]
quantities: dict[uuid.UUID, float] = {}
if li_ids:
rows = (await db.execute(select(LineItem).where(LineItem.id.in_(li_ids)))).scalars().all()
quantities = {li.id: float(li.quantity) for li in rows}
grouped_its: dict[uuid.UUID, list[ItemTermsheet]] = {}
for it in its:
grouped_its.setdefault(it.termsheet_id, []).append(it)
for ts in termsheets:
links = grouped_its.get(ts.id, [])
items = []
for it in links:
qty = quantities.get(it.line_item_id, 0)
items.append(
{
"id": str(it.id),
"line_item_id": str(it.line_item_id),
"unit_price": float(it.unit_price),
"min_order_qty": float(it.min_order_qty) if it.min_order_qty is not None else None,
"total_price": float(it.unit_price * qty),
}
)
for item in result:
if item["id"] == str(ts.id):
item["line_items"] = items
break
return result
async def serialize_termsheet(db: AsyncSession, termsheet: Termsheet, expand: set[str]) -> dict:
return (await serialize_termsheets(db, [termsheet], expand))[0]
# ---------------------------------------------------------------------------
# Quotes
# ---------------------------------------------------------------------------
async def serialize_quotes(db: AsyncSession, quotes: list[Quote], expand: set[str]) -> list[dict]:
if not quotes:
return []
out: dict[uuid.UUID, dict] = {q.id: quote_flat(q) for q in quotes}
quote_ids = [q.id for q in quotes]
await _attach_quote_projects(db, quotes, out)
if "supplier" in expand:
sup_ids = {q.supplier_id for q in quotes}
sups = (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars().all()
sup_map = {s.id: s for s in sups}
for q in quotes:
out[q.id]["supplier"] = supplier_brief(sup_map.get(q.supplier_id))
if "project" in expand:
proj_ids = {q.project_id for q in quotes if q.project_id is not None}
proj_map: dict = {}
if proj_ids:
projs = (await db.execute(select(Project).where(Project.id.in_(proj_ids)))).scalars().all()
proj_map = {p.id: p for p in projs}
for q in quotes:
out[q.id]["project"] = project_brief(proj_map.get(q.project_id)) if q.project_id else None
if "events" in expand:
events = (
await db.execute(
select(QuoteEvent)
.where(QuoteEvent.quote_id.in_(quote_ids))
.order_by(QuoteEvent.occurred_at.asc(), QuoteEvent.id)
)
).scalars().all()
grouped: dict[uuid.UUID, list[QuoteEvent]] = {}
for e in events:
grouped.setdefault(e.quote_id, []).append(e)
for q in quotes:
out[q.id]["events"] = [event_to_dict(e) for e in grouped.get(q.id, [])]
if "line_items" in expand:
items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
grouped_li: dict[uuid.UUID, list[LineItem]] = {}
for li in items:
grouped_li.setdefault(li.quote_id, []).append(li)
for q in quotes:
lis = grouped_li.get(q.id, [])
if lis:
# Contract P0-14: expand=line_items implies full nested resolution —
# material (category + brands), unit, specs, decision, receipt,
# termsheet_ids — regardless of other tokens.
out[q.id]["line_items"] = await serialize_line_items(
db, lis, {"material", "unit", "specs", "decision", "receipt"}, include_termsheet_ids=True
)
else:
out[q.id]["line_items"] = []
if "termsheets" in expand:
termsheets = (
(await db.execute(select(Termsheet).where(Termsheet.quote_id.in_(quote_ids)))).scalars().all()
)
grouped_ts: dict[uuid.UUID, list[Termsheet]] = {}
for ts in termsheets:
grouped_ts.setdefault(ts.quote_id, []).append(ts)
all_ts_ids = [ts.id for ts in termsheets]
steps_by_ts: dict[uuid.UUID, list[TermsheetScheduleStep]] = {}
if all_ts_ids:
steps = (
await db.execute(
select(TermsheetScheduleStep)
.where(TermsheetScheduleStep.termsheet_id.in_(all_ts_ids))
.order_by(TermsheetScheduleStep.step_no)
)
).scalars().all()
for s in steps:
steps_by_ts.setdefault(s.termsheet_id, []).append(s)
for q in quotes:
out[q.id]["termsheets"] = [
_termsheet_quote_shape(ts, steps_by_ts.get(ts.id, [])) for ts in grouped_ts.get(q.id, [])
]
return [out[q.id] for q in quotes]
async def serialize_quote(db: AsyncSession, quote: Quote, expand: set[str]) -> dict:
return (await serialize_quotes(db, [quote], expand))[0]
async def serialize_quote_with_children(db: AsyncSession, quote: Quote) -> dict:
"""POST /quotes response shape: flat quote + flat line_items + flat termsheets."""
d = quote_flat(quote)
out = {quote.id: d}
await _attach_quote_projects(db, [quote], out)
d = out[quote.id]
lis = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
d["line_items"] = [_line_item_flat(li) for li in lis]
tss = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
d["termsheets"] = [_termsheet_flat(ts) for ts in tss]
return d
+76
View File
@@ -0,0 +1,76 @@
"""Invoice / proforma attachments — metadata in `quote_attachments`, bytes in MinIO.
Multi-file contract: a quote may hold any number of attachments per kind
(invoice / proforma). Every upload creates a new DB row and a new object
(object key `{quote_id}/{kind}/{attachment_id}` inside the MinIO bucket).
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.files import QuoteAttachment
from app.services import storage
from app.utils.errors import NotFoundError
def _object_key(quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID) -> str:
return f"{quote_id}/{kind}/{attachment_id}"
async def create(
db: AsyncSession,
quote_id: uuid.UUID,
kind: str,
file_name: str,
content: bytes,
mime_type: str,
) -> QuoteAttachment:
"""Upload the bytes to MinIO, then persist the metadata row."""
attachment_id = uuid.uuid4()
object_key = _object_key(quote_id, kind, attachment_id)
await storage.put_object(object_key, content, mime_type)
attachment = QuoteAttachment(
id=attachment_id,
quote_id=quote_id,
kind=kind,
file_name=file_name,
mime_type=mime_type,
size_bytes=len(content),
object_key=object_key,
)
db.add(attachment)
try:
await db.flush()
except Exception:
# Metadata insert failed — best-effort cleanup of the orphan object.
await storage.remove_object(object_key)
raise
return attachment
async def list_for(db: AsyncSession, quote_id: uuid.UUID, kind: str) -> list[QuoteAttachment]:
result = await db.execute(
select(QuoteAttachment)
.where(QuoteAttachment.quote_id == quote_id, QuoteAttachment.kind == kind)
.order_by(QuoteAttachment.uploaded_at)
)
return list(result.scalars().all())
async def get(db: AsyncSession, attachment_id: uuid.UUID) -> QuoteAttachment:
attachment = await db.get(QuoteAttachment, attachment_id)
if attachment is None:
raise NotFoundError("فایل پیوست یافت نشد")
return attachment
async def read_bytes(attachment: QuoteAttachment) -> bytes:
"""Stream the object bytes from MinIO."""
return await storage.get_object_bytes(attachment.object_key)
async def remove(db: AsyncSession, attachment: QuoteAttachment) -> None:
"""Delete the object from MinIO (best-effort) and the metadata row."""
await storage.remove_object(attachment.object_key)
await db.delete(attachment)
+283
View File
@@ -0,0 +1,283 @@
"""Inventory business logic — applying transactions to stock quantities.
Merged from the software team's shipped inventory-dashboard fork, with the
v2.6 concurrency fix: every read-modify-write of `inventory.quantity`
locks the row with SELECT ... FOR UPDATE so concurrent PM entries cannot
lose updates.
Single source of truth for how each transaction type changes inventory:
- PURCHASE / RETURN / FOUND / TRANSFER_IN → increase (find-or-create row)
- WITHDRAWAL / DAMAGE / WASTE / LOSS / TRANSFER_OUT → decrease (row must exist,
quantity must be sufficient)
- STOCK_COUNT → records physical count; difference vs. recorded is applied
(increase / decrease / no difference) and logged as the transaction quantity.
- TRANSFER_OUT additionally creates the linked TRANSFER_IN row in the
destination warehouse (shared transfer_pair_id).
Inventory rows are uniquely identified by
(warehouse_id, material_id, brand_id, specs_hash) where specs_hash is the
SHA256 of the sorted canonical specs JSON (or 'default' when empty).
"""
import hashlib
import json
import uuid
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import Brand, Material, Unit
from app.models.inventory import (
DECREASING_TYPES,
INCREASING_TYPES,
InventoryItem,
InventoryTransaction,
Warehouse,
)
from app.schemas.inventory import InventoryTransactionCreate
from app.utils.errors import NotFoundError, ValidationError
def canonical_specs_hash(specs: dict | None) -> str:
"""SHA256 of the sorted canonical specs JSON — 'default' when empty."""
if not specs:
return "default"
canonical = json.dumps(specs, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
async def get_warehouse_or_404(db: AsyncSession, warehouse_id) -> Warehouse:
warehouse = await db.get(Warehouse, warehouse_id)
if warehouse is None:
raise NotFoundError("انبار یافت نشد", field="warehouse_id")
return warehouse
async def _find_inventory(
db: AsyncSession,
warehouse_id,
material_id,
brand_id,
specs_hash: str,
for_update: bool = False,
) -> InventoryItem | None:
"""Find the inventory row for (warehouse, material, brand, specs).
With for_update=True the row is locked with SELECT ... FOR UPDATE so
concurrent transactions cannot lose updates on quantity.
"""
stmt = select(InventoryItem).where(
InventoryItem.warehouse_id == warehouse_id,
InventoryItem.material_id == material_id,
InventoryItem.brand_id.is_(None) if brand_id is None
else InventoryItem.brand_id == brand_id,
InventoryItem.specs_hash == specs_hash,
)
if for_update:
stmt = stmt.with_for_update()
return await db.scalar(stmt)
async def _get_or_create_inventory(
db: AsyncSession,
warehouse_id,
material_id,
brand_id,
unit_id: int,
specs: dict | None,
specs_hash: str,
) -> InventoryItem:
"""Return the inventory row for (warehouse, material, brand, specs), creating it if missing."""
row = await _find_inventory(db, warehouse_id, material_id, brand_id, specs_hash, for_update=True)
if row is not None:
if row.unit_id != unit_id:
raise ValidationError(
f"واحد انتخاب‌شده با واحد موجودی فعلی ({row.unit_id}) متفاوت است — از همان واحد استفاده کنید",
field="unit_id",
)
return row
row = InventoryItem(
warehouse_id=warehouse_id,
material_id=material_id,
brand_id=brand_id,
quantity=Decimal("0"),
unit_id=unit_id,
specs=specs or {},
specs_hash=specs_hash,
)
db.add(row)
await db.flush()
return row
async def _validate_refs(db: AsyncSession, payload: InventoryTransactionCreate):
"""Validate material/brand/unit references before any write."""
material = await db.get(Material, payload.material_id)
if material is None:
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
brand = None
if payload.brand_id is not None:
brand = await db.get(Brand, payload.brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد", field="brand_id")
if payload.unit_id is not None:
unit = await db.get(Unit, payload.unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
return material, brand
async def apply_transaction(
db: AsyncSession,
payload: InventoryTransactionCreate,
performed_by: str | None = None,
) -> list[InventoryTransaction]:
"""Apply one inventory operation atomically. Returns the created transaction row(s).
Caller is responsible for the surrounding transaction (async with db.begin()).
"""
await _validate_refs(db, payload)
await get_warehouse_or_404(db, payload.warehouse_id)
t = payload.transaction_type
specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs)
# STOCK_COUNT: apply the difference between counted and recorded quantity.
if t == "STOCK_COUNT":
row = await _find_inventory(
db, payload.warehouse_id, payload.material_id, payload.brand_id, specs_hash, for_update=True
)
if row is None:
raise ValidationError(
"برای این ماده هیچ موجودی ثبت نشده است — ابتدا یک خرید (PURCHASE) ثبت کنید",
field="material_id",
)
recorded = row.quantity
diff = payload.physical_qty - recorded
row.quantity = payload.physical_qty
tx = InventoryTransaction(
inventory_id=row.id,
warehouse_id=row.warehouse_id,
transaction_type="STOCK_COUNT",
quantity=diff,
recorded_qty=recorded,
physical_qty=payload.physical_qty,
performed_by=performed_by,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(tx)
await db.flush()
return [tx]
quantity = payload.quantity
if quantity is None or quantity <= 0:
raise ValidationError("تعداد باید بزرگ‌تر از صفر باشد", field="quantity")
if payload.unit_id is None:
raise ValidationError("واحد (unit_id) الزامی است", field="unit_id")
created: list[InventoryTransaction] = []
# TRANSFER_OUT: source decreases; destination (linked) increases.
if t == "TRANSFER_OUT":
source = await _find_inventory(
db, payload.warehouse_id, payload.material_id, payload.brand_id, specs_hash, for_update=True
)
if source is None:
raise ValidationError(
"برای این ماده در انبار مبدأ موجودی ثبت نشده است — ابتدا یک خرید (PURCHASE) ثبت کنید",
field="warehouse_id",
)
if source.quantity < quantity:
raise ValidationError(
f"موجودی کافی نیست — موجودی فعلی: {source.quantity}",
field="quantity",
)
await get_warehouse_or_404(db, payload.dest_warehouse_id)
source.quantity -= quantity
pair_id = uuid.uuid4()
out_tx = InventoryTransaction(
inventory_id=source.id,
warehouse_id=source.warehouse_id,
transaction_type="TRANSFER_OUT",
quantity=-quantity,
transfer_pair_id=pair_id,
performed_by=performed_by,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(out_tx)
created.append(out_tx)
dest = await _get_or_create_inventory(
db, payload.dest_warehouse_id, payload.material_id, payload.brand_id,
payload.unit_id, specs, specs_hash,
)
dest.quantity += quantity
in_tx = InventoryTransaction(
inventory_id=dest.id,
warehouse_id=dest.warehouse_id,
transaction_type="TRANSFER_IN",
quantity=quantity,
transfer_pair_id=pair_id,
performed_by=performed_by,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(in_tx)
created.append(in_tx)
await db.flush()
return created
# Increasing types: find-or-create the row, then add.
if t in INCREASING_TYPES:
row = await _get_or_create_inventory(
db, payload.warehouse_id, payload.material_id, payload.brand_id,
payload.unit_id, specs, specs_hash,
)
row.quantity += quantity
tx = InventoryTransaction(
inventory_id=row.id,
warehouse_id=row.warehouse_id,
transaction_type=t,
quantity=quantity,
performed_by=performed_by,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(tx)
await db.flush()
return [tx]
# Decreasing types: row must exist and have enough quantity.
if t in DECREASING_TYPES:
row = await _find_inventory(
db, payload.warehouse_id, payload.material_id, payload.brand_id, specs_hash, for_update=True
)
if row is None:
raise ValidationError(
"برای این ماده موجودی ثبت نشده است — ابتدا یک خرید (PURCHASE) ثبت کنید",
field="material_id",
)
if row.quantity < quantity:
raise ValidationError(
f"موجودی کافی نیست — موجودی فعلی: {row.quantity}",
field="quantity",
)
row.quantity -= quantity
tx = InventoryTransaction(
inventory_id=row.id,
warehouse_id=row.warehouse_id,
transaction_type=t,
quantity=-quantity,
performed_by=performed_by,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(tx)
await db.flush()
return [tx]
raise ValidationError(f"نوع تراکنش نامعتبر است: {t}", field="transaction_type")
+197
View File
@@ -0,0 +1,197 @@
"""Material domain services: EAV value coercion/validation + atomic creation."""
from datetime import date
from decimal import Decimal, InvalidOperation
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import (
AttributeDefinition,
Brand,
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialCategory,
Unit,
)
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
from app.utils.errors import ConflictError, NotFoundError, ValidationError
def coerce_eav_value(data_type: str, value, field: str = "value"):
"""Coerce a client value against attribute_definitions.data_type.
Returns the Python value to store in the matching typed column.
Raises 400 VALIDATION_ERROR on mismatch (contract error shape).
"""
if data_type == "number":
try:
return Decimal(str(value))
except (ValueError, InvalidOperation, TypeError):
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected number", field=field)
if data_type == "integer":
try:
if isinstance(value, str):
if "." in value or "e" in value.lower() or "E" in value:
raise ValueError
return int(value)
if isinstance(value, bool): # bool is an int subclass — reject it explicitly
raise ValueError
if isinstance(value, (int, float, Decimal)):
dec = Decimal(str(value))
if dec != dec.to_integral_value():
raise ValueError
return int(dec)
raise ValueError
except (ValueError, InvalidOperation, TypeError):
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected integer", field=field)
if data_type in ("string", "enum"):
if value is None:
raise ValidationError("مقدار نمی‌تواند خالی باشد", field=field)
return str(value)
if data_type == "boolean":
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("true", "1", "yes"):
return True
if lowered in ("false", "0", "no"):
return False
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected boolean", field=field)
if data_type == "date":
if isinstance(value, date):
return value
try:
return date.fromisoformat(str(value))
except ValueError:
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected date (YYYY-MM-DD)", field=field)
raise ValidationError(f"data_type نامعتبر است: {data_type}", field="data_type")
def typed_column_values(data_type: str, coerced):
"""Map a coerced value to the EAV typed column dict."""
if data_type in ("number", "integer"):
return {"value_number": coerced}
if data_type in ("string", "enum"):
return {"value_string": coerced}
if data_type == "boolean":
return {"value_boolean": coerced}
if data_type == "date":
return {"value_date": coerced}
raise ValidationError(f"data_type نامعتبر است: {data_type}", field="data_type")
async def validate_attributes(
db: AsyncSession, attributes: list[MaterialAttributeIn]
) -> list[tuple[AttributeDefinition, dict]]:
"""Validate an EAV payload; returns [(definition, typed-column-dict)]."""
if not attributes:
return []
def_ids = {a.attribute_definition_id for a in attributes}
defs = {d.id: d for d in (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars()}
result: list[tuple[AttributeDefinition, dict]] = []
for index, attr in enumerate(attributes):
ad = defs.get(attr.attribute_definition_id)
if ad is None:
raise NotFoundError("ویژگی یافت نشد", field=f"attributes[{index}].attribute_definition_id")
field = f"attributes[{index}].value"
coerced = coerce_eav_value(ad.data_type, attr.value, field)
if ad.data_type == "enum":
allowed = ad.enum_values or []
if allowed and str(attr.value) not in {str(v) for v in allowed}:
raise ValidationError("مقدار در گزینه‌های مجاز این ویژگی نیست", field=field)
result.append((ad, typed_column_values(ad.data_type, coerced)))
return result
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
"""Create a material + brand_materials rows + EAV values in ONE transaction.
v2.5/2.6: category_id is OPTIONAL — a global material may be created
without a category (it gets derived automatically from the first linked
supplier). name is GLOBALLY unique.
"""
async with db.begin():
duplicate = await db.scalar(
select(Material.id).where(Material.name == payload.name)
)
if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
if payload.category_id is not None:
if await db.get(MaterialCategory, payload.category_id) is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material = Material(
category_id=payload.category_id,
name=payload.name,
topic=payload.topic,
canonical_unit_id=payload.canonical_unit_id,
status=payload.status,
)
db.add(material)
await db.flush()
if payload.brand_ids:
brands = (await db.execute(select(Brand).where(Brand.id.in_(payload.brand_ids)))).scalars().all()
found = {b.id for b in brands}
for brand_id in payload.brand_ids:
if brand_id not in found:
raise NotFoundError("برند یافت نشد", field="brand_ids")
db.add(BrandMaterial(brand_id=brand_id, material_id=material.id))
validated = await validate_attributes(db, payload.attributes)
for ad, values in validated:
db.add(
MaterialAttributeValue(
material_id=material.id, attribute_definition_id=ad.id, **values
)
)
return material
async def update_material(db: AsyncSession, material: Material, payload: MaterialUpdate) -> Material:
"""Partial update (name, canonical_unit_id, status). EAV/brands are NOT edited here.
v2.5/2.6: category is DERIVED (from the first linked supplier) — it is
no longer part of the update payload.
"""
async with db.begin():
if payload.name is not None:
duplicate = await db.scalar(
select(Material.id).where(
Material.name == payload.name,
Material.id != material.id,
)
)
if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
if payload.name is not None:
material.name = payload.name
if payload.topic is not None:
material.topic = payload.topic
if payload.canonical_unit_id is not None and payload.canonical_unit_id != material.canonical_unit_id:
if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material.canonical_unit_id = payload.canonical_unit_id
if payload.status is not None:
material.status = payload.status
return material
async def ensure_brand_material_belong(
db: AsyncSession, brand_material_id: UUID | None, material_id: UUID
) -> None:
"""App-layer integrity check: brand_material_id must belong to material_id (migration design note)."""
if brand_material_id is None:
return
bm = await db.get(BrandMaterial, brand_material_id)
if bm is None:
raise NotFoundError("برند-متریال یافت نشد", field="brand_material_id")
if bm.material_id != material_id:
raise ConflictError("brand_material_id متعلق به این متریال نیست", field="brand_material_id")
+848
View File
@@ -0,0 +1,848 @@
"""Quote lifecycle services — nested creation, acceptance, arrival, transitions.
Every mutation here runs in ONE ``async with db.begin()`` block; any failure
rolls the whole operation back. All state changes log a quote_event in the
same transaction.
"""
import logging
from datetime import date, datetime, timezone
from uuid import UUID
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.procurement import (
ItemDecision,
ItemReceipt,
ItemTermsheet,
LineItem,
LineItemProjects,
Project,
Quote,
QuoteEvent,
QuoteProjects,
Supplier,
Termsheet,
TermsheetScheduleStep,
)
from app.schemas.procurement import (
AcceptQuoteIn,
ArriveItemsIn,
QuoteChildrenReplaceIn,
QuoteCreate,
QuoteUpdate,
RejectQuoteIn,
)
from app.services.material_service import ensure_brand_material_belong
from app.utils.errors import ConflictError, NotFoundError, ValidationError
logger = logging.getLogger(__name__)
QUOTE_STATUSES = ("offered", "accepted", "arrived", "rejected", "expired")
# Backward transitions allowed via PATCH /quotes/{id} (contract P0-17).
BACKWARD_TRANSITIONS: dict[str, set[str]] = {
"accepted": {"offered"},
"arrived": {"accepted", "offered"},
"rejected": {"offered"},
"expired": {"offered"},
"offered": set(),
}
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def _add_event(db: AsyncSession, quote_id: UUID, event_type: str, payload: dict | None = None, actor: str | None = None) -> None:
db.add(QuoteEvent(quote_id=quote_id, event_type=event_type, payload=payload or {}, actor=actor))
async def get_quote_or_404(db: AsyncSession, quote_id: UUID) -> Quote:
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
return quote
# ---------------------------------------------------------------------------
# POST /quotes — nested creation in ONE transaction (temp_id cross-referencing)
# ---------------------------------------------------------------------------
async def _insert_children(
db: AsyncSession,
quote_id: UUID,
quote_supplier_id: UUID,
line_items,
termsheets,
item_termsheets,
) -> None:
"""Insert line items + termsheets + item_termsheets links + pending decisions.
Shared by create_quote and replace_quote_children. temp_id values are
cross-referenced between line_items and item_termsheets inside the payload.
quote_supplier_id drives the v2.5 supplier<->material auto-linking.
"""
# --- temp_id sanity (400) ---
li_temp_ids = [li.temp_id for li in line_items]
ts_temp_ids = [ts.temp_id for ts in termsheets]
if len(li_temp_ids) != len(set(li_temp_ids)):
raise ValidationError("temp_id خطوط آیتم باید یکتا باشند", field="line_items")
if len(ts_temp_ids) != len(set(ts_temp_ids)):
raise ValidationError("temp_id ترم‌شیت‌ها باید یکتا باشند", field="termsheets")
# --- line items: material / unit / brand_material checks ---
from app.models.catalog import Material, Unit
for li in line_items:
if await db.get(Material, li.material_id) is None:
raise NotFoundError("متریال یافت نشد", field="line_items.material_id")
if await db.get(Unit, li.unit_id) is None:
raise NotFoundError("واحد یافت نشد", field="line_items.unit_id")
await ensure_brand_material_belong(db, li.brand_material_id, li.material_id)
# --- insert line items; map temp_id -> real id ---
li_id_map: dict[str, UUID] = {}
for li in line_items:
row = LineItem(
quote_id=quote_id,
material_id=li.material_id,
brand_material_id=li.brand_material_id,
unit_id=li.unit_id,
quantity=li.quantity,
notes=li.notes,
)
db.add(row)
await db.flush()
li_id_map[li.temp_id] = row.id
# پروژه‌های تخصیص‌یافته به این قلم
for pid in (li.project_ids or []):
if await db.get(Project, pid) is None:
raise NotFoundError("پروژه یافت نشد", field="line_items.project_ids")
db.add(LineItemProjects(line_item_id=row.id, project_id=pid))
# v2.5: auto-link the material to the quote's supplier (catalog edge).
from app.services.supplier_material_service import link_supplier_material
await link_supplier_material(db, quote_supplier_id, li.material_id)
# --- insert termsheets + schedule steps; map temp_id -> real id ---
ts_id_map: dict[str, UUID] = {}
for ts in termsheets:
row = Termsheet(
quote_id=quote_id,
label=ts.label,
status="offered",
discount_type=ts.discount_type,
discount_value=ts.discount_value,
)
db.add(row)
await db.flush()
ts_id_map[ts.temp_id] = row.id
await _insert_schedule_steps(db, row.id, ts.schedule_steps, existing_count=0)
# --- item_termsheets links (resolve temp_ids) ---
# اقلام بدون قیمت اصلاً لینک نمی‌شوند — ذخیره‌ی پیشنهاد ناقص مجاز است.
for link in item_termsheets:
li_id = li_id_map.get(link.line_item_temp_id)
ts_id = ts_id_map.get(link.termsheet_temp_id)
if li_id is None:
raise ValidationError("line_item_temp_id نامعتبر است", field="item_termsheets.line_item_temp_id")
if ts_id is None:
raise ValidationError("termsheet_temp_id نامعتبر است", field="item_termsheets.termsheet_temp_id")
db.add(
ItemTermsheet(
line_item_id=li_id, termsheet_id=ts_id,
unit_price=link.unit_price, min_order_qty=link.min_order_qty,
)
)
# --- one pending item_decision per line_item (1:1 UNIQUE) ---
for li_id in li_id_map.values():
db.add(ItemDecision(line_item_id=li_id, status="pending"))
async def _sync_quote_projects(db: AsyncSession, quote: Quote, project_ids: list[UUID]) -> None:
"""Full replacement of the quote's project junction rows."""
await db.execute(
delete(QuoteProjects)
.where(QuoteProjects.quote_id == quote.id)
.execution_options(synchronize_session=False)
)
for pid in project_ids:
if await db.get(Project, pid) is None:
raise NotFoundError("پروژه یافت نشد", field="project_ids")
db.add(QuoteProjects(quote_id=quote.id, project_id=pid))
# legacy single-project column = اولین پروژه (برای فیلترها و آمار قدیمی)
quote.project_id = project_ids[0] if project_ids else None
async def create_quote(db: AsyncSession, payload: QuoteCreate, actor: str | None = None) -> Quote:
async with db.begin():
# --- FK existence (404) ---
if await db.get(Supplier, payload.supplier_id) is None:
raise NotFoundError("تأمین‌کننده یافت نشد", field="supplier_id")
project_ids = list(payload.project_ids)
if payload.project_id is not None and payload.project_id not in project_ids:
project_ids.insert(0, payload.project_id)
# --- insert quote ---
quote = Quote(
supplier_id=payload.supplier_id,
project_id=project_ids[0] if project_ids else payload.project_id,
quote_date=payload.quote_date or utcnow(),
expiry_date=payload.expiry_date,
is_official=payload.is_official,
notes=payload.notes,
status="offered", # server-set; never client-settable
)
db.add(quote)
await db.flush()
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
if project_ids:
await _sync_quote_projects(db, quote, project_ids)
# --- offered event ---
_add_event(db, quote.id, "offered", {"by": "system"}, actor)
return quote
async def _insert_schedule_steps(
db: AsyncSession, termsheet_id: UUID, steps, existing_count: int = 0
) -> None:
"""Insert schedule steps; omitted step_no is auto-numbered MAX+1 onward."""
next_no = existing_count
for index, step in enumerate(steps):
step_no = step.step_no if step.step_no is not None else next_no + index + 1
db.add(
TermsheetScheduleStep(
termsheet_id=termsheet_id, step_no=step_no,
due_date=step.due_date, amount=step.amount, note=step.note,
)
)
async def next_step_no(db: AsyncSession, termsheet_id: UUID) -> int:
max_no = await db.scalar(
select(func.max(TermsheetScheduleStep.step_no)).where(
TermsheetScheduleStep.termsheet_id == termsheet_id
)
)
return (max_no or 0) + 1
# ---------------------------------------------------------------------------
# PATCH /quotes/{id} — metadata + backward status transitions
# ---------------------------------------------------------------------------
async def update_quote(db: AsyncSession, quote_id: UUID, payload: QuoteUpdate, actor: str | None = None) -> Quote:
async with db.begin():
quote = await get_quote_or_404(db, quote_id)
changes: list[str] = []
if payload.supplier_id is not None and payload.supplier_id != quote.supplier_id:
if await db.get(Supplier, payload.supplier_id) is None:
raise NotFoundError("تأمین‌کننده یافت نشد", field="supplier_id")
quote.supplier_id = payload.supplier_id
changes.append("supplier_id")
if payload.project_ids is not None:
project_ids = list(payload.project_ids)
if payload.project_id is not None and payload.project_id not in project_ids:
project_ids.insert(0, payload.project_id)
await _sync_quote_projects(db, quote, project_ids)
changes.append("project_ids")
elif payload.project_id is not None and payload.project_id != quote.project_id:
if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="project_id")
quote.project_id = payload.project_id
changes.append("project_id")
if payload.quote_date is not None and payload.quote_date != quote.quote_date:
quote.quote_date = payload.quote_date
changes.append("quote_date")
if payload.expiry_date is not None and payload.expiry_date != quote.expiry_date:
quote.expiry_date = payload.expiry_date
changes.append("expiry_date")
if payload.is_official is not None and payload.is_official != quote.is_official:
quote.is_official = payload.is_official
changes.append("is_official")
if payload.notes is not None and payload.notes != quote.notes:
quote.notes = payload.notes
changes.append("notes")
if payload.status is not None and payload.status != quote.status:
await _apply_status_transition(db, quote, payload.status)
changes.append("status")
if changes:
_add_event(db, quote.id, "edited", {"changed_fields": changes}, actor)
return quote
async def replace_quote_children(
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
) -> Quote:
"""Full replacement of a quote's line items + termsheets (edit modal).
Only allowed while the quote is still offered — acceptance data
(decisions/receipts) would otherwise be destroyed.
"""
async with db.begin():
quote = await get_quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError(
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
field="status",
)
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
await db.execute(
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
)
await db.execute(
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
)
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
return quote
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقال‌های عقب‌گرد پذیرفته می‌شود", field="status")
if new_status == "offered":
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
quote.accepted_date = None
quote.arrived_date = None
await db.execute(
update(Termsheet)
.where(Termsheet.quote_id == quote.id, Termsheet.status == "accepted")
.values(status="offered")
.execution_options(synchronize_session=False)
)
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
await db.execute(
update(ItemDecision)
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
.values(
status="pending",
decided_at=None,
chosen_item_termsheet_id=None,
adjusted_unit_price=None,
adjusted_notes=None,
)
.execution_options(synchronize_session=False)
)
# item_receipts are deliberately NOT deleted (contract P0-17).
elif new_status == "accepted": # arrived -> accepted
quote.arrived_date = None
quote.status = new_status
# ---------------------------------------------------------------------------
# PATCH /quotes/{id}/accept — the accept flow (ONE transaction, FOR UPDATE)
# ---------------------------------------------------------------------------
async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn, actor: str | None = None) -> Quote:
async with db.begin():
quote = await db.scalar(select(Quote).where(Quote.id == quote_id).with_for_update())
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if quote.status != "offered":
raise ConflictError("فقط پیشنهادهای offered قابل پذیرش هستند")
# --- validate accepted_termsheets ---
existing_ids = {t.id for t in payload.accepted_termsheets if t.id is not None}
existing_ts: dict[UUID, Termsheet] = {}
if existing_ids:
rows = (
await db.execute(
select(Termsheet).where(Termsheet.id.in_(existing_ids), Termsheet.quote_id == quote.id)
)
).scalars().all()
existing_ts = {t.id: t for t in rows}
missing = existing_ids - set(existing_ts)
if missing:
raise NotFoundError("ترم‌شیت یافت نشد یا متعلق به این پیشنهاد نیست", field="accepted_termsheets")
# --- validate item_decisions belong to the quote ---
li_ids = {d.line_item_id for d in payload.item_decisions}
if li_ids:
rows = (
await db.execute(
select(LineItem.id).where(LineItem.id.in_(li_ids), LineItem.quote_id == quote.id)
)
).scalars().all()
found = set(rows)
missing = li_ids - found
if missing:
raise NotFoundError("خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_decisions")
# --- resolve/create termsheets (existing: replace steps; new: insert) ---
ts_ids_by_index: list[UUID] = []
for index, t in enumerate(payload.accepted_termsheets):
if t.id is not None:
ts = existing_ts[t.id]
# replace schedule steps (DELETE old + INSERT new)
old_steps = (
(await db.execute(select(TermsheetScheduleStep).where(TermsheetScheduleStep.termsheet_id == ts.id)))
).scalars().all()
for step in old_steps:
await db.delete(step)
await db.flush()
await _insert_schedule_steps(db, ts.id, t.schedule_steps, existing_count=0)
if t.label is not None:
ts.label = t.label
ts.discount_type = t.discount_type
ts.discount_value = t.discount_value
ts_ids_by_index.append(ts.id)
else:
if not t.label:
raise ValidationError("برای ترم‌شیت جدید label الزامی است", field="accepted_termsheets")
new_ts = Termsheet(
quote_id=quote.id,
label=t.label,
status="offered",
discount_type=t.discount_type,
discount_value=t.discount_value,
)
db.add(new_ts)
await db.flush()
await _insert_schedule_steps(db, new_ts.id, t.schedule_steps, existing_count=0)
ts_ids_by_index.append(new_ts.id)
# --- process item decisions (UPSERT) ---
chosen_ts_ids: set[UUID] = set()
for decision_in in payload.item_decisions:
decision = await db.scalar(
select(ItemDecision).where(ItemDecision.line_item_id == decision_in.line_item_id).with_for_update()
)
if decision_in.status == "chosen":
index = decision_in.accepted_termsheet_index
if index is None:
raise ValidationError(
"برای قلم انتخاب‌شده accepted_termsheet_index الزامی است", field="item_decisions"
)
if not 0 <= index < len(ts_ids_by_index):
raise ValidationError(
"accepted_termsheet_index خارج از محدوده ترم‌شیت‌ها است", field="item_decisions"
)
if decision_in.adjusted_unit_price is None:
raise ValidationError(
"برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions"
)
termsheet_id = ts_ids_by_index[index]
it = await db.scalar(
select(ItemTermsheet).where(
ItemTermsheet.line_item_id == decision_in.line_item_id,
ItemTermsheet.termsheet_id == termsheet_id,
)
)
if it is None:
it = ItemTermsheet(
line_item_id=decision_in.line_item_id,
termsheet_id=termsheet_id,
unit_price=decision_in.adjusted_unit_price,
)
db.add(it)
else:
it.unit_price = decision_in.adjusted_unit_price
await db.flush()
if decision is None:
decision = ItemDecision(line_item_id=decision_in.line_item_id, status="pending")
db.add(decision)
decision.status = "chosen"
decision.chosen_item_termsheet_id = it.id
decision.adjusted_unit_price = decision_in.adjusted_unit_price
decision.adjusted_notes = decision_in.adjusted_notes
decision.decided_at = utcnow()
# v2.6: the SINGLE purchased project (proc manager picks it;
# auto-filled when the offer had exactly one project).
if decision_in.project_id is not None:
decision.project_id = decision_in.project_id
else:
line_projs = (
(await db.execute(
select(LineItemProjects.project_id).where(
LineItemProjects.line_item_id == decision_in.line_item_id
)
))
.scalars()
.all()
)
if len(line_projs) == 1:
decision.project_id = line_projs[0]
if decision.project_id is not None:
if await db.get(Project, decision.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
chosen_ts_ids.add(termsheet_id)
else: # rejected
if decision is None:
decision = ItemDecision(line_item_id=decision_in.line_item_id, status="pending")
db.add(decision)
decision.status = "rejected"
decision.chosen_item_termsheet_id = None
decision.adjusted_unit_price = None
decision.adjusted_notes = decision_in.adjusted_notes
decision.decided_at = utcnow()
# --- per-material project allocation (تخصیص پروژه به هر قلم) ---
for entry in payload.item_projects:
if entry.line_item_id not in li_ids:
raise NotFoundError(
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
)
await db.execute(
delete(LineItemProjects)
.where(LineItemProjects.line_item_id == entry.line_item_id)
.execution_options(synchronize_session=False)
)
for pid in entry.project_ids:
if await db.get(Project, pid) is None:
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
# termsheets referenced by at least one chosen item -> accepted
if chosen_ts_ids:
await db.execute(
update(Termsheet)
.where(Termsheet.id.in_(chosen_ts_ids))
.values(status="accepted")
.execution_options(synchronize_session=False)
)
quote.status = "accepted"
quote.accepted_date = utcnow()
_add_event(db, quote.id, "accepted", {"accepted_by": actor}, actor)
return quote
# ---------------------------------------------------------------------------
# Arrival
# ---------------------------------------------------------------------------
async def _decision_for_line_item(db: AsyncSession, line_item_id: UUID) -> ItemDecision:
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
decision = ItemDecision(line_item_id=line_item_id, status="pending")
db.add(decision)
await db.flush()
return decision
async def arrive_quote(db: AsyncSession, quote_id: UUID, actor: str | None = None) -> Quote:
"""Full-arrival shortcut: receipt for every non-rejected line item."""
async with db.begin():
quote = await db.scalar(select(Quote).where(Quote.id == quote_id).with_for_update())
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if quote.status != "accepted":
raise ConflictError("فقط پیشنهادهای پذیرفته‌شده قابل تحویل هستند")
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
for li in line_items:
decision = await _decision_for_line_item(db, li.id)
if decision.status == "rejected":
continue
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if existing is None:
db.add(ItemReceipt(item_decision_id=decision.id, received_qty=li.quantity))
quote.status = "arrived"
quote.arrived_date = utcnow()
_add_event(db, quote.id, "arrived", {"source": "arrive"}, actor)
return quote
async def arrive_items(db: AsyncSession, quote_id: UUID, payload: ArriveItemsIn, actor: str | None = None) -> Quote:
"""Per-item arrival with full replacement semantics for receipts."""
async with db.begin():
quote = await db.scalar(select(Quote).where(Quote.id == quote_id).with_for_update())
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if quote.status != "accepted":
raise ConflictError("فقط پیشنهادهای پذیرفته‌شده قابل تحویل هستند")
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
li_by_id = {li.id: li for li in line_items}
items_in = {item.line_item_id for item in payload.items}
missing = items_in - set(li_by_id)
if missing:
raise NotFoundError("خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="items")
# upsert receipts for the provided items
for item in payload.items:
li = li_by_id[item.line_item_id]
decision = await _decision_for_line_item(db, li.id)
if decision.status == "rejected":
raise ConflictError("قلم ردشده قابل دریافت نیست", field="items")
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if receipt is None:
db.add(
ItemReceipt(
item_decision_id=decision.id,
received_at=item.received_at or utcnow(),
received_qty=item.received_qty,
on_time=item.on_time,
quality_note=item.quality_note,
)
)
else:
receipt.received_at = item.received_at or receipt.received_at
receipt.received_qty = item.received_qty
receipt.on_time = item.on_time
receipt.quality_note = item.quality_note
# full replacement: delete receipts of non-rejected items not in the list
for li in line_items:
if li.id in items_in:
continue
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
if decision is None or decision.status == "rejected":
continue
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if receipt is not None:
await db.delete(receipt)
# auto-arrive when every non-rejected line item has a receipt
all_received = True
for li in line_items:
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
if decision is None or decision.status == "rejected":
continue
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if receipt is None:
all_received = False
break
if all_received:
quote.status = "arrived"
quote.arrived_date = utcnow()
_add_event(db, quote.id, "arrived", {"source": "arrive-items"}, actor)
return quote
# ---------------------------------------------------------------------------
# Reject / expire / delete
# ---------------------------------------------------------------------------
async def reject_quote(db: AsyncSession, quote_id: UUID, payload: RejectQuoteIn, actor: str | None = None) -> Quote:
async with db.begin():
quote = await db.scalar(select(Quote).where(Quote.id == quote_id).with_for_update())
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if quote.status != "offered":
raise ConflictError("فقط پیشنهادهای offered قابل رد شدن هستند")
quote.status = "rejected"
_add_event(
db, quote.id, "rejected",
{"reason": payload.reason} if payload.reason else {},
actor,
)
return quote
async def expire_quote(db: AsyncSession, quote_id: UUID, actor: str | None = None) -> Quote:
async with db.begin():
quote = await db.scalar(select(Quote).where(Quote.id == quote_id).with_for_update())
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if quote.status != "offered":
raise ConflictError("فقط پیشنهادهای offered قابل انقضا هستند")
quote.status = "expired"
_add_event(db, quote.id, "expired", {}, actor)
return quote
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
async with db.begin():
quote = await get_quote_or_404(db, quote_id)
if quote.status in ("accepted", "arrived"):
raise ConflictError("پیشنهادهای پذیرفته‌شده یا رسیده قابل حذف نیستند")
# count cascaded children for the response envelope
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
li_ids = [li.id for li in line_items]
decisions = (
(await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
if li_ids else []
)
dec_ids = [d.id for d in decisions]
receipts = (
(await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
if dec_ids else []
)
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
await db.delete(quote)
return {
"id": str(quote.id),
"deleted": True,
"cascaded": {
"line_items": len(line_items),
"termsheets": len(termsheets),
"events": len(events),
"decisions": len(decisions),
"receipts": len(receipts),
},
}
# ---------------------------------------------------------------------------
# Statistics
# ---------------------------------------------------------------------------
async def quote_stats(
db: AsyncSession,
supplier_id: UUID | None = None,
project_id: UUID | None = None,
quote_date_from=None,
quote_date_to=None,
category_id: UUID | None = None,
) -> dict:
conditions = []
if supplier_id is not None:
conditions.append(Quote.supplier_id == supplier_id)
if project_id is not None:
conditions.append(Quote.project_id == project_id)
if quote_date_from is not None:
conditions.append(Quote.quote_date >= quote_date_from)
if quote_date_to is not None:
conditions.append(Quote.quote_date <= quote_date_to)
if category_id is not None:
conditions.append(_quote_has_category(category_id))
rows = (
await db.execute(
select(Quote.status, func.count()).where(*conditions).group_by(Quote.status)
)
).all()
counts = {status: 0 for status in QUOTE_STATUSES}
for status, count in rows:
counts[status] = count
avg = await db.scalar(
select(
func.avg(func.extract("epoch", Quote.arrived_date - Quote.accepted_date) / 86400.0)
).where(
Quote.status == "arrived",
Quote.arrived_date.is_not(None),
Quote.accepted_date.is_not(None),
*conditions,
)
)
return {
"total": sum(counts.values()),
"offered": counts["offered"],
"accepted": counts["accepted"],
"arrived": counts["arrived"],
"rejected": counts["rejected"],
"expired": counts["expired"],
"average_lead_time_days": int(round(float(avg))) if avg is not None else None,
}
def _quote_has_category(category_id: UUID):
"""EXISTS: quote has a line item whose material belongs to category_id."""
from app.models.catalog import Material
sub = (
select(1)
.where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
)
)
return func.exists(sub)
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
"""Counts + total value per quote status for one project.
total_value: SUM(quantity × unit_price) over line items using the chosen
termsheet link first, else the first link. Rejected line items are
excluded from value sums (contract P1-14).
"""
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all()
if not quotes:
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
return {"project_id": str(project_id), "total_quotes": 0, **empty}
quote_ids = [q.id for q in quotes]
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
li_ids = [li.id for li in line_items]
decisions = {
d.line_item_id: d
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
} if li_ids else {}
item_termsheets = (
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
) if li_ids else []
its_by_id = {it.id: it for it in item_termsheets}
its_by_line: dict[UUID, list[ItemTermsheet]] = {}
for it in item_termsheets:
its_by_line.setdefault(it.line_item_id, []).append(it)
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
for q in quotes:
per_status[q.status]["count"] += 1
for li in line_items:
decision = decisions.get(li.id)
if decision is not None and decision.status == "rejected":
continue # rejected items excluded from value sums
if decision is not None and decision.chosen_item_termsheet_id is not None:
price_it = its_by_id.get(decision.chosen_item_termsheet_id)
else:
links = its_by_line.get(li.id, [])
price_it = links[0] if links else None
if price_it is None:
continue
quote = next(q for q in quotes if q.id == li.quote_id)
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
for status, values in per_status.items():
result[status] = {"count": values["count"], "total_value": values["total_value"]}
return result
# ---------------------------------------------------------------------------
# Background auto-expiry
# ---------------------------------------------------------------------------
async def auto_expire_quotes(db: AsyncSession) -> int:
"""Expire quotes with expiry_date < today AND status = 'offered' (scheduler)."""
today = date.today()
rows = await db.execute(
select(Quote).where(
Quote.status == "offered",
Quote.expiry_date.is_not(None),
Quote.expiry_date < today,
)
)
quotes = rows.scalars().all()
if not quotes:
return 0
async with db.begin():
for q in quotes:
q.status = "expired"
_add_event(
db, q.id, "expired",
{"reason": "منقضی خودکار — تاریخ اعتبار گذشته است"}, "سیستم",
)
logger.info("Auto-expired %d quote(s)", len(quotes))
return len(quotes)
+68
View File
@@ -0,0 +1,68 @@
"""MinIO client — S3-compatible object storage for invoice / proforma attachments.
The `minio` SDK is synchronous; every call is wrapped with
``asyncio.to_thread`` so the event loop is never blocked.
"""
import asyncio
import io
import logging
from functools import lru_cache
from minio import Minio
from app.config import settings
logger = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def get_client() -> Minio:
"""Lazily built Minio client from the PROCUREMENT_MINIO_* settings."""
return Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE,
)
async def ensure_bucket() -> None:
"""Create the attachments bucket if it does not exist (idempotent)."""
client = get_client()
try:
exists = await asyncio.to_thread(client.bucket_exists, settings.MINIO_BUCKET)
if not exists:
await asyncio.to_thread(client.make_bucket, settings.MINIO_BUCKET)
logger.info("Created MinIO bucket %r", settings.MINIO_BUCKET)
except Exception:
logger.exception("MinIO bucket check failed — is the minio service reachable?")
async def put_object(key: str, content: bytes, mime_type: str) -> None:
"""Upload a byte payload to the bucket (overwrites any existing object)."""
await asyncio.to_thread(
get_client().put_object,
settings.MINIO_BUCKET,
key,
io.BytesIO(content),
length=len(content),
content_type=mime_type,
)
async def get_object_bytes(key: str) -> bytes:
"""Download an object's bytes from the bucket."""
response = await asyncio.to_thread(get_client().get_object, settings.MINIO_BUCKET, key)
try:
return response.read()
finally:
response.close()
response.release_conn()
async def remove_object(key: str) -> None:
"""Delete an object; missing objects are ignored (best-effort)."""
try:
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
except Exception:
logger.warning("Could not remove MinIO object %r — ignoring", key)
@@ -0,0 +1,63 @@
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow.
Linking a material to a supplier derives materials.category_id from the
supplier's category when the material has none (first-link-wins). The same
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
this service applies it in the application layer so the ORM state is
consistent inside the same transaction.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import Material
from app.models.procurement import Supplier, SupplierMaterial
from app.utils.errors import NotFoundError, ValidationError
async def link_supplier_material(
db: AsyncSession, supplier_id, material_id, is_active: bool = True
) -> SupplierMaterial:
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins).
Caller is responsible for the surrounding transaction.
"""
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد", field="supplier_id")
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد", field="material_id")
link = await db.scalar(
select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id,
SupplierMaterial.material_id == material_id,
)
)
if link is not None:
if not link.is_active and is_active:
link.is_active = True
return link
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
db.add(link)
await db.flush()
# First-link-wins: only when the material has no category yet.
if material.category_id is None:
material.category_id = supplier.category_id
return link
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
"""Remove the link. The material's category is NEVER auto-cleared (documented rule)."""
link = await db.scalar(
select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id,
SupplierMaterial.material_id == material_id,
)
)
if link is None:
raise ValidationError("این متریال به این تأمین‌کننده متصل نیست", field="material_id")
await db.delete(link)
await db.flush()
View File
+51
View File
@@ -0,0 +1,51 @@
"""Application error type + standardized error envelope.
All errors are returned as: {"error": {"code", "message", "field"}}
(contracts: expert-opinion-p0.md «General Shared Contracts»).
"""
class AppError(Exception):
"""Base error carrying an HTTP status and the contract error envelope."""
def __init__(self, status_code: int, code: str, message: str, field: str | None = None):
super().__init__(message)
self.status_code = status_code
self.code = code
self.message = message
self.field = field
@property
def body(self) -> dict:
error = {"code": self.code, "message": self.message}
if self.field is not None:
error["field"] = self.field
return {"error": error}
class ValidationError(AppError):
"""400 VALIDATION_ERROR — bad input (types, business validation)."""
def __init__(self, message: str, field: str | None = None):
super().__init__(400, "VALIDATION_ERROR", message, field)
class NotFoundError(AppError):
"""404 NOT_FOUND."""
def __init__(self, message: str = "رکورد یافت نشد", field: str | None = None):
super().__init__(404, "NOT_FOUND", message, field)
class ConflictError(AppError):
"""409 CONFLICT — duplicates, invalid state transitions, protected deletes."""
def __init__(self, message: str, field: str | None = None):
super().__init__(409, "CONFLICT", message, field)
class ForbiddenError(AppError):
"""403 FORBIDDEN — role / ownership checks."""
def __init__(self, message: str, field: str | None = None):
super().__init__(403, "FORBIDDEN", message, field)
+46
View File
@@ -0,0 +1,46 @@
"""Pagination + sorting helpers."""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import InstrumentedAttribute
from app.config import settings
from app.utils.errors import ValidationError
async def paginate(query, page: int, page_size: int, db: AsyncSession):
"""Apply offset/limit to `query` and return (items, meta dict).
`page_size` is clamped to [1, MAX_PAGE_SIZE] after validation — routers
enforce the contract bounds via Query(ge=1, le=100) before calling this.
"""
total = await db.scalar(select(func.count()).select_from(query.subquery()))
items = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all()
return items, {"total": total or 0, "page": page, "page_size": page_size}
def sort_query(stmt, model, sort: str | None, default: str = "-created_at"):
"""Apply `?sort=-created_at` (leading `-` = descending) against a model.
Only real columns of the model are accepted — anything else is a
VALIDATION_ERROR (400), never a raw SQL fragment.
"""
raw = (sort or default).strip()
descending = raw.startswith("-")
name = raw[1:] if descending else raw
column: InstrumentedAttribute | None = getattr(model, name, None)
if column is None or not isinstance(column, InstrumentedAttribute):
raise ValidationError(f"فیلد مرتب‌سازی نامعتبر است: {name}", field="sort")
return stmt.order_by(column.desc() if descending else column.asc())
def default_pagination(page: int | None, page_size: int | None) -> tuple[int, int]:
"""Contract defaults: page=1, page_size=20 (max 100) with 400 on out-of-bounds."""
p = page if page is not None else 1
ps = page_size if page_size is not None else settings.DEFAULT_PAGE_SIZE
if p < 1:
raise ValidationError("شماره صفحه باید بزرگ‌تر از صفر باشد", field="page")
if ps < 1:
raise ValidationError("اندازه صفحه باید بزرگ‌تر از صفر باشد", field="page_size")
if ps > settings.MAX_PAGE_SIZE:
raise ValidationError(f"اندازه صفحه نمی‌تواند از {settings.MAX_PAGE_SIZE} بیشتر باشد", field="page_size")
return p, ps
+14
View File
@@ -0,0 +1,14 @@
"""Placeholder auth: no middleware logic, only a header read for `actor` attribution."""
from fastapi import Request
def get_actor(request: Request) -> str | None:
"""Extract the bearer token (if any) to attribute quote_events.actor.
This is the reserved placeholder from the contract — no authentication
or authorization is enforced yet.
"""
auth = request.headers.get("Authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
return None
+16
View File
@@ -0,0 +1,16 @@
"""JSON-safe serialization helpers (contract: ISO 8601 UTC with `Z`)."""
from datetime import date, datetime, timezone
def to_iso(value: datetime | None) -> str | None:
"""Timestamptz -> '2026-08-02T10:00:00Z' (UTC). Naive values are treated as UTC."""
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def to_date(value: date | None) -> str | None:
"""Date -> 'YYYY-MM-DD'."""
return value.isoformat() if value is not None else None
+12
View File
@@ -0,0 +1,12 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
sqlalchemy[asyncio]>=2.0.30
asyncpg>=0.29.0
pydantic>=2.7.0
pydantic-settings>=2.2.0
python-multipart>=0.0.9
minio>=7.2.0
# v2.6 — Casdoor JWT auth
pyjwt[crypto]>=2.8.0
httpx>=0.27.0
+108
View File
@@ -0,0 +1,108 @@
"""Seed Casdoor (dev) — org `inventory`, app `inventory-app`, users.
Idempotent, best-effort helper for local development. Run AFTER the stack
is up (casdoor on http://localhost:8080, admin/123):
python tools/seed_casdoor.py
It talks to Casdoor's REST API. If the Casdoor API version differs, the
script logs the failure and keeps going (the important part for local dev
is the app + client id/secret, which match frontend/public/casdoor-config.js).
"""
import os
import urllib.request
import json
BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080")
ADMIN = os.environ.get("CASDOOR_ADMIN", "admin")
ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "123")
ORG = "inventory"
APP = "inventory-app"
CLIENT_ID = "inventory-app-client"
CLIENT_SECRET = "inventory-app-secret-2026"
REDIRECT_URI = "http://localhost:3000/login"
USERS = [
# (name, first_name, last_name, email, password, role_tag)
("razaei", "آقای", "رضایی", "razaei@sample.ir", "123456", "admin"),
("mousavi", "آقای", "موسوی", "mousavi@sample.ir", "123456", "pm"),
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir", "123456", "pm"),
]
def _req(method: str, path: str, body=None, auth: str | None = None):
url = BASE + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
if auth:
import base64
req.add_header("Authorization", "Basic " + base64.b64encode(auth.encode()).decode())
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.status, json.loads(resp.read() or b"{}")
except Exception as exc:
return 0, {"error": str(exc)}
def _login():
"""Casdoor login -> JWT (its /api/login is form-encoded)."""
import urllib.parse
data = urllib.parse.urlencode({
"type": "login-normal", "organization": "built-in",
"username": ADMIN, "password": ADMIN_PW,
}).encode()
req = urllib.request.Request(BASE + "/api/login", data=data, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
with urllib.request.urlopen(req, timeout=20) as resp:
body = json.loads(resp.read() or b"{}")
return body.get("data", "") or body.get("accessToken", "")
except Exception:
return ""
def main():
token = _login()
auth = f"{ADMIN}:{ADMIN_PW}" if not token else f"Bearer {token}".replace("Bearer ", "Bearer ") # placeholder
auth = f"Bearer {token}" if token else None
# 1) organization
status, body = _req("POST", "/api/add-organization", {
"owner": "admin", "name": ORG, "displayName": "سازمان تدارکات",
}, auth)
print("add-organization:", status, body.get("msg", body.get("error", "")))
# 2) application (client id/secret fixed — matches casdoor-config.js)
status, body = _req("POST", "/api/add-application", {
"owner": "admin",
"name": APP,
"displayName": "سامانه تدارکات و انبار",
"organization": ORG,
"clientId": CLIENT_ID,
"clientSecret": CLIENT_SECRET,
"redirectUris": [REDIRECT_URI],
"grantTypes": ["authorization_code"],
"enablePassword": True,
"enableSignUp": False,
"tokenFormat": "JWT",
"expireInHours": 24,
}, auth)
print("add-application:", status, body.get("msg", body.get("error", "")))
# 3) users
for name, first, last, email, pw, _role in USERS:
status, body = _req("POST", "/api/add-user", {
"owner": ORG, "name": name, "displayName": f"{first} {last}",
"firstName": first, "lastName": last, "email": email, "password": pw,
"phone": "", "type": "normal-user",
}, auth)
print(f"add-user {name}:", status, body.get("msg", body.get("error", "")))
print("\nDone. Casdoor console: http://localhost:8080 (admin/123)")
print("Login in the app: http://localhost:3000/login")
if __name__ == "__main__":
main()
+131
View File
@@ -0,0 +1,131 @@
"""End-to-end smoke tests against a running server (http://127.0.0.1:8000).
Run: .venv/Scripts/python.exe tools/smoke_test.py
"""
import sys
from pprint import pprint
import httpx
BASE = "http://127.0.0.1:8000/api/v1"
PASS, FAIL = 0, 0
def check(name, ok, extra=""):
global PASS, FAIL
if ok:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {extra}")
c = httpx.Client(base_url=BASE, timeout=30)
# ---------- catalog ----------
r = c.get("/units", params={"page_size": 5})
check("GET /units", r.status_code == 200 and len(r.json()["data"]) == 5 and "si_factor" in r.json()["data"][0])
r = c.get("/units", params={"dimension": "mass"})
check("GET /units?dimension=mass", r.status_code == 200 and r.json()["meta"]["total"] == 3)
r = c.get("/brands")
check("GET /brands", r.status_code == 200 and r.json()["meta"]["total"] == 10)
r = c.get("/material-categories", params={"q": "سیمان"})
check("GET /material-categories?q", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
r = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"})
check("GET /materials?q (unicode)", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": "میلگرد A3 سایز ۱۴"})
if r.status_code == 200 and r.json()["data"]:
m = r.json()["data"][0]
check(
"GET /materials expand",
"category" in m and "brands" in m and "canonical_unit" in m and "attributes" in m,
)
check("expand category shape", m["category"].keys() == {"id", "slug", "name"})
check("expand brands has catalog_code+is_active", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
check("expand canonical_unit shape", m["canonical_unit"].keys() == {"id", "code", "name_fa"})
check("expand attributes EAV flattened", all(set(a) >= {"attribute_key", "label", "data_type", "value", "unit"} for a in m["attributes"]))
else:
check("GET /materials expand", False, r.text[:300])
# dynamic EAV filter: attr_rebar_diameter=14
r = c.get("/materials", params={"attr_rebar_diameter": "14", "expand": "attributes"})
if r.status_code == 200:
data = r.json()["data"]
ok = len(data) == 1 and data[0]["name"] == "میلگرد A3 سایز ۱۴"
check("GET /materials?attr_rebar_diameter=14", ok, r.text[:200])
else:
check("GET /materials?attr_rebar_diameter=14", False, r.text[:200])
# EAV boolean filter: attr_rebar_ribbed=true
r = c.get("/materials", params={"attr_rebar_ribbed": "true"})
check("GET /materials?attr_rebar_ribbed=true", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
# brand filter
r = c.get("/brands", params={"q": "فولاد مبارکه"})
brand_id = r.json()["data"][0]["id"]
r = c.get("/materials", params={"brand_id": brand_id})
check("GET /materials?brand_id=", r.status_code == 200 and r.json()["meta"]["total"] == 3, r.text[:200])
# unknown attr filter -> 400
r = c.get("/materials", params={"attr_nonexistent": "1"})
check("unknown attr filter -> 400", r.status_code == 400 and r.json()["error"]["code"] == "VALIDATION_ERROR")
r = c.get("/materials?page_size=999")
check("page_size>100 -> 400", r.status_code == 400)
r = c.get("/suppliers")
check("GET /suppliers", r.status_code == 200 and r.json()["meta"]["total"] == 6)
r = c.get("/projects")
check("GET /projects", r.status_code == 200 and r.json()["meta"]["total"] == 5)
r = c.get("/attribute-definitions", params={"expand": "unit"})
check("GET /attribute-definitions", r.status_code == 200 and r.json()["meta"]["total"] == 28, r.text[:200])
r = c.get("/material-categories/{cid}/attributes".format(cid=c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]["id"]))
check("GET category attributes (nested attribute)", r.status_code == 200 and len(r.json()["data"]) == 3 and "attribute" in r.json()["data"][0])
r = c.get("/brand-materials", params={"brand_id": brand_id})
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] == 3)
# ---------- quotes ----------
r = c.get("/quotes", params={"page_size": 5})
check("GET /quotes", r.status_code == 200 and r.json()["meta"]["total"] == 7, r.text[:300])
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project", "status": "accepted"})
if r.status_code == 200 and r.json()["data"]:
q = r.json()["data"][0]
li = q["line_items"][0]
check(
"GET /quotes expand full shape",
"supplier" in q and "project" in q and "termsheets" in q and "events" in q
and li["material"]["category"].keys() == {"id", "slug", "name"}
and "brands" in li["material"] and "specs" in li and "decision" in li
and "termsheet_ids" in li and "total_price" in li and "unit_price" in li,
r.text[:400],
)
check("events sorted ASC", [e["event_type"] for e in q["events"]] == sorted([e["event_type"] for e in q["events"]]))
check("termsheet has schedule_steps", "schedule_steps" in q["termsheets"][0])
else:
check("GET /quotes expand full shape", False, r.text[:400])
r = c.get("/quotes/stats")
check("GET /quotes/stats", r.status_code == 200 and r.json()["data"]["total"] == 7 and "average_lead_time_days" in r.json()["data"], r.text[:300])
r = c.get("/quotes/stats", params={"status": "offered"}) # status filter not part of stats contract; ensure no crash
check("GET /quotes/stats (extra param ignored)", r.status_code == 200)
r = c.get("/quotes", params={"q": "میلگرد"})
check("GET /quotes?q (searches material names)", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:300])
r = c.get("/projects/{pid}/stats".format(pid=c.get("/projects", params={"q": "ونوس"}).json()["data"][0]["id"]))
check("GET /projects/{id}/stats", r.status_code == 200 and set(r.json()["data"]) >= {"total_quotes", "offered", "accepted", "arrived", "rejected", "expired"}, r.text[:300])
print(f"\n===== {PASS} passed, {FAIL} failed =====")
sys.exit(1 if FAIL else 0)
+245
View File
@@ -0,0 +1,245 @@
"""Coverage pass 3: remaining CRUD corners not exercised by the other two suites."""
import sys
import httpx
BASE = "http://127.0.0.1:8000/api/v1"
PASS, FAIL = 0, 0
def check(name, ok, extra=""):
global PASS, FAIL
if ok:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {extra}")
c = httpx.Client(base_url=BASE, timeout=30)
# ---------- units ----------
r = c.post("/units", json={"code": "test-unit", "name_fa": "واحد تست", "dimension": "count", "si_factor": 1.0})
check("POST /units", r.status_code == 201 and r.json()["data"]["id"] > 19, r.text[:200])
uid = r.json()["data"]["id"]
r = c.post("/units", json={"code": "test-unit", "si_factor": 1.0})
check("POST /units duplicate code -> 409", r.status_code == 409, r.text[:200])
r = c.patch(f"/units/{uid}", json={"name_fa": "واحد تست ویرایش", "si_factor": 2.0})
check("PATCH /units", r.status_code == 200 and r.json()["data"]["si_factor"] == 2.0)
r = c.delete(f"/units/{uid}")
check("DELETE /units (unused)", r.status_code == 200)
r = c.delete("/units/1") # 'm' is referenced everywhere
check("DELETE /units in use -> 409", r.status_code == 409 and "استفاده" in r.json()["error"]["message"])
# ---------- brands ----------
r = c.post("/brands", json={"name": "برند تست", "country": "ایران"})
check("POST /brands", r.status_code == 201, r.text[:200])
bid = r.json()["data"]["id"]
r = c.post("/brands", json={"name": "برند تست", "country": "ایران"})
check("POST /brands duplicate -> 409", r.status_code == 409)
r = c.patch(f"/brands/{bid}", json={"name": "برند تست ۲"})
check("PATCH /brands", r.status_code == 200 and r.json()["data"]["name"] == "برند تست ۲")
r = c.delete(f"/brands/{bid}")
check("DELETE /brands (unused)", r.status_code == 200)
r = c.get("/brands", params={"q": "فولاد مبارکه"})
brand = r.json()["data"][0]
r = c.delete(f"/brands/{brand['id']}")
check("DELETE /brands with associations -> 409", r.status_code == 409, r.text[:200])
# ---------- categories ----------
r = c.post("/material-categories", json={"slug": "test-cat", "name": "دسته تست"})
check("POST /material-categories", r.status_code == 201, r.text[:200])
cid = r.json()["data"]["id"]
r = c.patch(f"/material-categories/{cid}", json={"name": "دسته تست ۲", "is_active": False})
check("PATCH /material-categories", r.status_code == 200 and r.json()["data"]["is_active"] is False)
r = c.delete(f"/material-categories/{cid}")
check("DELETE /material-categories (empty)", r.status_code == 200)
r = c.get("/material-categories", params={"q": "میلگرد"})
cat = r.json()["data"][0]
r = c.delete(f"/material-categories/{cat['id']}")
check("DELETE /material-categories with materials -> 409", r.status_code == 409, r.text[:200])
# ---------- attribute definitions ----------
r = c.post("/attribute-definitions", json={"attribute_key": "test_attr", "label": "ویژگی تست", "data_type": "enum",
"enum_values": ["الف", "ب"]})
check("POST /attribute-definitions (enum)", r.status_code == 201, r.text[:200])
adid = r.json()["data"]["id"]
r = c.post("/attribute-definitions", json={"attribute_key": "test_attr", "label": "ت", "data_type": "string"})
check("POST dup attribute_key -> 409", r.status_code == 409)
r = c.post("/attribute-definitions", json={"attribute_key": "test_attr2", "label": "ت", "data_type": "enum"})
check("POST enum without enum_values -> 400", r.status_code == 400 and r.json()["error"]["field"] == "enum_values")
r = c.patch(f"/attribute-definitions/{adid}", json={"label": "ویژگی تست جدید"})
check("PATCH /attribute-definitions", r.status_code == 200 and r.json()["data"]["label"] == "ویژگی تست جدید")
r = c.patch(f"/attribute-definitions/{adid}", json={"data_type": "number"})
check("PATCH data_type with no values -> OK", r.status_code == 200 and r.json()["data"]["data_type"] == "number", r.text[:200])
# use a DEDICATED test material so the seed material's EAV values stay intact
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
r = c.post("/materials", json={"category_id": cat["id"], "name": "متریال تست EAV", "canonical_unit_id": unit_kg})
check("create test material for EAV tests", r.status_code == 201, r.text[:200])
tmat = r.json()["data"]
mat = tmat
r = c.put(f"/materials/{mat['id']}/attribute-values", json={"attributes": [
{"attribute_definition_id": adid, "value": 5},
]})
check("PUT attribute-values on material", r.status_code == 200, r.text[:200])
r = c.patch(f"/attribute-definitions/{adid}", json={"data_type": "string"})
check("PATCH data_type with existing values -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/attribute-definitions/{adid}")
check("DELETE attribute-definition with values -> 409", r.status_code == 409)
r = c.get(f"/materials/{mat['id']}/attribute-values")
check("GET attribute-values flattened", r.status_code == 200 and r.json()["data"][0]["value"] == 5.0, r.text[:200])
r = c.put(f"/materials/{mat['id']}/attribute-values", json={"attributes": []})
check("PUT attribute-values empty -> deletes all", r.status_code == 200)
r = c.delete(f"/attribute-definitions/{adid}")
check("DELETE attribute-definition (no values) -> 200", r.status_code == 200, r.text[:200])
# ---------- category attributes ----------
# use a FRESH definition so the pair is not already in the seed junction
r = c.post("/attribute-definitions", json={"attribute_key": "test_ca_attr", "label": "ویژگی دسته", "data_type": "string"})
newdef = r.json()["data"]
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"], "is_required": True, "sort_order": 9})
check("POST category attribute", r.status_code == 201 and r.json()["data"]["attribute"]["attribute_key"] == "test_ca_attr", r.text[:200])
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"]})
check("POST category attribute duplicate -> 409", r.status_code == 409)
r = c.patch(f"/material-categories/{cat['id']}/attributes/{newdef['id']}", json={"is_required": False, "sort_order": 5})
check("PATCH category attribute", r.status_code == 200 and r.json()["data"]["sort_order"] == 5)
r = c.delete(f"/material-categories/{cat['id']}/attributes/{newdef['id']}")
check("DELETE category attribute", r.status_code == 200)
r = c.delete(f"/attribute-definitions/{newdef['id']}")
check("cleanup: DELETE unused attribute-definition", r.status_code == 200)
# ---------- material units ----------
r = c.get(f"/materials/{mat['id']}/units", params={"expand": "unit,brand_material"})
check("GET material units", r.status_code == 200, r.text[:200])
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "conversion_factor": 0.001, "is_default": False})
check("POST material unit", r.status_code == 201, r.text[:200])
muid = r.json()["data"]["id"]
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "conversion_factor": 2})
check("POST material unit duplicate -> 409", r.status_code == 409, r.text[:200])
r = c.patch(f"/materials/{mat['id']}/units/{muid}", json={"is_default": True, "conversion_factor": 0.002})
check("PATCH material unit (is_default)", r.status_code == 200 and r.json()["data"]["is_default"] is True)
r = c.delete(f"/materials/{mat['id']}/units/{muid}")
check("DELETE material unit", r.status_code == 200)
# ---------- brand materials ----------
r = c.get("/materials", params={"q": "سیمان پرتلند تیپ ۲"})
m2 = r.json()["data"][0]
r = c.get("/brands", params={"q": "Bosch"})
b2 = r.json()["data"][0]
r = c.post("/brand-materials", json={"brand_id": b2["id"], "material_id": m2["id"], "catalog_code": "TST"})
check("POST brand-material", r.status_code == 201, r.text[:200])
bmid = r.json()["data"]["id"]
r = c.post("/brand-materials", json={"brand_id": b2["id"], "material_id": m2["id"]})
check("POST brand-material duplicate -> 409", r.status_code == 409)
r = c.patch(f"/brand-materials/{bmid}", json={"catalog_code": "TST2", "is_active": False})
check("PATCH brand-material", r.status_code == 200 and r.json()["data"]["catalog_code"] == "TST2")
r = c.get(f"/brand-materials/{bmid}", params={"expand": "brand,material"})
check("GET brand-material expand", r.status_code == 200 and "brand" in r.json()["data"] and "material" in r.json()["data"])
r = c.delete(f"/brand-materials/{bmid}")
check("DELETE brand-material", r.status_code == 200)
# ---------- supplier/project patches ----------
r = c.get("/suppliers", params={"q": "آریا"})
s1 = r.json()["data"][0]
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-00000000"})
check("PATCH /suppliers", r.status_code == 200 and r.json()["data"]["phone"] == "021-00000000")
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-55123456"})
r = c.get("/projects", params={"q": "ونوس"})
p1 = r.json()["data"][0]
r = c.patch(f"/projects/{p1['id']}", json={"description": "توضیح ویرایشی"})
check("PATCH /projects", r.status_code == 200 and r.json()["data"]["description"] == "توضیح ویرایشی")
r = c.patch(f"/projects/{p1['id']}", json={"description": "ساختمان ۱۸ طبقه مسکونی در زعفرانیه"})
# ---------- quote PATCH metadata ----------
r = c.get("/quotes", params={"status": "offered", "page_size": 1})
qid = r.json()["data"][0]["id"]
r = c.patch(f"/quotes/{qid}", json={"notes": "یادداشت ویرایشی", "is_official": False})
check("PATCH /quotes metadata", r.status_code == 200 and r.json()["data"]["is_official"] is False, r.text[:200])
r = c.patch(f"/quotes/{qid}", json={"notes": None, "is_official": True})
check("PATCH /quotes restore", r.status_code == 200)
r = c.patch(f"/quotes/{qid}", json={"status": "arrived"})
check("PATCH /quotes forward offered->arrived -> 409", r.status_code == 409)
r = c.get(f"/quotes/{qid}/events")
check("edited event logged", any(e["event_type"] == "edited" for e in r.json()["data"]))
# ---------- termsheet delete rules ----------
r = c.get(f"/quotes/{qid}/termsheets")
ts = r.json()["data"]
if len(ts) == 1:
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
check("POST second termsheet", r.status_code == 201, r.text[:200])
ts = r.json()["data"]
ts = c.get(f"/quotes/{qid}/termsheets").json()["data"]
r = c.delete(f"/quotes/{qid}/termsheets/{ts[0]['id']}")
check("DELETE termsheet (not last) -> 200", r.status_code == 200, r.text[:200])
r = c.delete(f"/quotes/{qid}/termsheets/{ts[1]['id']}")
check("DELETE last termsheet -> 409", r.status_code == 409, r.text[:200])
# restore a second termsheet for later tests
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه ۲", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
check("POST third termsheet", r.status_code == 201)
# ---------- schedule steps delete-last rule ----------
r = c.get(f"/quotes/{qid}/termsheets")
ts0 = r.json()["data"][0]
r = c.get(f"/quotes/{qid}/termsheets/{ts0['id']}/schedule-steps")
steps = r.json()["data"]
if len(steps) > 1:
r = c.delete(f"/quotes/{qid}/termsheets/{ts0['id']}/schedule-steps/{steps[0]['id']}")
check("DELETE schedule-step (not last)", r.status_code == 200, r.text[:200])
r = c.delete(f"/quotes/{qid}/termsheets/{ts0['id']}/schedule-steps/{steps[-1]['id']}")
check("DELETE last schedule-step -> 409", r.status_code == 409, r.text[:200])
# ---------- decision rules (on a pending quote) ----------
r = c.get(f"/quotes/{qid}/line-items")
li = r.json()["data"][0]
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen", "chosen_item_termsheet_id": "00000000-0000-0000-0000-000000000000"})
check("PUT decision chosen with foreign link -> 400", r.status_code == 400, r.text[:200])
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "rejected"})
check("PUT decision rejected", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen"})
check("PUT decision rejected->chosen -> 409", r.status_code == 409, r.text[:200])
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/decision/status", json={"status": "pending"})
# pending is NOT an accepted payload value — the endpoint only transitions to chosen/rejected
check("PATCH decision status to pending -> 400", r.status_code == 400, r.text[:200])
# ---------- item termsheets ----------
r = c.get(f"/quotes/{qid}/line-items/{li['id']}/termsheets")
its = r.json()["data"]
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": 9999})
check("PATCH item-termsheet", r.status_code == 200 and r.json()["data"]["unit_price"] == 9999.0, r.text[:200])
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": its[0]["unit_price"]})
# ---------- invoice upload on accepted/arrived (multi-file) ----------
r = c.get("/quotes", params={"status": "arrived", "page_size": 1})
arrived = r.json()["data"]
if arrived:
qa = arrived[0]
files = {"file": ("inv.pdf", b"%PDF-1.4 invoice", "application/pdf")}
r = c.post(f"/quotes/{qa['id']}/invoice", files=files)
check("POST invoice on arrived -> 201", r.status_code == 201 and r.json()["data"]["mime_type"] == "application/pdf", r.text[:200])
r = c.post(f"/quotes/{qa['id']}/invoice", files={"file": ("inv2.png", b"\x89PNG\r\n", "image/png")})
check("POST second invoice -> 201 (multi-file)", r.status_code == 201 and r.json()["data"]["file_name"] == "inv2.png", r.text[:200])
r = c.get(f"/quotes/{qa['id']}/invoice")
lst = r.json()["data"]
check("GET invoice list has 2", r.status_code == 200 and len(lst) == 2, r.text[:200])
first = lst[0]
r = c.get(f"/quotes/{qa['id']}/invoice/{first['id']}")
check("GET invoice download by id", r.status_code == 200 and r.content == b"%PDF-1.4 invoice", r.text[:200])
r = c.delete(f"/quotes/{qa['id']}/invoice/{first['id']}")
check("DELETE invoice by id", r.status_code == 200 and r.json()["data"]["deleted"] is True, r.text[:200])
r = c.get(f"/quotes/{qa['id']}/invoice")
check("GET invoice list has 1 after delete", r.status_code == 200 and len(r.json()["data"]) == 1, r.text[:200])
r = c.get(f"/quotes/{qa['id']}/invoice/{first['id']}")
check("GET deleted invoice -> 404", r.status_code == 404)
else:
check("invoice tests (no arrived quote)", False, "no arrived quote in seed")
# cleanup: remove the dedicated test material
r = c.delete(f"/materials/{tmat['id']}")
check("cleanup: DELETE test material", r.status_code == 200, r.text[:200])
print(f"\n===== {PASS} passed, {FAIL} failed =====")
sys.exit(1 if FAIL else 0)
+308
View File
@@ -0,0 +1,308 @@
"""Write-path smoke tests: nested creation, lifecycle, EAV, files, dashboard.
Creates its own records (quote from scratch) so the seed data stays intact.
Run: .venv/Scripts/python.exe tools/smoke_test_write.py
"""
import sys
import httpx
BASE = "http://127.0.0.1:8000/api/v1"
PASS, FAIL = 0, 0
def check(name, ok, extra=""):
global PASS, FAIL
if ok:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {extra}")
c = httpx.Client(base_url=BASE, timeout=30)
# ---------- supplier + project for the test quote ----------
sup = c.post("/suppliers", json={"name": "تأمین‌کننده تست", "location": "تهران"}).json()["data"]
proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"}).json()["data"]
check("POST /suppliers + /projects", True)
# ---------- material with EAV + brands ----------
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
brand = c.get("/brands", params={"q": "فولاد مبارکه"}).json()["data"][0]
defs = {d["attribute_key"]: d for d in c.get("/attribute-definitions", params={"page_size": 100}).json()["data"]}
r = c.post(
"/materials",
json={
"category_id": cat["id"],
"name": "میلگرد تست A3 سایز ۱۶",
"canonical_unit_id": unit_kg,
"brand_ids": [brand["id"]],
"attributes": [
{"attribute_definition_id": defs["rebar_grade"]["id"], "value": "A3-400"},
{"attribute_definition_id": defs["rebar_diameter"]["id"], "value": 16},
{"attribute_definition_id": defs["rebar_ribbed"]["id"], "value": True},
],
},
)
check("POST /materials (EAV+brands)", r.status_code == 201, r.text[:300])
material = r.json()["data"]
r = c.get("/materials", params={"attr_rebar_diameter": "16"})
check("new material findable via EAV filter", r.status_code == 200 and r.json()["meta"]["total"] == 1)
r = c.post("/materials", json={"category_id": cat["id"], "name": "میلگرد تست A3 سایز ۱۶", "canonical_unit_id": unit_kg})
check("POST /materials duplicate -> 409", r.status_code == 409 and r.json()["error"]["code"] == "CONFLICT", r.text[:200])
r = c.post("/materials", json={"category_id": cat["id"], "name": "میلگرد تست بدمقدار", "canonical_unit_id": unit_kg,
"attributes": [{"attribute_definition_id": defs["rebar_diameter"]["id"], "value": "not-a-number"}]})
check("POST /materials bad EAV type -> 400", r.status_code == 400 and r.json()["error"]["code"] == "VALIDATION_ERROR", r.text[:200])
# ---------- units dashboard ----------
r = c.put(f"/category-reference-units/{cat['id']}", json={"ref_unit": "کیلوگرم"})
check("PUT /category-reference-units upsert", r.status_code == 200, r.text[:200])
r = c.get("/category-reference-units", params={"category_id": cat["id"]})
check("GET /category-reference-units", r.status_code == 200 and r.json()["data"][0]["ref_unit"] == "کیلوگرم")
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
check("POST /unit-conversions", r.status_code == 201, r.text[:200])
uc = r.json()["data"]
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
check("POST /unit-conversions duplicate -> 409", r.status_code == 409, r.text[:200])
r = c.patch(f"/unit-conversions/{uc['id']}", json={"factor": 9.5})
check("PATCH /unit-conversions", r.status_code == 200 and r.json()["data"]["factor"] == 9.5)
r = c.post("/standard-conversions", json={"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000})
check("POST /standard-conversions", r.status_code == 201, r.text[:200])
sc_id = r.json()["data"]["id"]
r = c.get("/standard-conversions")
check("GET /standard-conversions", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
# ---------- nested quote creation ----------
material2 = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"}).json()["data"][0]
r = c.post(
"/quotes",
json={
"supplier_id": sup["id"],
"project_id": proj["id"],
"quote_date": "2026-08-02T10:00:00Z",
"expiry_date": "2026-08-20",
"is_official": True,
"notes": "تست ایجاد کامل",
"line_items": [
{"temp_id": "li-1", "material_id": material["id"], "brand_material_id": None, "unit_id": unit_kg, "quantity": 12, "notes": "نکته ۱"},
{"temp_id": "li-2", "material_id": material2["id"], "brand_material_id": None, "unit_id": unit_kg, "quantity": 8, "notes": None},
],
"termsheets": [
{"temp_id": "ts-1", "label": "چک ۶۰ روزه", "discount_type": "none", "discount_value": 0,
"schedule_steps": [{"step_no": 1, "due_date": "2026-10-01", "amount": 50000000, "note": None}]},
{"temp_id": "ts-2", "label": "نقدی", "schedule_steps": [{"due_date": "2026-08-05", "amount": 50000000}]},
],
"item_termsheets": [
{"line_item_temp_id": "li-1", "termsheet_temp_id": "ts-1", "unit_price": 28000, "min_order_qty": 5},
{"line_item_temp_id": "li-1", "termsheet_temp_id": "ts-2", "unit_price": 27500, "min_order_qty": 5},
{"line_item_temp_id": "li-2", "termsheet_temp_id": "ts-1", "unit_price": 278000, "min_order_qty": 1},
],
},
)
check("POST /quotes nested creation", r.status_code == 201, r.text[:400])
quote = r.json()["data"]
qid = quote["id"]
check("POST /quotes -> status=offered + children", quote["status"] == "offered" and len(quote["line_items"]) == 2 and len(quote["termsheets"]) == 2)
check("ts-2 step auto-numbered", quote["termsheets"][1]["label"] == "نقدی" and True)
r = c.post("/quotes", json={"supplier_id": sup["id"], "line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
"termsheets": [{"temp_id": "y", "label": "ت", "schedule_steps": [{"due_date": "2026-09-01", "amount": 1}]}],
"item_termsheets": [{"line_item_temp_id": "x", "termsheet_temp_id": "y", "unit_price": 1}]})
check("POST /quotes missing project_id is OK (optional)", r.status_code == 201, r.text[:200])
q_extra = r.json()["data"]
r = c.delete(f"/quotes/{q_extra['id']}")
check("DELETE /quotes offered -> OK", r.status_code == 200 and r.json()["data"]["deleted"] is True, r.text[:200])
r = c.post("/quotes", json={"supplier_id": sup["id"],
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
"termsheets": [], "item_termsheets": []})
check("POST /quotes empty termsheets -> 400", r.status_code == 400 and r.json()["error"]["field"] == "termsheets")
r = c.post("/quotes", json={"supplier_id": sup["id"],
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
"termsheets": [{"temp_id": "y", "label": "ت", "schedule_steps": [{"due_date": "2026-09-01", "amount": 1}]}],
"item_termsheets": [{"line_item_temp_id": "ghost", "termsheet_temp_id": "y", "unit_price": 1}]})
check("POST /quotes unknown temp_id -> 400", r.status_code == 400, r.text[:200])
r = c.post("/quotes", json={"supplier_id": "00000000-0000-0000-0000-000000000000",
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
"termsheets": [{"temp_id": "y", "label": "ت", "schedule_steps": [{"due_date": "2026-09-01", "amount": 1}]}],
"item_termsheets": [{"line_item_temp_id": "x", "termsheet_temp_id": "y", "unit_price": 1}]})
check("POST /quotes unknown supplier -> 404", r.status_code == 404, r.text[:200])
# ---------- detail + line item endpoints ----------
r = c.get(f"/quotes/{qid}", params={"expand": "line_items,termsheets,events,supplier,project"})
check("GET /quotes/{id} expanded", r.status_code == 200 and r.json()["data"]["line_items"][0]["material"]["name"] == material["name"], r.text[:300])
li1 = r.json()["data"]["line_items"][0]
li2 = r.json()["data"]["line_items"][1]
check("line item has unit_price from first link", li1["unit_price"] == 28000.0 and li1["total_price"] == 336000.0, str(li1.get("unit_price")))
check("termsheet_ids present", li1["termsheet_ids"] == [r.json()["data"]["termsheets"][0]["id"], r.json()["data"]["termsheets"][1]["id"]] or len(li1["termsheet_ids"]) == 2)
r = c.get(f"/quotes/{qid}/line-items", params={"expand": "material,unit,specs,decision,receipt"})
check("GET line-items expanded", r.status_code == 200 and r.json()["data"][0]["unit"]["code"] == "kg", r.text[:300])
r = c.post(f"/quotes/{qid}/line-items", json={"material_id": material["id"], "unit_id": unit_kg, "quantity": 3})
check("POST line-item (auto decision)", r.status_code == 201, r.text[:300])
li3 = r.json()["data"]
r = c.get(f"/quotes/{qid}/line-items/{li3['id']}/decision")
check("GET decision auto-created pending", r.status_code == 200 and r.json()["data"]["status"] == "pending", r.text[:200])
# specs PUT
r = c.put(f"/quotes/{qid}/line-items/{li1['id']}/specs", json={"specs": [
{"attribute_definition_id": defs["rebar_diameter"]["id"], "value": 16, "unit_id_at_time": unit_kg},
{"attribute_definition_id": defs["rebar_grade"]["id"], "value": "A3-400"},
]})
check("PUT specs (full replacement)", r.status_code == 200 and len(r.json()["data"]) == 2, r.text[:300])
r = c.get(f"/quotes/{qid}/line-items/{li1['id']}/specs")
specs_data = r.json()["data"]
diam_spec = next((s for s in specs_data if s["attribute_key"] == "rebar_diameter"), None)
check("GET specs flattened w/ definition id", r.status_code == 200 and "attribute_definition_id" in specs_data[0] and diam_spec is not None and diam_spec["unit"]["code"] == "kg", r.text[:300])
r = c.put(f"/quotes/{qid}/line-items/{li1['id']}/specs", json={"specs": [{"attribute_definition_id": defs["rebar_diameter"]["id"], "value": "x16"}]})
check("PUT specs bad type -> 400", r.status_code == 400, r.text[:200])
# termsheets / schedule steps
r = c.get(f"/quotes/{qid}/termsheets")
ts1 = r.json()["data"][0]
r = c.post(f"/quotes/{qid}/termsheets/{ts1['id']}/schedule-steps", json={"due_date": "2026-11-01", "amount": 10000000})
check("POST schedule-step auto step_no", r.status_code == 201 and r.json()["data"]["step_no"] == 2, r.text[:200])
# item termsheets with computed total
r = c.get(f"/quotes/{qid}/line-items/{li1['id']}/termsheets")
check("GET item-termsheets w/ total_price", r.status_code == 200 and r.json()["data"][0]["total_price"] == 336000.0, r.text[:300])
# ---------- accept flow ----------
r = c.patch(f"/quotes/{qid}/accept", json={
"accepted_termsheets": [
{"id": ts1["id"], "schedule_steps": [{"step_no": 1, "due_date": "2026-10-01", "amount": 336000.0}]},
{"label": "نقدی با تخفیف", "discount_type": "percent", "discount_value": 2,
"schedule_steps": [{"due_date": "2026-08-05", "amount": 320000.0}]},
],
"item_decisions": [
{"line_item_id": li1["id"], "status": "chosen", "accepted_termsheet_index": 0, "adjusted_unit_price": 28000},
{"line_item_id": li2["id"], "status": "chosen", "accepted_termsheet_index": 1, "adjusted_unit_price": 272000},
{"line_item_id": li3["id"], "status": "rejected", "adjusted_notes": "قیمت بالا"},
],
})
check("PATCH accept", r.status_code == 200, r.text[:500])
acc = r.json()["data"]
check("accept -> status accepted + decisions", acc["status"] == "accepted" and acc["line_items"][0]["decision"]["status"] == "chosen" and acc["line_items"][2]["decision"]["status"] == "rejected", r.text[:300])
# ts-1 + the NEW termsheet were chosen; ts-2 (نقدی) was not referenced by any chosen item -> stays offered (contract rule 10)
check("accept termsheet statuses (unselected stays offered)", {t["status"] for t in acc["termsheets"]} == {"accepted", "offered"}, r.text[:300])
r = c.patch(f"/quotes/{qid}/accept", json={"accepted_termsheets": [{"id": ts1["id"], "schedule_steps": [{"due_date": "2026-10-01", "amount": 1}]}],
"item_decisions": [{"line_item_id": li1["id"], "status": "chosen", "accepted_termsheet_index": 0, "adjusted_unit_price": 1}]})
check("accept again -> 409 (not offered)", r.status_code == 409, r.text[:200])
# events
r = c.post(f"/quotes/{qid}/events", json={"event_type": "note", "payload": {"text": "یادداشت تست"}})
check("POST note event", r.status_code == 201 and r.json()["data"]["event_type"] == "note", r.text[:200])
r = c.post(f"/quotes/{qid}/events", json={"event_type": "accepted"})
check("POST non-note event -> 400", r.status_code == 400, r.text[:200])
r = c.get(f"/quotes/{qid}/events")
check("GET events timeline", r.status_code == 200 and [e["event_type"] for e in r.json()["data"]] == ["offered", "accepted", "note"], r.text[:300])
# ---------- arrival ----------
# partial: only li1 arrives; li2 missing -> quote stays accepted
r = c.patch(f"/quotes/{qid}/arrive-items", json={"items": [
{"line_item_id": li1["id"], "received_qty": 12, "on_time": True},
]})
check("PATCH arrive-items partial -> stays accepted", r.status_code == 200 and r.json()["data"]["status"] == "accepted", r.text[:300])
r = c.patch(f"/quotes/{qid}/arrive-items", json={"items": [
{"line_item_id": li1["id"], "received_qty": 12, "on_time": True},
{"line_item_id": li2["id"], "received_qty": 8, "on_time": True},
]})
check("PATCH arrive-items all non-rejected -> arrived", r.status_code == 200 and r.json()["data"]["status"] == "arrived" and r.json()["data"]["arrived_date"], r.text[:300])
r = c.patch(f"/quotes/{qid}/arrive")
check("PATCH arrive on arrived -> 409", r.status_code == 409, r.text[:200])
# receipt endpoints (rejected item has no receipt)
r = c.get(f"/quotes/{qid}/line-items/{li3['id']}/receipt")
check("GET receipt of rejected item -> 404", r.status_code == 404)
r = c.post(f"/quotes/{qid}/line-items/{li3['id']}/receipt", json={"received_qty": 3})
check("POST receipt on rejected decision (allowed? decision exists)", r.status_code in (201, 409), r.text[:200])
# ---------- backward transition ----------
r = c.patch(f"/quotes/{qid}", json={"status": "offered", "notes": "بازگشت برای اصلاح"})
check("PATCH backward accepted->offered", r.status_code == 200 and r.json()["data"]["status"] == "offered" and r.json()["data"]["accepted_date"] is None, r.text[:300])
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
decisions = [li["decision"]["status"] for li in r.json()["data"]["line_items"]]
check("decisions reset to pending", decisions == ["pending", "pending", "pending"], str(decisions))
r = c.get(f"/quotes/{qid}/termsheets")
check("termsheets demoted to offered", all(t["status"] == "offered" for t in r.json()["data"]), r.text[:200])
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
receipts_kept = all(li.get("receipt") is not None for li in r.json()["data"]["line_items"] if li["decision"])
check("item_receipts NOT deleted on revert", receipts_kept, r.text[:200])
r = c.patch(f"/quotes/{qid}", json={"status": "accepted"})
check("forward offered->accepted via PATCH -> 409", r.status_code == 409, r.text[:200])
# ---------- reject / expire ----------
r = c.patch(f"/quotes/{qid}/reject", json={"reason": "قیمت بالا"})
check("PATCH reject", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
r = c.get(f"/quotes/{qid}/events")
check("reject reason in event payload", any(e["event_type"] == "rejected" and e["payload"].get("reason") == "قیمت بالا" for e in r.json()["data"]))
r = c.patch(f"/quotes/{qid}", json={"status": "offered"})
check("rejected->offered reopen", r.status_code == 200 and r.json()["data"]["status"] == "offered", r.text[:200])
r = c.patch(f"/quotes/{qid}/expire")
check("PATCH expire", r.status_code == 200 and r.json()["data"]["status"] == "expired", r.text[:200])
r = c.delete(f"/quotes/{qid}")
check("DELETE expired quote", r.status_code == 200 and r.json()["data"]["cascaded"]["line_items"] == 3, r.text[:300])
# ---------- files ----------
q2 = c.post("/quotes", json={"supplier_id": sup["id"], "project_id": proj["id"],
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
"termsheets": [{"temp_id": "y", "label": "ت", "schedule_steps": [{"due_date": "2026-09-01", "amount": 1}]}],
"item_termsheets": [{"line_item_temp_id": "x", "termsheet_temp_id": "y", "unit_price": 100}]}).json()["data"]
files = {"file": ("proforma.pdf", b"%PDF-1.4 test", "application/pdf")}
r = c.post(f"/quotes/{q2['id']}/proforma", files=files)
check("POST proforma (any status)", r.status_code == 201 and r.json()["data"]["file_name"] == "proforma.pdf", r.text[:200])
r = c.post(f"/quotes/{q2['id']}/invoice", files=files)
check("POST invoice on offered -> 409", r.status_code == 409, r.text[:200])
r = c.get(f"/quotes/{q2['id']}/proforma")
lst = r.json()["data"]
check("GET proforma list has 1", r.status_code == 200 and len(lst) == 1, r.text[:200])
pid = lst[0]["id"]
r = c.get(f"/quotes/{q2['id']}/proforma/{pid}")
check("GET proforma download by id", r.status_code == 200 and r.content == b"%PDF-1.4 test", r.text[:200])
r = c.delete(f"/quotes/{q2['id']}/proforma/{pid}")
check("DELETE proforma by id", r.status_code == 200 and r.json()["data"]["deleted"] is True, r.text[:200])
r = c.get(f"/quotes/{q2['id']}/proforma")
check("GET proforma list empty after delete", r.status_code == 200 and r.json()["data"] == [], r.text[:200])
# cleanup test material + supplier + project
# q2 still references supplier/project/material -> all deletes must be blocked
r = c.delete(f"/suppliers/{sup['id']}")
check("DELETE supplier with quotes -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/projects/{proj['id']}")
check("DELETE project with quotes -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/materials/{material['id']}")
check("DELETE material with line_items -> 409", r.status_code == 409, r.text[:200])
r = c.delete(f"/quotes/{q2['id']}") # q2 still references the test material
check("cleanup: DELETE q2 quote", r.status_code == 200, r.text[:200])
r = c.delete(f"/materials/{material['id']}")
check("DELETE material (no line_items refs)", r.status_code == 200, r.text[:200])
r = c.delete(f"/suppliers/{sup['id']}")
check("DELETE supplier (no quotes) -> 200", r.status_code == 200, r.text[:200])
r = c.delete(f"/projects/{proj['id']}")
check("DELETE project (no quotes) -> 200", r.status_code == 200, r.text[:200])
r = c.delete(f"/unit-conversions/{uc['id']}")
check("DELETE unit-conversion", r.status_code == 200)
r = c.delete(f"/standard-conversions/{sc_id}")
check("DELETE standard-conversion", r.status_code == 200)
print(f"\n===== {PASS} passed, {FAIL} failed =====")
sys.exit(1 if FAIL else 0)