mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
fix speed up 02
This commit is contained in:
parent
cf3b56deeb
commit
585b6bcfc1
30 changed files with 2458 additions and 1022 deletions
|
|
@ -4,6 +4,7 @@ from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
|
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
|
|
@ -19,8 +20,11 @@ from app.models.topic_data_template import TopicDataTemplate
|
||||||
from app.services.notifications import EVENT_REQUEST_DATA as NOTIFICATION_EVENT_REQUEST_DATA, notify_request_event, unread_admin_summary
|
from app.services.notifications import EVENT_REQUEST_DATA as NOTIFICATION_EVENT_REQUEST_DATA, notify_request_event, unread_admin_summary
|
||||||
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_client
|
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_client
|
||||||
from app.services.chat_secure_service import (
|
from app.services.chat_secure_service import (
|
||||||
|
clamp_chat_window_limit,
|
||||||
|
DEFAULT_CHAT_WINDOW_LIMIT,
|
||||||
create_admin_or_lawyer_message,
|
create_admin_or_lawyer_message,
|
||||||
get_chat_activity_summary,
|
get_chat_activity_summary,
|
||||||
|
list_messages_for_request_window,
|
||||||
list_messages_for_request,
|
list_messages_for_request,
|
||||||
mark_messages_delivered_for_staff,
|
mark_messages_delivered_for_staff,
|
||||||
mark_messages_read_for_staff,
|
mark_messages_read_for_staff,
|
||||||
|
|
@ -154,6 +158,21 @@ def _slugify_key(raw: str) -> str:
|
||||||
return (slug or "data-field")[:80]
|
return (slug or "data-field")[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_live_attachment(row: Attachment) -> dict:
|
||||||
|
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),
|
||||||
|
"created_at": _iso_or_none(row.created_at),
|
||||||
|
"updated_at": _iso_or_none(row.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_value_type(raw: str | None) -> str:
|
def _normalize_value_type(raw: str | None) -> str:
|
||||||
value = str(raw or "text").strip().lower()
|
value = str(raw or "text").strip().lower()
|
||||||
if value not in ALLOWED_VALUE_TYPES:
|
if value not in ALLOWED_VALUE_TYPES:
|
||||||
|
|
@ -269,6 +288,42 @@ def list_request_messages(
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{request_id}/messages-window")
|
||||||
|
def list_request_messages_window(
|
||||||
|
request_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
|
before_count: int = 0,
|
||||||
|
limit: int = DEFAULT_CHAT_WINDOW_LIMIT,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||||
|
):
|
||||||
|
req = _request_for_id_or_404(db, request_id)
|
||||||
|
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||||
|
mark_messages_read_for_staff(db, request_id=req.id)
|
||||||
|
rows, total, has_more, loaded_count = list_messages_for_request_window(
|
||||||
|
db,
|
||||||
|
req.id,
|
||||||
|
limit=limit,
|
||||||
|
before_count=before_count,
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"rows": serialize_messages_for_request(db, req.id, rows),
|
||||||
|
"total": total,
|
||||||
|
"has_more": has_more,
|
||||||
|
"loaded_count": loaded_count,
|
||||||
|
"limit": clamp_chat_window_limit(limit),
|
||||||
|
}
|
||||||
|
_audit_admin_chat_read(
|
||||||
|
db,
|
||||||
|
admin=admin,
|
||||||
|
http_request=http_request,
|
||||||
|
req=req,
|
||||||
|
action="READ_CHAT_MESSAGES",
|
||||||
|
details={"rows": len(rows), "window": True},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/messages", status_code=201)
|
@router.post("/requests/{request_id}/messages", status_code=201)
|
||||||
def create_request_message(
|
def create_request_message(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
|
@ -318,6 +373,29 @@ def get_request_live_state(
|
||||||
latest_activity_iso = _iso_or_none(latest_activity_at)
|
latest_activity_iso = _iso_or_none(latest_activity_at)
|
||||||
cursor_dt = _parse_cursor(cursor)
|
cursor_dt = _parse_cursor(cursor)
|
||||||
has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt))
|
has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt))
|
||||||
|
delta_messages = []
|
||||||
|
delta_attachments = []
|
||||||
|
if has_updates and cursor_dt is not None:
|
||||||
|
message_rows = (
|
||||||
|
db.query(Message)
|
||||||
|
.filter(
|
||||||
|
Message.request_id == req.id,
|
||||||
|
func.coalesce(Message.updated_at, Message.created_at) > cursor_dt,
|
||||||
|
)
|
||||||
|
.order_by(Message.created_at.asc(), Message.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
attachment_rows = (
|
||||||
|
db.query(Attachment)
|
||||||
|
.filter(
|
||||||
|
Attachment.request_id == req.id,
|
||||||
|
func.coalesce(Attachment.updated_at, Attachment.created_at) > cursor_dt,
|
||||||
|
)
|
||||||
|
.order_by(Attachment.created_at.asc(), Attachment.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
delta_messages = serialize_messages_for_request(db, req.id, message_rows)
|
||||||
|
delta_attachments = [_serialize_live_attachment(row) for row in attachment_rows]
|
||||||
|
|
||||||
actor_sub = str(admin.get("sub") or "").strip() or "unknown"
|
actor_sub = str(admin.get("sub") or "").strip() or "unknown"
|
||||||
actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
|
actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
|
||||||
|
|
@ -331,6 +409,8 @@ def get_request_live_state(
|
||||||
"attachment_count": int(summary.get("attachment_count") or 0),
|
"attachment_count": int(summary.get("attachment_count") or 0),
|
||||||
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
|
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
|
||||||
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
|
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
|
||||||
|
"messages": delta_messages,
|
||||||
|
"attachments": delta_attachments,
|
||||||
"typing": typing_rows,
|
"typing": typing_rows,
|
||||||
"unread": unread_admin_summary(
|
"unread": unread_admin_summary(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,52 @@ def query_invoices(
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-request/{request_id}")
|
||||||
|
def list_invoices_by_request(
|
||||||
|
request_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
actor_id = _actor_uuid_or_401(admin)
|
||||||
|
|
||||||
|
req = db.get(Request, _uuid_or_400(request_id, "request_id"))
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
|
||||||
|
|
||||||
|
rows = db.query(Invoice).filter(Invoice.request_id == req.id).order_by(Invoice.issued_at.desc(), Invoice.id.desc()).all()
|
||||||
|
issuer_ids = {row.issued_by_admin_user_id for row in rows if row.issued_by_admin_user_id}
|
||||||
|
users = db.query(AdminUser.id, AdminUser.name, AdminUser.email).filter(AdminUser.id.in_(issuer_ids)).all() if issuer_ids else []
|
||||||
|
issuer_map = {str(user_id): (name or email or str(user_id)) for user_id, name, email in users}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"rows": [
|
||||||
|
_serialize_invoice(
|
||||||
|
row,
|
||||||
|
request_track=req.track_number,
|
||||||
|
issuer_name=issuer_map.get(str(row.issued_by_admin_user_id)) if row.issued_by_admin_user_id else None,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
"total": len(rows),
|
||||||
|
}
|
||||||
|
record_pii_access_event(
|
||||||
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=str(admin.get("sub") or admin.get("email") or ""),
|
||||||
|
actor_ip=extract_client_ip(http_request),
|
||||||
|
action="READ_REQUEST_INVOICE_LIST",
|
||||||
|
scope="INVOICE",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"rows": int(len(rows)), "track_number": req.track_number},
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{invoice_id}")
|
@router.get("/{invoice_id}")
|
||||||
def get_invoice(
|
def get_invoice(
|
||||||
invoice_id: str,
|
invoice_id: str,
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,33 @@ def _extract_assigned_lawyer_from_audit(diff: dict | None, action: str | None) -
|
||||||
return 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")
|
@router.get("/overview")
|
||||||
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))):
|
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()
|
role = str(admin.get("role") or "").upper()
|
||||||
actor_id = str(admin.get("sub") or "").strip()
|
actor_id = str(admin.get("sub") or "").strip()
|
||||||
actor_uuid = _uuid_or_none(actor_id)
|
actor_uuid = _uuid_or_none(actor_id)
|
||||||
|
|
@ -314,7 +339,6 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
my_unread_by_event = dict(my_unread_notifications.get("by_event") or {})
|
my_unread_by_event = dict(my_unread_notifications.get("by_event") or {})
|
||||||
scoped_lawyer_loads = lawyer_loads
|
scoped_lawyer_loads = lawyer_loads
|
||||||
|
|
||||||
sla_snapshot = compute_sla_snapshot(db)
|
|
||||||
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
|
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
deadline_alert_query = (
|
deadline_alert_query = (
|
||||||
db.query(func.count(Request.id))
|
db.query(func.count(Request.id))
|
||||||
|
|
@ -327,7 +351,7 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
elif role == "LAWYER":
|
elif role == "LAWYER":
|
||||||
deadline_alert_query = deadline_alert_query.filter(Request.id.is_(None))
|
deadline_alert_query = deadline_alert_query.filter(Request.id.is_(None))
|
||||||
deadline_alert_total = int(deadline_alert_query.scalar() or 0)
|
deadline_alert_total = int(deadline_alert_query.scalar() or 0)
|
||||||
return {
|
payload = {
|
||||||
"scope": role if role in {"ADMIN", "LAWYER", "CURATOR"} else "ADMIN",
|
"scope": role if role in {"ADMIN", "LAWYER", "CURATOR"} else "ADMIN",
|
||||||
"new": int(by_status.get("NEW", 0)),
|
"new": int(by_status.get("NEW", 0)),
|
||||||
"by_status": by_status,
|
"by_status": by_status,
|
||||||
|
|
@ -343,11 +367,6 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
"month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2)
|
"month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2)
|
||||||
if role == "LAWYER"
|
if role == "LAWYER"
|
||||||
else round(sum(_to_float(row.get("monthly_salary")) for row in lawyer_loads), 2),
|
else round(sum(_to_float(row.get("monthly_salary")) for row in lawyer_loads), 2),
|
||||||
"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_clients": int(unread_for_clients),
|
||||||
"unread_for_lawyers": int(unread_for_lawyers),
|
"unread_for_lawyers": int(unread_for_lawyers),
|
||||||
"unread_for_clients_by_event": dict(unread_for_clients_notifications.get("by_event") or {}),
|
"unread_for_clients_by_event": dict(unread_for_clients_notifications.get("by_event") or {}),
|
||||||
|
|
@ -357,6 +376,14 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
"service_request_unread_total": int(service_request_unread_total),
|
"service_request_unread_total": int(service_request_unread_total),
|
||||||
"lawyer_loads": scoped_lawyer_loads,
|
"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")
|
@router.get("/lawyers/{lawyer_id}/active-requests")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import case, func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
|
|
@ -202,6 +202,87 @@ def sort_kanban_items(items: list[dict[str, object]], sort_mode: str) -> list[di
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_sql_safe_boolean_filters(
|
||||||
|
query,
|
||||||
|
*,
|
||||||
|
boolean_filters: list[tuple[str, str, bool]],
|
||||||
|
role: str,
|
||||||
|
actor: str,
|
||||||
|
terminal_codes: set[str],
|
||||||
|
next_day_start: datetime,
|
||||||
|
):
|
||||||
|
remaining_filters: list[tuple[str, str, bool]] = []
|
||||||
|
for field, op, expected in boolean_filters:
|
||||||
|
if field != "deadline_alert":
|
||||||
|
remaining_filters.append((field, op, expected))
|
||||||
|
continue
|
||||||
|
actual_true_expr = (
|
||||||
|
Request.important_date_at.is_not(None)
|
||||||
|
& (Request.important_date_at < next_day_start)
|
||||||
|
& Request.status_code.notin_(terminal_codes)
|
||||||
|
)
|
||||||
|
if role == "LAWYER":
|
||||||
|
actual_true_expr = actual_true_expr & (Request.assigned_lawyer_id == actor)
|
||||||
|
|
||||||
|
target_true = expected if op == "=" else not expected
|
||||||
|
query = query.filter(actual_true_expr if target_true else ~actual_true_expr)
|
||||||
|
return query, remaining_filters
|
||||||
|
|
||||||
|
|
||||||
|
def _build_lawyer_sort_order(query, db: Session) -> list[str]:
|
||||||
|
assigned_id_rows = (
|
||||||
|
query.with_entities(Request.assigned_lawyer_id)
|
||||||
|
.filter(Request.assigned_lawyer_id.is_not(None))
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assigned_ids = [str(raw_id).strip() for (raw_id,) in assigned_id_rows if str(raw_id or "").strip()]
|
||||||
|
if not assigned_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
valid_lawyer_ids: list[UUID] = []
|
||||||
|
for raw in assigned_ids:
|
||||||
|
try:
|
||||||
|
valid_lawyer_ids.append(UUID(raw))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lawyer_name_rows = db.query(AdminUser.id, AdminUser.name).filter(AdminUser.id.in_(valid_lawyer_ids)).all() if valid_lawyer_ids else []
|
||||||
|
lawyer_name_map = {
|
||||||
|
str(lawyer_id): str(name or "").strip()
|
||||||
|
for lawyer_id, name in lawyer_name_rows
|
||||||
|
if str(lawyer_id or "").strip()
|
||||||
|
}
|
||||||
|
return sorted(
|
||||||
|
lawyer_name_map.keys(),
|
||||||
|
key=lambda lawyer_id: (
|
||||||
|
1 if not lawyer_name_map.get(lawyer_id) else 0,
|
||||||
|
lawyer_name_map.get(lawyer_id, "").lower(),
|
||||||
|
lawyer_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_sql_sort(query, *, sort_mode: str, lawyer_sort_order: list[str] | None = None):
|
||||||
|
if sort_mode == "lawyer":
|
||||||
|
ordered_ids = lawyer_sort_order or []
|
||||||
|
if ordered_ids:
|
||||||
|
lawyer_rank_case = case(
|
||||||
|
*[(Request.assigned_lawyer_id == lawyer_id, index) for index, lawyer_id in enumerate(ordered_ids)],
|
||||||
|
else_=len(ordered_ids) + 1,
|
||||||
|
)
|
||||||
|
return query.order_by(
|
||||||
|
lawyer_rank_case.asc(),
|
||||||
|
case((Request.assigned_lawyer_id.is_(None), 1), else_=0).asc(),
|
||||||
|
Request.created_at.desc(),
|
||||||
|
)
|
||||||
|
return query.order_by(
|
||||||
|
case((Request.assigned_lawyer_id.is_(None), 1), else_=0).asc(),
|
||||||
|
Request.created_at.desc(),
|
||||||
|
)
|
||||||
|
return query.order_by(Request.created_at.desc())
|
||||||
|
|
||||||
|
|
||||||
def get_requests_kanban_service(
|
def get_requests_kanban_service(
|
||||||
db: Session,
|
db: Session,
|
||||||
admin: dict,
|
admin: dict,
|
||||||
|
|
@ -226,6 +307,13 @@ def get_requests_kanban_service(
|
||||||
|
|
||||||
normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
|
normalized_sort_mode = sort_mode if sort_mode in ALLOWED_KANBAN_SORT_MODES else "created_newest"
|
||||||
query_filters, boolean_filters = parse_kanban_filters_or_400(filters)
|
query_filters, boolean_filters = parse_kanban_filters_or_400(filters)
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
|
terminal_codes = {
|
||||||
|
str(code).strip()
|
||||||
|
for (code,) in db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
|
||||||
|
if str(code or "").strip()
|
||||||
|
} or {"RESOLVED", "CLOSED", "REJECTED"}
|
||||||
if query_filters:
|
if query_filters:
|
||||||
base_query = apply_universal_query(
|
base_query = apply_universal_query(
|
||||||
base_query,
|
base_query,
|
||||||
|
|
@ -237,7 +325,28 @@ def get_requests_kanban_service(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
request_rows: list[Request] = base_query.all()
|
base_query, boolean_filters = _apply_sql_safe_boolean_filters(
|
||||||
|
base_query,
|
||||||
|
boolean_filters=boolean_filters,
|
||||||
|
role=role,
|
||||||
|
actor=actor,
|
||||||
|
terminal_codes=terminal_codes,
|
||||||
|
next_day_start=next_day_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
can_apply_sql_window = normalized_sort_mode in {"created_newest", "lawyer"} and not boolean_filters
|
||||||
|
total = 0
|
||||||
|
request_query = base_query
|
||||||
|
if can_apply_sql_window:
|
||||||
|
lawyer_sort_order = _build_lawyer_sort_order(request_query, db) if normalized_sort_mode == "lawyer" else []
|
||||||
|
total = request_query.count()
|
||||||
|
request_query = _apply_sql_sort(
|
||||||
|
request_query,
|
||||||
|
sort_mode=normalized_sort_mode,
|
||||||
|
lawyer_sort_order=lawyer_sort_order,
|
||||||
|
).limit(limit)
|
||||||
|
|
||||||
|
request_rows: list[Request] = request_query.all()
|
||||||
|
|
||||||
request_id_to_row = {str(row.id): row for row in request_rows}
|
request_id_to_row = {str(row.id): row for row in request_rows}
|
||||||
request_ids = [row.id for row in request_rows]
|
request_ids = [row.id for row in request_rows]
|
||||||
|
|
@ -266,9 +375,6 @@ def get_requests_kanban_service(
|
||||||
if notification_request_id is not None
|
if notification_request_id is not None
|
||||||
}
|
}
|
||||||
status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()}
|
status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()}
|
||||||
now_utc = datetime.now(timezone.utc)
|
|
||||||
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
|
|
||||||
|
|
||||||
status_meta_map: dict[str, dict[str, object]] = {}
|
status_meta_map: dict[str, dict[str, object]] = {}
|
||||||
if status_codes:
|
if status_codes:
|
||||||
status_rows = (
|
status_rows = (
|
||||||
|
|
@ -537,11 +643,15 @@ def get_requests_kanban_service(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if boolean_filters:
|
||||||
items = apply_boolean_kanban_filters(items, boolean_filters)
|
items = apply_boolean_kanban_filters(items, boolean_filters)
|
||||||
|
if not can_apply_sql_window:
|
||||||
items = sort_kanban_items(items, normalized_sort_mode)
|
items = sort_kanban_items(items, normalized_sort_mode)
|
||||||
total = len(items)
|
total = len(items)
|
||||||
if total > limit:
|
if total > limit:
|
||||||
items = items[:limit]
|
items = items[:limit]
|
||||||
|
else:
|
||||||
|
items = sort_kanban_items(items, normalized_sort_mode)
|
||||||
|
|
||||||
for row in items:
|
for row in items:
|
||||||
key = str(row.get("status_group") or "").strip()
|
key = str(row.get("status_group") or "").strip()
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from .service import (
|
||||||
create_request_service,
|
create_request_service,
|
||||||
delete_request_service,
|
delete_request_service,
|
||||||
get_request_service,
|
get_request_service,
|
||||||
|
get_request_workspace_service,
|
||||||
query_requests_service,
|
query_requests_service,
|
||||||
reassign_request_service,
|
reassign_request_service,
|
||||||
update_request_service,
|
update_request_service,
|
||||||
|
|
@ -103,6 +104,30 @@ def get_request(
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{request_id}/workspace")
|
||||||
|
def get_request_workspace(
|
||||||
|
request_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||||
|
):
|
||||||
|
payload = get_request_workspace_service(request_id, db, admin)
|
||||||
|
request_payload = payload.get("request") or {}
|
||||||
|
record_pii_access_event(
|
||||||
|
db,
|
||||||
|
actor_role=str(admin.get("role") or "ADMIN").upper(),
|
||||||
|
actor_subject=str(admin.get("sub") or admin.get("email") or ""),
|
||||||
|
actor_ip=extract_client_ip(http_request),
|
||||||
|
action="READ_REQUEST_WORKSPACE",
|
||||||
|
scope="REQUEST_CARD",
|
||||||
|
request_id=request_payload.get("id"),
|
||||||
|
details={"track_number": request_payload.get("track_number")},
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/status-change")
|
@router.post("/{request_id}/status-change")
|
||||||
def change_request_status(
|
def change_request_status(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,19 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.attachment import Attachment
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.invoice import Invoice
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.request_service_request import RequestServiceRequest
|
from app.models.request_service_request import RequestServiceRequest
|
||||||
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
|
from app.schemas.admin import RequestAdminCreate, RequestAdminPatch
|
||||||
|
from app.services.chat_secure_service import (
|
||||||
|
DEFAULT_CHAT_WINDOW_LIMIT,
|
||||||
|
list_messages_for_request_window,
|
||||||
|
mark_messages_read_for_staff,
|
||||||
|
serialize_messages_for_request,
|
||||||
|
)
|
||||||
from app.schemas.universal import UniversalQuery
|
from app.schemas.universal import UniversalQuery
|
||||||
from app.services.billing_flow import apply_billing_transition_effects
|
from app.services.billing_flow import apply_billing_transition_effects
|
||||||
from app.services.notifications import (
|
from app.services.notifications import (
|
||||||
|
|
@ -44,7 +52,7 @@ from .permissions import (
|
||||||
ensure_lawyer_can_view_request_or_403,
|
ensure_lawyer_can_view_request_or_403,
|
||||||
request_uuid_or_400,
|
request_uuid_or_400,
|
||||||
)
|
)
|
||||||
from .status_flow import apply_request_special_filters, split_request_special_filters
|
from .status_flow import apply_request_special_filters, get_request_status_route_service, split_request_special_filters
|
||||||
|
|
||||||
|
|
||||||
def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict[str, Any]:
|
def query_requests_service(uq: UniversalQuery, db: Session, admin: dict) -> dict[str, Any]:
|
||||||
|
|
@ -399,6 +407,104 @@ def get_request_service(request_id: str, db: Session, admin: dict) -> dict[str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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) -> dict[str, Any]:
|
||||||
|
request_payload = get_request_service(request_id, db, admin)
|
||||||
|
request_uuid = request_uuid_or_400(request_id)
|
||||||
|
req = db.get(Request, request_uuid)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
|
||||||
|
mark_messages_read_for_staff(db, request_id=req.id)
|
||||||
|
message_rows, messages_total, messages_has_more, messages_loaded_count = list_messages_for_request_window(
|
||||||
|
db,
|
||||||
|
req.id,
|
||||||
|
limit=DEFAULT_CHAT_WINDOW_LIMIT,
|
||||||
|
before_count=0,
|
||||||
|
)
|
||||||
|
attachment_rows = (
|
||||||
|
db.query(Attachment)
|
||||||
|
.filter(Attachment.request_id == req.id)
|
||||||
|
.order_by(Attachment.created_at.asc(), Attachment.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
invoice_rows: list[Invoice] = []
|
||||||
|
if role in {"ADMIN", "LAWYER"}:
|
||||||
|
invoice_rows = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.request_id == req.id)
|
||||||
|
.order_by(Invoice.issued_at.desc(), Invoice.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return {
|
||||||
|
"request": request_payload,
|
||||||
|
"messages": serialize_messages_for_request(db, req.id, message_rows),
|
||||||
|
"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": get_request_status_route_service(request_id, db, admin),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
def claim_request_service(request_id: str, db: Session, admin: dict) -> dict[str, Any]:
|
||||||
request_uuid = request_uuid_or_400(request_id)
|
request_uuid = request_uuid_or_400(request_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from app.models.attachment import Attachment
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
||||||
|
from app.api.admin.requests_modules.permissions import ensure_lawyer_can_view_request_or_403
|
||||||
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
||||||
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
|
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
|
||||||
from app.services.security_audit import record_file_security_event
|
from app.services.security_audit import record_file_security_event
|
||||||
|
|
@ -165,6 +166,26 @@ def _normalize_avatar_to_webp_or_400(storage, *, key: str) -> tuple[int, str]:
|
||||||
return int(len(optimized)), "image/webp"
|
return int(len(optimized)), "image/webp"
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_attachment(row: Attachment) -> dict:
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/init", response_model=UploadInitResponse)
|
@router.post("/init", response_model=UploadInitResponse)
|
||||||
def upload_init(
|
def upload_init(
|
||||||
payload: UploadInitPayload,
|
payload: UploadInitPayload,
|
||||||
|
|
@ -424,6 +445,22 @@ def upload_complete(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/request-attachments/{request_id}")
|
||||||
|
def list_request_attachments(
|
||||||
|
request_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||||
|
):
|
||||||
|
request_uuid = _uuid_or_400(request_id, "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)
|
||||||
|
rows = db.query(Attachment).filter(Attachment.request_id == req.id).order_by(Attachment.created_at.asc(), Attachment.id.asc()).all()
|
||||||
|
return {"rows": [_serialize_attachment(row) for row in rows], "total": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/object/{object_key:path}")
|
@router.get("/object/{object_key:path}")
|
||||||
def get_object_proxy(
|
def get_object_proxy(
|
||||||
object_key: str,
|
object_key: str,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
|
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.deps import get_public_session
|
from app.core.deps import get_public_session
|
||||||
|
|
@ -15,8 +16,11 @@ from app.schemas.public import PublicMessageCreate
|
||||||
from app.services.chat_presence import list_typing_presence, set_typing_presence
|
from app.services.chat_presence import list_typing_presence, set_typing_presence
|
||||||
from app.services.notifications import EVENT_REQUEST_DATA as NOTIFICATION_EVENT_REQUEST_DATA, notify_request_event, unread_client_summary
|
from app.services.notifications import EVENT_REQUEST_DATA as NOTIFICATION_EVENT_REQUEST_DATA, notify_request_event, unread_client_summary
|
||||||
from app.services.chat_secure_service import (
|
from app.services.chat_secure_service import (
|
||||||
|
DEFAULT_CHAT_WINDOW_LIMIT,
|
||||||
|
clamp_chat_window_limit,
|
||||||
create_client_message,
|
create_client_message,
|
||||||
get_chat_activity_summary,
|
get_chat_activity_summary,
|
||||||
|
list_messages_for_request_window,
|
||||||
list_messages_for_request,
|
list_messages_for_request,
|
||||||
mark_messages_delivered_for_client,
|
mark_messages_delivered_for_client,
|
||||||
mark_messages_read_for_client,
|
mark_messages_read_for_client,
|
||||||
|
|
@ -86,6 +90,19 @@ def _attachment_meta_for_public(req: Request, value_text: str | None, db: Sessio
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_public_attachment(row: Attachment) -> dict:
|
||||||
|
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),
|
||||||
|
"created_at": _iso_or_none(row.created_at),
|
||||||
|
"download_url": f"/api/public/uploads/object/{row.id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_phone(raw: str | None) -> str:
|
def _normalize_phone(raw: str | None) -> str:
|
||||||
value = str(raw or "").strip()
|
value = str(raw or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
|
|
@ -181,6 +198,42 @@ def list_messages_by_track(
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{track_number}/messages-window")
|
||||||
|
def list_messages_window_by_track(
|
||||||
|
track_number: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
|
before_count: int = 0,
|
||||||
|
limit: int = DEFAULT_CHAT_WINDOW_LIMIT,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, track_number)
|
||||||
|
_ensure_view_access_or_403(session, req)
|
||||||
|
mark_messages_read_for_client(db, request_id=req.id)
|
||||||
|
rows, total, has_more, loaded_count = list_messages_for_request_window(
|
||||||
|
db,
|
||||||
|
req.id,
|
||||||
|
limit=limit,
|
||||||
|
before_count=before_count,
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"rows": serialize_messages_for_request(db, req.id, rows),
|
||||||
|
"total": total,
|
||||||
|
"has_more": has_more,
|
||||||
|
"loaded_count": loaded_count,
|
||||||
|
"limit": clamp_chat_window_limit(limit),
|
||||||
|
}
|
||||||
|
_audit_public_chat_read(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=http_request,
|
||||||
|
req=req,
|
||||||
|
action="READ_CHAT_MESSAGES",
|
||||||
|
details={"rows": len(rows), "window": True},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{track_number}/messages", status_code=201)
|
@router.post("/requests/{track_number}/messages", status_code=201)
|
||||||
def create_message_by_track(
|
def create_message_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
|
@ -212,6 +265,29 @@ def get_live_chat_state_by_track(
|
||||||
latest_activity_iso = _iso_or_none(latest_activity_at)
|
latest_activity_iso = _iso_or_none(latest_activity_at)
|
||||||
cursor_dt = _parse_cursor(cursor)
|
cursor_dt = _parse_cursor(cursor)
|
||||||
has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt))
|
has_updates = bool(latest_activity_at and (cursor_dt is None or latest_activity_at > cursor_dt))
|
||||||
|
delta_messages = []
|
||||||
|
delta_attachments = []
|
||||||
|
if has_updates and cursor_dt is not None:
|
||||||
|
message_rows = (
|
||||||
|
db.query(Message)
|
||||||
|
.filter(
|
||||||
|
Message.request_id == req.id,
|
||||||
|
func.coalesce(Message.updated_at, Message.created_at) > cursor_dt,
|
||||||
|
)
|
||||||
|
.order_by(Message.created_at.asc(), Message.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
attachment_rows = (
|
||||||
|
db.query(Attachment)
|
||||||
|
.filter(
|
||||||
|
Attachment.request_id == req.id,
|
||||||
|
func.coalesce(Attachment.updated_at, Attachment.created_at) > cursor_dt,
|
||||||
|
)
|
||||||
|
.order_by(Attachment.created_at.asc(), Attachment.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
delta_messages = serialize_messages_for_request(db, req.id, message_rows)
|
||||||
|
delta_attachments = [_serialize_public_attachment(row) for row in attachment_rows]
|
||||||
|
|
||||||
subject = _require_view_session_or_403(session)
|
subject = _require_view_session_or_403(session)
|
||||||
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
|
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
|
||||||
|
|
@ -224,6 +300,8 @@ def get_live_chat_state_by_track(
|
||||||
"attachment_count": int(summary.get("attachment_count") or 0),
|
"attachment_count": int(summary.get("attachment_count") or 0),
|
||||||
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
|
"latest_message_at": _iso_or_none(_as_utc_datetime(summary.get("latest_message_at"))),
|
||||||
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
|
"latest_attachment_at": _iso_or_none(_as_utc_datetime(summary.get("latest_attachment_at"))),
|
||||||
|
"messages": delta_messages,
|
||||||
|
"attachments": delta_attachments,
|
||||||
"typing": typing_rows,
|
"typing": typing_rows,
|
||||||
"unread": unread_client_summary(
|
"unread": unread_client_summary(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,23 @@ _FRAMEABLE_PATH_PATTERNS = (
|
||||||
)
|
)
|
||||||
|
|
||||||
_PERF_PATH_PATTERNS = (
|
_PERF_PATH_PATTERNS = (
|
||||||
|
("admin_metrics_overview", re.compile(r"^/api/admin/metrics/overview$")),
|
||||||
|
("admin_metrics_overview_sla", re.compile(r"^/api/admin/metrics/overview-sla$")),
|
||||||
("admin_kanban", re.compile(r"^/api/admin/requests/kanban$")),
|
("admin_kanban", re.compile(r"^/api/admin/requests/kanban$")),
|
||||||
|
("admin_request_workspace", re.compile(r"^/api/admin/requests/[^/]+/workspace$")),
|
||||||
|
("admin_request_detail", re.compile(r"^/api/admin/requests/[^/]+$")),
|
||||||
("admin_request_detail", re.compile(r"^/api/admin/crud/requests/[^/]+$")),
|
("admin_request_detail", re.compile(r"^/api/admin/crud/requests/[^/]+$")),
|
||||||
("admin_chat_messages", re.compile(r"^/api/admin/chat/requests/[^/]+/messages$")),
|
("admin_chat_messages", re.compile(r"^/api/admin/chat/requests/[^/]+/messages$")),
|
||||||
|
("admin_chat_messages_window", re.compile(r"^/api/admin/chat/requests/[^/]+/messages-window$")),
|
||||||
("admin_chat_live", re.compile(r"^/api/admin/chat/requests/[^/]+/live$")),
|
("admin_chat_live", re.compile(r"^/api/admin/chat/requests/[^/]+/live$")),
|
||||||
("admin_request_status_route", re.compile(r"^/api/admin/requests/[^/]+/status-route$")),
|
("admin_request_status_route", re.compile(r"^/api/admin/requests/[^/]+/status-route$")),
|
||||||
|
("admin_request_attachments_query", re.compile(r"^/api/admin/uploads/request-attachments/[^/]+$")),
|
||||||
("admin_request_attachments_query", re.compile(r"^/api/admin/crud/attachments/query$")),
|
("admin_request_attachments_query", re.compile(r"^/api/admin/crud/attachments/query$")),
|
||||||
|
("admin_request_invoices_query", re.compile(r"^/api/admin/invoices/by-request/[^/]+$")),
|
||||||
("admin_request_invoices_query", re.compile(r"^/api/admin/invoices/query$")),
|
("admin_request_invoices_query", re.compile(r"^/api/admin/invoices/query$")),
|
||||||
("public_request_detail", re.compile(r"^/api/public/requests/[^/]+$")),
|
("public_request_detail", re.compile(r"^/api/public/requests/[^/]+$")),
|
||||||
("public_chat_messages", re.compile(r"^/api/public/chat/requests/[^/]+/messages$")),
|
("public_chat_messages", re.compile(r"^/api/public/chat/requests/[^/]+/messages$")),
|
||||||
|
("public_chat_messages_window", re.compile(r"^/api/public/chat/requests/[^/]+/messages-window$")),
|
||||||
("public_chat_live", re.compile(r"^/api/public/chat/requests/[^/]+/live$")),
|
("public_chat_live", re.compile(r"^/api/public/chat/requests/[^/]+/live$")),
|
||||||
("public_request_attachments", re.compile(r"^/api/public/requests/[^/]+/attachments$")),
|
("public_request_attachments", re.compile(r"^/api/public/requests/[^/]+/attachments$")),
|
||||||
("public_request_invoices", re.compile(r"^/api/public/requests/[^/]+/invoices$")),
|
("public_request_invoices", re.compile(r"^/api/public/requests/[^/]+/invoices$")),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSA
|
||||||
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
|
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
|
||||||
|
|
||||||
MAX_CHAT_MESSAGE_LEN = 12_000
|
MAX_CHAT_MESSAGE_LEN = 12_000
|
||||||
|
DEFAULT_CHAT_WINDOW_LIMIT = 50
|
||||||
|
MAX_CHAT_WINDOW_LIMIT = 200
|
||||||
CHAT_PARTICIPANT_ADMIN_IDS_KEY = "chat_participant_admin_ids"
|
CHAT_PARTICIPANT_ADMIN_IDS_KEY = "chat_participant_admin_ids"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,6 +39,45 @@ def list_messages_for_request(db: Session, request_id: Any) -> list[Message]:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_chat_window_limit(limit: int | None) -> int:
|
||||||
|
if limit is None:
|
||||||
|
return DEFAULT_CHAT_WINDOW_LIMIT
|
||||||
|
try:
|
||||||
|
normalized = int(limit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
normalized = DEFAULT_CHAT_WINDOW_LIMIT
|
||||||
|
return max(1, min(normalized, MAX_CHAT_WINDOW_LIMIT))
|
||||||
|
|
||||||
|
|
||||||
|
def list_messages_for_request_window(
|
||||||
|
db: Session,
|
||||||
|
request_id: Any,
|
||||||
|
*,
|
||||||
|
limit: int | None,
|
||||||
|
before_count: int = 0,
|
||||||
|
) -> tuple[list[Message], int, bool, int]:
|
||||||
|
window_limit = clamp_chat_window_limit(limit)
|
||||||
|
loaded_count = max(0, int(before_count or 0))
|
||||||
|
base_query = db.query(Message).filter(Message.request_id == request_id)
|
||||||
|
total = int(base_query.count() or 0)
|
||||||
|
if total <= 0 or loaded_count >= total:
|
||||||
|
return [], total, False, loaded_count
|
||||||
|
|
||||||
|
remaining = total - loaded_count
|
||||||
|
window_size = min(window_limit, remaining)
|
||||||
|
offset = max(total - loaded_count - window_size, 0)
|
||||||
|
rows = (
|
||||||
|
base_query
|
||||||
|
.order_by(Message.created_at.asc(), Message.id.asc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(window_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
next_loaded_count = loaded_count + len(rows)
|
||||||
|
has_more = offset > 0
|
||||||
|
return rows, total, has_more, next_loaded_count
|
||||||
|
|
||||||
|
|
||||||
def _iso_or_none(value: datetime | None) -> str | None:
|
def _iso_or_none(value: datetime | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,27 @@ def compute_sla_snapshot(
|
||||||
now_utc = _as_utc(now, datetime.now(timezone.utc))
|
now_utc = _as_utc(now, datetime.now(timezone.utc))
|
||||||
terminal_codes = _terminal_status_codes(db)
|
terminal_codes = _terminal_status_codes(db)
|
||||||
active_requests = db.query(Request).filter(Request.status_code.notin_(terminal_codes)).all()
|
active_requests = db.query(Request).filter(Request.status_code.notin_(terminal_codes)).all()
|
||||||
|
active_request_ids = [row.id for row in active_requests if row.id is not None]
|
||||||
|
|
||||||
status_rows = db.query(StatusHistory).order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.asc()).all()
|
if not active_request_ids:
|
||||||
|
result = {
|
||||||
|
"checked_active_requests": 0,
|
||||||
|
"overdue_total": 0,
|
||||||
|
"overdue_by_status": {},
|
||||||
|
"overdue_by_transition": {},
|
||||||
|
"frt_avg_minutes": None,
|
||||||
|
"avg_time_in_status_hours": {},
|
||||||
|
}
|
||||||
|
if include_overdue_requests:
|
||||||
|
result["overdue_requests"] = []
|
||||||
|
return result
|
||||||
|
|
||||||
|
status_rows = (
|
||||||
|
db.query(StatusHistory)
|
||||||
|
.filter(StatusHistory.request_id.in_(active_request_ids))
|
||||||
|
.order_by(StatusHistory.request_id.asc(), StatusHistory.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
rows_by_request: dict[str, list[StatusHistory]] = defaultdict(list)
|
rows_by_request: dict[str, list[StatusHistory]] = defaultdict(list)
|
||||||
for row in status_rows:
|
for row in status_rows:
|
||||||
rows_by_request[str(row.request_id)].append(row)
|
rows_by_request[str(row.request_id)].append(row)
|
||||||
|
|
@ -127,7 +146,10 @@ def compute_sla_snapshot(
|
||||||
|
|
||||||
first_response_rows = (
|
first_response_rows = (
|
||||||
db.query(Message.request_id, Message.created_at)
|
db.query(Message.request_id, Message.created_at)
|
||||||
.filter(Message.author_type == "LAWYER")
|
.filter(
|
||||||
|
Message.author_type == "LAWYER",
|
||||||
|
Message.request_id.in_(active_request_ids),
|
||||||
|
)
|
||||||
.order_by(Message.request_id.asc(), Message.created_at.asc())
|
.order_by(Message.request_id.asc(), Message.created_at.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
910
app/web/admin.js
910
app/web/admin.js
File diff suppressed because one or more lines are too long
|
|
@ -1156,6 +1156,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [userId, setUserId] = useState("");
|
const [userId, setUserId] = useState("");
|
||||||
const [activeSection, setActiveSection] = useState(initialSection);
|
const [activeSection, setActiveSection] = useState(initialSection);
|
||||||
|
const dashboardLoadRef = useRef(0);
|
||||||
|
|
||||||
const [dashboardData, setDashboardData] = useState({
|
const [dashboardData, setDashboardData] = useState({
|
||||||
scope: "",
|
scope: "",
|
||||||
|
|
@ -1289,6 +1290,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
submitRequestStatusChange,
|
submitRequestStatusChange,
|
||||||
submitRequestModalMessage,
|
submitRequestModalMessage,
|
||||||
probeRequestLive,
|
probeRequestLive,
|
||||||
|
loadOlderRequestMessages,
|
||||||
setRequestTyping,
|
setRequestTyping,
|
||||||
loadRequestDataTemplates,
|
loadRequestDataTemplates,
|
||||||
loadRequestDataBatch,
|
loadRequestDataBatch,
|
||||||
|
|
@ -2154,37 +2156,39 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
|
|
||||||
const loadDashboard = useCallback(
|
const loadDashboard = useCallback(
|
||||||
async (tokenOverride) => {
|
async (tokenOverride) => {
|
||||||
|
const loadId = Date.now();
|
||||||
|
dashboardLoadRef.current = loadId;
|
||||||
setStatus("dashboard", "Загрузка...", "");
|
setStatus("dashboard", "Загрузка...", "");
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/admin/metrics/overview", {}, tokenOverride);
|
const buildDashboardCards = (scope, payload) =>
|
||||||
const scope = String(data.scope || role || "");
|
|
||||||
const cards =
|
|
||||||
scope === "LAWYER"
|
scope === "LAWYER"
|
||||||
? [
|
? [
|
||||||
{ label: "Мои заявки", value: data.assigned_total ?? 0 },
|
{ label: "Мои заявки", value: payload.assigned_total ?? 0 },
|
||||||
{ label: "Мои активные", value: data.active_assigned_total ?? 0 },
|
{ label: "Мои активные", value: payload.active_assigned_total ?? 0 },
|
||||||
{ label: "Неназначенные", value: data.unassigned_total ?? 0 },
|
{ label: "Неназначенные", value: payload.unassigned_total ?? 0 },
|
||||||
{ label: "Мои непрочитанные", value: data.my_unread_notifications_total ?? data.my_unread_updates ?? 0 },
|
{ label: "Мои непрочитанные", value: payload.my_unread_notifications_total ?? payload.my_unread_updates ?? 0 },
|
||||||
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
|
{ label: "Просрочено SLA", value: payload.sla_overdue ?? 0 },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ label: "Новые", value: data.new ?? 0 },
|
{ label: "Новые", value: payload.new ?? 0 },
|
||||||
{ label: "Назначенные", value: data.assigned_total ?? 0 },
|
{ label: "Назначенные", value: payload.assigned_total ?? 0 },
|
||||||
{ label: "Неназначенные", value: data.unassigned_total ?? 0 },
|
{ label: "Неназначенные", value: payload.unassigned_total ?? 0 },
|
||||||
{ label: "Просрочено SLA", value: data.sla_overdue ?? 0 },
|
{ label: "Просрочено SLA", value: payload.sla_overdue ?? 0 },
|
||||||
{ label: "Мои непрочитанные", value: data.my_unread_notifications_total ?? data.my_unread_updates ?? 0 },
|
{ label: "Мои непрочитанные", value: payload.my_unread_notifications_total ?? payload.my_unread_updates ?? 0 },
|
||||||
{ label: "Выручка (мес.)", value: Number(data.month_revenue ?? 0).toFixed(2) },
|
{ label: "Выручка (мес.)", value: Number(payload.month_revenue ?? 0).toFixed(2) },
|
||||||
{ label: "Расходы (мес.)", value: Number(data.month_expenses ?? 0).toFixed(2) },
|
{ label: "Расходы (мес.)", value: Number(payload.month_expenses ?? 0).toFixed(2) },
|
||||||
{ label: "Непрочитано юристами", value: data.unread_for_lawyers ?? 0 },
|
{ label: "Непрочитано юристами", value: payload.unread_for_lawyers ?? 0 },
|
||||||
{ label: "Непрочитано клиентами", value: data.unread_for_clients ?? 0 },
|
{ label: "Непрочитано клиентами", value: payload.unread_for_clients ?? 0 },
|
||||||
];
|
];
|
||||||
|
const data = await api("/api/admin/metrics/overview?include_sla=false", {}, tokenOverride);
|
||||||
|
const scope = String(data.scope || role || "");
|
||||||
const localized = {};
|
const localized = {};
|
||||||
Object.entries(data.by_status || {}).forEach(([code, count]) => {
|
Object.entries(data.by_status || {}).forEach(([code, count]) => {
|
||||||
localized[statusLabel(code)] = count;
|
localized[statusLabel(code)] = count;
|
||||||
});
|
});
|
||||||
setDashboardData({
|
setDashboardData({
|
||||||
scope,
|
scope,
|
||||||
cards,
|
cards: buildDashboardCards(scope, data),
|
||||||
byStatus: localized,
|
byStatus: localized,
|
||||||
lawyerLoads: data.lawyer_loads || [],
|
lawyerLoads: data.lawyer_loads || [],
|
||||||
myUnreadByEvent: data.my_unread_by_event || {},
|
myUnreadByEvent: data.my_unread_by_event || {},
|
||||||
|
|
@ -2198,6 +2202,18 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
monthExpenses: Number(data.month_expenses || 0),
|
monthExpenses: Number(data.month_expenses || 0),
|
||||||
});
|
});
|
||||||
setStatus("dashboard", "Данные обновлены", "ok");
|
setStatus("dashboard", "Данные обновлены", "ok");
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const slaData = await api("/api/admin/metrics/overview-sla", {}, tokenOverride);
|
||||||
|
if (dashboardLoadRef.current !== loadId) return;
|
||||||
|
setDashboardData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
cards: buildDashboardCards(String(prev.scope || scope || ""), { ...data, ...slaData }),
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
// Keep fast dashboard payload if SLA snapshot is unavailable.
|
||||||
|
}
|
||||||
|
})();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("dashboard", "Ошибка: " + error.message, "error");
|
setStatus("dashboard", "Ошибка: " + error.message, "error");
|
||||||
}
|
}
|
||||||
|
|
@ -3480,17 +3496,13 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
setEmail(payload.email);
|
setEmail(payload.email);
|
||||||
setUserId(String(payload.sub || ""));
|
setUserId(String(payload.sub || ""));
|
||||||
|
|
||||||
await bootstrapReferenceData(nextToken, payload.role);
|
|
||||||
setActiveSection("dashboard");
|
setActiveSection("dashboard");
|
||||||
await loadDashboard(nextToken);
|
|
||||||
await loadTotpStatus(nextToken);
|
|
||||||
|
|
||||||
setStatus("login", "Успешный вход", "ok");
|
setStatus("login", "Успешный вход", "ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("login", "Ошибка входа: " + error.message, "error");
|
setStatus("login", "Ошибка входа: " + error.message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, bootstrapReferenceData, loadDashboard, loadTotpStatus, setStatus]
|
[api, setStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -3523,14 +3535,14 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
if (!token || !role) return;
|
if (!token || !role) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
await bootstrapReferenceData(token, role);
|
bootstrapReferenceData(token, role);
|
||||||
if (!cancelled) await loadDashboard(token);
|
if (!cancelled && !isRequestWorkspaceRoute && !routeInfo.section) await loadDashboard(token);
|
||||||
if (!cancelled) await loadTotpStatus(token);
|
if (!cancelled) await loadTotpStatus(token);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [bootstrapReferenceData, loadDashboard, loadTotpStatus, role, token]);
|
}, [bootstrapReferenceData, isRequestWorkspaceRoute, loadDashboard, loadTotpStatus, role, routeInfo.section, token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !role) return;
|
if (!token || !role) return;
|
||||||
|
|
@ -3970,6 +3982,8 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
currentImportantDateAt={requestModal.currentImportantDateAt || ""}
|
currentImportantDateAt={requestModal.currentImportantDateAt || ""}
|
||||||
pendingStatusChangePreset={requestModal.pendingStatusChangePreset}
|
pendingStatusChangePreset={requestModal.pendingStatusChangePreset}
|
||||||
messages={requestModal.messages || []}
|
messages={requestModal.messages || []}
|
||||||
|
messagesHasMore={Boolean(requestModal.messagesHasMore)}
|
||||||
|
messagesLoadingMore={Boolean(requestModal.messagesLoadingMore)}
|
||||||
attachments={requestModal.attachments || []}
|
attachments={requestModal.attachments || []}
|
||||||
messageDraft={requestModal.messageDraft || ""}
|
messageDraft={requestModal.messageDraft || ""}
|
||||||
selectedFiles={requestModal.selectedFiles || []}
|
selectedFiles={requestModal.selectedFiles || []}
|
||||||
|
|
@ -3977,6 +3991,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
status={getStatus("requestModal")}
|
status={getStatus("requestModal")}
|
||||||
onMessageChange={updateRequestModalMessageDraft}
|
onMessageChange={updateRequestModalMessageDraft}
|
||||||
onSendMessage={submitRequestModalMessage}
|
onSendMessage={submitRequestModalMessage}
|
||||||
|
onLoadOlderMessages={loadOlderRequestMessages}
|
||||||
onFilesSelect={appendRequestModalFiles}
|
onFilesSelect={appendRequestModalFiles}
|
||||||
onRemoveSelectedFile={removeRequestModalFile}
|
onRemoveSelectedFile={removeRequestModalFile}
|
||||||
onClearSelectedFiles={clearRequestModalFiles}
|
onClearSelectedFiles={clearRequestModalFiles}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export function RequestWorkspace({
|
||||||
currentImportantDateAt,
|
currentImportantDateAt,
|
||||||
pendingStatusChangePreset,
|
pendingStatusChangePreset,
|
||||||
messages,
|
messages,
|
||||||
|
messagesHasMore,
|
||||||
|
messagesLoadingMore,
|
||||||
attachments,
|
attachments,
|
||||||
messageDraft,
|
messageDraft,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
|
|
@ -32,6 +34,7 @@ export function RequestWorkspace({
|
||||||
status,
|
status,
|
||||||
onMessageChange,
|
onMessageChange,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
onLoadOlderMessages,
|
||||||
onFilesSelect,
|
onFilesSelect,
|
||||||
onRemoveSelectedFile,
|
onRemoveSelectedFile,
|
||||||
onClearSelectedFiles,
|
onClearSelectedFiles,
|
||||||
|
|
@ -1660,6 +1663,18 @@ export function RequestWorkspace({
|
||||||
|
|
||||||
{chatTab === "chat" ? (
|
{chatTab === "chat" ? (
|
||||||
<>
|
<>
|
||||||
|
{messagesHasMore ? (
|
||||||
|
<div className="request-chat-history-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={onLoadOlderMessages}
|
||||||
|
disabled={loading || fileUploading || messagesLoadingMore}
|
||||||
|
>
|
||||||
|
{messagesLoadingMore ? "Загрузка истории..." : "Показать предыдущие сообщения"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<ul className="simple-list request-modal-list request-chat-list" id={idMap.messagesList} ref={chatListRef}>
|
<ul className="simple-list request-modal-list request-chat-list" id={idMap.messagesList} ref={chatListRef}>
|
||||||
{chatTimelineItems.length ? (
|
{chatTimelineItems.length ? (
|
||||||
chatTimelineItems.map((entry) =>
|
chatTimelineItems.map((entry) =>
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,46 @@ function isRetryableUploadError(error) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortRowsByCreatedAt(rows) {
|
||||||
|
return [...rows].sort((left, right) => {
|
||||||
|
const leftTs = new Date(left?.created_at || left?.updated_at || 0).getTime();
|
||||||
|
const rightTs = new Date(right?.created_at || right?.updated_at || 0).getTime();
|
||||||
|
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) return leftTs - rightTs;
|
||||||
|
return String(left?.id || "").localeCompare(String(right?.id || ""), "ru");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRowsById(existingRows, incomingRows) {
|
||||||
|
const merged = new Map();
|
||||||
|
(Array.isArray(existingRows) ? existingRows : []).forEach((row) => {
|
||||||
|
const key = String(row?.id || "").trim();
|
||||||
|
if (key) merged.set(key, row);
|
||||||
|
});
|
||||||
|
(Array.isArray(incomingRows) ? incomingRows : []).forEach((row) => {
|
||||||
|
const key = String(row?.id || "").trim();
|
||||||
|
if (key) merged.set(key, row);
|
||||||
|
});
|
||||||
|
return sortRowsByCreatedAt(Array.from(merged.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessageAuthors(rows, users) {
|
||||||
|
const usersByEmail = new Map(
|
||||||
|
(Array.isArray(users) ? users : [])
|
||||||
|
.filter((user) => user && user.email)
|
||||||
|
.map((user) => [String(user.email).toLowerCase(), String(user.name || user.email)])
|
||||||
|
);
|
||||||
|
return (Array.isArray(rows) ? rows : []).map((item) => {
|
||||||
|
if (!item || typeof item !== "object") return item;
|
||||||
|
const authorType = String(item.author_type || "").toUpperCase();
|
||||||
|
const authorName = String(item.author_name || "").trim();
|
||||||
|
if ((authorType === "LAWYER" || authorType === "SYSTEM") && authorName.includes("@")) {
|
||||||
|
const mapped = usersByEmail.get(authorName.toLowerCase());
|
||||||
|
if (mapped) return { ...item, author_name: mapped };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useRequestWorkspace(options) {
|
export function useRequestWorkspace(options) {
|
||||||
const { useCallback, useRef, useState } = React;
|
const { useCallback, useRef, useState } = React;
|
||||||
const opts = options || {};
|
const opts = options || {};
|
||||||
|
|
@ -63,7 +103,6 @@ export function useRequestWorkspace(options) {
|
||||||
const setActiveSection = opts.setActiveSection;
|
const setActiveSection = opts.setActiveSection;
|
||||||
const token = opts.token || "";
|
const token = opts.token || "";
|
||||||
const users = Array.isArray(opts.users) ? opts.users : [];
|
const users = Array.isArray(opts.users) ? opts.users : [];
|
||||||
const buildUniversalQuery = opts.buildUniversalQuery;
|
|
||||||
const resolveAdminObjectSrc = opts.resolveAdminObjectSrc;
|
const resolveAdminObjectSrc = opts.resolveAdminObjectSrc;
|
||||||
|
|
||||||
const [requestModal, setRequestModal] = useState(createRequestModalState());
|
const [requestModal, setRequestModal] = useState(createRequestModalState());
|
||||||
|
|
@ -193,24 +232,21 @@ export function useRequestWorkspace(options) {
|
||||||
financeSummary: null,
|
financeSummary: null,
|
||||||
invoices: [],
|
invoices: [],
|
||||||
statusRouteNodes: [],
|
statusRouteNodes: [],
|
||||||
|
messagesHasMore: false,
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messagesLoadedCount: 0,
|
||||||
|
messagesTotal: 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestFilter = [{ field: "request_id", op: "=", value: String(requestId) }];
|
|
||||||
try {
|
try {
|
||||||
const [row, messagesData, attachmentsData, statusRouteData, invoicesData] = await Promise.all([
|
const workspaceData = await api("/api/admin/requests/" + requestId + "/workspace");
|
||||||
api("/api/admin/crud/requests/" + requestId),
|
const row = workspaceData?.request || null;
|
||||||
api("/api/admin/chat/requests/" + requestId + "/messages"),
|
const messagesData = { rows: workspaceData?.messages || [] };
|
||||||
api("/api/admin/crud/attachments/query", {
|
const attachmentsData = { rows: workspaceData?.attachments || [] };
|
||||||
method: "POST",
|
const statusRouteData = workspaceData?.status_route || { nodes: [] };
|
||||||
body: buildUniversalQuery(requestFilter, [{ field: "created_at", dir: "asc" }], 500, 0),
|
const invoicesData = { rows: workspaceData?.invoices || [] };
|
||||||
}),
|
const financeSummaryData = workspaceData?.finance_summary || null;
|
||||||
api("/api/admin/requests/" + requestId + "/status-route").catch(() => ({ nodes: [] })),
|
|
||||||
api("/api/admin/invoices/query", {
|
|
||||||
method: "POST",
|
|
||||||
body: buildUniversalQuery(requestFilter, [{ field: "issued_at", dir: "desc" }], 500, 0),
|
|
||||||
}).catch(() => ({ rows: [] })),
|
|
||||||
]);
|
|
||||||
const usersById = new Map(users.filter((user) => user && user.id).map((user) => [String(user.id), user]));
|
const usersById = new Map(users.filter((user) => user && user.id).map((user) => [String(user.id), user]));
|
||||||
const rowData = row && typeof row === "object" ? { ...row } : row;
|
const rowData = row && typeof row === "object" ? { ...row } : row;
|
||||||
if (rowData && typeof rowData === "object") {
|
if (rowData && typeof rowData === "object") {
|
||||||
|
|
@ -227,19 +263,7 @@ export function useRequestWorkspace(options) {
|
||||||
...item,
|
...item,
|
||||||
download_url: resolveAdminObjectSrc(item.s3_key, token),
|
download_url: resolveAdminObjectSrc(item.s3_key, token),
|
||||||
}));
|
}));
|
||||||
const usersByEmail = new Map(
|
const normalizedMessages = normalizeMessageAuthors(messagesData.rows || [], users);
|
||||||
users.filter((user) => user && user.email).map((user) => [String(user.email).toLowerCase(), String(user.name || user.email)])
|
|
||||||
);
|
|
||||||
const normalizedMessages = (messagesData.rows || []).map((item) => {
|
|
||||||
if (!item || typeof item !== "object") return item;
|
|
||||||
const authorType = String(item.author_type || "").toUpperCase();
|
|
||||||
const authorName = String(item.author_name || "").trim();
|
|
||||||
if ((authorType === "LAWYER" || authorType === "SYSTEM") && authorName.includes("@")) {
|
|
||||||
const mapped = usersByEmail.get(authorName.toLowerCase());
|
|
||||||
if (mapped) return { ...item, author_name: mapped };
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
const invoices = Array.isArray(invoicesData?.rows) ? invoicesData.rows : [];
|
const invoices = Array.isArray(invoicesData?.rows) ? invoicesData.rows : [];
|
||||||
const paidInvoices = invoices.filter(
|
const paidInvoices = invoices.filter(
|
||||||
(item) => String(item?.status || "").toUpperCase() === "PAID"
|
(item) => String(item?.status || "").toUpperCase() === "PAID"
|
||||||
|
|
@ -262,7 +286,7 @@ export function useRequestWorkspace(options) {
|
||||||
requestId: rowData?.id || requestId,
|
requestId: rowData?.id || requestId,
|
||||||
trackNumber: String(rowData?.track_number || ""),
|
trackNumber: String(rowData?.track_number || ""),
|
||||||
requestData: rowData,
|
requestData: rowData,
|
||||||
financeSummary: {
|
financeSummary: financeSummaryData || {
|
||||||
request_cost: rowData?.request_cost ?? null,
|
request_cost: rowData?.request_cost ?? null,
|
||||||
effective_rate: rowData?.effective_rate ?? null,
|
effective_rate: rowData?.effective_rate ?? null,
|
||||||
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
|
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
|
||||||
|
|
@ -274,6 +298,10 @@ export function useRequestWorkspace(options) {
|
||||||
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
|
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
|
||||||
currentImportantDateAt: String(statusRouteData?.current_important_date_at || rowData?.important_date_at || ""),
|
currentImportantDateAt: String(statusRouteData?.current_important_date_at || rowData?.important_date_at || ""),
|
||||||
messages: normalizedMessages,
|
messages: normalizedMessages,
|
||||||
|
messagesHasMore: Boolean(workspaceData?.messages_has_more),
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messagesLoadedCount: Number(workspaceData?.messages_loaded_count || normalizedMessages.length || 0),
|
||||||
|
messagesTotal: Number(workspaceData?.messages_total || normalizedMessages.length || 0),
|
||||||
attachments,
|
attachments,
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
fileUploading: false,
|
fileUploading: false,
|
||||||
|
|
@ -292,6 +320,10 @@ export function useRequestWorkspace(options) {
|
||||||
availableStatuses: [],
|
availableStatuses: [],
|
||||||
currentImportantDateAt: "",
|
currentImportantDateAt: "",
|
||||||
messages: [],
|
messages: [],
|
||||||
|
messagesHasMore: false,
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messagesLoadedCount: 0,
|
||||||
|
messagesTotal: 0,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
fileUploading: false,
|
fileUploading: false,
|
||||||
|
|
@ -299,7 +331,7 @@ export function useRequestWorkspace(options) {
|
||||||
if (typeof setStatus === "function") setStatus("requestModal", "Ошибка: " + error.message, "error");
|
if (typeof setStatus === "function") setStatus("requestModal", "Ошибка: " + error.message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, buildUniversalQuery, resolveAdminObjectSrc, setStatus, token, users]
|
[api, resolveAdminObjectSrc, setStatus, token, users]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshRequestModal = useCallback(async () => {
|
const refreshRequestModal = useCallback(async () => {
|
||||||
|
|
@ -450,13 +482,68 @@ export function useRequestWorkspace(options) {
|
||||||
const query = cursor ? "?cursor=" + encodeURIComponent(String(cursor)) : "";
|
const query = cursor ? "?cursor=" + encodeURIComponent(String(cursor)) : "";
|
||||||
const payload = await api("/api/admin/chat/requests/" + requestId + "/live" + query);
|
const payload = await api("/api/admin/chat/requests/" + requestId + "/live" + query);
|
||||||
if (payload && payload.has_updates) {
|
if (payload && payload.has_updates) {
|
||||||
await loadRequestModalData(requestId, { showLoading: false });
|
const nextMessages = normalizeMessageAuthors(payload?.messages || [], users);
|
||||||
|
const nextAttachments = (payload?.attachments || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
download_url: resolveAdminObjectSrc(item?.s3_key, token),
|
||||||
|
}));
|
||||||
|
if (nextMessages.length || nextAttachments.length) {
|
||||||
|
setRequestModal((prev) => {
|
||||||
|
const mergedMessages = mergeRowsById(prev.messages, nextMessages);
|
||||||
|
const previousCount = Array.isArray(prev.messages) ? prev.messages.length : 0;
|
||||||
|
const addedCount = Math.max(0, mergedMessages.length - previousCount);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
messages: mergedMessages,
|
||||||
|
messagesLoadedCount: Number(prev.messagesLoadedCount || previousCount) + addedCount,
|
||||||
|
messagesTotal: Number(prev.messagesTotal || previousCount) + addedCount,
|
||||||
|
attachments: mergeRowsById(prev.attachments, nextAttachments),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return payload || { has_updates: false, typing: [], cursor: null };
|
return payload || { has_updates: false, typing: [], cursor: null };
|
||||||
},
|
},
|
||||||
[api, loadRequestModalData, requestModal.requestId]
|
[api, requestModal.requestId, resolveAdminObjectSrc, token, users]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const loadOlderRequestMessages = useCallback(async () => {
|
||||||
|
const requestId = String(requestModal.requestId || "").trim();
|
||||||
|
const loadedCount = Number(requestModal.messagesLoadedCount || 0);
|
||||||
|
if (!api || !requestId || requestModal.messagesLoadingMore || !requestModal.messagesHasMore) return null;
|
||||||
|
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: true }));
|
||||||
|
try {
|
||||||
|
const payload = await api(
|
||||||
|
"/api/admin/chat/requests/" +
|
||||||
|
requestId +
|
||||||
|
"/messages-window?before_count=" +
|
||||||
|
encodeURIComponent(String(loadedCount))
|
||||||
|
);
|
||||||
|
const nextMessages = normalizeMessageAuthors(payload?.rows || [], users);
|
||||||
|
setRequestModal((prev) => ({
|
||||||
|
...prev,
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messages: mergeRowsById(nextMessages, prev.messages),
|
||||||
|
messagesHasMore: Boolean(payload?.has_more),
|
||||||
|
messagesLoadedCount: Number(payload?.loaded_count || prev.messagesLoadedCount || 0),
|
||||||
|
messagesTotal: Number(payload?.total || prev.messagesTotal || 0),
|
||||||
|
}));
|
||||||
|
return payload || null;
|
||||||
|
} catch (error) {
|
||||||
|
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: false }));
|
||||||
|
if (typeof setStatus === "function") setStatus("requestModal", "Ошибка загрузки истории: " + error.message, "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
requestModal.messagesHasMore,
|
||||||
|
requestModal.messagesLoadedCount,
|
||||||
|
requestModal.messagesLoadingMore,
|
||||||
|
requestModal.requestId,
|
||||||
|
setStatus,
|
||||||
|
users,
|
||||||
|
]);
|
||||||
|
|
||||||
const setRequestTyping = useCallback(
|
const setRequestTyping = useCallback(
|
||||||
async ({ typing } = {}) => {
|
async ({ typing } = {}) => {
|
||||||
const requestId = requestModal.requestId;
|
const requestId = requestModal.requestId;
|
||||||
|
|
@ -583,6 +670,7 @@ export function useRequestWorkspace(options) {
|
||||||
submitRequestStatusChange,
|
submitRequestStatusChange,
|
||||||
submitRequestModalMessage,
|
submitRequestModalMessage,
|
||||||
probeRequestLive,
|
probeRequestLive,
|
||||||
|
loadOlderRequestMessages,
|
||||||
setRequestTyping,
|
setRequestTyping,
|
||||||
loadRequestDataTemplates,
|
loadRequestDataTemplates,
|
||||||
loadRequestDataBatch,
|
loadRequestDataBatch,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ export function createRequestModalState() {
|
||||||
currentImportantDateAt: "",
|
currentImportantDateAt: "",
|
||||||
pendingStatusChangePreset: null,
|
pendingStatusChangePreset: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
messagesHasMore: false,
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messagesLoadedCount: 0,
|
||||||
|
messagesTotal: 0,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
messageDraft: "",
|
messageDraft: "",
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,28 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
(function () {
|
(function () {
|
||||||
const { useCallback, useEffect, useMemo, useRef, useState } = React;
|
const { useCallback, useEffect, useMemo, useRef, useState } = React;
|
||||||
|
|
||||||
|
function sortRowsByCreatedAt(rows) {
|
||||||
|
return [...rows].sort((left, right) => {
|
||||||
|
const leftTs = new Date(left?.created_at || left?.updated_at || 0).getTime();
|
||||||
|
const rightTs = new Date(right?.created_at || right?.updated_at || 0).getTime();
|
||||||
|
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) return leftTs - rightTs;
|
||||||
|
return String(left?.id || "").localeCompare(String(right?.id || ""), "ru");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRowsById(existingRows, incomingRows) {
|
||||||
|
const merged = new Map();
|
||||||
|
(Array.isArray(existingRows) ? existingRows : []).forEach((row) => {
|
||||||
|
const key = String(row?.id || "").trim();
|
||||||
|
if (key) merged.set(key, row);
|
||||||
|
});
|
||||||
|
(Array.isArray(incomingRows) ? incomingRows : []).forEach((row) => {
|
||||||
|
const key = String(row?.id || "").trim();
|
||||||
|
if (key) merged.set(key, row);
|
||||||
|
});
|
||||||
|
return sortRowsByCreatedAt(Array.from(merged.values()));
|
||||||
|
}
|
||||||
|
|
||||||
function StatusLine({ status }) {
|
function StatusLine({ status }) {
|
||||||
return <p className={"status" + (status?.kind ? " " + status.kind : "")}>{status?.message || ""}</p>;
|
return <p className={"status" + (status?.kind ? " " + status.kind : "")}>{status?.message || ""}</p>;
|
||||||
}
|
}
|
||||||
|
|
@ -586,7 +608,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
}
|
}
|
||||||
const [requestData, messagesData, attachmentsData, invoicesData, statusRouteData, serviceRequestsData] = await Promise.all([
|
const [requestData, messagesData, attachmentsData, invoicesData, statusRouteData, serviceRequestsData] = await Promise.all([
|
||||||
apiJson("/api/public/requests/" + encodeURIComponent(track), null, "Не удалось открыть заявку"),
|
apiJson("/api/public/requests/" + encodeURIComponent(track), null, "Не удалось открыть заявку"),
|
||||||
apiJson("/api/public/chat/requests/" + encodeURIComponent(track) + "/messages", null, "Не удалось загрузить сообщения"),
|
apiJson("/api/public/chat/requests/" + encodeURIComponent(track) + "/messages-window", null, "Не удалось загрузить сообщения"),
|
||||||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/attachments", null, "Не удалось загрузить файлы"),
|
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/attachments", null, "Не удалось загрузить файлы"),
|
||||||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/invoices", null, "Не удалось загрузить счета"),
|
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/invoices", null, "Не удалось загрузить счета"),
|
||||||
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/status-route", null, "Не удалось загрузить маршрут статусов"),
|
apiJson("/api/public/requests/" + encodeURIComponent(track) + "/status-route", null, "Не удалось загрузить маршрут статусов"),
|
||||||
|
|
@ -627,7 +649,11 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
availableStatuses: [],
|
availableStatuses: [],
|
||||||
currentImportantDateAt: String(statusRouteData?.current_important_date_at || requestData?.important_date_at || ""),
|
currentImportantDateAt: String(statusRouteData?.current_important_date_at || requestData?.important_date_at || ""),
|
||||||
invoices,
|
invoices,
|
||||||
messages: Array.isArray(messagesData) ? messagesData : [],
|
messages: Array.isArray(messagesData?.rows) ? messagesData.rows : [],
|
||||||
|
messagesHasMore: Boolean(messagesData?.has_more),
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messagesLoadedCount: Number(messagesData?.loaded_count || 0),
|
||||||
|
messagesTotal: Number(messagesData?.total || 0),
|
||||||
attachments: Array.isArray(attachmentsData) ? attachmentsData : [],
|
attachments: Array.isArray(attachmentsData) ? attachmentsData : [],
|
||||||
fileUploading: false,
|
fileUploading: false,
|
||||||
}));
|
}));
|
||||||
|
|
@ -635,6 +661,44 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
[apiJson]
|
[apiJson]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const loadOlderPublicMessages = useCallback(async () => {
|
||||||
|
const track = String(activeTrack || requestModal.trackNumber || "").trim().toUpperCase();
|
||||||
|
const loadedCount = Number(requestModal.messagesLoadedCount || 0);
|
||||||
|
if (!track || requestModal.messagesLoadingMore || !requestModal.messagesHasMore) return null;
|
||||||
|
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: true }));
|
||||||
|
try {
|
||||||
|
const payload = await apiJson(
|
||||||
|
"/api/public/chat/requests/" +
|
||||||
|
encodeURIComponent(track) +
|
||||||
|
"/messages-window?before_count=" +
|
||||||
|
encodeURIComponent(String(loadedCount)),
|
||||||
|
null,
|
||||||
|
"Не удалось загрузить историю сообщений"
|
||||||
|
);
|
||||||
|
setRequestModal((prev) => ({
|
||||||
|
...prev,
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messages: mergeRowsById(payload?.rows || [], prev.messages),
|
||||||
|
messagesHasMore: Boolean(payload?.has_more),
|
||||||
|
messagesLoadedCount: Number(payload?.loaded_count || prev.messagesLoadedCount || 0),
|
||||||
|
messagesTotal: Number(payload?.total || prev.messagesTotal || 0),
|
||||||
|
}));
|
||||||
|
return payload || null;
|
||||||
|
} catch (error) {
|
||||||
|
setRequestModal((prev) => ({ ...prev, messagesLoadingMore: false }));
|
||||||
|
setPageStatus(error?.message || "Не удалось загрузить историю сообщений", "error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeTrack,
|
||||||
|
apiJson,
|
||||||
|
requestModal.messagesHasMore,
|
||||||
|
requestModal.messagesLoadedCount,
|
||||||
|
requestModal.messagesLoadingMore,
|
||||||
|
requestModal.trackNumber,
|
||||||
|
setPageStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
const refreshRequestsList = useCallback(async () => {
|
const refreshRequestsList = useCallback(async () => {
|
||||||
const data = await apiJson("/api/public/requests/my", null, "Не удалось загрузить список заявок");
|
const data = await apiJson("/api/public/requests/my", null, "Не удалось загрузить список заявок");
|
||||||
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
||||||
|
|
@ -657,6 +721,10 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
statusRouteNodes: [],
|
statusRouteNodes: [],
|
||||||
statusHistory: [],
|
statusHistory: [],
|
||||||
messages: [],
|
messages: [],
|
||||||
|
messagesHasMore: false,
|
||||||
|
messagesLoadingMore: false,
|
||||||
|
messagesLoadedCount: 0,
|
||||||
|
messagesTotal: 0,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
fileUploading: false,
|
fileUploading: false,
|
||||||
selectedFiles: [],
|
selectedFiles: [],
|
||||||
|
|
@ -878,11 +946,26 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
"Не удалось получить live-обновления чата"
|
"Не удалось получить live-обновления чата"
|
||||||
);
|
);
|
||||||
if (payload && payload.has_updates) {
|
if (payload && payload.has_updates) {
|
||||||
await loadRequestWorkspace(track, false);
|
const nextMessages = Array.isArray(payload?.messages) ? payload.messages : [];
|
||||||
|
const nextAttachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
|
||||||
|
if (nextMessages.length || nextAttachments.length) {
|
||||||
|
setRequestModal((prev) => {
|
||||||
|
const mergedMessages = mergeRowsById(prev.messages, nextMessages);
|
||||||
|
const previousCount = Array.isArray(prev.messages) ? prev.messages.length : 0;
|
||||||
|
const addedCount = Math.max(0, mergedMessages.length - previousCount);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
messages: mergedMessages,
|
||||||
|
messagesLoadedCount: Number(prev.messagesLoadedCount || previousCount) + addedCount,
|
||||||
|
messagesTotal: Number(prev.messagesTotal || previousCount) + addedCount,
|
||||||
|
attachments: mergeRowsById(prev.attachments, nextAttachments),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return payload || { has_updates: false, typing: [], cursor: null };
|
return payload || { has_updates: false, typing: [], cursor: null };
|
||||||
},
|
},
|
||||||
[activeTrack, apiJson, loadRequestWorkspace]
|
[activeTrack, apiJson]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setTypingSignal = useCallback(
|
const setTypingSignal = useCallback(
|
||||||
|
|
@ -1107,6 +1190,8 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
currentImportantDateAt={requestModal.currentImportantDateAt || ""}
|
currentImportantDateAt={requestModal.currentImportantDateAt || ""}
|
||||||
pendingStatusChangePreset={null}
|
pendingStatusChangePreset={null}
|
||||||
messages={requestModal.messages || []}
|
messages={requestModal.messages || []}
|
||||||
|
messagesHasMore={Boolean(requestModal.messagesHasMore)}
|
||||||
|
messagesLoadingMore={Boolean(requestModal.messagesLoadingMore)}
|
||||||
attachments={requestModal.attachments || []}
|
attachments={requestModal.attachments || []}
|
||||||
messageDraft={requestModal.messageDraft || ""}
|
messageDraft={requestModal.messageDraft || ""}
|
||||||
selectedFiles={requestModal.selectedFiles || []}
|
selectedFiles={requestModal.selectedFiles || []}
|
||||||
|
|
@ -1114,6 +1199,7 @@ import { detectAttachmentPreviewKind, fmtShortDateTime, statusLabel } from "./ad
|
||||||
status={status}
|
status={status}
|
||||||
onMessageChange={updateMessageDraft}
|
onMessageChange={updateMessageDraft}
|
||||||
onSendMessage={submitMessage}
|
onSendMessage={submitMessage}
|
||||||
|
onLoadOlderMessages={loadOlderPublicMessages}
|
||||||
onFilesSelect={appendFiles}
|
onFilesSelect={appendFiles}
|
||||||
onRemoveSelectedFile={removeFile}
|
onRemoveSelectedFile={removeFile}
|
||||||
onClearSelectedFiles={clearFiles}
|
onClearSelectedFiles={clearFiles}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| PERF-01 | Зафиксировать baseline по ключевым endpoint и сценариям | in_progress | P0 | — |
|
| PERF-01 | Зафиксировать baseline по ключевым endpoint и сценариям | in_progress | P0 | — |
|
||||||
| PERF-02 | Добавить индекс на `requests.assigned_lawyer_id` | completed | P0 | — |
|
| PERF-02 | Добавить индекс на `requests.assigned_lawyer_id` | completed | P0 | — |
|
||||||
| PERF-03 | Убрать full reload карточки заявки при live-обновлениях | planned | P0 | PERF-01 |
|
| PERF-03 | Убрать full reload карточки заявки при live-обновлениях | completed | P0 | PERF-01 |
|
||||||
| PERF-04 | Собрать единый endpoint карточки заявки | planned | P0 | PERF-01 |
|
| PERF-04 | Собрать единый endpoint карточки заявки | in_progress | P0 | PERF-01 |
|
||||||
| PERF-05 | Выделить узкие request-scoped endpoints для вложений и счетов | planned | P0 | PERF-04 |
|
| PERF-05 | Выделить узкие request-scoped endpoints для вложений и счетов | completed | P0 | PERF-04 |
|
||||||
| PERF-06 | Переписать kanban на SQL-first фильтрацию/limit | planned | P0 | PERF-01, PERF-02 |
|
| PERF-06 | Переписать kanban на SQL-first фильтрацию/limit | in_progress | P0 | PERF-01, PERF-02 |
|
||||||
| PERF-07 | Ограничить initial chat payload и добавить догрузку истории | planned | P1 | PERF-03, PERF-04 |
|
| PERF-07 | Ограничить initial chat payload и добавить догрузку истории | in_progress | P1 | PERF-03, PERF-04 |
|
||||||
| PERF-08 | Добавить нужные вспомогательные индексы и повторный profiling | planned | P1 | PERF-01 |
|
| PERF-08 | Добавить нужные вспомогательные индексы и повторный profiling | planned | P1 | PERF-01 |
|
||||||
|
|
||||||
## PERF-01
|
## PERF-01
|
||||||
|
|
@ -48,8 +48,33 @@
|
||||||
- 2026-03-16: добавлен ops-скрипт `scripts/ops/perf_baseline.sh` для repeatable baseline по admin workspace.
|
- 2026-03-16: добавлен ops-скрипт `scripts/ops/perf_baseline.sh` для repeatable baseline по admin workspace.
|
||||||
- 2026-03-16: baseline еще не снят, потому что локальный контур на `localhost:8081` не поднят.
|
- 2026-03-16: baseline еще не снят, потому что локальный контур на `localhost:8081` не поднят.
|
||||||
- 2026-03-16: выполнен `PERF-02` - добавлен индекс `ix_requests_assigned_lawyer_id`, миграционный тест пройден.
|
- 2026-03-16: выполнен `PERF-02` - добавлен индекс `ix_requests_assigned_lawyer_id`, миграционный тест пройден.
|
||||||
|
- 2026-03-16: dashboard перестроен на приоритетную загрузку `overview`, справочники уходят в неблокирующий bootstrap.
|
||||||
|
- 2026-03-16: карточка заявки переведена с generic `attachments/query` и `invoices/query` на узкие request-scoped endpoint.
|
||||||
|
- 2026-03-16: `PERF-05` закрыт, backend регресс по чатам/счетам/hardening пройден.
|
||||||
|
- 2026-03-16: `PERF-03` начат - admin `/live` отдает delta `messages/attachments`, hook больше не делает полный `loadRequestModalData()` на polling.
|
||||||
|
- 2026-03-16: регресс `tests.admin.test_lawyer_chat` пройден, локальная сборка `admin/index.jsx` пройдена.
|
||||||
|
- 2026-03-16: `PERF-03` завершен - client `/live` тоже переведен на delta без полного reload workspace.
|
||||||
|
- 2026-03-16: `PERF-04` начат - admin карточка заявки переведена на единый endpoint `/api/admin/requests/{id}/workspace`.
|
||||||
|
- 2026-03-16: `PERF-06` начат - для канбана в сценарии `created_newest` без boolean-фильтров `count/order_by/limit` перенесены в SQL, чтобы не загружать весь filtered set в Python.
|
||||||
|
- 2026-03-16: добавлен регресс на канбан `limit + total + truncated`, контейнерный тест `tests.admin.test_status_flow_kanban` пройден.
|
||||||
|
- 2026-03-16: в `compute_sla_snapshot` выборки `StatusHistory` и первых lawyer messages ограничены только активными заявками; это должно ускорить `/api/admin/metrics/overview` на первом запросе.
|
||||||
|
- 2026-03-16: `overview` добавлен в perf-labels и в `scripts/ops/perf_baseline.sh`, чтобы дальше мерить dashboard отдельно от канбана/workspace.
|
||||||
|
- 2026-03-16: контейнерный регресс `tests.admin.test_metrics_templates tests.test_dashboard_finance` пройден после оптимизации overview/SLA.
|
||||||
|
- 2026-03-16: поднят минимальный локальный контур для живых замеров (`db`, `redis`, `minio`, `email-service`, `chat-service`, `backend`, `frontend`); `frontend` ушел в restart-loop из-за `host not found in upstream "minio"` в nginx-конфиге, поэтому full baseline через `:8081` не собран.
|
||||||
|
- 2026-03-16: снят manual backend-baseline напрямую через `:8002` для `metrics_overview`, `kanban`, `request_workspace`; цифры получились низкими на локальном seed и полезны только как smoke-check, а не как репрезентативный perf baseline.
|
||||||
|
- 2026-03-16: исправлен restart-loop `frontend` через lazy DNS resolve для `minio` в nginx, полноценный baseline через `http://localhost:8081` снова доступен.
|
||||||
|
- 2026-03-16: получен baseline через `:8081` после оптимизаций dashboard/workspace: `kanban ~17.8 ms avg`, `metrics_overview core ~12.9 ms avg`, `metrics_overview_sla ~8.2 ms avg`, `request_workspace ~14.9 ms avg`, `chat_live ~14.4 ms avg` на локальном seed.
|
||||||
|
- 2026-03-16: `overview` переведен на двухфазную загрузку: быстрый `include_sla=false` + фоновый `/api/admin/metrics/overview-sla`, чтобы убрать `compute_sla_snapshot()` из критического пути dashboard.
|
||||||
|
- 2026-03-16: `PERF-07` начат - initial chat payload ограничен окном сообщений, добавлены `messages-window` endpoints для admin/public и догрузка старой истории в UI по кнопке.
|
||||||
|
- 2026-03-16: контейнерные регрессы после paged-chat пройдены: `tests.admin.test_lawyer_chat`, `tests.test_public_cabinet`, `tests.admin.test_metrics_templates`, `tests.test_dashboard_finance`, `tests.test_http_hardening`.
|
||||||
|
- 2026-03-16: добавлен сценарий `scripts/ops/perf_long_chat_workspace.sh`, который сидит request с длинным чатом и меряет first open workspace и `messages-window` на живом контуре.
|
||||||
|
- 2026-03-16: long-chat baseline снят на `2000` сообщениях, отчет `reports/perf/perf-long-chat-workspace-20260316-201459.md`: `request_workspace ~579 ms avg`, `messages_window ~650 ms avg`, initial payload и older-page оба возвращают только `50` сообщений из `2000`.
|
||||||
|
- 2026-03-16: при первом прогоне long-chat сценария выяснилось, что `chat-service` работал на старом контейнере без актуальных `X-Perf-*` headers; после rebuild `chat-service` server timing для `messages-window` подтвержден на живом контуре.
|
||||||
|
- 2026-03-16: `PERF-06` продвинут дальше - SQL-first window теперь покрывает не только `created_newest`, но и `sort_mode=lawyer`, а boolean-фильтр `deadline_alert` переносится в SQL до загрузки строк.
|
||||||
|
- 2026-03-16: добавлен контейнерный регресс `test_requests_kanban_lawyer_sort_uses_limit_without_losing_total`, подтверждающий `limit/truncated/total` для `sort_mode=lawyer`.
|
||||||
|
|
||||||
## Дальше
|
## Дальше
|
||||||
|
|
||||||
1. Поднять локальный контур и выполнить `./scripts/ops/perf_baseline.sh http://localhost:8081`.
|
1. Разобрать server-side стоимость `request_workspace` и `messages-window` на длинном чате: window-пагинация уже работает, но оба endpoint остаются около `0.6-0.65s`, значит узкое место теперь в запросах/сериализации, а не в объеме initial payload.
|
||||||
2. После снятия baseline перейти к `PERF-06` и убирать `base_query.all()` из kanban.
|
2. Довести `PERF-04` до конца и решить, нужен ли такой же unified endpoint для client workspace.
|
||||||
|
3. Продолжить `PERF-06` для оставшихся режимов канбана, где все еще остается Python-side post-processing: `deadline`, `overdue`, `has_unread_updates`.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ server {
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
absolute_redirect off;
|
absolute_redirect off;
|
||||||
client_max_body_size 25m;
|
client_max_body_size 25m;
|
||||||
|
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
@ -89,7 +90,8 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location /s3/ {
|
location /s3/ {
|
||||||
proxy_pass http://minio:9000/;
|
set $minio_upstream http://minio:9000;
|
||||||
|
proxy_pass $minio_upstream/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host minio:9000;
|
proxy_set_header Host minio:9000;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ server {
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
absolute_redirect off;
|
absolute_redirect off;
|
||||||
client_max_body_size 25m;
|
client_max_body_size 25m;
|
||||||
|
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
@ -89,7 +90,8 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location /s3/ {
|
location /s3/ {
|
||||||
proxy_pass https://minio:9000/;
|
set $minio_upstream https://minio:9000;
|
||||||
|
proxy_pass $minio_upstream/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host minio:9000;
|
proxy_set_header Host minio:9000;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,6 @@ print(request_id)
|
||||||
PY
|
PY
|
||||||
)"
|
)"
|
||||||
|
|
||||||
ATTACHMENTS_BODY="$(printf '{"filters":[{"field":"request_id","op":"=","value":%s}],"sort":[{"field":"created_at","dir":"asc"}],"page":{"limit":500,"offset":0}}' "$(json_escape "$REQUEST_ID")")"
|
|
||||||
INVOICES_BODY="$(printf '{"filters":[{"field":"request_id","op":"=","value":%s}],"sort":[{"field":"issued_at","dir":"desc"}],"page":{"limit":500,"offset":0}}' "$(json_escape "$REQUEST_ID")")"
|
|
||||||
|
|
||||||
measure_endpoint() {
|
measure_endpoint() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
local method="$2"
|
local method="$2"
|
||||||
|
|
@ -144,12 +141,10 @@ PY
|
||||||
: >"$TMP_DIR/raw.tsv"
|
: >"$TMP_DIR/raw.tsv"
|
||||||
|
|
||||||
measure_endpoint "kanban" "GET" "/api/admin/requests/kanban?limit=${KANBAN_LIMIT}&sort_mode=created_newest"
|
measure_endpoint "kanban" "GET" "/api/admin/requests/kanban?limit=${KANBAN_LIMIT}&sort_mode=created_newest"
|
||||||
measure_endpoint "request_detail" "GET" "/api/admin/crud/requests/${REQUEST_ID}"
|
measure_endpoint "metrics_overview" "GET" "/api/admin/metrics/overview?include_sla=false"
|
||||||
measure_endpoint "chat_messages" "GET" "/api/admin/chat/requests/${REQUEST_ID}/messages"
|
measure_endpoint "metrics_overview_sla" "GET" "/api/admin/metrics/overview-sla"
|
||||||
|
measure_endpoint "request_workspace" "GET" "/api/admin/requests/${REQUEST_ID}/workspace"
|
||||||
measure_endpoint "chat_live" "GET" "/api/admin/chat/requests/${REQUEST_ID}/live"
|
measure_endpoint "chat_live" "GET" "/api/admin/chat/requests/${REQUEST_ID}/live"
|
||||||
measure_endpoint "status_route" "GET" "/api/admin/requests/${REQUEST_ID}/status-route"
|
|
||||||
measure_endpoint "attachments_query" "POST" "/api/admin/crud/attachments/query" "$ATTACHMENTS_BODY"
|
|
||||||
measure_endpoint "invoices_query" "POST" "/api/admin/invoices/query" "$INVOICES_BODY"
|
|
||||||
|
|
||||||
python3 - "$TMP_DIR/raw.tsv" "$REPORT_FILE" "$TS_HUMAN" "$BASE_URL" "$REQUEST_ID" "$ITERATIONS" <<'PY'
|
python3 - "$TMP_DIR/raw.tsv" "$REPORT_FILE" "$TS_HUMAN" "$BASE_URL" "$REQUEST_ID" "$ITERATIONS" <<'PY'
|
||||||
import csv
|
import csv
|
||||||
|
|
@ -190,12 +185,10 @@ with open(report_path, "w", encoding="utf-8") as out:
|
||||||
out.write("|---|---|---:|---:|---:|\n")
|
out.write("|---|---|---:|---:|---:|\n")
|
||||||
for name in [
|
for name in [
|
||||||
"kanban",
|
"kanban",
|
||||||
"request_detail",
|
"metrics_overview",
|
||||||
"chat_messages",
|
"metrics_overview_sla",
|
||||||
|
"request_workspace",
|
||||||
"chat_live",
|
"chat_live",
|
||||||
"status_route",
|
|
||||||
"attachments_query",
|
|
||||||
"invoices_query",
|
|
||||||
]:
|
]:
|
||||||
items = rows.get(name, [])
|
items = rows.get(name, [])
|
||||||
totals = sorted(item["total_ms"] for item in items)
|
totals = sorted(item["total_ms"] for item in items)
|
||||||
|
|
|
||||||
266
scripts/ops/perf_long_chat_workspace.sh
Executable file
266
scripts/ops/perf_long_chat_workspace.sh
Executable file
|
|
@ -0,0 +1,266 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
BASE_URL="${1:-http://localhost:8081}"
|
||||||
|
REPORT_DIR="${REPORT_DIR:-reports/perf}"
|
||||||
|
ITERATIONS="${PERF_ITERATIONS:-5}"
|
||||||
|
ADMIN_EMAIL="${PERF_ADMIN_EMAIL:-admin@example.com}"
|
||||||
|
ADMIN_PASSWORD="${PERF_ADMIN_PASSWORD:-admin123}"
|
||||||
|
MESSAGE_COUNT="${PERF_LONG_CHAT_MESSAGES:-2000}"
|
||||||
|
WINDOW_LIMIT="${PERF_CHAT_WINDOW_LIMIT:-50}"
|
||||||
|
TS_HUMAN="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
|
||||||
|
TS_FILE="$(date -u +"%Y%m%d-%H%M%S")"
|
||||||
|
REPORT_FILE="${REPORT_DIR}/perf-long-chat-workspace-${TS_FILE}.md"
|
||||||
|
|
||||||
|
mkdir -p "$REPORT_DIR"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || {
|
||||||
|
echo "missing command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd curl
|
||||||
|
require_cmd python3
|
||||||
|
require_cmd docker
|
||||||
|
|
||||||
|
json_escape() {
|
||||||
|
python3 - "$1" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
print(json.dumps(sys.argv[1]))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
SEED_JSON_FILE="$TMP_DIR/seed.json"
|
||||||
|
PERF_LONG_CHAT_MESSAGES="$MESSAGE_COUNT" docker compose -f docker-compose.yml -f docker-compose.local.yml exec -T backend python - <<'PY' >"$SEED_JSON_FILE"
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.message import Message
|
||||||
|
|
||||||
|
message_count = max(1, int(os.environ.get("PERF_LONG_CHAT_MESSAGES") or "2000"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
track = f"TRK-PERF-CHAT-{now.strftime('%Y%m%d%H%M%S')}"
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
req = Request(
|
||||||
|
track_number=track,
|
||||||
|
client_name="Perf Chat Client",
|
||||||
|
client_phone="+79990009999",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description=f"Perf long chat seed ({message_count})",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
started_at = now - timedelta(minutes=message_count)
|
||||||
|
batch = []
|
||||||
|
for index in range(message_count):
|
||||||
|
created_at = started_at + timedelta(minutes=index)
|
||||||
|
batch.append(
|
||||||
|
Message(
|
||||||
|
request_id=req.id,
|
||||||
|
author_type="CLIENT" if index % 2 == 0 else "LAWYER",
|
||||||
|
author_name="Клиент" if index % 2 == 0 else "Юрист",
|
||||||
|
body=f"perf message {index}",
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(batch) >= 500:
|
||||||
|
db.add_all(batch)
|
||||||
|
db.flush()
|
||||||
|
batch.clear()
|
||||||
|
if batch:
|
||||||
|
db.add_all(batch)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(json.dumps({"request_id": str(req.id), "track_number": req.track_number, "message_count": message_count}))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
PY
|
||||||
|
|
||||||
|
REQUEST_ID="$(python3 - "$SEED_JSON_FILE" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
print(str(data["request_id"]))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
TRACK_NUMBER="$(python3 - "$SEED_JSON_FILE" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
print(str(data["track_number"]))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
LOGIN_BODY="$(printf '{"email":%s,"password":%s}' "$(json_escape "$ADMIN_EMAIL")" "$(json_escape "$ADMIN_PASSWORD")")"
|
||||||
|
LOGIN_RESPONSE_FILE="$TMP_DIR/login.json"
|
||||||
|
|
||||||
|
curl -fsS \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST \
|
||||||
|
-d "$LOGIN_BODY" \
|
||||||
|
"$BASE_URL/api/admin/auth/login" >"$LOGIN_RESPONSE_FILE"
|
||||||
|
|
||||||
|
AUTH_TOKEN="$(python3 - "$LOGIN_RESPONSE_FILE" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
token = str(data.get("access_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise SystemExit("login did not return access_token")
|
||||||
|
print(token)
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
measure_endpoint() {
|
||||||
|
local name="$1"
|
||||||
|
local path="$2"
|
||||||
|
local headers_file body_file curl_meta status_code total_ms
|
||||||
|
for run in $(seq 1 "$ITERATIONS"); do
|
||||||
|
headers_file="$TMP_DIR/${name}-${run}.headers"
|
||||||
|
body_file="$TMP_DIR/${name}-${run}.body"
|
||||||
|
curl_meta="$(curl -sS \
|
||||||
|
-D "$headers_file" \
|
||||||
|
-o "$body_file" \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||||
|
-w '%{http_code} %{time_total}' \
|
||||||
|
"$BASE_URL$path")"
|
||||||
|
|
||||||
|
status_code="$(echo "$curl_meta" | awk '{print $1}')"
|
||||||
|
total_ms="$(echo "$curl_meta" | awk '{printf "%.2f", $2 * 1000}')"
|
||||||
|
|
||||||
|
if [[ "$status_code" != "200" ]]; then
|
||||||
|
echo "endpoint ${name} failed: HTTP ${status_code}" >&2
|
||||||
|
cat "$body_file" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$headers_file" "$body_file" "$name" "$run" "$total_ms" >>"$TMP_DIR/raw.tsv" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
headers_path, body_path, name, run, total_ms = sys.argv[1:6]
|
||||||
|
headers = {}
|
||||||
|
with open(headers_path, "r", encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or ":" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
headers[key.strip().lower()] = value.strip()
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
with open(body_path, "r", encoding="utf-8") as fh:
|
||||||
|
try:
|
||||||
|
payload = json.load(fh)
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
rows = payload.get("rows") or payload.get("messages") or []
|
||||||
|
print("\t".join([
|
||||||
|
name,
|
||||||
|
run,
|
||||||
|
total_ms,
|
||||||
|
headers.get("x-perf-label", ""),
|
||||||
|
headers.get("x-perf-duration-ms", ""),
|
||||||
|
str(len(rows) if isinstance(rows, list) else 0),
|
||||||
|
str(payload.get("total", payload.get("messages_total", 0)) or 0),
|
||||||
|
str(payload.get("has_more", payload.get("messages_has_more", False))),
|
||||||
|
]))
|
||||||
|
PY
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
: >"$TMP_DIR/raw.tsv"
|
||||||
|
|
||||||
|
measure_endpoint "request_workspace_long_chat" "/api/admin/requests/${REQUEST_ID}/workspace"
|
||||||
|
measure_endpoint "messages_window_older_page" "/api/admin/chat/requests/${REQUEST_ID}/messages-window?before_count=${WINDOW_LIMIT}&limit=${WINDOW_LIMIT}"
|
||||||
|
|
||||||
|
python3 - "$TMP_DIR/raw.tsv" "$REPORT_FILE" "$TS_HUMAN" "$BASE_URL" "$REQUEST_ID" "$TRACK_NUMBER" "$MESSAGE_COUNT" "$ITERATIONS" <<'PY'
|
||||||
|
import csv
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
raw_path, report_path, ts_human, base_url, request_id, track_number, message_count, iterations = sys.argv[1:9]
|
||||||
|
|
||||||
|
rows = defaultdict(list)
|
||||||
|
with open(raw_path, "r", encoding="utf-8") as fh:
|
||||||
|
reader = csv.reader(fh, delimiter="\t")
|
||||||
|
for name, run, total_ms, perf_label, perf_duration, rows_len, total_items, has_more in reader:
|
||||||
|
rows[name].append(
|
||||||
|
{
|
||||||
|
"run": int(run),
|
||||||
|
"total_ms": float(total_ms or 0),
|
||||||
|
"perf_label": perf_label or "-",
|
||||||
|
"perf_duration_ms": float(perf_duration or 0),
|
||||||
|
"rows_len": int(rows_len or 0),
|
||||||
|
"total_items": int(total_items or 0),
|
||||||
|
"has_more": str(has_more).strip().lower() == "true",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def percentile(sorted_values, ratio):
|
||||||
|
if not sorted_values:
|
||||||
|
return 0.0
|
||||||
|
if len(sorted_values) == 1:
|
||||||
|
return sorted_values[0]
|
||||||
|
index = round((len(sorted_values) - 1) * ratio)
|
||||||
|
return sorted_values[index]
|
||||||
|
|
||||||
|
with open(report_path, "w", encoding="utf-8") as out:
|
||||||
|
out.write("# Perf Long Chat Workspace Report\n\n")
|
||||||
|
out.write(f"- Timestamp: `{ts_human}`\n")
|
||||||
|
out.write(f"- Base URL: `{base_url}`\n")
|
||||||
|
out.write(f"- Request ID: `{request_id}`\n")
|
||||||
|
out.write(f"- Track Number: `{track_number}`\n")
|
||||||
|
out.write(f"- Seeded Messages: `{message_count}`\n")
|
||||||
|
out.write(f"- Iterations per endpoint: `{iterations}`\n\n")
|
||||||
|
out.write("| Endpoint | Perf Label | Avg Total ms | P95 Total ms | Avg Server ms | Rows | Total | Has More |\n")
|
||||||
|
out.write("|---|---|---:|---:|---:|---:|---:|---|\n")
|
||||||
|
for name in ["request_workspace_long_chat", "messages_window_older_page"]:
|
||||||
|
items = rows.get(name, [])
|
||||||
|
totals = sorted(item["total_ms"] for item in items)
|
||||||
|
servers = [item["perf_duration_ms"] for item in items if item["perf_duration_ms"] > 0]
|
||||||
|
avg_total = statistics.mean(totals) if totals else 0.0
|
||||||
|
p95_total = percentile(totals, 0.95)
|
||||||
|
avg_server = statistics.mean(servers) if servers else 0.0
|
||||||
|
label = items[0]["perf_label"] if items else "-"
|
||||||
|
sample = items[0] if items else {"rows_len": 0, "total_items": 0, "has_more": False}
|
||||||
|
out.write(
|
||||||
|
f"| {name} | `{label}` | {avg_total:.2f} | {p95_total:.2f} | {avg_server:.2f} | "
|
||||||
|
f"{sample['rows_len']} | {sample['total_items']} | {sample['has_more']} |\n"
|
||||||
|
)
|
||||||
|
out.write("\n## Raw Runs\n\n")
|
||||||
|
out.write("| Endpoint | Run | Total ms | Server ms | Rows | Total | Has More |\n")
|
||||||
|
out.write("|---|---:|---:|---:|---:|---:|---|\n")
|
||||||
|
for name, items in rows.items():
|
||||||
|
for item in sorted(items, key=lambda value: value["run"]):
|
||||||
|
out.write(
|
||||||
|
f"| {name} | {item['run']} | {item['total_ms']:.2f} | {item['perf_duration_ms']:.2f} | "
|
||||||
|
f"{item['rows_len']} | {item['total_items']} | {item['has_more']} |\n"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "report: $REPORT_FILE"
|
||||||
|
|
@ -30,6 +30,7 @@ from app.models.client import Client
|
||||||
from app.models.form_field import FormField
|
from app.models.form_field import FormField
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
|
from app.models.invoice import Invoice
|
||||||
from app.models.table_availability import TableAvailability
|
from app.models.table_availability import TableAvailability
|
||||||
from app.models.quote import Quote
|
from app.models.quote import Quote
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
|
@ -71,6 +72,7 @@ class AdminUniversalCrudBase(unittest.TestCase):
|
||||||
TopicStatusTransition.__table__.create(bind=cls.engine)
|
TopicStatusTransition.__table__.create(bind=cls.engine)
|
||||||
AdminUserTopic.__table__.create(bind=cls.engine)
|
AdminUserTopic.__table__.create(bind=cls.engine)
|
||||||
Notification.__table__.create(bind=cls.engine)
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
Invoice.__table__.create(bind=cls.engine)
|
||||||
TableAvailability.__table__.create(bind=cls.engine)
|
TableAvailability.__table__.create(bind=cls.engine)
|
||||||
AuditLog.__table__.create(bind=cls.engine)
|
AuditLog.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
|
@ -78,6 +80,7 @@ class AdminUniversalCrudBase(unittest.TestCase):
|
||||||
def tearDownClass(cls):
|
def tearDownClass(cls):
|
||||||
AuditLog.__table__.drop(bind=cls.engine)
|
AuditLog.__table__.drop(bind=cls.engine)
|
||||||
Notification.__table__.drop(bind=cls.engine)
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
Invoice.__table__.drop(bind=cls.engine)
|
||||||
TableAvailability.__table__.drop(bind=cls.engine)
|
TableAvailability.__table__.drop(bind=cls.engine)
|
||||||
AdminUserTopic.__table__.drop(bind=cls.engine)
|
AdminUserTopic.__table__.drop(bind=cls.engine)
|
||||||
RequestDataRequirement.__table__.drop(bind=cls.engine)
|
RequestDataRequirement.__table__.drop(bind=cls.engine)
|
||||||
|
|
@ -117,6 +120,7 @@ class AdminUniversalCrudBase(unittest.TestCase):
|
||||||
db.execute(delete(TopicStatusTransition))
|
db.execute(delete(TopicStatusTransition))
|
||||||
db.execute(delete(AdminUserTopic))
|
db.execute(delete(AdminUserTopic))
|
||||||
db.execute(delete(Notification))
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(Invoice))
|
||||||
db.execute(delete(TableAvailability))
|
db.execute(delete(TableAvailability))
|
||||||
db.execute(delete(Quote))
|
db.execute(delete(Quote))
|
||||||
db.execute(delete(AdminUser))
|
db.execute(delete(AdminUser))
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,7 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
own_id = str(own.id)
|
own_id = str(own.id)
|
||||||
|
foreign_id = str(foreign.id)
|
||||||
unassigned_id = str(unassigned.id)
|
unassigned_id = str(unassigned.id)
|
||||||
foreign_msg_id = str(msg_foreign.id)
|
foreign_msg_id = str(msg_foreign.id)
|
||||||
foreign_att_id = str(att_foreign.id)
|
foreign_att_id = str(att_foreign.id)
|
||||||
|
|
@ -281,6 +282,17 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
attachment_request_ids = {str(row.get("request_id")) for row in (attachments_query.json().get("rows") or [])}
|
attachment_request_ids = {str(row.get("request_id")) for row in (attachments_query.json().get("rows") or [])}
|
||||||
self.assertEqual(attachment_request_ids, {own_id, unassigned_id})
|
self.assertEqual(attachment_request_ids, {own_id, unassigned_id})
|
||||||
|
|
||||||
|
own_request_attachments = self.client.get(f"/api/admin/uploads/request-attachments/{own_id}", headers=headers)
|
||||||
|
self.assertEqual(own_request_attachments.status_code, 200)
|
||||||
|
self.assertEqual(len(own_request_attachments.json().get("rows") or []), 1)
|
||||||
|
|
||||||
|
unassigned_request_attachments = self.client.get(f"/api/admin/uploads/request-attachments/{unassigned_id}", headers=headers)
|
||||||
|
self.assertEqual(unassigned_request_attachments.status_code, 200)
|
||||||
|
self.assertEqual(len(unassigned_request_attachments.json().get("rows") or []), 1)
|
||||||
|
|
||||||
|
blocked_request_attachments = self.client.get(f"/api/admin/uploads/request-attachments/{foreign_id}", headers=headers)
|
||||||
|
self.assertEqual(blocked_request_attachments.status_code, 403)
|
||||||
|
|
||||||
foreign_message_get = self.client.get(f"/api/admin/crud/messages/{foreign_msg_id}", headers=headers)
|
foreign_message_get = self.client.get(f"/api/admin/crud/messages/{foreign_msg_id}", headers=headers)
|
||||||
self.assertEqual(foreign_message_get.status_code, 403)
|
self.assertEqual(foreign_message_get.status_code, 403)
|
||||||
foreign_attachment_get = self.client.get(f"/api/admin/crud/attachments/{foreign_att_id}", headers=headers)
|
foreign_attachment_get = self.client.get(f"/api/admin/crud/attachments/{foreign_att_id}", headers=headers)
|
||||||
|
|
@ -302,6 +314,98 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
)
|
)
|
||||||
self.assertEqual(blocked_unassigned_create.status_code, 403)
|
self.assertEqual(blocked_unassigned_create.status_code, 403)
|
||||||
|
|
||||||
|
def test_request_workspace_endpoint_returns_compound_payload_with_role_scope(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer_self = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Workspace",
|
||||||
|
email="lawyer.workspace@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer_other = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Чужой Workspace",
|
||||||
|
email="lawyer.workspace.other@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([lawyer_self, lawyer_other])
|
||||||
|
db.flush()
|
||||||
|
self_id = str(lawyer_self.id)
|
||||||
|
other_id = str(lawyer_other.id)
|
||||||
|
|
||||||
|
own = Request(
|
||||||
|
track_number="TRK-WORKSPACE-OWN",
|
||||||
|
client_name="Клиент Workspace",
|
||||||
|
client_phone="+79990010111",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description="workspace own",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=self_id,
|
||||||
|
)
|
||||||
|
foreign = Request(
|
||||||
|
track_number="TRK-WORKSPACE-FOREIGN",
|
||||||
|
client_name="Клиент Workspace Foreign",
|
||||||
|
client_phone="+79990010112",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description="workspace foreign",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=other_id,
|
||||||
|
)
|
||||||
|
db.add_all([own, foreign])
|
||||||
|
db.flush()
|
||||||
|
own_id = str(own.id)
|
||||||
|
foreign_id = str(foreign.id)
|
||||||
|
|
||||||
|
for index in range(55):
|
||||||
|
db.add(
|
||||||
|
Message(
|
||||||
|
request_id=own.id,
|
||||||
|
author_type="CLIENT",
|
||||||
|
author_name="Клиент",
|
||||||
|
body=f"workspace msg {index}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
Attachment(
|
||||||
|
request_id=own.id,
|
||||||
|
file_name="workspace.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
size_bytes=64,
|
||||||
|
s3_key=f"requests/{own.id}/workspace.pdf",
|
||||||
|
immutable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
headers = self._auth_headers("LAWYER", email="lawyer.workspace@example.com", sub=self_id)
|
||||||
|
own_workspace = self.client.get(f"/api/admin/requests/{own_id}/workspace", headers=headers)
|
||||||
|
self.assertEqual(own_workspace.status_code, 200)
|
||||||
|
payload = own_workspace.json()
|
||||||
|
self.assertEqual(str((payload.get("request") or {}).get("id")), own_id)
|
||||||
|
self.assertEqual(len(payload.get("messages") or []), 50)
|
||||||
|
self.assertTrue(bool(payload.get("messages_has_more")))
|
||||||
|
self.assertEqual(int(payload.get("messages_total") or 0), 55)
|
||||||
|
self.assertEqual(int(payload.get("messages_loaded_count") or 0), 50)
|
||||||
|
self.assertEqual(len(payload.get("attachments") or []), 1)
|
||||||
|
self.assertIn("status_route", payload)
|
||||||
|
self.assertIn("finance_summary", payload)
|
||||||
|
|
||||||
|
older_messages = self.chat_client.get(
|
||||||
|
f"/api/admin/chat/requests/{own_id}/messages-window",
|
||||||
|
headers=headers,
|
||||||
|
params={"before_count": 50, "limit": 10},
|
||||||
|
)
|
||||||
|
self.assertEqual(older_messages.status_code, 200)
|
||||||
|
older_payload = older_messages.json()
|
||||||
|
self.assertEqual(len(older_payload.get("rows") or []), 5)
|
||||||
|
self.assertFalse(bool(older_payload.get("has_more")))
|
||||||
|
self.assertEqual(int(older_payload.get("loaded_count") or 0), 55)
|
||||||
|
|
||||||
|
foreign_workspace = self.client.get(f"/api/admin/requests/{foreign_id}/workspace", headers=headers)
|
||||||
|
self.assertEqual(foreign_workspace.status_code, 403)
|
||||||
|
|
||||||
def test_topic_status_flow_supports_branching_transitions(self):
|
def test_topic_status_flow_supports_branching_transitions(self):
|
||||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
@ -508,6 +612,35 @@ class AdminLawyerChatTests(AdminUniversalCrudBase):
|
||||||
self.assertEqual(own_live_no_delta.status_code, 200)
|
self.assertEqual(own_live_no_delta.status_code, 200)
|
||||||
self.assertFalse(bool(own_live_no_delta.json().get("has_updates")))
|
self.assertFalse(bool(own_live_no_delta.json().get("has_updates")))
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
own_req = db.get(Request, UUID(own_id))
|
||||||
|
self.assertIsNotNone(own_req)
|
||||||
|
live_message = Message(request_id=own_req.id, author_type="CLIENT", author_name="Клиент", body="live delta", immutable=False)
|
||||||
|
db.add(live_message)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
Attachment(
|
||||||
|
request_id=own_req.id,
|
||||||
|
message_id=live_message.id,
|
||||||
|
file_name="live-delta.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
size_bytes=321,
|
||||||
|
s3_key=f"requests/{own_req.id}/live-delta.pdf",
|
||||||
|
immutable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
own_live_delta = self.chat_client.get(
|
||||||
|
f"/api/admin/chat/requests/{own_id}/live",
|
||||||
|
headers=lawyer_headers,
|
||||||
|
params={"cursor": own_cursor},
|
||||||
|
)
|
||||||
|
self.assertEqual(own_live_delta.status_code, 200)
|
||||||
|
self.assertTrue(bool(own_live_delta.json().get("has_updates")))
|
||||||
|
self.assertEqual(len(own_live_delta.json().get("messages") or []), 1)
|
||||||
|
self.assertEqual(len(own_live_delta.json().get("attachments") or []), 1)
|
||||||
|
|
||||||
foreign_live = self.chat_client.get(f"/api/admin/chat/requests/{foreign_id}/live", headers=lawyer_headers)
|
foreign_live = self.chat_client.get(f"/api/admin/chat/requests/{foreign_id}/live", headers=lawyer_headers)
|
||||||
self.assertEqual(foreign_live.status_code, 403)
|
self.assertEqual(foreign_live.status_code, 403)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,63 @@ class AdminMetricsTemplatesTests(AdminUniversalCrudBase):
|
||||||
self.assertAlmostEqual(float(body["frt_avg_minutes"]), 20.0, places=1)
|
self.assertAlmostEqual(float(body["frt_avg_minutes"]), 20.0, places=1)
|
||||||
self.assertIn("NEW", body.get("avg_time_in_status_hours") or {})
|
self.assertIn("NEW", body.get("avg_time_in_status_hours") or {})
|
||||||
|
|
||||||
|
def test_dashboard_metrics_can_skip_sla_for_fast_overview(self):
|
||||||
|
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=1, is_terminal=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-SLA-M-FAST-1",
|
||||||
|
client_name="Клиент SLA Fast",
|
||||||
|
client_phone="+79990000013",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
created_at=now - timedelta(hours=30),
|
||||||
|
updated_at=now - timedelta(hours=30),
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
Message(
|
||||||
|
request_id=req.id,
|
||||||
|
author_type="LAWYER",
|
||||||
|
author_name="Юрист",
|
||||||
|
body="Ответ",
|
||||||
|
created_at=req.created_at + timedelta(minutes=20),
|
||||||
|
updated_at=req.created_at + timedelta(minutes=20),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
StatusHistory(
|
||||||
|
request_id=req.id,
|
||||||
|
from_status=None,
|
||||||
|
to_status="NEW",
|
||||||
|
changed_by_admin_id=None,
|
||||||
|
created_at=now - timedelta(hours=30),
|
||||||
|
updated_at=now - timedelta(hours=30),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = self.client.get("/api/admin/metrics/overview?include_sla=false", headers=headers)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(int(body.get("sla_overdue") or 0), 0)
|
||||||
|
self.assertIsNone(body.get("frt_avg_minutes"))
|
||||||
|
self.assertEqual(body.get("avg_time_in_status_hours"), {})
|
||||||
|
|
||||||
|
sla_response = self.client.get("/api/admin/metrics/overview-sla", headers=headers)
|
||||||
|
self.assertEqual(sla_response.status_code, 200)
|
||||||
|
sla_body = sla_response.json()
|
||||||
|
self.assertGreaterEqual(int(sla_body.get("sla_overdue") or 0), 1)
|
||||||
|
self.assertIsNotNone(sla_body.get("frt_avg_minutes"))
|
||||||
|
|
||||||
def test_admin_can_manage_admin_user_topics_only_for_lawyers(self):
|
def test_admin_can_manage_admin_user_topics_only_for_lawyers(self):
|
||||||
headers = self._auth_headers("ADMIN", email="root@example.com")
|
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
|
||||||
|
|
@ -896,3 +896,104 @@ class AdminStatusFlowKanbanTests(AdminUniversalCrudBase):
|
||||||
sorted_rows = sorted_by_deadline.json().get("rows") or []
|
sorted_rows = sorted_by_deadline.json().get("rows") or []
|
||||||
self.assertTrue(sorted_rows)
|
self.assertTrue(sorted_rows)
|
||||||
self.assertEqual(sorted_rows[0]["id"], request_overdue_id)
|
self.assertEqual(sorted_rows[0]["id"], request_overdue_id)
|
||||||
|
|
||||||
|
def test_requests_kanban_created_newest_uses_limit_without_losing_total(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add(Status(code="NEW", name="Новая", enabled=True, sort_order=1, is_terminal=False, kind="DEFAULT"))
|
||||||
|
db.flush()
|
||||||
|
base_created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
request_ids = []
|
||||||
|
for index in range(4):
|
||||||
|
request_row = Request(
|
||||||
|
track_number=f"TRK-KANBAN-LIMIT-{index}",
|
||||||
|
client_name=f"Клиент {index}",
|
||||||
|
client_phone=f"+7999000010{index}",
|
||||||
|
status_code="NEW",
|
||||||
|
description=f"limit-{index}",
|
||||||
|
extra_fields={},
|
||||||
|
created_at=base_created_at + timedelta(minutes=index),
|
||||||
|
)
|
||||||
|
db.add(request_row)
|
||||||
|
db.flush()
|
||||||
|
request_ids.append(str(request_row.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
|
response = self.client.get("/api/admin/requests/kanban?limit=2", headers=headers)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
rows = payload.get("rows") or []
|
||||||
|
|
||||||
|
self.assertEqual(payload["total"], 4)
|
||||||
|
self.assertTrue(bool(payload["truncated"]))
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual([row["id"] for row in rows], [request_ids[3], request_ids[2]])
|
||||||
|
|
||||||
|
def test_requests_kanban_lawyer_sort_uses_limit_without_losing_total(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add(Status(code="NEW", name="Новая", enabled=True, sort_order=1, is_terminal=False, kind="DEFAULT"))
|
||||||
|
lawyer_b = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Boris Lawyer",
|
||||||
|
email="lawyer-boris@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer_a = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Alex Lawyer",
|
||||||
|
email="lawyer-alexey@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([lawyer_b, lawyer_a])
|
||||||
|
db.flush()
|
||||||
|
base_created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
||||||
|
rows = [
|
||||||
|
Request(
|
||||||
|
track_number="TRK-KANBAN-LAWYER-1",
|
||||||
|
client_name="Клиент 1",
|
||||||
|
client_phone="+79990001111",
|
||||||
|
status_code="NEW",
|
||||||
|
description="lawyer-sort-1",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(lawyer_b.id),
|
||||||
|
created_at=base_created_at + timedelta(minutes=1),
|
||||||
|
),
|
||||||
|
Request(
|
||||||
|
track_number="TRK-KANBAN-LAWYER-2",
|
||||||
|
client_name="Клиент 2",
|
||||||
|
client_phone="+79990001112",
|
||||||
|
status_code="NEW",
|
||||||
|
description="lawyer-sort-2",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(lawyer_a.id),
|
||||||
|
created_at=base_created_at + timedelta(minutes=2),
|
||||||
|
),
|
||||||
|
Request(
|
||||||
|
track_number="TRK-KANBAN-LAWYER-3",
|
||||||
|
client_name="Клиент 3",
|
||||||
|
client_phone="+79990001113",
|
||||||
|
status_code="NEW",
|
||||||
|
description="lawyer-sort-3",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=None,
|
||||||
|
created_at=base_created_at + timedelta(minutes=3),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db.add_all(rows)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
headers = self._auth_headers("ADMIN", email="root@example.com")
|
||||||
|
response = self.client.get("/api/admin/requests/kanban?limit=2&sort_mode=lawyer", headers=headers)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
response_rows = payload.get("rows") or []
|
||||||
|
|
||||||
|
self.assertEqual(payload["total"], 3)
|
||||||
|
self.assertTrue(bool(payload["truncated"]))
|
||||||
|
self.assertEqual(len(response_rows), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
[str(item.get("assigned_lawyer_name") or "") for item in response_rows],
|
||||||
|
["Alex Lawyer", "Boris Lawyer"],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,66 @@ class HttpHardeningTests(unittest.TestCase):
|
||||||
}
|
}
|
||||||
self.assertEqual(_performance_label(Request(scope)), "public_request_status_route")
|
self.assertEqual(_performance_label(Request(scope)), "public_request_status_route")
|
||||||
|
|
||||||
|
def test_performance_label_maps_admin_chat_messages_window(self):
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/admin/chat/requests/123/messages-window",
|
||||||
|
"raw_path": b"/api/admin/chat/requests/123/messages-window",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
}
|
||||||
|
self.assertEqual(_performance_label(Request(scope)), "admin_chat_messages_window")
|
||||||
|
|
||||||
|
def test_performance_label_maps_public_chat_messages_window(self):
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/public/chat/requests/TRK-1/messages-window",
|
||||||
|
"raw_path": b"/api/public/chat/requests/TRK-1/messages-window",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
}
|
||||||
|
self.assertEqual(_performance_label(Request(scope)), "public_chat_messages_window")
|
||||||
|
|
||||||
|
def test_performance_label_maps_admin_metrics_overview(self):
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/admin/metrics/overview",
|
||||||
|
"raw_path": b"/api/admin/metrics/overview",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
}
|
||||||
|
self.assertEqual(_performance_label(Request(scope)), "admin_metrics_overview")
|
||||||
|
|
||||||
|
def test_performance_label_maps_admin_metrics_overview_sla(self):
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/admin/metrics/overview-sla",
|
||||||
|
"raw_path": b"/api/admin/metrics/overview-sla",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
}
|
||||||
|
self.assertEqual(_performance_label(Request(scope)), "admin_metrics_overview_sla")
|
||||||
|
|
||||||
def test_non_file_paths_keep_deny_framing(self):
|
def test_non_file_paths_keep_deny_framing(self):
|
||||||
scope = {
|
scope = {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,15 @@ class InvoiceApiTests(unittest.TestCase):
|
||||||
self.assertEqual(len(rows), 1)
|
self.assertEqual(len(rows), 1)
|
||||||
self.assertEqual(rows[0]["id"], own_invoice_id)
|
self.assertEqual(rows[0]["id"], own_invoice_id)
|
||||||
|
|
||||||
|
listed_by_request = self.client.get(f"/api/admin/invoices/by-request/{self.request_a_id}", headers=lawyer_a_headers)
|
||||||
|
self.assertEqual(listed_by_request.status_code, 200)
|
||||||
|
direct_rows = listed_by_request.json()["rows"]
|
||||||
|
self.assertEqual(len(direct_rows), 1)
|
||||||
|
self.assertEqual(direct_rows[0]["id"], own_invoice_id)
|
||||||
|
|
||||||
|
foreign_by_request = self.client.get(f"/api/admin/invoices/by-request/{self.request_b_id}", headers=lawyer_a_headers)
|
||||||
|
self.assertEqual(foreign_by_request.status_code, 403)
|
||||||
|
|
||||||
foreign_get = self.client.get(f"/api/admin/invoices/{foreign_invoice_id}", headers=lawyer_a_headers)
|
foreign_get = self.client.get(f"/api/admin/invoices/{foreign_invoice_id}", headers=lawyer_a_headers)
|
||||||
self.assertEqual(foreign_get.status_code, 403)
|
self.assertEqual(foreign_get.status_code, 403)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,26 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
self.assertEqual(len(listed.json()), 1)
|
self.assertEqual(len(listed.json()), 1)
|
||||||
self.assertIn("выделенный сервис", listed.json()[0]["body"])
|
self.assertIn("выделенный сервис", listed.json()[0]["body"])
|
||||||
|
|
||||||
|
for index in range(4):
|
||||||
|
created_extra = self.chat_client.post(
|
||||||
|
"/api/public/chat/requests/TRK-CHAT-001/messages",
|
||||||
|
cookies=cookies,
|
||||||
|
json={"body": f"Сообщение {index}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(created_extra.status_code, 201)
|
||||||
|
|
||||||
|
listed_window = self.chat_client.get(
|
||||||
|
"/api/public/chat/requests/TRK-CHAT-001/messages-window",
|
||||||
|
cookies=cookies,
|
||||||
|
params={"limit": 2},
|
||||||
|
)
|
||||||
|
self.assertEqual(listed_window.status_code, 200)
|
||||||
|
window_payload = listed_window.json()
|
||||||
|
self.assertEqual(len(window_payload.get("rows") or []), 2)
|
||||||
|
self.assertTrue(bool(window_payload.get("has_more")))
|
||||||
|
self.assertEqual(int(window_payload.get("loaded_count") or 0), 2)
|
||||||
|
self.assertEqual(int(window_payload.get("total") or 0), 5)
|
||||||
|
|
||||||
denied = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
|
denied = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-001/messages", cookies=self._public_cookies("TRK-OTHER"))
|
||||||
self.assertEqual(denied.status_code, 404)
|
self.assertEqual(denied.status_code, 404)
|
||||||
|
|
||||||
|
|
@ -441,6 +461,39 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
self.assertEqual(live_no_delta.status_code, 200)
|
self.assertEqual(live_no_delta.status_code, 200)
|
||||||
self.assertFalse(bool(live_no_delta.json().get("has_updates")))
|
self.assertFalse(bool(live_no_delta.json().get("has_updates")))
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = db.query(Request).filter(Request.track_number == "TRK-LIVE-001").first()
|
||||||
|
self.assertIsNotNone(req)
|
||||||
|
live_message = Message(
|
||||||
|
request_id=req.id,
|
||||||
|
author_type="LAWYER",
|
||||||
|
author_name="Юрист",
|
||||||
|
body="Новое сообщение live",
|
||||||
|
)
|
||||||
|
db.add(live_message)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
Attachment(
|
||||||
|
request_id=req.id,
|
||||||
|
message_id=live_message.id,
|
||||||
|
file_name="live-public.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
size_bytes=512,
|
||||||
|
s3_key=f"requests/{req.id}/live-public.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
live_delta = self.chat_client.get(
|
||||||
|
"/api/public/chat/requests/TRK-LIVE-001/live",
|
||||||
|
params={"cursor": current_cursor},
|
||||||
|
cookies=cookies,
|
||||||
|
)
|
||||||
|
self.assertEqual(live_delta.status_code, 200)
|
||||||
|
self.assertTrue(bool(live_delta.json().get("has_updates")))
|
||||||
|
self.assertEqual(len(live_delta.json().get("messages") or []), 1)
|
||||||
|
self.assertEqual(len(live_delta.json().get("attachments") or []), 1)
|
||||||
|
|
||||||
typing_on = self.chat_client.post(
|
typing_on = self.chat_client.post(
|
||||||
"/api/public/chat/requests/TRK-LIVE-001/typing",
|
"/api/public/chat/requests/TRK-LIVE-001/typing",
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue