mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-19 02:23:45 +03:00
180 lines
7.2 KiB
Python
180 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.message import Message
|
|
from app.models.request import Request
|
|
from app.models.status import Status
|
|
from app.models.status_history import StatusHistory
|
|
from app.models.topic_status_transition import TopicStatusTransition
|
|
|
|
DEFAULT_TERMINAL_STATUS_CODES = {"RESOLVED", "CLOSED", "REJECTED"}
|
|
DEFAULT_SLA_HOURS_BY_STATUS = {
|
|
"NEW": 24,
|
|
"IN_PROGRESS": 72,
|
|
"WAITING_CLIENT": 168,
|
|
"WAITING_COURT": 336,
|
|
}
|
|
DEFAULT_SLA_HOURS = 72
|
|
|
|
|
|
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 _as_utc(value: datetime | None, fallback: datetime) -> datetime:
|
|
if value is None:
|
|
return fallback
|
|
if value.tzinfo is None:
|
|
return value.replace(tzinfo=timezone.utc)
|
|
return value.astimezone(timezone.utc)
|
|
|
|
|
|
def _load_topic_sla_maps(db: Session) -> tuple[dict[tuple[str, str], int], dict[tuple[str, str, str], int]]:
|
|
rows = (
|
|
db.query(
|
|
TopicStatusTransition.topic_code,
|
|
TopicStatusTransition.from_status,
|
|
TopicStatusTransition.to_status,
|
|
TopicStatusTransition.sla_hours,
|
|
)
|
|
.filter(
|
|
TopicStatusTransition.enabled.is_(True),
|
|
TopicStatusTransition.sla_hours.is_not(None),
|
|
TopicStatusTransition.sla_hours > 0,
|
|
)
|
|
.all()
|
|
)
|
|
|
|
outgoing_sla: dict[tuple[str, str], int] = {}
|
|
exact_sla: dict[tuple[str, str, str], int] = {}
|
|
for topic_code, from_status, to_status, sla_hours in rows:
|
|
topic = str(topic_code or "").strip()
|
|
from_code = str(from_status or "").strip()
|
|
to_code = str(to_status or "").strip()
|
|
if not topic or not from_code or not to_code:
|
|
continue
|
|
sla = int(sla_hours or 0)
|
|
if sla <= 0:
|
|
continue
|
|
exact_sla[(topic, from_code, to_code)] = sla
|
|
key = (topic, from_code)
|
|
if key not in outgoing_sla or sla < outgoing_sla[key]:
|
|
outgoing_sla[key] = sla
|
|
return outgoing_sla, exact_sla
|
|
|
|
|
|
def _current_status_started_at(req: Request, request_rows: list[StatusHistory], now_utc: datetime) -> datetime:
|
|
current_status = str(req.status_code or "").strip()
|
|
if current_status and request_rows:
|
|
for row in reversed(request_rows):
|
|
if str(row.to_status or "").strip() == current_status:
|
|
return _as_utc(row.created_at, now_utc)
|
|
return _as_utc(req.updated_at or req.created_at, now_utc)
|
|
|
|
|
|
def compute_sla_snapshot(
|
|
db: Session,
|
|
now: datetime | None = None,
|
|
*,
|
|
include_overdue_requests: bool = False,
|
|
) -> dict[str, Any]:
|
|
now_utc = _as_utc(now, datetime.now(timezone.utc))
|
|
terminal_codes = _terminal_status_codes(db)
|
|
active_requests = db.query(Request).filter(Request.status_code.notin_(terminal_codes)).all()
|
|
|
|
status_rows = db.query(StatusHistory).order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.asc()).all()
|
|
rows_by_request: dict[str, list[StatusHistory]] = defaultdict(list)
|
|
for row in status_rows:
|
|
rows_by_request[str(row.request_id)].append(row)
|
|
|
|
outgoing_sla_map, _ = _load_topic_sla_maps(db)
|
|
|
|
overdue_by_status: dict[str, int] = defaultdict(int)
|
|
overdue_by_transition: dict[str, int] = defaultdict(int)
|
|
overdue_requests: list[dict[str, Any]] = []
|
|
for req in active_requests:
|
|
status_code = str(req.status_code or "").strip() or "UNKNOWN"
|
|
topic_code = str(req.topic_code or "").strip()
|
|
threshold_hours = outgoing_sla_map.get(
|
|
(topic_code, status_code),
|
|
int(DEFAULT_SLA_HOURS_BY_STATUS.get(status_code, DEFAULT_SLA_HOURS)),
|
|
)
|
|
status_started_at = _current_status_started_at(req, rows_by_request.get(str(req.id), []), now_utc)
|
|
hours_in_status = (now_utc - status_started_at).total_seconds() / 3600.0
|
|
if hours_in_status > threshold_hours:
|
|
overdue_by_status[status_code] += 1
|
|
transition_key = f"{topic_code or '*'}:{status_code}->*"
|
|
overdue_by_transition[transition_key] += 1
|
|
if include_overdue_requests:
|
|
overdue_requests.append(
|
|
{
|
|
"request_id": str(req.id),
|
|
"track_number": req.track_number,
|
|
"topic_code": req.topic_code,
|
|
"status_code": req.status_code,
|
|
"assigned_lawyer_id": req.assigned_lawyer_id,
|
|
"hours_in_status": round(hours_in_status, 2),
|
|
"threshold_hours": int(threshold_hours),
|
|
}
|
|
)
|
|
|
|
first_response_rows = (
|
|
db.query(Message.request_id, Message.created_at)
|
|
.filter(Message.author_type == "LAWYER")
|
|
.order_by(Message.request_id.asc(), Message.created_at.asc())
|
|
.all()
|
|
)
|
|
first_response_map = {}
|
|
for request_id, created_at in first_response_rows:
|
|
key = str(request_id)
|
|
if key not in first_response_map and created_at is not None:
|
|
first_response_map[key] = created_at
|
|
|
|
frt_minutes: list[float] = []
|
|
for req in active_requests:
|
|
first_response_at = first_response_map.get(str(req.id))
|
|
if not first_response_at or not req.created_at:
|
|
continue
|
|
first_dt = _as_utc(first_response_at, now_utc)
|
|
req_created = _as_utc(req.created_at, now_utc)
|
|
delta_min = (first_dt - req_created).total_seconds() / 60.0
|
|
if delta_min >= 0:
|
|
frt_minutes.append(delta_min)
|
|
|
|
durations_by_status: dict[str, list[float]] = defaultdict(list)
|
|
for req in active_requests:
|
|
request_rows = rows_by_request.get(str(req.id), [])
|
|
if not request_rows:
|
|
started_at = _as_utc(req.created_at, now_utc)
|
|
status_code = str(req.status_code or "").strip() or "UNKNOWN"
|
|
durations_by_status[status_code].append(max((now_utc - started_at).total_seconds() / 3600.0, 0.0))
|
|
continue
|
|
|
|
for idx, row in enumerate(request_rows):
|
|
start = _as_utc(row.created_at, now_utc)
|
|
end_raw = request_rows[idx + 1].created_at if idx + 1 < len(request_rows) else now_utc
|
|
end = _as_utc(end_raw, now_utc)
|
|
status_code = str(row.to_status or "").strip() or "UNKNOWN"
|
|
duration_hours = max((end - start).total_seconds() / 3600.0, 0.0)
|
|
durations_by_status[status_code].append(duration_hours)
|
|
|
|
result = {
|
|
"checked_active_requests": int(len(active_requests)),
|
|
"overdue_total": int(sum(overdue_by_status.values())),
|
|
"overdue_by_status": dict(overdue_by_status),
|
|
"overdue_by_transition": dict(overdue_by_transition),
|
|
"frt_avg_minutes": round(sum(frt_minutes) / len(frt_minutes), 2) if frt_minutes else None,
|
|
"avg_time_in_status_hours": {
|
|
code: round(sum(values) / len(values), 2) for code, values in durations_by_status.items() if values
|
|
},
|
|
}
|
|
if include_overdue_requests:
|
|
result["overdue_requests"] = overdue_requests
|
|
return result
|