mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
765 lines
32 KiB
Python
765 lines
32 KiB
Python
from __future__ import annotations
|
||
|
||
import logging
|
||
from time import perf_counter
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
from uuid import UUID, uuid4
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import case, func, or_, update
|
||
from sqlalchemy.exc import DataError, IntegrityError, SQLAlchemyError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.admin_user import AdminUser
|
||
from app.models.attachment import Attachment
|
||
from app.models.audit_log import AuditLog
|
||
from app.models.invoice import Invoice
|
||
from app.models.notification import Notification
|
||
from app.models.request import Request
|
||
from app.models.request_service_request import RequestServiceRequest
|
||
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
|
||
from app.services.chat_secure_service import (
|
||
get_chat_activity_summary,
|
||
list_messages_for_request_window,
|
||
mark_messages_read_for_staff,
|
||
serialize_messages_for_request,
|
||
)
|
||
from app.schemas.universal import UniversalQuery
|
||
from app.services.billing_flow import apply_billing_transition_effects
|
||
from app.services.notifications import (
|
||
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||
mark_admin_notifications_read,
|
||
notify_request_event,
|
||
)
|
||
from app.services.request_assignment_events import apply_assignment_change
|
||
from app.services.request_read_markers import (
|
||
EVENT_STATUS,
|
||
clear_unread_for_lawyer,
|
||
mark_unread_for_client,
|
||
)
|
||
from app.services.request_deadline import initial_important_date_at
|
||
from app.services.request_finance_validation import (
|
||
normalize_request_financial_payload_or_400,
|
||
request_financial_data_error_or_400,
|
||
)
|
||
from app.services.request_status import apply_status_change_effects
|
||
from app.services.request_templates import validate_required_topic_fields_or_400
|
||
from app.services.status_flow import transition_allowed_for_topic
|
||
from app.services.status_transition_requirements import validate_transition_requirements_or_400
|
||
from app.services.universal_query import apply_universal_query
|
||
|
||
from .common import normalize_important_date_or_default
|
||
from .permissions import (
|
||
REQUEST_FINANCIAL_FIELDS,
|
||
active_lawyer_or_400,
|
||
client_for_request_payload_or_400,
|
||
ensure_lawyer_can_manage_request_or_403,
|
||
ensure_lawyer_can_view_request_or_403,
|
||
request_uuid_or_400,
|
||
)
|
||
from .status_flow import apply_request_special_filters, get_request_status_route_service, split_request_special_filters
|
||
|
||
_WORKSPACE_LOG = logging.getLogger("uvicorn.error")
|
||
INITIAL_WORKSPACE_CHAT_WINDOW_LIMIT = 20
|
||
|
||
|
||
def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict[str, Any]:
|
||
base_query = db.query(Request)
|
||
role = str(admin.get("role") or "").upper()
|
||
actor = str(admin.get("sub") or "").strip()
|
||
if role == "LAWYER":
|
||
if not actor:
|
||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||
base_query = base_query.filter(
|
||
or_(
|
||
Request.assigned_lawyer_id == actor,
|
||
Request.assigned_lawyer_id.is_(None),
|
||
)
|
||
)
|
||
|
||
regular_uq, special_filters = split_request_special_filters(uq)
|
||
base_query = apply_request_special_filters(
|
||
base_query,
|
||
db=db,
|
||
role=role,
|
||
actor_id=actor,
|
||
special_filters=special_filters,
|
||
)
|
||
q = apply_universal_query(base_query, Request, regular_uq)
|
||
total = q.count()
|
||
rows = q.offset(uq.page.offset).limit(uq.page.limit).all()
|
||
row_ids = [str(row.id) for row in rows if row and row.id]
|
||
|
||
unread_service_requests_by_request: dict[str, int] = {}
|
||
viewer_unread_by_request: dict[str, dict[str, Any]] = {}
|
||
if row_ids:
|
||
unread_query = (
|
||
db.query(RequestServiceRequest.request_id, func.count(RequestServiceRequest.id))
|
||
.filter(RequestServiceRequest.request_id.in_(row_ids))
|
||
)
|
||
if role == "LAWYER":
|
||
unread_query = unread_query.filter(
|
||
RequestServiceRequest.type == "CURATOR_CONTACT",
|
||
RequestServiceRequest.assigned_lawyer_id == actor,
|
||
RequestServiceRequest.lawyer_unread.is_(True),
|
||
)
|
||
else:
|
||
unread_query = unread_query.filter(RequestServiceRequest.admin_unread.is_(True))
|
||
unread_rows = unread_query.group_by(RequestServiceRequest.request_id).all()
|
||
unread_service_requests_by_request = {str(request_id): int(count or 0) for request_id, count in unread_rows if request_id}
|
||
|
||
if actor:
|
||
try:
|
||
actor_uuid = UUID(str(actor))
|
||
except ValueError:
|
||
actor_uuid = None
|
||
if actor_uuid is not None:
|
||
try:
|
||
notif_rows = (
|
||
db.query(Notification.request_id, Notification.event_type, func.count(Notification.id))
|
||
.filter(
|
||
Notification.recipient_type == "ADMIN_USER",
|
||
Notification.recipient_admin_user_id == actor_uuid,
|
||
Notification.is_read.is_(False),
|
||
Notification.request_id.in_(row_ids),
|
||
)
|
||
.group_by(Notification.request_id, Notification.event_type)
|
||
.all()
|
||
)
|
||
except SQLAlchemyError:
|
||
notif_rows = []
|
||
for request_id, event_type, count in notif_rows:
|
||
request_key = str(request_id or "")
|
||
if not request_key:
|
||
continue
|
||
bucket = viewer_unread_by_request.setdefault(request_key, {"total": 0, "by_event": {}})
|
||
event_key = str(event_type or "").strip().upper()
|
||
event_count = int(count or 0)
|
||
if event_key:
|
||
bucket["by_event"][event_key] = int(bucket["by_event"].get(event_key, 0)) + event_count
|
||
bucket["total"] = int(bucket["total"]) + event_count
|
||
|
||
return {
|
||
"rows": [
|
||
{
|
||
"id": str(r.id),
|
||
"track_number": r.track_number,
|
||
"client_id": str(r.client_id) if r.client_id else None,
|
||
"status_code": r.status_code,
|
||
"client_name": r.client_name,
|
||
"client_phone": r.client_phone,
|
||
"topic_code": r.topic_code,
|
||
"important_date_at": r.important_date_at.isoformat() if r.important_date_at else None,
|
||
"effective_rate": float(r.effective_rate) if r.effective_rate is not None else None,
|
||
"request_cost": float(r.request_cost) if r.request_cost is not None else None,
|
||
"invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None,
|
||
"paid_at": r.paid_at.isoformat() if r.paid_at else None,
|
||
"paid_by_admin_id": r.paid_by_admin_id,
|
||
"client_has_unread_updates": r.client_has_unread_updates,
|
||
"client_unread_event_type": r.client_unread_event_type,
|
||
"lawyer_has_unread_updates": r.lawyer_has_unread_updates,
|
||
"lawyer_unread_event_type": r.lawyer_unread_event_type,
|
||
"service_requests_unread_count": int(unread_service_requests_by_request.get(str(r.id), 0)),
|
||
"has_service_requests_unread": bool(unread_service_requests_by_request.get(str(r.id), 0)),
|
||
"viewer_unread_total": int((viewer_unread_by_request.get(str(r.id)) or {}).get("total", 0)),
|
||
"viewer_unread_by_event": dict((viewer_unread_by_request.get(str(r.id)) or {}).get("by_event", {})),
|
||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||
}
|
||
for r in rows
|
||
],
|
||
"total": total,
|
||
}
|
||
|
||
|
||
def create_request_service(payload: RequestAdminCreate, db: Session, admin: dict) -> dict[str, Any]:
|
||
actor_role = str(admin.get("role") or "").upper()
|
||
if actor_role == "LAWYER" and str(payload.assigned_lawyer_id or "").strip():
|
||
raise HTTPException(status_code=403, detail="Юрист не может назначать заявку при создании")
|
||
if actor_role == "LAWYER":
|
||
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.model_fields_set)))
|
||
if forbidden_fields:
|
||
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
||
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
|
||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||
client = client_for_request_payload_or_400(
|
||
db,
|
||
client_id=payload.client_id,
|
||
client_name=payload.client_name,
|
||
client_phone=payload.client_phone,
|
||
responsible=responsible,
|
||
)
|
||
finance_payload = normalize_request_financial_payload_or_400(payload.model_dump())
|
||
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
|
||
effective_rate = finance_payload.get("effective_rate")
|
||
if assigned_lawyer_id:
|
||
assigned_lawyer = active_lawyer_or_400(db, assigned_lawyer_id)
|
||
assigned_lawyer_id = str(assigned_lawyer.id)
|
||
if effective_rate is None:
|
||
effective_rate = assigned_lawyer.default_rate
|
||
important_date_at = payload.important_date_at or initial_important_date_at()
|
||
row = Request(
|
||
track_number=track,
|
||
client_id=client.id,
|
||
client_name=client.full_name,
|
||
client_phone=client.phone,
|
||
topic_code=payload.topic_code,
|
||
status_code=payload.status_code,
|
||
important_date_at=important_date_at,
|
||
description=payload.description,
|
||
extra_fields=payload.extra_fields,
|
||
assigned_lawyer_id=assigned_lawyer_id,
|
||
effective_rate=effective_rate,
|
||
request_cost=finance_payload.get("request_cost"),
|
||
invoice_amount=finance_payload.get("invoice_amount"),
|
||
paid_at=payload.paid_at,
|
||
paid_by_admin_id=payload.paid_by_admin_id,
|
||
total_attachments_bytes=payload.total_attachments_bytes,
|
||
responsible=responsible,
|
||
)
|
||
try:
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
except DataError as exc:
|
||
db.rollback()
|
||
raise request_financial_data_error_or_400() from exc
|
||
except IntegrityError as exc:
|
||
db.rollback()
|
||
raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc
|
||
if assigned_lawyer_id:
|
||
apply_assignment_change(
|
||
db,
|
||
request=row,
|
||
old_lawyer_id=None,
|
||
new_lawyer_id=assigned_lawyer_id,
|
||
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||
actor_admin_user_id=admin.get("sub"),
|
||
responsible=responsible,
|
||
actor_name=str(admin.get("email") or "").strip() or "Администратор системы",
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return {"id": str(row.id), "track_number": row.track_number}
|
||
|
||
|
||
def update_request_service(request_id: str, payload: RequestAdminPatch, db: Session, admin: dict) -> dict[str, Any]:
|
||
request_uuid = request_uuid_or_400(request_id)
|
||
row = db.get(Request, request_uuid)
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
ensure_lawyer_can_manage_request_or_403(admin, row)
|
||
changes = payload.model_dump(exclude_unset=True)
|
||
changes = normalize_request_financial_payload_or_400(changes)
|
||
actor_role = str(admin.get("role") or "").upper()
|
||
if actor_role == "LAWYER":
|
||
if "assigned_lawyer_id" in changes:
|
||
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
||
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(changes.keys())))
|
||
if forbidden_fields:
|
||
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||
if actor_role == "ADMIN" and "assigned_lawyer_id" in changes:
|
||
assigned_raw = changes.get("assigned_lawyer_id")
|
||
if assigned_raw is None or not str(assigned_raw).strip():
|
||
changes["assigned_lawyer_id"] = None
|
||
else:
|
||
assigned_lawyer = active_lawyer_or_400(db, str(assigned_raw))
|
||
changes["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||
if row.effective_rate is None and "effective_rate" not in changes:
|
||
changes["effective_rate"] = assigned_lawyer.default_rate
|
||
old_status = str(row.status_code or "")
|
||
old_assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
|
||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||
if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())):
|
||
client = client_for_request_payload_or_400(
|
||
db,
|
||
client_id=changes.get("client_id", row.client_id),
|
||
client_name=changes.get("client_name", row.client_name),
|
||
client_phone=changes.get("client_phone", row.client_phone),
|
||
responsible=responsible,
|
||
)
|
||
changes["client_id"] = client.id
|
||
changes["client_name"] = client.full_name
|
||
changes["client_phone"] = client.phone
|
||
status_changed = "status_code" in changes and str(changes.get("status_code") or "") != old_status
|
||
if status_changed and ("important_date_at" not in changes or changes.get("important_date_at") is None):
|
||
changes["important_date_at"] = normalize_important_date_or_default(None)
|
||
if status_changed:
|
||
next_status = str(changes.get("status_code") or "").strip()
|
||
if not transition_allowed_for_topic(
|
||
db,
|
||
str(row.topic_code or "").strip() or None,
|
||
old_status,
|
||
next_status,
|
||
):
|
||
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
||
extra_fields_override = changes.get("extra_fields")
|
||
validate_transition_requirements_or_400(
|
||
db,
|
||
row,
|
||
old_status,
|
||
next_status,
|
||
extra_fields_override=extra_fields_override if isinstance(extra_fields_override, dict) else None,
|
||
)
|
||
for key, value in changes.items():
|
||
setattr(row, key, value)
|
||
new_assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
|
||
assigned_changed = old_assigned_lawyer_id != new_assigned_lawyer_id
|
||
if status_changed:
|
||
next_status = str(changes.get("status_code") or "")
|
||
important_date_at = row.important_date_at
|
||
billing_note = apply_billing_transition_effects(
|
||
db,
|
||
req=row,
|
||
from_status=old_status,
|
||
to_status=next_status,
|
||
admin=admin,
|
||
responsible=responsible,
|
||
)
|
||
mark_unread_for_client(row, EVENT_STATUS)
|
||
apply_status_change_effects(
|
||
db,
|
||
row,
|
||
from_status=old_status,
|
||
to_status=next_status,
|
||
admin=admin,
|
||
responsible=responsible,
|
||
)
|
||
notify_request_event(
|
||
db,
|
||
request=row,
|
||
event_type=NOTIFICATION_EVENT_STATUS,
|
||
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||
actor_admin_user_id=admin.get("sub"),
|
||
body=(
|
||
f"{old_status} -> {next_status}"
|
||
+ (f"\nВажная дата: {important_date_at.isoformat()}" if important_date_at else "")
|
||
+ (f"\n{billing_note}" if billing_note else "")
|
||
),
|
||
responsible=responsible,
|
||
)
|
||
if actor_role == "ADMIN" and assigned_changed and new_assigned_lawyer_id:
|
||
apply_assignment_change(
|
||
db,
|
||
request=row,
|
||
old_lawyer_id=old_assigned_lawyer_id,
|
||
new_lawyer_id=new_assigned_lawyer_id,
|
||
actor_role="ADMIN",
|
||
actor_admin_user_id=admin.get("sub"),
|
||
responsible=responsible,
|
||
actor_name=str(admin.get("email") or "").strip() or "Администратор системы",
|
||
)
|
||
try:
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
except DataError as exc:
|
||
db.rollback()
|
||
raise request_financial_data_error_or_400() from exc
|
||
except IntegrityError as exc:
|
||
db.rollback()
|
||
raise HTTPException(status_code=400, detail="Заявка с таким номером уже существует") from exc
|
||
return {"status": "обновлено", "id": str(row.id), "track_number": row.track_number}
|
||
|
||
|
||
def delete_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||
request_uuid = request_uuid_or_400(request_id)
|
||
row = db.get(Request, request_uuid)
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
ensure_lawyer_can_manage_request_or_403(admin, row)
|
||
db.delete(row)
|
||
db.commit()
|
||
return {"status": "удалено"}
|
||
|
||
|
||
def get_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||
request_uuid = request_uuid_or_400(request_id)
|
||
req = db.get(Request, request_uuid)
|
||
if not req:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
ensure_lawyer_can_view_request_or_403(admin, req)
|
||
_apply_request_open_side_effects(db, req, admin, mark_chat_read=False)
|
||
return _serialize_request_row(req)
|
||
|
||
|
||
def _serialize_request_row(req: Request) -> dict[str, Any]:
|
||
return {
|
||
"id": str(req.id),
|
||
"track_number": req.track_number,
|
||
"client_id": str(req.client_id) if req.client_id else None,
|
||
"client_name": req.client_name,
|
||
"client_phone": req.client_phone,
|
||
"topic_code": req.topic_code,
|
||
"status_code": req.status_code,
|
||
"important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
|
||
"description": req.description,
|
||
"extra_fields": req.extra_fields,
|
||
"assigned_lawyer_id": req.assigned_lawyer_id,
|
||
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
|
||
"request_cost": float(req.request_cost) if req.request_cost is not None else None,
|
||
"invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None,
|
||
"paid_at": req.paid_at.isoformat() if req.paid_at else None,
|
||
"paid_by_admin_id": req.paid_by_admin_id,
|
||
"total_attachments_bytes": req.total_attachments_bytes,
|
||
"client_has_unread_updates": req.client_has_unread_updates,
|
||
"client_unread_event_type": req.client_unread_event_type,
|
||
"lawyer_has_unread_updates": req.lawyer_has_unread_updates,
|
||
"lawyer_unread_event_type": req.lawyer_unread_event_type,
|
||
"created_at": req.created_at.isoformat() if req.created_at else None,
|
||
"updated_at": req.updated_at.isoformat() if req.updated_at else None,
|
||
}
|
||
|
||
|
||
def _apply_request_open_side_effects(db: Session, req: Request, admin: dict, *, mark_chat_read: bool) -> None:
|
||
changed = False
|
||
if str(admin.get("role") or "").upper() == "LAWYER" and clear_unread_for_lawyer(req):
|
||
changed = True
|
||
db.add(req)
|
||
read_count = mark_admin_notifications_read(
|
||
db,
|
||
admin_user_id=admin.get("sub"),
|
||
request_id=req.id,
|
||
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||
)
|
||
if read_count:
|
||
changed = True
|
||
if mark_chat_read and mark_messages_read_for_staff(db, request_id=req.id, commit=False):
|
||
changed = True
|
||
if changed:
|
||
db.commit()
|
||
if str(admin.get("role") or "").upper() == "LAWYER":
|
||
db.refresh(req)
|
||
|
||
|
||
def _serialize_request_attachment(row: Attachment) -> dict[str, Any]:
|
||
return {
|
||
"id": str(row.id),
|
||
"request_id": str(row.request_id),
|
||
"message_id": str(row.message_id) if row.message_id else None,
|
||
"file_name": row.file_name,
|
||
"mime_type": row.mime_type,
|
||
"size_bytes": int(row.size_bytes or 0),
|
||
"s3_key": row.s3_key,
|
||
"immutable": bool(row.immutable),
|
||
"scan_status": row.scan_status,
|
||
"scan_signature": row.scan_signature,
|
||
"scan_error": row.scan_error,
|
||
"scanned_at": row.scanned_at.isoformat() if row.scanned_at else None,
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||
"responsible": row.responsible,
|
||
}
|
||
|
||
|
||
def _serialize_request_invoice(row: Invoice) -> dict[str, Any]:
|
||
return {
|
||
"id": str(row.id),
|
||
"invoice_number": row.invoice_number,
|
||
"request_id": str(row.request_id),
|
||
"client_id": str(row.client_id) if row.client_id else None,
|
||
"status": row.status,
|
||
"amount": float(row.amount) if row.amount is not None else None,
|
||
"currency": row.currency,
|
||
"payer_display_name": row.payer_display_name,
|
||
"issued_by_admin_user_id": str(row.issued_by_admin_user_id) if row.issued_by_admin_user_id else None,
|
||
"issued_by_role": row.issued_by_role,
|
||
"issued_at": row.issued_at.isoformat() if row.issued_at else None,
|
||
"paid_at": row.paid_at.isoformat() if row.paid_at else None,
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||
"responsible": row.responsible,
|
||
"pdf_url": f"/api/admin/invoices/{row.id}/pdf",
|
||
}
|
||
|
||
|
||
def get_request_workspace_service(request_id: str, db: Session, admin: dict, *, include_related: bool = True) -> dict[str, Any]:
|
||
started_at = perf_counter()
|
||
request_uuid = request_uuid_or_400(request_id)
|
||
req = db.get(Request, request_uuid)
|
||
if req is None:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
ensure_lawyer_can_view_request_or_403(admin, req)
|
||
|
||
side_effects_started_at = perf_counter()
|
||
_apply_request_open_side_effects(db, req, admin, mark_chat_read=True)
|
||
side_effects_ms = (perf_counter() - side_effects_started_at) * 1000.0
|
||
|
||
serialize_request_started_at = perf_counter()
|
||
request_payload = _serialize_request_row(req)
|
||
request_row_ms = (perf_counter() - serialize_request_started_at) * 1000.0
|
||
|
||
messages_started_at = perf_counter()
|
||
message_rows, messages_has_more = list_messages_for_request_window(
|
||
db,
|
||
req.id,
|
||
limit=INITIAL_WORKSPACE_CHAT_WINDOW_LIMIT,
|
||
before_count=0,
|
||
)
|
||
messages_query_ms = (perf_counter() - messages_started_at) * 1000.0
|
||
|
||
serialize_messages_started_at = perf_counter()
|
||
serialized_messages = serialize_messages_for_request(
|
||
db,
|
||
req.id,
|
||
message_rows,
|
||
request_extra_fields=req.extra_fields,
|
||
include_bodies=False,
|
||
)
|
||
serialize_messages_ms = (perf_counter() - serialize_messages_started_at) * 1000.0
|
||
messages_total = int(get_chat_activity_summary(db, req.id).get("message_count") or len(message_rows))
|
||
messages_loaded_count = len(message_rows)
|
||
|
||
attachment_rows: list[Attachment] = []
|
||
invoice_rows: list[Invoice] = []
|
||
status_route_payload: dict[str, Any] = {
|
||
"nodes": [],
|
||
"history": [],
|
||
"available_statuses": [],
|
||
"current_important_date_at": request_payload.get("important_date_at"),
|
||
}
|
||
attachments_query_ms = 0.0
|
||
invoices_query_ms = 0.0
|
||
status_route_ms = 0.0
|
||
if include_related:
|
||
attachments_started_at = perf_counter()
|
||
attachment_rows = (
|
||
db.query(Attachment)
|
||
.filter(Attachment.request_id == req.id)
|
||
.order_by(Attachment.created_at.asc(), Attachment.id.asc())
|
||
.all()
|
||
)
|
||
attachments_query_ms = (perf_counter() - attachments_started_at) * 1000.0
|
||
role = str(admin.get("role") or "").upper()
|
||
if role in {"ADMIN", "LAWYER"}:
|
||
invoices_started_at = perf_counter()
|
||
invoice_rows = (
|
||
db.query(Invoice)
|
||
.filter(Invoice.request_id == req.id)
|
||
.order_by(Invoice.issued_at.desc(), Invoice.id.desc())
|
||
.all()
|
||
)
|
||
invoices_query_ms = (perf_counter() - invoices_started_at) * 1000.0
|
||
status_route_started_at = perf_counter()
|
||
status_route_payload = get_request_status_route_service(request_id, db, admin, request_row=req)
|
||
status_route_ms = (perf_counter() - status_route_started_at) * 1000.0
|
||
|
||
paid_invoices = [row for row in invoice_rows if str(row.status or "").upper() == "PAID"]
|
||
paid_total = round(sum(float(row.amount or 0) for row in paid_invoices), 2)
|
||
latest_paid_at = None
|
||
for row in paid_invoices:
|
||
if row.paid_at is None:
|
||
continue
|
||
if latest_paid_at is None or row.paid_at > latest_paid_at:
|
||
latest_paid_at = row.paid_at
|
||
|
||
payload = {
|
||
"request": request_payload,
|
||
"messages": serialized_messages,
|
||
"messages_total": messages_total,
|
||
"messages_has_more": messages_has_more,
|
||
"messages_loaded_count": messages_loaded_count,
|
||
"attachments": [_serialize_request_attachment(row) for row in attachment_rows],
|
||
"invoices": [_serialize_request_invoice(row) for row in invoice_rows],
|
||
"finance_summary": {
|
||
"request_cost": request_payload.get("request_cost"),
|
||
"effective_rate": request_payload.get("effective_rate"),
|
||
"paid_total": paid_total,
|
||
"last_paid_at": latest_paid_at.isoformat() if latest_paid_at else request_payload.get("paid_at"),
|
||
},
|
||
"status_route": status_route_payload,
|
||
}
|
||
total_ms = (perf_counter() - started_at) * 1000.0
|
||
_WORKSPACE_LOG.info(
|
||
"workspace request_id=%s include_related=%s total_ms=%.2f side_effects_ms=%.2f request_row_ms=%.2f "
|
||
"messages_query_ms=%.2f serialize_messages_ms=%.2f attachments_query_ms=%.2f invoices_query_ms=%.2f "
|
||
"status_route_ms=%.2f messages=%s attachments=%s invoices=%s messages_total=%s",
|
||
str(req.id),
|
||
bool(include_related),
|
||
total_ms,
|
||
side_effects_ms,
|
||
request_row_ms,
|
||
messages_query_ms,
|
||
serialize_messages_ms,
|
||
attachments_query_ms,
|
||
invoices_query_ms,
|
||
status_route_ms,
|
||
len(message_rows),
|
||
len(attachment_rows),
|
||
len(invoice_rows),
|
||
messages_total,
|
||
)
|
||
return payload
|
||
|
||
|
||
def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||
request_uuid = request_uuid_or_400(request_id)
|
||
|
||
lawyer_sub = str(admin.get("sub") or "").strip()
|
||
if not lawyer_sub:
|
||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||
try:
|
||
lawyer_uuid = UUID(lawyer_sub)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=401, detail="Некорректный токен") from exc
|
||
|
||
lawyer = db.get(AdminUser, lawyer_uuid)
|
||
if not lawyer or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active):
|
||
raise HTTPException(status_code=403, detail="Доступно только активному юристу")
|
||
|
||
now = datetime.now(timezone.utc)
|
||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||
|
||
stmt = (
|
||
update(Request)
|
||
.where(Request.id == request_uuid, Request.assigned_lawyer_id.is_(None))
|
||
.values(
|
||
assigned_lawyer_id=str(lawyer_uuid),
|
||
effective_rate=case((Request.effective_rate.is_(None), lawyer.default_rate), else_=Request.effective_rate),
|
||
updated_at=now,
|
||
responsible=responsible,
|
||
)
|
||
)
|
||
|
||
try:
|
||
updated_rows = db.execute(stmt).rowcount or 0
|
||
if updated_rows == 0:
|
||
existing = db.get(Request, request_uuid)
|
||
if existing is None:
|
||
db.rollback()
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
db.rollback()
|
||
raise HTTPException(status_code=409, detail="Заявка уже назначена")
|
||
|
||
db.add(
|
||
AuditLog(
|
||
actor_admin_id=lawyer_uuid,
|
||
entity="requests",
|
||
entity_id=str(request_uuid),
|
||
action="MANUAL_CLAIM",
|
||
diff={"assigned_lawyer_id": str(lawyer_uuid)},
|
||
)
|
||
)
|
||
db.commit()
|
||
except HTTPException:
|
||
raise
|
||
except Exception:
|
||
db.rollback()
|
||
raise
|
||
|
||
row = db.get(Request, request_uuid)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
|
||
apply_assignment_change(
|
||
db,
|
||
request=row,
|
||
old_lawyer_id=None,
|
||
new_lawyer_id=str(lawyer_uuid),
|
||
actor_role="LAWYER",
|
||
actor_admin_user_id=str(lawyer_uuid),
|
||
responsible=responsible,
|
||
actor_name=str(lawyer.name or lawyer.email or lawyer_uuid),
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
|
||
return {
|
||
"status": "claimed",
|
||
"id": str(row.id),
|
||
"track_number": row.track_number,
|
||
"assigned_lawyer_id": row.assigned_lawyer_id,
|
||
}
|
||
|
||
|
||
def reassign_request_service(request_id: str, lawyer_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||
request_uuid = request_uuid_or_400(request_id)
|
||
|
||
try:
|
||
lawyer_uuid = UUID(str(lawyer_id))
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail="Некорректный идентификатор юриста") from exc
|
||
|
||
target_lawyer = db.get(AdminUser, lawyer_uuid)
|
||
if not target_lawyer or str(target_lawyer.role or "").upper() != "LAWYER" or not bool(target_lawyer.is_active):
|
||
raise HTTPException(status_code=400, detail="Можно переназначить только на активного юриста")
|
||
|
||
req = db.get(Request, request_uuid)
|
||
if req is None:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
if req.assigned_lawyer_id is None:
|
||
raise HTTPException(status_code=400, detail="Заявка не назначена")
|
||
if str(req.assigned_lawyer_id) == str(lawyer_uuid):
|
||
raise HTTPException(status_code=400, detail="Заявка уже назначена на выбранного юриста")
|
||
|
||
old_assigned = str(req.assigned_lawyer_id)
|
||
now = datetime.now(timezone.utc)
|
||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||
admin_actor_id = None
|
||
try:
|
||
admin_actor_id = UUID(str(admin.get("sub") or ""))
|
||
except ValueError:
|
||
admin_actor_id = None
|
||
|
||
stmt = (
|
||
update(Request)
|
||
.where(Request.id == request_uuid, Request.assigned_lawyer_id == old_assigned)
|
||
.values(
|
||
assigned_lawyer_id=str(lawyer_uuid),
|
||
effective_rate=case((Request.effective_rate.is_(None), target_lawyer.default_rate), else_=Request.effective_rate),
|
||
updated_at=now,
|
||
responsible=responsible,
|
||
)
|
||
)
|
||
|
||
try:
|
||
updated_rows = db.execute(stmt).rowcount or 0
|
||
if updated_rows == 0:
|
||
db.rollback()
|
||
raise HTTPException(status_code=409, detail="Заявка уже была переназначена")
|
||
|
||
db.add(
|
||
AuditLog(
|
||
actor_admin_id=admin_actor_id,
|
||
entity="requests",
|
||
entity_id=str(request_uuid),
|
||
action="MANUAL_REASSIGN",
|
||
diff={"from_lawyer_id": old_assigned, "to_lawyer_id": str(lawyer_uuid)},
|
||
)
|
||
)
|
||
db.commit()
|
||
except HTTPException:
|
||
raise
|
||
except Exception:
|
||
db.rollback()
|
||
raise
|
||
|
||
row = db.get(Request, request_uuid)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||
|
||
apply_assignment_change(
|
||
db,
|
||
request=row,
|
||
old_lawyer_id=old_assigned,
|
||
new_lawyer_id=str(lawyer_uuid),
|
||
actor_role="ADMIN",
|
||
actor_admin_user_id=admin.get("sub"),
|
||
responsible=responsible,
|
||
actor_name=str(admin.get("email") or "").strip() or "Администратор системы",
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
|
||
return {
|
||
"status": "reassigned",
|
||
"id": str(row.id),
|
||
"track_number": row.track_number,
|
||
"from_lawyer_id": old_assigned,
|
||
"assigned_lawyer_id": row.assigned_lawyer_id,
|
||
}
|