feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -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
|
||||
]}
|
||||
Reference in New Issue
Block a user