From 170aed66ea3f83f17df6fa8cebc9bde6fd241c02 Mon Sep 17 00:00:00 2001 From: ali_mohaghegh Date: Tue, 25 Aug 2026 19:08:14 +0330 Subject: [PATCH] =?UTF-8?q?feat(backend):=20add=20FastAPI=20backend=20?= =?UTF-8?q?=E2=80=94=20procurement,=20inventory=20domain,=20Casdoor=20auth?= =?UTF-8?q?,=20PM=20arrival=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.dockerignore | 6 + backend/Dockerfile | 23 + backend/app/__init__.py | 0 backend/app/auth.py | 136 +++ backend/app/config.py | 56 ++ backend/app/db/__init__.py | 0 backend/app/db/base.py | 5 + backend/app/db/engine.py | 29 + backend/app/db/init_db.py | 385 ++++++++ backend/app/main.py | 167 ++++ backend/app/models/__init__.py | 62 ++ backend/app/models/catalog.py | 261 ++++++ backend/app/models/files.py | 52 ++ backend/app/models/inventory.py | 178 ++++ backend/app/models/procurement.py | 404 +++++++++ backend/app/routers/__init__.py | 58 ++ backend/app/routers/attribute_definitions.py | 158 ++++ backend/app/routers/brand_materials.py | 135 +++ backend/app/routers/brands.py | 94 ++ backend/app/routers/category_attributes.py | 136 +++ backend/app/routers/category_ref_units.py | 69 ++ backend/app/routers/files.py | 151 ++++ backend/app/routers/inventory.py | 547 +++++++++++ backend/app/routers/item_decisions.py | 138 +++ backend/app/routers/item_receipts.py | 83 ++ backend/app/routers/item_termsheets.py | 136 +++ backend/app/routers/line_item_specs.py | 79 ++ backend/app/routers/line_items.py | 138 +++ backend/app/routers/material_categories.py | 104 +++ backend/app/routers/materials.py | 385 ++++++++ backend/app/routers/pm.py | 432 +++++++++ backend/app/routers/projects.py | 124 +++ backend/app/routers/quote_events.py | 51 ++ backend/app/routers/quotes.py | 270 ++++++ backend/app/routers/schedule_steps.py | 123 +++ backend/app/routers/standard_conversions.py | 103 +++ backend/app/routers/supplier_materials.py | 104 +++ backend/app/routers/suppliers.py | 133 +++ backend/app/routers/termsheets.py | 160 ++++ backend/app/routers/unit_conversions.py | 146 +++ backend/app/routers/units.py | 120 +++ backend/app/routers/users.py | 60 ++ backend/app/schemas/__init__.py | 0 backend/app/schemas/catalog.py | 166 ++++ backend/app/schemas/common.py | 18 + backend/app/schemas/files.py | 1 + backend/app/schemas/inventory.py | 78 ++ backend/app/schemas/procurement.py | 313 +++++++ backend/app/services/__init__.py | 0 backend/app/services/eav_service.py | 74 ++ backend/app/services/expand_service.py | 657 ++++++++++++++ backend/app/services/file_service.py | 76 ++ backend/app/services/inventory_service.py | 283 ++++++ backend/app/services/material_service.py | 197 ++++ backend/app/services/quote_service.py | 848 ++++++++++++++++++ backend/app/services/storage.py | 68 ++ .../app/services/supplier_material_service.py | 63 ++ backend/app/utils/__init__.py | 0 backend/app/utils/errors.py | 51 ++ backend/app/utils/pagination.py | 46 + backend/app/utils/security.py | 14 + backend/app/utils/serialization.py | 16 + backend/requirements.txt | 12 + backend/tools/seed_casdoor.py | 108 +++ backend/tools/smoke_test.py | 131 +++ backend/tools/smoke_test_misc.py | 245 +++++ backend/tools/smoke_test_write.py | 308 +++++++ 67 files changed, 9774 insertions(+) create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/engine.py create mode 100644 backend/app/db/init_db.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/catalog.py create mode 100644 backend/app/models/files.py create mode 100644 backend/app/models/inventory.py create mode 100644 backend/app/models/procurement.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/attribute_definitions.py create mode 100644 backend/app/routers/brand_materials.py create mode 100644 backend/app/routers/brands.py create mode 100644 backend/app/routers/category_attributes.py create mode 100644 backend/app/routers/category_ref_units.py create mode 100644 backend/app/routers/files.py create mode 100644 backend/app/routers/inventory.py create mode 100644 backend/app/routers/item_decisions.py create mode 100644 backend/app/routers/item_receipts.py create mode 100644 backend/app/routers/item_termsheets.py create mode 100644 backend/app/routers/line_item_specs.py create mode 100644 backend/app/routers/line_items.py create mode 100644 backend/app/routers/material_categories.py create mode 100644 backend/app/routers/materials.py create mode 100644 backend/app/routers/pm.py create mode 100644 backend/app/routers/projects.py create mode 100644 backend/app/routers/quote_events.py create mode 100644 backend/app/routers/quotes.py create mode 100644 backend/app/routers/schedule_steps.py create mode 100644 backend/app/routers/standard_conversions.py create mode 100644 backend/app/routers/supplier_materials.py create mode 100644 backend/app/routers/suppliers.py create mode 100644 backend/app/routers/termsheets.py create mode 100644 backend/app/routers/unit_conversions.py create mode 100644 backend/app/routers/units.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/catalog.py create mode 100644 backend/app/schemas/common.py create mode 100644 backend/app/schemas/files.py create mode 100644 backend/app/schemas/inventory.py create mode 100644 backend/app/schemas/procurement.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/eav_service.py create mode 100644 backend/app/services/expand_service.py create mode 100644 backend/app/services/file_service.py create mode 100644 backend/app/services/inventory_service.py create mode 100644 backend/app/services/material_service.py create mode 100644 backend/app/services/quote_service.py create mode 100644 backend/app/services/storage.py create mode 100644 backend/app/services/supplier_material_service.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/errors.py create mode 100644 backend/app/utils/pagination.py create mode 100644 backend/app/utils/security.py create mode 100644 backend/app/utils/serialization.py create mode 100644 backend/requirements.txt create mode 100644 backend/tools/seed_casdoor.py create mode 100644 backend/tools/smoke_test.py create mode 100644 backend/tools/smoke_test_misc.py create mode 100644 backend/tools/smoke_test_write.py diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..82c426a --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +uploads +.env +tests diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7c5c3c5 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..ce22a0c --- /dev/null +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..55e0a5d --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..92628b8 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Declarative base for all ORM models.""" diff --git a/backend/app/db/engine.py b/backend/app/db/engine.py new file mode 100644 index 0000000..2295245 --- /dev/null +++ b/backend/app/db/engine.py @@ -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 diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py new file mode 100644 index 0000000..355e445 --- /dev/null +++ b/backend/app/db/init_db.py @@ -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?") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..db7cad4 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..b4c1912 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/catalog.py b/backend/app/models/catalog.py new file mode 100644 index 0000000..e9e776c --- /dev/null +++ b/backend/app/models/catalog.py @@ -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() + ) diff --git a/backend/app/models/files.py b/backend/app/models/files.py new file mode 100644 index 0000000..1cf3893 --- /dev/null +++ b/backend/app/models/files.py @@ -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), + } diff --git a/backend/app/models/inventory.py b/backend/app/models/inventory.py new file mode 100644 index 0000000..74a3606 --- /dev/null +++ b/backend/app/models/inventory.py @@ -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", +] diff --git a/backend/app/models/procurement.py b/backend/app/models/procurement.py new file mode 100644 index 0000000..f41b355 --- /dev/null +++ b/backend/app/models/procurement.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..b8ec151 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -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, +] diff --git a/backend/app/routers/attribute_definitions.py b/backend/app/routers/attribute_definitions.py new file mode 100644 index 0000000..8e0c0ad --- /dev/null +++ b/backend/app/routers/attribute_definitions.py @@ -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}} diff --git a/backend/app/routers/brand_materials.py b/backend/app/routers/brand_materials.py new file mode 100644 index 0000000..1200f96 --- /dev/null +++ b/backend/app/routers/brand_materials.py @@ -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}} diff --git a/backend/app/routers/brands.py b/backend/app/routers/brands.py new file mode 100644 index 0000000..9173c48 --- /dev/null +++ b/backend/app/routers/brands.py @@ -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}} diff --git a/backend/app/routers/category_attributes.py b/backend/app/routers/category_attributes.py new file mode 100644 index 0000000..bc65c9a --- /dev/null +++ b/backend/app/routers/category_attributes.py @@ -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}} diff --git a/backend/app/routers/category_ref_units.py b/backend/app/routers/category_ref_units.py new file mode 100644 index 0000000..a95ac8c --- /dev/null +++ b/backend/app/routers/category_ref_units.py @@ -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}} diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py new file mode 100644 index 0000000..0b1adc0 --- /dev/null +++ b/backend/app/routers/files.py @@ -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}} diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py new file mode 100644 index 0000000..1ddf5bb --- /dev/null +++ b/backend/app/routers/inventory.py @@ -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 + ]} diff --git a/backend/app/routers/item_decisions.py b/backend/app/routers/item_decisions.py new file mode 100644 index 0000000..dd9e3ce --- /dev/null +++ b/backend/app/routers/item_decisions.py @@ -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)} diff --git a/backend/app/routers/item_receipts.py b/backend/app/routers/item_receipts.py new file mode 100644 index 0000000..126da72 --- /dev/null +++ b/backend/app/routers/item_receipts.py @@ -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)} diff --git a/backend/app/routers/item_termsheets.py b/backend/app/routers/item_termsheets.py new file mode 100644 index 0000000..2d2977c --- /dev/null +++ b/backend/app/routers/item_termsheets.py @@ -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}} diff --git a/backend/app/routers/line_item_specs.py b/backend/app/routers/line_item_specs.py new file mode 100644 index 0000000..9f49e95 --- /dev/null +++ b/backend/app/routers/line_item_specs.py @@ -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)} diff --git a/backend/app/routers/line_items.py b/backend/app/routers/line_items.py new file mode 100644 index 0000000..346f7cd --- /dev/null +++ b/backend/app/routers/line_items.py @@ -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}} diff --git a/backend/app/routers/material_categories.py b/backend/app/routers/material_categories.py new file mode 100644 index 0000000..99409aa --- /dev/null +++ b/backend/app/routers/material_categories.py @@ -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}} diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py new file mode 100644 index 0000000..42d39f0 --- /dev/null +++ b/backend/app/routers/materials.py @@ -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)}} diff --git a/backend/app/routers/pm.py b/backend/app/routers/pm.py new file mode 100644 index 0000000..4785297 --- /dev/null +++ b/backend/app/routers/pm.py @@ -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 + ]} diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py new file mode 100644 index 0000000..3cc921b --- /dev/null +++ b/backend/app/routers/projects.py @@ -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)} diff --git a/backend/app/routers/quote_events.py b/backend/app/routers/quote_events.py new file mode 100644 index 0000000..1f91c41 --- /dev/null +++ b/backend/app/routers/quote_events.py @@ -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)} diff --git a/backend/app/routers/quotes.py b/backend/app/routers/quotes.py new file mode 100644 index 0000000..ee6249a --- /dev/null +++ b/backend/app/routers/quotes.py @@ -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} diff --git a/backend/app/routers/schedule_steps.py b/backend/app/routers/schedule_steps.py new file mode 100644 index 0000000..fc265e7 --- /dev/null +++ b/backend/app/routers/schedule_steps.py @@ -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}} diff --git a/backend/app/routers/standard_conversions.py b/backend/app/routers/standard_conversions.py new file mode 100644 index 0000000..b63434a --- /dev/null +++ b/backend/app/routers/standard_conversions.py @@ -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}} diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py new file mode 100644 index 0000000..fb8a975 --- /dev/null +++ b/backend/app/routers/supplier_materials.py @@ -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} diff --git a/backend/app/routers/suppliers.py b/backend/app/routers/suppliers.py new file mode 100644 index 0000000..3ab5175 --- /dev/null +++ b/backend/app/routers/suppliers.py @@ -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}} diff --git a/backend/app/routers/termsheets.py b/backend/app/routers/termsheets.py new file mode 100644 index 0000000..16c8f98 --- /dev/null +++ b/backend/app/routers/termsheets.py @@ -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}} diff --git a/backend/app/routers/unit_conversions.py b/backend/app/routers/unit_conversions.py new file mode 100644 index 0000000..aef42fb --- /dev/null +++ b/backend/app/routers/unit_conversions.py @@ -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}} diff --git a/backend/app/routers/units.py b/backend/app/routers/units.py new file mode 100644 index 0000000..7152ecd --- /dev/null +++ b/backend/app/routers/units.py @@ -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}} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..03be558 --- /dev/null +++ b/backend/app/routers/users.py @@ -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)} diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/catalog.py b/backend/app/schemas/catalog.py new file mode 100644 index 0000000..7ba47ae --- /dev/null +++ b/backend/app/schemas/catalog.py @@ -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) diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..e32d6f1 --- /dev/null +++ b/backend/app/schemas/common.py @@ -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) diff --git a/backend/app/schemas/files.py b/backend/app/schemas/files.py new file mode 100644 index 0000000..32c1bdc --- /dev/null +++ b/backend/app/schemas/files.py @@ -0,0 +1 @@ +"""Pydantic schemas for file endpoints — request side is a multipart UploadFile.""" diff --git a/backend/app/schemas/inventory.py b/backend/app/schemas/inventory.py new file mode 100644 index 0000000..086cf43 --- /dev/null +++ b/backend/app/schemas/inventory.py @@ -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 diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py new file mode 100644 index 0000000..e71d875 --- /dev/null +++ b/backend/app/schemas/procurement.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/eav_service.py b/backend/app/services/eav_service.py new file mode 100644 index 0000000..43a8fbd --- /dev/null +++ b/backend/app/services/eav_service.py @@ -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 diff --git a/backend/app/services/expand_service.py b/backend/app/services/expand_service.py new file mode 100644 index 0000000..572ddb8 --- /dev/null +++ b/backend/app/services/expand_service.py @@ -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 diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py new file mode 100644 index 0000000..868f0dc --- /dev/null +++ b/backend/app/services/file_service.py @@ -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) diff --git a/backend/app/services/inventory_service.py b/backend/app/services/inventory_service.py new file mode 100644 index 0000000..5af9ecd --- /dev/null +++ b/backend/app/services/inventory_service.py @@ -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") diff --git a/backend/app/services/material_service.py b/backend/app/services/material_service.py new file mode 100644 index 0000000..d0f2002 --- /dev/null +++ b/backend/app/services/material_service.py @@ -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") diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py new file mode 100644 index 0000000..882b5f3 --- /dev/null +++ b/backend/app/services/quote_service.py @@ -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) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..96cb1cd --- /dev/null +++ b/backend/app/services/storage.py @@ -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) diff --git a/backend/app/services/supplier_material_service.py b/backend/app/services/supplier_material_service.py new file mode 100644 index 0000000..28d8982 --- /dev/null +++ b/backend/app/services/supplier_material_service.py @@ -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() diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/errors.py b/backend/app/utils/errors.py new file mode 100644 index 0000000..f076ed0 --- /dev/null +++ b/backend/app/utils/errors.py @@ -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) diff --git a/backend/app/utils/pagination.py b/backend/app/utils/pagination.py new file mode 100644 index 0000000..98a1469 --- /dev/null +++ b/backend/app/utils/pagination.py @@ -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 diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..a78c1cb --- /dev/null +++ b/backend/app/utils/security.py @@ -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 diff --git a/backend/app/utils/serialization.py b/backend/app/utils/serialization.py new file mode 100644 index 0000000..c7b4434 --- /dev/null +++ b/backend/app/utils/serialization.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c779078 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tools/seed_casdoor.py b/backend/tools/seed_casdoor.py new file mode 100644 index 0000000..42eb95d --- /dev/null +++ b/backend/tools/seed_casdoor.py @@ -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() diff --git a/backend/tools/smoke_test.py b/backend/tools/smoke_test.py new file mode 100644 index 0000000..0d10c12 --- /dev/null +++ b/backend/tools/smoke_test.py @@ -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) diff --git a/backend/tools/smoke_test_misc.py b/backend/tools/smoke_test_misc.py new file mode 100644 index 0000000..02db408 --- /dev/null +++ b/backend/tools/smoke_test_misc.py @@ -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) diff --git a/backend/tools/smoke_test_write.py b/backend/tools/smoke_test_write.py new file mode 100644 index 0000000..0fa7826 --- /dev/null +++ b/backend/tools/smoke_test_write.py @@ -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)