v2.7 dropped materials.category_id and the API started returning category: null for
every material, but the frontend still read material.category as the material's label.
Effect: the edit page's material field rendered blank for every row, the dashboard
category filter listed nothing (and matched nothing), the wizard's category suggestions
were empty, and the conversion lookups keyed by category (refConverted →
categoryUnits/unitConversions) silently returned null, dropping the per-reference-unit
price. Saving an edited quote matched materials on an always-true category term, so two
materials sharing brand+specs were interchangeable.
Auditing the dropped column also turned up four places still dereferencing it, one
request away from a 500 and hidden only because the inventory is empty:
GET /inventory (serializer), GET /inventory?category_id=…, GET /inventory/materials/options
and DELETE /material-categories/{id}.
Backend:
- expand_service: category_by_material() resolves a material's display category from its
FIRST linked supplier (oldest supplier_materials row — the v2.5 trigger's rule), used by
the material and line-item payloads; category_id is populated again when not expanded.
- inventory: list filter, row serializer and material-options filter match through
supplier_materials ⋈ suppliers, mirroring GET /materials?category_id=…
- material_categories: the delete guard blocks on suppliers (the RESTRICT FK) and says so.
Frontend:
- Material.category → Material.supplier_category (supplier-derived, documented); the
rename pushed every read site through tsc.
- Labels use the material's own name; the edit modal field is «متریال» and prefills by
name; formatMaterialTitle no longer falls back to a category.
- Identity matching (edit modal, wizard, material memory) is name + brand + specs via a
shared matchMaterial(), so same-brand same-spec materials are not conflated; legacy
composed names are still found through materialBaseName().
- Filters read the supplier's category (dashboard options + quote filter, consistent with
the backend's quote filtering); supplier categories now come from the supplier FK
instead of the removed JSONB column. Conversion keys use supplier_category.
- Removed dead code carrying the old logic: two uncalled topicCategories copies and
materialOptions' unused "cat" branch.
Verified: both images build clean (tsc -b && vite build); the API was exercised in-process
against a restore of the current production dump with an inventory row added — material
and line-item payloads carry the supplier category, the inventory endpoints that used to
raise return 200, and the category delete guard returns 409 instead of 500.
612 lines
26 KiB
Python
612 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 exists, 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, Supplier, SupplierMaterial
|
|
from app.schemas.inventory import (
|
|
InventoryTransactionCreate,
|
|
WarehouseCreate,
|
|
WarehouseUpdate,
|
|
)
|
|
from app.services.expand_service import category_by_material
|
|
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):
|
|
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 == 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:
|
|
# v2.7: materials carry no category — match through their suppliers
|
|
# (any link, mirroring GET /materials?category_id=…).
|
|
stmt = stmt.where(
|
|
exists(
|
|
select(1)
|
|
.select_from(SupplierMaterial)
|
|
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
|
.where(
|
|
SupplierMaterial.material_id == InventoryItem.material_id,
|
|
Supplier.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()}
|
|
# v2.7: the category shown for a material comes from its first linked supplier.
|
|
material_category = await category_by_material(db, mat_ids)
|
|
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],
|
|
material_category.get(i.material_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")
|
|
|
|
# v2.7: a material has no category column — it belongs to a category when a
|
|
# linked supplier does (same rule as GET /materials?category_id=…).
|
|
stmt = select(Material).where(
|
|
Material.status == "active",
|
|
exists(
|
|
select(1)
|
|
.select_from(SupplierMaterial)
|
|
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
|
.where(
|
|
SupplierMaterial.material_id == Material.id,
|
|
Supplier.category_id == category_id,
|
|
)
|
|
),
|
|
)
|
|
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
|
|
]}
|