from __future__ import annotations from datetime import datetime, timedelta, 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.audit_log import AuditLog from app.models.request import Request from app.models.request_service_request import RequestServiceRequest from app.models.status import Status from app.models.status_history import StatusHistory from app.services.notifications import ( unread_admin_summary, unread_global_summary_for_clients, unread_global_summary_for_lawyers, ) 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 def _extract_assigned_lawyer_from_audit(diff: dict | None, action: str | None) -> str | None: if not isinstance(diff, dict): return None action_code = str(action or "").upper() if action_code == "MANUAL_CLAIM": value = diff.get("assigned_lawyer_id") return str(value).strip() if value else None if action_code == "MANUAL_REASSIGN": value = diff.get("to_lawyer_id") return str(value).strip() if value else None if action_code in {"CREATE", "UPDATE"}: after = diff.get("after") before = diff.get("before") if action_code == "UPDATE": if not isinstance(after, dict) or not isinstance(before, dict): return None prev_value = str(before.get("assigned_lawyer_id") or "").strip() next_value = str(after.get("assigned_lawyer_id") or "").strip() if not next_value or next_value == prev_value: return None return next_value if isinstance(after, dict): value = str(after.get("assigned_lawyer_id") or "").strip() return value or None return None def _empty_sla_snapshot() -> dict[str, object]: return { "frt_avg_minutes": None, "sla_overdue": 0, "overdue_by_status": {}, "overdue_by_transition": {}, "avg_time_in_status_hours": {}, } def _overview_sla_payload(db: Session) -> dict[str, object]: sla_snapshot = compute_sla_snapshot(db) return { "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", {}), } @router.get("/overview") def overview( include_sla: bool = True, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): 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_flags = ( db.query(func.count(Request.id)) .filter(Request.client_has_unread_updates.is_(True)) .scalar() or 0 ) unread_for_lawyers_flags = ( db.query(func.count(Request.id)) .filter(Request.lawyer_has_unread_updates.is_(True)) .scalar() or 0 ) unread_for_clients_notifications = unread_global_summary_for_clients(db) unread_for_lawyers_notifications = unread_global_summary_for_lawyers(db) unread_for_clients = max(int(unread_for_clients_flags), int(unread_for_clients_notifications.get("total") or 0)) unread_for_lawyers = max(int(unread_for_lawyers_flags), int(unread_for_lawyers_notifications.get("total") or 0)) my_unread_notifications = ( unread_admin_summary(db, admin_user_id=str(actor_uuid), request_id=None) if actor_uuid is not None else {"total": 0, "by_event": {}} ) if role == "LAWYER" and actor_uuid is not None: service_request_unread_total = int( db.query(func.count(RequestServiceRequest.id)) .filter( RequestServiceRequest.type == "CURATOR_CONTACT", RequestServiceRequest.assigned_lawyer_id == str(actor_uuid), RequestServiceRequest.lawyer_unread.is_(True), ) .scalar() or 0 ) elif role == "LAWYER": service_request_unread_total = 0 else: service_request_unread_total = int( db.query(func.count(RequestServiceRequest.id)) .filter(RequestServiceRequest.admin_unread.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} monthly_completed_rows = ( db.query(Request.assigned_lawyer_id, func.count(func.distinct(StatusHistory.request_id))) .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_({str(code).upper() for code in terminal_codes})) .group_by(Request.assigned_lawyer_id) .all() ) monthly_completed_map = {str(lawyer_id): int(count) for lawyer_id, count in monthly_completed_rows if lawyer_id} monthly_assigned_map: dict[str, int] = {} audit_rows = ( db.query(AuditLog.action, AuditLog.diff) .filter(AuditLog.entity == "requests") .filter(AuditLog.created_at >= month_start, AuditLog.created_at < next_month_start) .all() ) for action, diff in audit_rows: assigned_to = _extract_assigned_lawyer_from_audit(diff, action) if not assigned_to: continue monthly_assigned_map[assigned_to] = int(monthly_assigned_map.get(assigned_to, 0)) + 1 monthly_revenue = round(sum(monthly_gross_map.values()), 2) 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_assigned_count": monthly_assigned_map.get(lawyer_id, 0), "monthly_completed_count": monthly_completed_map.get(lawyer_id, 0), "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} notif_total = int(my_unread_notifications.get("total") or 0) notif_by_event = dict(my_unread_notifications.get("by_event") or {}) if notif_total > my_unread_updates: my_unread_updates = notif_total my_unread_by_event = notif_by_event 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 = int(my_unread_notifications.get("total") or 0) my_unread_by_event = dict(my_unread_notifications.get("by_event") or {}) scoped_lawyer_loads = lawyer_loads next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1) deadline_alert_query = ( db.query(func.count(Request.id)) .filter(Request.important_date_at.is_not(None)) .filter(Request.important_date_at < next_day_start) .filter(Request.status_code.notin_(terminal_codes)) ) if role == "LAWYER" and actor_uuid is not None: deadline_alert_query = deadline_alert_query.filter(Request.assigned_lawyer_id == str(actor_uuid)) elif role == "LAWYER": deadline_alert_query = deadline_alert_query.filter(Request.id.is_(None)) deadline_alert_total = int(deadline_alert_query.scalar() or 0) payload = { "scope": role if role in {"ADMIN", "LAWYER", "CURATOR"} 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, "my_unread_notifications_total": int(my_unread_notifications.get("total") or 0), "my_unread_notifications_by_event": dict(my_unread_notifications.get("by_event") or {}), "deadline_alert_total": deadline_alert_total, "month_revenue": monthly_revenue, "month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2) if role == "LAWYER" else round(sum(_to_float(row.get("monthly_salary")) for row in lawyer_loads), 2), "unread_for_clients": int(unread_for_clients), "unread_for_lawyers": int(unread_for_lawyers), "unread_for_clients_by_event": dict(unread_for_clients_notifications.get("by_event") or {}), "unread_for_lawyers_by_event": dict(unread_for_lawyers_notifications.get("by_event") or {}), "unread_for_clients_notifications_total": int(unread_for_clients_notifications.get("total") or 0), "unread_for_lawyers_notifications_total": int(unread_for_lawyers_notifications.get("total") or 0), "service_request_unread_total": int(service_request_unread_total), "lawyer_loads": scoped_lawyer_loads, } payload.update(_overview_sla_payload(db) if include_sla else _empty_sla_snapshot()) return payload @router.get("/overview-sla") def overview_sla(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))): _ = admin return _overview_sla_payload(db) @router.get("/lawyers/{lawyer_id}/active-requests") def lawyer_active_requests( lawyer_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER")), ): actor_role = str(admin.get("role") or "").upper() actor_id = str(admin.get("sub") or "").strip() if actor_role == "LAWYER" and str(lawyer_id) != actor_id: return {"rows": [], "total": 0, "totals": {"amount": 0.0, "salary": 0.0}} lawyer = db.query(AdminUser).filter(AdminUser.id == _uuid_or_none(lawyer_id), AdminUser.role == "LAWYER").first() if lawyer is None: return {"rows": [], "total": 0, "totals": {"amount": 0.0, "salary": 0.0}} 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) salary_percent = _to_float(lawyer.salary_percent) paid_by_request_rows = ( db.query(StatusHistory.request_id, func.count(StatusHistory.id)) .join(Request, Request.id == StatusHistory.request_id) .filter(Request.assigned_lawyer_id == str(lawyer.id)) .filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start) .filter(func.upper(StatusHistory.to_status).in_(paid_codes)) .group_by(StatusHistory.request_id) .all() ) paid_events_per_request = {str(req_id): int(count) for req_id, count in paid_by_request_rows if req_id} request_rows = ( db.query(Request) .filter(Request.assigned_lawyer_id == str(lawyer.id)) .filter(Request.status_code.notin_(terminal_codes)) .order_by(Request.created_at.desc(), Request.track_number.asc()) .all() ) rows = [] total_amount = 0.0 total_salary = 0.0 for req in request_rows: req_id = str(req.id) invoice_amount = _to_float(req.invoice_amount) paid_events = int(paid_events_per_request.get(req_id, 0)) month_paid_amount = round(invoice_amount * paid_events, 2) month_salary_amount = round(month_paid_amount * salary_percent / 100.0, 2) total_amount += month_paid_amount total_salary += month_salary_amount rows.append( { "id": req_id, "track_number": req.track_number, "status_code": req.status_code, "client_name": req.client_name, "invoice_amount": round(invoice_amount, 2), "month_paid_events": paid_events, "month_paid_amount": month_paid_amount, "month_salary_amount": month_salary_amount, "created_at": req.created_at.isoformat() if req.created_at else None, "paid_at": req.paid_at.isoformat() if req.paid_at else None, } ) return { "rows": rows, "total": len(rows), "totals": { "amount": round(total_amount, 2), "salary": round(total_salary, 2), }, }