218 lines
9.4 KiB
Python
218 lines
9.4 KiB
Python
"""MVP dashboard router — read-only aggregations for the warehouse +
|
|
procurement dashboard widgets consumed by the frontend `apiV26.dashboard`
|
|
client (frontend/src/lib/api.ts).
|
|
|
|
Scope (same resolution pattern as the inventory / pm routers):
|
|
- role 'pm' → limited to the projects they manage (projects.pm_user_id, 1:N);
|
|
an explicit ?project_id must be one of them.
|
|
- role 'admin' / 'procurement_manager' → whole organization; ?project_id is
|
|
an optional filter.
|
|
- Dev fallback via X-User-Role / X-Project-Id headers, ONLY when
|
|
PROCUREMENT_DEV_LOGIN_ENABLED=true (read-only endpoints, same gate as the
|
|
inventory router).
|
|
|
|
Endpoints:
|
|
- GET /dashboard/filters — «What» cascade options
|
|
- GET /dashboard/inventory/drilldown — org → project → warehouse tree
|
|
- GET /dashboard/inventory/dead-stock — dormant stock table
|
|
- GET /dashboard/inventory/flow — daily inbound/outbound/returns/balance
|
|
- GET /dashboard/daily-control — today's tickets + performers
|
|
- GET /dashboard/procurement/workload — workload per project (density)
|
|
|
|
Every response is the standard `{"data": …}` envelope.
|
|
"""
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import date as date_cls
|
|
from datetime import datetime, timedelta
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import UserContext, get_optional_user
|
|
from app.config import settings
|
|
from app.db.engine import get_db
|
|
from app.services import dashboard_service
|
|
from app.utils.errors import (
|
|
ForbiddenError,
|
|
UnauthorizedError,
|
|
ValidationError,
|
|
)
|
|
|
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
|
|
|
|
|
@dataclass
|
|
class DashboardScope:
|
|
role: str # "pm" | "office"
|
|
project_ids: list | None # PM: managed project ids; office: None = all
|
|
project_id: uuid.UUID | None # explicit ?project_id filter (validated for PM)
|
|
|
|
|
|
async def dashboard_scope(
|
|
request: Request,
|
|
project_id: uuid.UUID | None = Query(default=None, description="فیلتر پروژه (PM: فقط پروژههای خودش)"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> DashboardScope:
|
|
"""Resolve the dashboard caller — Casdoor JWT first, dev headers as fallback."""
|
|
if request.headers.get("authorization"):
|
|
user: UserContext | None = await get_optional_user(request, db)
|
|
if user is not None:
|
|
if user.role is None:
|
|
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
|
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 DashboardScope(role="pm", project_ids=allowed, project_id=project_id)
|
|
if not allowed:
|
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
|
return DashboardScope(role="pm", project_ids=allowed, project_id=None)
|
|
if user.role in ("admin", "procurement_manager"):
|
|
return DashboardScope(role="office", project_ids=None, project_id=project_id)
|
|
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا دفتر تدارکات است")
|
|
|
|
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 "").strip().lower()
|
|
if role == "procurement_manager" or role == "admin":
|
|
role = "office"
|
|
elif role == "pm":
|
|
role = "pm"
|
|
else:
|
|
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
|
|
if role == "pm":
|
|
raw = request.headers.get("x-project-id")
|
|
if not raw:
|
|
raise ValidationError("هدر X-Project-Id (پروژه مسئول) برای نقش PM الزامی است", field="X-Project-Id")
|
|
try:
|
|
if project_id is None:
|
|
project_id = uuid.UUID(str(raw))
|
|
except ValueError:
|
|
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
|
|
return DashboardScope(role="pm", project_ids=[project_id], project_id=project_id)
|
|
return DashboardScope(role="office", project_ids=None, project_id=project_id)
|
|
|
|
|
|
def _what_filters(
|
|
category_id: uuid.UUID | None,
|
|
material_id: uuid.UUID | None,
|
|
brand_id: uuid.UUID | None,
|
|
specs_hash: str | None,
|
|
) -> dict:
|
|
return {
|
|
"category_id": category_id,
|
|
"material_id": material_id,
|
|
"brand_id": brand_id,
|
|
"specs_hash": specs_hash,
|
|
}
|
|
|
|
|
|
@router.get("/filters")
|
|
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
|
"""Cascading options for the global filter bar (Category→Material→Brand→Specs)."""
|
|
return await dashboard_service.filter_options(db)
|
|
|
|
|
|
@router.get("/inventory/drilldown")
|
|
async def inventory_drilldown(
|
|
scope: DashboardScope = Depends(dashboard_scope),
|
|
db: AsyncSession = Depends(get_db),
|
|
category_id: uuid.UUID | None = Query(default=None),
|
|
material_id: uuid.UUID | None = Query(default=None),
|
|
brand_id: uuid.UUID | None = Query(default=None),
|
|
specs_hash: str | None = Query(default=None),
|
|
):
|
|
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
|
|
return await dashboard_service.inventory_drilldown(
|
|
db,
|
|
project_ids=scope.project_ids,
|
|
project_id=scope.project_id,
|
|
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
|
)
|
|
|
|
|
|
@router.get("/inventory/dead-stock")
|
|
async def dead_stock(
|
|
scope: DashboardScope = Depends(dashboard_scope),
|
|
db: AsyncSession = Depends(get_db),
|
|
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
|
limit: int = Query(default=200, ge=1, le=1000),
|
|
category_id: uuid.UUID | None = Query(default=None),
|
|
material_id: uuid.UUID | None = Query(default=None),
|
|
brand_id: uuid.UUID | None = Query(default=None),
|
|
specs_hash: str | None = Query(default=None),
|
|
):
|
|
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازهی رکود."""
|
|
return await dashboard_service.dead_stock(
|
|
db,
|
|
project_ids=scope.project_ids,
|
|
project_id=scope.project_id,
|
|
idle_days=idle_days,
|
|
limit=limit,
|
|
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
|
)
|
|
|
|
|
|
@router.get("/inventory/flow")
|
|
async def inventory_flow(
|
|
scope: DashboardScope = Depends(dashboard_scope),
|
|
db: AsyncSession = Depends(get_db),
|
|
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
|
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
|
warehouse_id: uuid.UUID | None = Query(default=None),
|
|
category_id: uuid.UUID | None = Query(default=None),
|
|
material_id: uuid.UUID | None = Query(default=None),
|
|
brand_id: uuid.UUID | None = Query(default=None),
|
|
specs_hash: str | None = Query(default=None),
|
|
):
|
|
"""Daily flow series: ورود / خروج (با لایهی ضایعات) / برگشت / مانده."""
|
|
today = datetime.now(dashboard_service.TEHRAN).date()
|
|
date_from = date_from or (today - timedelta(days=29))
|
|
date_to = date_to or (today + timedelta(days=1))
|
|
if date_to <= date_from:
|
|
raise ValidationError("بازهی تاریخ نامعتبر است — «تا» باید بعد از «از» باشد", field="date_to")
|
|
if (date_to - date_from).days > 180:
|
|
raise ValidationError("بازهی تاریخ حداکثر ۱۸۰ روز است", field="date_to")
|
|
return await dashboard_service.daily_flow(
|
|
db,
|
|
project_ids=scope.project_ids,
|
|
project_id=scope.project_id,
|
|
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
|
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
|
warehouse_id=warehouse_id,
|
|
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
|
)
|
|
|
|
|
|
@router.get("/daily-control")
|
|
async def daily_control(
|
|
scope: DashboardScope = Depends(dashboard_scope),
|
|
db: AsyncSession = Depends(get_db),
|
|
date: date_cls | None = Query(default=None, description="روز موردنظر (ISO، پیشفرض امروز)"),
|
|
warehouse_id: uuid.UUID | None = Query(default=None),
|
|
):
|
|
"""Daily control: today's outbound/return tickets + exact performer names."""
|
|
day = date or datetime.now(dashboard_service.TEHRAN).date()
|
|
return await dashboard_service.daily_control(
|
|
db,
|
|
project_ids=scope.project_ids,
|
|
project_id=scope.project_id,
|
|
day=day,
|
|
warehouse_id=warehouse_id,
|
|
)
|
|
|
|
|
|
@router.get("/procurement/workload")
|
|
async def procurement_workload(
|
|
scope: DashboardScope = Depends(dashboard_scope),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Project density: توزیع بار تدارکات (پیشنهادهای باز + خطوط خرید جاری) روی پروژهها."""
|
|
return await dashboard_service.procurement_workload(
|
|
db,
|
|
project_ids=scope.project_ids,
|
|
project_id=scope.project_id,
|
|
)
|