Law/app/api/admin/metrics.py
2026-02-23 15:20:00 +03:00

241 lines
9.5 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.deps import require_role
from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.request import Request
from app.models.status import Status
from app.models.status_history import StatusHistory
from app.services.sla_metrics import compute_sla_snapshot
router = APIRouter()
DEFAULT_TERMINAL_STATUS_CODES = {"RESOLVED", "CLOSED", "REJECTED"}
PAID_STATUS_CODES = {"PAID", "ОПЛАЧЕНО"}
def _terminal_status_codes(db: Session) -> set[str]:
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
codes = {str(code).strip() for (code,) in rows if code}
return codes or set(DEFAULT_TERMINAL_STATUS_CODES)
def _paid_status_codes() -> set[str]:
return set(PAID_STATUS_CODES)
def _month_bounds(now_utc: datetime) -> tuple[datetime, datetime]:
start = datetime(now_utc.year, now_utc.month, 1, tzinfo=timezone.utc)
if now_utc.month == 12:
end = datetime(now_utc.year + 1, 1, 1, tzinfo=timezone.utc)
else:
end = datetime(now_utc.year, now_utc.month + 1, 1, tzinfo=timezone.utc)
return start, end
def _to_float(value) -> float:
if value is None:
return 0.0
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _uuid_or_none(value: str | None) -> UUID | None:
try:
return UUID(str(value or ""))
except ValueError:
return None
@router.get("/overview")
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
role = str(admin.get("role") or "").upper()
actor_id = str(admin.get("sub") or "").strip()
actor_uuid = _uuid_or_none(actor_id)
terminal_codes = _terminal_status_codes(db)
paid_codes = _paid_status_codes()
now_utc = datetime.now(timezone.utc)
month_start, next_month_start = _month_bounds(now_utc)
unread_for_clients = (
db.query(func.count(Request.id))
.filter(Request.client_has_unread_updates.is_(True))
.scalar()
or 0
)
unread_for_lawyers = (
db.query(func.count(Request.id))
.filter(Request.lawyer_has_unread_updates.is_(True))
.scalar()
or 0
)
active_load_rows = (
db.query(Request.assigned_lawyer_id, func.count(Request.id))
.filter(Request.assigned_lawyer_id.is_not(None))
.filter(Request.status_code.notin_(terminal_codes))
.group_by(Request.assigned_lawyer_id)
.all()
)
total_load_rows = (
db.query(Request.assigned_lawyer_id, func.count(Request.id))
.filter(Request.assigned_lawyer_id.is_not(None))
.group_by(Request.assigned_lawyer_id)
.all()
)
active_amount_rows = (
db.query(Request.assigned_lawyer_id, func.coalesce(func.sum(func.coalesce(Request.invoice_amount, 0)), 0))
.filter(Request.assigned_lawyer_id.is_not(None))
.filter(Request.status_code.notin_(terminal_codes))
.group_by(Request.assigned_lawyer_id)
.all()
)
paid_rows = (
db.query(
Request.assigned_lawyer_id,
func.count(StatusHistory.id),
func.coalesce(func.sum(func.coalesce(Request.invoice_amount, 0)), 0),
)
.join(StatusHistory, StatusHistory.request_id == Request.id)
.filter(Request.assigned_lawyer_id.is_not(None))
.filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start)
.filter(func.upper(StatusHistory.to_status).in_(paid_codes))
.group_by(Request.assigned_lawyer_id)
.all()
)
active_load_map = {str(lawyer_id): int(count) for lawyer_id, count in active_load_rows if lawyer_id}
total_load_map = {str(lawyer_id): int(count) for lawyer_id, count in total_load_rows if lawyer_id}
active_amount_map = {str(lawyer_id): _to_float(amount) for lawyer_id, amount in active_amount_rows if lawyer_id}
paid_events_map = {str(lawyer_id): int(events) for lawyer_id, events, _ in paid_rows if lawyer_id}
monthly_gross_map = {str(lawyer_id): _to_float(gross) for lawyer_id, _, gross in paid_rows if lawyer_id}
lawyers = (
db.query(AdminUser)
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
.all()
)
lawyer_loads = []
for lawyer in lawyers:
lawyer_id = str(lawyer.id)
salary_percent = _to_float(lawyer.salary_percent)
monthly_paid_gross = monthly_gross_map.get(lawyer_id, 0.0)
monthly_salary = monthly_paid_gross * salary_percent / 100.0
lawyer_loads.append(
{
"lawyer_id": lawyer_id,
"name": lawyer.name,
"email": lawyer.email,
"avatar_url": lawyer.avatar_url,
"primary_topic_code": lawyer.primary_topic_code,
"default_rate": _to_float(lawyer.default_rate),
"salary_percent": salary_percent,
"active_load": active_load_map.get(lawyer_id, 0),
"total_assigned": total_load_map.get(lawyer_id, 0),
"active_amount": round(active_amount_map.get(lawyer_id, 0.0), 2),
"monthly_paid_events": paid_events_map.get(lawyer_id, 0),
"monthly_paid_gross": round(monthly_paid_gross, 2),
"monthly_salary": round(monthly_salary, 2),
}
)
lawyer_loads.sort(key=lambda row: (-row["active_load"], row["name"] or "", row["email"] or ""))
if role == "LAWYER" and actor_uuid is not None:
scoped_by_status_rows = (
db.query(Request.status_code, func.count(Request.id))
.filter(Request.assigned_lawyer_id == str(actor_uuid))
.group_by(Request.status_code)
.all()
)
by_status = {status: int(count) for status, count in scoped_by_status_rows}
assigned_total = int(sum(by_status.values()))
active_assigned_total = int(
db.query(func.count(Request.id))
.filter(Request.assigned_lawyer_id == str(actor_uuid))
.filter(Request.status_code.notin_(terminal_codes))
.scalar()
or 0
)
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
my_unread_updates = int(
db.query(func.count(Request.id))
.filter(
Request.assigned_lawyer_id == str(actor_uuid),
Request.lawyer_has_unread_updates.is_(True),
)
.scalar()
or 0
)
my_unread_by_event_rows = (
db.query(Request.lawyer_unread_event_type, func.count(Request.id))
.filter(
Request.assigned_lawyer_id == str(actor_uuid),
Request.lawyer_has_unread_updates.is_(True),
Request.lawyer_unread_event_type.is_not(None),
)
.group_by(Request.lawyer_unread_event_type)
.all()
)
my_unread_by_event = {str(event_type): int(count) for event_type, count in my_unread_by_event_rows if event_type}
scoped_lawyer_loads = [row for row in lawyer_loads if str(row["lawyer_id"]) == str(actor_uuid)]
elif role == "LAWYER":
by_status = {}
assigned_total = 0
active_assigned_total = 0
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
my_unread_updates = 0
my_unread_by_event = {}
scoped_lawyer_loads = []
else:
scoped_by_status_rows = db.query(Request.status_code, func.count(Request.id)).group_by(Request.status_code).all()
by_status = {status: int(count) for status, count in scoped_by_status_rows}
assigned_total = int(
db.query(func.count(Request.id))
.filter(Request.assigned_lawyer_id.is_not(None))
.scalar()
or 0
)
active_assigned_total = int(
db.query(func.count(Request.id))
.filter(Request.assigned_lawyer_id.is_not(None))
.filter(Request.status_code.notin_(terminal_codes))
.scalar()
or 0
)
unassigned_total = int(db.query(func.count(Request.id)).filter(Request.assigned_lawyer_id.is_(None)).scalar() or 0)
my_unread_updates = 0
my_unread_by_event = {}
scoped_lawyer_loads = lawyer_loads
sla_snapshot = compute_sla_snapshot(db)
return {
"scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN",
"new": int(by_status.get("NEW", 0)),
"by_status": by_status,
"assigned_total": assigned_total,
"active_assigned_total": active_assigned_total,
"unassigned_total": unassigned_total,
"my_unread_updates": my_unread_updates,
"my_unread_by_event": my_unread_by_event,
"frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"),
"sla_overdue": sla_snapshot.get("overdue_total", 0),
"overdue_by_status": sla_snapshot.get("overdue_by_status", {}),
"overdue_by_transition": sla_snapshot.get("overdue_by_transition", {}),
"avg_time_in_status_hours": sla_snapshot.get("avg_time_in_status_hours", {}),
"unread_for_clients": int(unread_for_clients),
"unread_for_lawyers": int(unread_for_lawyers),
"lawyer_loads": scoped_lawyer_loads,
}