Files
ERP/backend/app/routers/inventory.py
T
ali_mohaghegh 83f4fb0747 fix(backend): arrival-confirmation 500, receipt state, RBAC, Casdoor user sync
- pm.py/inventory.py: remove db.begin() on already-begun sessions — the
  scope dependencies read from the session first, so opening a new
  transaction raised InvalidRequestError -> 500 on every receipt/transaction
  (JWT path); get_db already commits/rolls back atomically
- pm.py: receipt response returns the real derived state (was hardcoded
  'arrived'); dev-user creation is race-free (insert on conflict)
- expand_service: aggregate 1:N receipts per line item (received_qty sum +
  count) so the ledger can show partial-receipt totals
- users.py: role changes are admin-only (RBAC); new POST /users/sync mirrors
  Casdoor org users into the app users table (identity only, roles untouched)
- config/compose: CASDOOR_ADMIN_USER/PASSWORD for the sync endpoint
- seed_casdoor: redirect URI updated for the Traefik edge
2026-09-01 17:28:06 +03:30

562 lines
24 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.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:
if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
# 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) — only
# when an explicit X-User-Role header is sent. No auth info at all → 401.
if 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")
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)
# 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)
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
]}