"""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 contextlib import nullcontext 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, UnauthorizedError, ValidationError, ) from app.utils.audit import record as audit_record 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, project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: داده‌های همین پروژه)"), db: AsyncSession = Depends(get_db), ) -> Scope: """Resolve the caller role/project — Casdoor JWT first, dev headers as fallback. The active project is explicit via `?project_id=…`. For a PM the backend verifies they manage that project (projects.pm_user_id, 1:N); without the param it falls back to their first assigned project. Admin / procurement manager may pass it as a filter (no param = all projects). """ if request.headers.get("authorization"): user: UserContext = await get_optional_user(request, db) if user is not None: if user.role is None: raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") # v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار. if user.role == "procurement_manager": return Scope(role="admin", project_id=project_id, actor=user.display_name) if user.role not in ("pm", "admin"): raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role") if user.role == "pm": allowed = list(user.project_ids or []) if project_id is not None: if project_id not in allowed: raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") return Scope(role="pm", project_id=project_id, actor=user.display_name) if not allowed: raise ValidationError( "کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id" ) return Scope(role="pm", project_id=allowed[0], actor=user.display_name) return Scope(role="admin", project_id=project_id, actor=user.display_name) # Dev fallback: header-based identity (fork's placeholder mode) — only # when DEV_LOGIN_ENABLED is explicitly turned on (local testing without # Casdoor). Disabled by default: unauthenticated header identity in # production would hand admin access to anyone who can reach the API. if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"): raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید") 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") # The explicit query param wins; otherwise the dev header carries it. if project_id is None: raw_project = request.headers.get("x-project-id") or settings.INVENTORY_DEFAULT_PROJECT_ID 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) # No explicit db.begin(): the session may already be inside an implicit # transaction (inventory_scope read from it in the JWT path); get_db # commits on success / rolls back on failure. with nullcontext(): 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) await audit_record( db, actor=scope.actor, action="inventory.transaction_created", entity="inventory", entity_id=str(txs[0].inventory_id) if txs else None, details={"type": payload.transaction_type, "quantity": str(payload.quantity), "warehouse_id": str(payload.warehouse_id), "tx_ids": [str(tx.id) for tx in txs]}, ) 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 ]}