"""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( material_id: str | None, brand_id: uuid.UUID | None, specs_hash: str | None, spec_id: str | None = None, ) -> dict: """دامنه‌ی «چه چیزی» را به پارامترهای سرویس تبدیل می‌کند. شناسه‌ی ماده اگر UUID معتبر باشد → دامنه‌ی همان ماده (همه‌ی مشخصات آن). اگر مقدار ارسالی UUID نباشد (یعنی شناسه‌ی مشخصه فرستاده شده) یا spec_id صریح بیاید → دامنه به همان مشخصه محدود می‌شود (specs_hash). """ resolved_material: uuid.UUID | None = None resolved_specs = specs_hash or spec_id if material_id: try: resolved_material = uuid.UUID(str(material_id)) except (ValueError, AttributeError, TypeError): # شناسه‌ی مشخصه (نه UUID ماده) — به‌عنوان دامنه‌ی مشخصه استفاده می‌شود resolved_specs = resolved_specs or str(material_id) return { "material_id": resolved_material, "brand_id": brand_id, "specs_hash": resolved_specs, } @router.get("/filters") async def dashboard_filters(db: AsyncSession = Depends(get_db)): """Options for the global filter bar (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), material_id: str | None = Query(default=None, description="UUID ماده یا شناسه‌ی مشخصه"), brand_id: uuid.UUID | None = Query(default=None), specs_hash: str | None = Query(default=None), spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"), ): """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(material_id, brand_id, specs_hash, spec_id), ) @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), material_id: str | None = Query(default=None, description="UUID ماده یا شناسه‌ی مشخصه"), brand_id: uuid.UUID | None = Query(default=None), specs_hash: str | None = Query(default=None), spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"), ): """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(material_id, brand_id, specs_hash, spec_id), ) @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, description="اختیاری — بدون آن همه‌ی انبارها"), material_id: str | None = Query(default=None, description="UUID ماده یا شناسه‌ی مشخصه"), brand_id: uuid.UUID | None = Query(default=None), specs_hash: str | None = Query(default=None), spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"), ): """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(material_id, brand_id, specs_hash, spec_id), ) @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, )