feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
uploads
|
||||
.env
|
||||
tests
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all ORM models."""
|
||||
@@ -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
|
||||
@@ -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?")
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
]}
|
||||
@@ -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)}
|
||||
@@ -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)}
|
||||
@@ -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}}
|
||||
@@ -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)}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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)}}
|
||||
@@ -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
|
||||
]}
|
||||
@@ -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)}
|
||||
@@ -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)}
|
||||
@@ -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}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic schemas for file endpoints — request side is a multipart UploadFile."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user