Files
ERP/backend/app/routers/inventory.py
T
Milad 4a3c226518 feat(org): panel-based access system replacing roles (procurement/inventory)
- new tables user_panels (panel: procurement/inventory/pm + level full/limited)
  and user_project_members (M:N membership, separate from pm responsibility)
- UserContext carries panels + member_project_ids; /users/me and the users
  list include them
- admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects
- inventory scope: panel inventory 'full' -> manager scope (all projects, NO
  stock change); 'limited' -> supervisor scope (own memberships, stock change
  allowed); legacy roles kept as migration fallback; admin untouched
- procurement writes: _procurement_writer is now panel-aware (admin, legacy
  procurement_manager, or procurement 'full') across all 18 write routers;
  quote CREATE additionally allows 'limited' users restricted to their member
  projects (validated server-side)
- frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement
  limited -> minimal RequesterHome), wizard project options restricted for
  limited users, change-inventory button/route gated to inventory 'limited',
  organization panel gains the panels & access-levels management section
2026-09-29 13:37:59 +03:30

580 lines
26 KiB
Python

"""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
project_ids: list[uuid.UUID] | None = 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:
panels = user.panels or {}
if user.role is None and not panels:
raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
# ادمین: دست‌نخورده — همه‌چیز از جمله تغییر موجودی
if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name)
# لایه پنل‌ها (جایگزین نقش‌ها): پنل انبار اولویت دارد
if "inventory" in panels:
if panels["inventory"] == "full":
# کل سیستم و تمام پروژه‌ها — به‌جز تغییر موجودی
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or []))
# limited: فقط پروژه‌های عضویت + امکان تغییر موجودی
allowed = list(user.member_project_ids or [])
if not allowed:
raise ForbiddenError("پروژه‌ای به شما تخصیص داده نشده است")
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی
if user.role in ("procurement_manager", "pm"):
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
if user.role == "supervisor":
allowed = list(user.project_ids or [])
if not allowed:
raise ForbiddenError("پروژه تخصیص داده نشده است")
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name)
raise ValidationError("نقش نامعتبر است", field="role")
# 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_inventory_modifier(scope: Scope) -> None:
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", 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 in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(Warehouse.project_id == project_id)
elif allowed:
stmt = stmt.where(Warehouse.project_id.in_(allowed))
else:
stmt = stmt.where(Warehouse.project_id == scope.project_id)
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 in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if payload.project_id not in allowed:
raise ValidationError("شما فقط می‌توانید برای پروژه تخصیص‌یافته به خود انبار بسازید", 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 in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
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):
raise ValidationError(
"پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد",
field="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 in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
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,
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 in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id
)
elif allowed:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id.in_(allowed)
)
else:
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 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()}
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],
None, 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 in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(Warehouse.project_id == project_id)
elif allowed:
stmt = stmt.where(Warehouse.project_id.in_(allowed))
else:
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 in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
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_inventory_modifier(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)
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if scope.role in (ROLE_PM, "supervisor"):
if wh.project_id not in allowed:
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 not in allowed:
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",
)]}