fix speed up 02

This commit is contained in:
TronoSfera 2026-03-17 00:49:39 +03:00
parent cf3b56deeb
commit 585b6bcfc1
30 changed files with 2458 additions and 1022 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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")

View file

@ -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(
} }
) )
items = apply_boolean_kanban_filters(items, boolean_filters) if boolean_filters:
items = sort_kanban_items(items, normalized_sort_mode) items = apply_boolean_kanban_filters(items, boolean_filters)
total = len(items) if not can_apply_sql_window:
if total > limit: items = sort_kanban_items(items, normalized_sort_mode)
items = items[:limit] total = len(items)
if total > 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()

View file

@ -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,

View file

@ -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)

View file

@ -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,

View file

@ -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,

View file

@ -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$")),

View file

@ -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

View file

@ -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()
) )

File diff suppressed because one or more lines are too long

View file

@ -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}

View file

@ -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) =>

View file

@ -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,

View file

@ -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

View file

@ -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}

View file

@ -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`.

View file

@ -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;

View file

@ -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;

View file

@ -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)

View 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"

View 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))

View file

@ -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)

View file

@ -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:

View file

@ -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"],
)

View file

@ -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",

View file

@ -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)

View file

@ -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,