- Show all available inventory items immediately when selecting a warehouse in withdrawal form - Add vertical scrolling container with max height to prevent layout overflow for large item lists - Add Material column to the inventory rows table for clear item identification - Add required 'delivered_to' recipient field in withdrawal form with frontend validation - Track delivered_to in database, models, schemas, and API - Display recipient badge on withdrawal cards in daily control page - Group daily control withdrawals by inventory ID and recipient
527 lines
22 KiB
Python
527 lines
22 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
|
|
|
|
|
|
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,
|
|
"delivered_to": tx.delivered_to,
|
|
"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,
|
|
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 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 == 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",
|
|
)]}
|