Task P052-P053

This commit is contained in:
TronoSfera 2026-02-26 18:55:02 +03:00
parent 4d87cefcee
commit 4b9b2df2e3
80 changed files with 12433 additions and 3066 deletions

View file

@ -0,0 +1,27 @@
"""add request_cost field to requests
Revision ID: 0019_request_cost_on_requests
Revises: 0018_status_groups
Create Date: 2026-02-26 00:15:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0019_request_cost_on_requests"
down_revision = "0018_status_groups"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("requests", sa.Column("request_cost", sa.Numeric(14, 2), nullable=True))
def downgrade() -> None:
op.drop_column("requests", "request_cost")

View file

@ -0,0 +1,28 @@
"""add phone to admin_users
Revision ID: 0020_admin_users_phone
Revises: 0019_request_cost_on_requests
Create Date: 2026-02-26 22:10:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0020_admin_users_phone"
down_revision = "0019_request_cost_on_requests"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("admin_users", sa.Column("phone", sa.String(length=30), nullable=True))
op.create_index(op.f("ix_admin_users_phone"), "admin_users", ["phone"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_admin_users_phone"), table_name="admin_users")
op.drop_column("admin_users", "phone")

View file

@ -0,0 +1,50 @@
"""extend request data templates and request data requirements for chat requests
Revision ID: 0021_request_data_chat_fields
Revises: 0020_admin_users_phone
Create Date: 2026-02-26 12:10:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0021_request_data_chat_fields"
down_revision = "0020_admin_users_phone"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("topic_data_templates", sa.Column("value_type", sa.String(length=20), nullable=False, server_default="text"))
op.add_column("topic_data_templates", sa.Column("document_name", sa.String(length=200), nullable=True))
op.create_index(op.f("ix_topic_data_templates_document_name"), "topic_data_templates", ["document_name"], unique=False)
op.add_column("request_data_requirements", sa.Column("request_message_id", sa.UUID(), nullable=True))
op.add_column("request_data_requirements", sa.Column("field_type", sa.String(length=20), nullable=False, server_default="text"))
op.add_column("request_data_requirements", sa.Column("document_name", sa.String(length=200), nullable=True))
op.add_column("request_data_requirements", sa.Column("value_text", sa.String(length=500), nullable=True))
op.add_column("request_data_requirements", sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"))
op.create_index(op.f("ix_request_data_requirements_request_message_id"), "request_data_requirements", ["request_message_id"], unique=False)
op.create_index(op.f("ix_request_data_requirements_document_name"), "request_data_requirements", ["document_name"], unique=False)
op.alter_column("topic_data_templates", "value_type", server_default=None)
op.alter_column("request_data_requirements", "field_type", server_default=None)
op.alter_column("request_data_requirements", "sort_order", server_default=None)
def downgrade() -> None:
op.drop_index(op.f("ix_request_data_requirements_document_name"), table_name="request_data_requirements")
op.drop_index(op.f("ix_request_data_requirements_request_message_id"), table_name="request_data_requirements")
op.drop_column("request_data_requirements", "sort_order")
op.drop_column("request_data_requirements", "value_text")
op.drop_column("request_data_requirements", "document_name")
op.drop_column("request_data_requirements", "field_type")
op.drop_column("request_data_requirements", "request_message_id")
op.drop_index(op.f("ix_topic_data_templates_document_name"), table_name="topic_data_templates")
op.drop_column("topic_data_templates", "document_name")
op.drop_column("topic_data_templates", "value_type")

View file

@ -0,0 +1,76 @@
"""add request data templates and template items tables
Revision ID: 0022_req_data_templates
Revises: 0021_request_data_chat_fields
Create Date: 2026-02-26 13:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "0022_req_data_templates"
down_revision = "0021_request_data_chat_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"request_data_templates",
sa.Column("topic_code", sa.String(length=50), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_by_admin_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
sa.UniqueConstraint("topic_code", "name", name="uq_request_data_templates_topic_name"),
)
op.create_index(op.f("ix_request_data_templates_topic_code"), "request_data_templates", ["topic_code"], unique=False)
op.create_index(op.f("ix_request_data_templates_name"), "request_data_templates", ["name"], unique=False)
op.create_index(op.f("ix_request_data_templates_created_by_admin_id"), "request_data_templates", ["created_by_admin_id"], unique=False)
op.create_table(
"request_data_template_items",
sa.Column("request_data_template_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("topic_data_template_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("key", sa.String(length=80), nullable=False),
sa.Column("label", sa.String(length=200), nullable=False),
sa.Column("value_type", sa.String(length=20), nullable=False, server_default="string"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
sa.UniqueConstraint("request_data_template_id", "key", name="uq_request_data_template_items_template_key"),
)
op.create_index(op.f("ix_request_data_template_items_request_data_template_id"), "request_data_template_items", ["request_data_template_id"], unique=False)
op.create_index(op.f("ix_request_data_template_items_topic_data_template_id"), "request_data_template_items", ["topic_data_template_id"], unique=False)
op.create_index(op.f("ix_request_data_template_items_key"), "request_data_template_items", ["key"], unique=False)
op.alter_column("request_data_templates", "enabled", server_default=None)
op.alter_column("request_data_templates", "sort_order", server_default=None)
op.alter_column("request_data_templates", "responsible", server_default=None)
op.alter_column("request_data_template_items", "value_type", server_default=None)
op.alter_column("request_data_template_items", "sort_order", server_default=None)
op.alter_column("request_data_template_items", "responsible", server_default=None)
def downgrade() -> None:
op.drop_index(op.f("ix_request_data_template_items_key"), table_name="request_data_template_items")
op.drop_index(op.f("ix_request_data_template_items_topic_data_template_id"), table_name="request_data_template_items")
op.drop_index(op.f("ix_request_data_template_items_request_data_template_id"), table_name="request_data_template_items")
op.drop_table("request_data_template_items")
op.drop_index(op.f("ix_request_data_templates_created_by_admin_id"), table_name="request_data_templates")
op.drop_index(op.f("ix_request_data_templates_name"), table_name="request_data_templates")
op.drop_index(op.f("ix_request_data_templates_topic_code"), table_name="request_data_templates")
op.drop_table("request_data_templates")

View file

@ -0,0 +1,35 @@
"""add important date to requests and status history
Revision ID: 0023_status_important_date
Revises: 0022_req_data_templates
Create Date: 2026-02-26 15:20:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0023_status_important_date"
down_revision = "0022_req_data_templates"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("requests", sa.Column("important_date_at", sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f("ix_requests_important_date_at"), "requests", ["important_date_at"], unique=False)
op.add_column("status_history", sa.Column("important_date_at", sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f("ix_status_history_important_date_at"), "status_history", ["important_date_at"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_status_history_important_date_at"), table_name="status_history")
op.drop_column("status_history", "important_date_at")
op.drop_index(op.f("ix_requests_important_date_at"), table_name="requests")
op.drop_column("requests", "important_date_at")

View file

@ -0,0 +1,72 @@
"""add landing featured staff carousel table
Revision ID: 0024_featured_staff_carousel
Revises: 0023_status_important_date
Create Date: 2026-02-26 23:45:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "0024_featured_staff_carousel"
down_revision = "0023_status_important_date"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"landing_featured_staff",
sa.Column("admin_user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("caption", sa.String(length=300), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
sa.UniqueConstraint("admin_user_id", name="uq_landing_featured_staff_admin_user"),
)
op.create_index(
op.f("ix_landing_featured_staff_admin_user_id"),
"landing_featured_staff",
["admin_user_id"],
unique=False,
)
op.create_index(
op.f("ix_landing_featured_staff_sort_order"),
"landing_featured_staff",
["sort_order"],
unique=False,
)
op.create_index(
op.f("ix_landing_featured_staff_pinned"),
"landing_featured_staff",
["pinned"],
unique=False,
)
op.create_index(
op.f("ix_landing_featured_staff_enabled"),
"landing_featured_staff",
["enabled"],
unique=False,
)
op.alter_column("landing_featured_staff", "sort_order", server_default=None)
op.alter_column("landing_featured_staff", "pinned", server_default=None)
op.alter_column("landing_featured_staff", "enabled", server_default=None)
op.alter_column("landing_featured_staff", "responsible", server_default=None)
def downgrade() -> None:
op.drop_index(op.f("ix_landing_featured_staff_enabled"), table_name="landing_featured_staff")
op.drop_index(op.f("ix_landing_featured_staff_pinned"), table_name="landing_featured_staff")
op.drop_index(op.f("ix_landing_featured_staff_sort_order"), table_name="landing_featured_staff")
op.drop_index(op.f("ix_landing_featured_staff_admin_user_id"), table_name="landing_featured_staff")
op.drop_table("landing_featured_staff")

View file

@ -8,10 +8,22 @@ from sqlalchemy.orm import Session
from app.core.deps import require_role from app.core.deps import require_role
from app.db.session import get_db from app.db.session import get_db
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request from app.models.request import Request
from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message from app.models.request_data_requirement import RequestDataRequirement
from app.models.request_data_template import RequestDataTemplate
from app.models.request_data_template_item import RequestDataTemplateItem
from app.models.topic_data_template import TopicDataTemplate
from app.services.chat_service import (
create_admin_or_lawyer_message,
list_messages_for_request,
serialize_message,
serialize_messages_for_request,
)
router = APIRouter() router = APIRouter()
ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"}
def _request_uuid_or_400(request_id: str) -> UUID: def _request_uuid_or_400(request_id: str) -> UUID:
@ -52,6 +64,121 @@ def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками") raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
def _parse_uuid_or_400(raw: str, field_name: str) -> UUID:
try:
return UUID(str(raw))
except ValueError:
raise HTTPException(status_code=400, detail=f'Некорректное поле "{field_name}"')
def _slugify_key(raw: str) -> str:
text = str(raw or "").strip().lower()
out = []
dash = False
for ch in text:
if ch.isalnum():
out.append(ch)
dash = False
continue
if not dash:
out.append("-")
dash = True
slug = "".join(out).strip("-")
return (slug or "data-field")[:80]
def _normalize_value_type(raw: str | None) -> str:
value = str(raw or "text").strip().lower()
if value not in ALLOWED_VALUE_TYPES:
raise HTTPException(status_code=400, detail='Тип поля должен быть одним из: string, text, date, number, file')
return value
def _serialize_template(row: TopicDataTemplate) -> dict:
return {
"id": str(row.id),
"topic_code": row.topic_code,
"key": row.key,
"label": row.label,
"value_type": str(row.value_type or "text"),
"document_name": row.document_name,
"description": row.description,
"sort_order": int(row.sort_order or 0),
"enabled": bool(row.enabled),
}
def _serialize_request_data_template(row: RequestDataTemplate) -> dict:
return {
"id": str(row.id),
"topic_code": row.topic_code,
"name": row.name,
"description": row.description,
"enabled": bool(row.enabled),
"sort_order": int(row.sort_order or 0),
"created_by_admin_id": str(row.created_by_admin_id) if row.created_by_admin_id else None,
}
def _serialize_request_data_template_item(row: RequestDataTemplateItem) -> dict:
return {
"id": str(row.id),
"request_data_template_id": str(row.request_data_template_id),
"topic_data_template_id": str(row.topic_data_template_id) if row.topic_data_template_id else None,
"key": row.key,
"label": row.label,
"value_type": str(row.value_type or "string"),
"sort_order": int(row.sort_order or 0),
}
def _serialize_data_request_items(db: Session, rows: list[RequestDataRequirement]) -> list[dict]:
attachment_ids: list[UUID] = []
for row in rows:
if str(row.field_type or "").lower() != "file":
continue
try:
attachment_ids.append(UUID(str(row.value_text or "").strip()))
except Exception:
continue
attachment_map = {}
if attachment_ids:
attachment_rows = db.query(Attachment).filter(Attachment.id.in_(attachment_ids)).all() # type: ignore[name-defined]
attachment_map = {str(item.id): item for item in attachment_rows}
out = []
for index, row in enumerate(rows, start=1):
value_text = str(row.value_text or "").strip()
value_file = None
if str(row.field_type or "").lower() == "file" and value_text:
attachment = attachment_map.get(value_text)
if attachment is not None:
value_file = {
"attachment_id": str(attachment.id),
"file_name": attachment.file_name,
"mime_type": attachment.mime_type,
"size_bytes": int(attachment.size_bytes or 0),
"download_url": f"/api/admin/uploads/object/{attachment.id}",
}
out.append(
{
"id": str(row.id),
"request_message_id": str(row.request_message_id) if row.request_message_id else None,
"topic_template_id": str(row.topic_template_id) if row.topic_template_id else None,
"key": row.key,
"label": row.label,
"field_type": str(row.field_type or "text"),
"document_name": row.document_name,
"description": row.description,
"value_text": row.value_text,
"value_file": value_file,
"is_filled": bool(value_text),
"sort_order": int(row.sort_order or 0),
"index": index,
}
)
return out
@router.get("/requests/{request_id}/messages") @router.get("/requests/{request_id}/messages")
def list_request_messages( def list_request_messages(
request_id: str, request_id: str,
@ -61,7 +188,7 @@ def list_request_messages(
req = _request_for_id_or_404(db, request_id) req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_view_request_or_403(admin, req) _ensure_lawyer_can_view_request_or_403(admin, req)
rows = list_messages_for_request(db, req.id) rows = list_messages_for_request(db, req.id)
return {"rows": [serialize_message(row) for row in rows], "total": len(rows)} return {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)}
@router.post("/requests/{request_id}/messages", status_code=201) @router.post("/requests/{request_id}/messages", status_code=201)
@ -95,3 +222,464 @@ def create_request_message(
actor_admin_user_id=actor_admin_user_id, actor_admin_user_id=actor_admin_user_id,
) )
return serialize_message(row) return serialize_message(row)
@router.get("/requests/{request_id}/data-request-templates")
def list_data_request_templates(
request_id: str,
document: str | None = None,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_manage_request_or_403(admin, req)
topic_code = str(req.topic_code or "").strip()
if not topic_code:
return {"rows": [], "documents": [], "templates": []}
query = db.query(TopicDataTemplate).filter(TopicDataTemplate.topic_code == topic_code)
document_name = str(document or "").strip()
if document_name:
query = query.filter(TopicDataTemplate.document_name == document_name)
rows = (
query.order_by(
TopicDataTemplate.document_name.asc().nullsfirst(),
TopicDataTemplate.sort_order.asc(),
TopicDataTemplate.label.asc(),
TopicDataTemplate.key.asc(),
).all()
)
all_docs_rows = (
db.query(TopicDataTemplate.document_name)
.filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.enabled.is_(True))
.distinct()
.all()
)
documents = sorted({str(row[0]).strip() for row in all_docs_rows if row[0]}, key=lambda x: x.lower())
template_rows = (
db.query(RequestDataTemplate)
.filter(RequestDataTemplate.topic_code == topic_code, RequestDataTemplate.enabled.is_(True))
.order_by(RequestDataTemplate.sort_order.asc(), RequestDataTemplate.name.asc())
.all()
)
return {
"rows": [_serialize_template(row) for row in rows if row.enabled], # legacy catalog payload
"documents": documents, # legacy
"templates": [_serialize_request_data_template(row) for row in template_rows],
}
@router.get("/requests/{request_id}/data-requests/{message_id}")
def get_data_request_batch(
request_id: str,
message_id: str,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_view_request_or_403(admin, req)
msg_uuid = _parse_uuid_or_400(message_id, "message_id")
message = db.get(Message, msg_uuid)
if message is None or message.request_id != req.id:
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
rows = (
db.query(RequestDataRequirement)
.filter(
RequestDataRequirement.request_id == req.id,
RequestDataRequirement.request_message_id == msg_uuid,
)
.order_by(RequestDataRequirement.sort_order.asc(), RequestDataRequirement.created_at.asc(), RequestDataRequirement.id.asc())
.all()
)
if not rows:
raise HTTPException(status_code=404, detail="Набор данных для сообщения не найден")
return {
"message_id": str(message.id),
"request_id": str(req.id),
"track_number": req.track_number,
"document_name": rows[0].document_name if rows else None,
"items": _serialize_data_request_items(db, rows),
}
@router.get("/requests/{request_id}/data-request-templates/{template_id}")
def get_data_request_template(
request_id: str,
template_id: str,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_manage_request_or_403(admin, req)
template_uuid = _parse_uuid_or_400(template_id, "template_id")
template = db.get(RequestDataTemplate, template_uuid)
if template is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
if str(template.topic_code or "").strip() != str(req.topic_code or "").strip():
raise HTTPException(status_code=400, detail="Шаблон не соответствует теме заявки")
rows = (
db.query(RequestDataTemplateItem)
.filter(RequestDataTemplateItem.request_data_template_id == template.id)
.order_by(RequestDataTemplateItem.sort_order.asc(), RequestDataTemplateItem.created_at.asc(), RequestDataTemplateItem.id.asc())
.all()
)
return {
"template": _serialize_request_data_template(template),
"items": [_serialize_request_data_template_item(row) for row in rows],
}
@router.post("/requests/{request_id}/data-request-templates", status_code=201)
def save_data_request_template(
request_id: str,
payload: dict,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_manage_request_or_403(admin, req)
topic_code = str(req.topic_code or "").strip()
if not topic_code:
raise HTTPException(status_code=400, detail="У заявки не указана тема")
body = payload or {}
name = str(body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Укажите название шаблона")
raw_items = body.get("items")
if not isinstance(raw_items, list) or not raw_items:
raise HTTPException(status_code=400, detail="Шаблон должен содержать хотя бы одно поле")
actor_uuid = None
raw_actor = str(admin.get("sub") or "").strip()
if raw_actor:
try:
actor_uuid = UUID(raw_actor)
except ValueError:
actor_uuid = None
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
template = None
template_id_raw = str(body.get("template_id") or "").strip()
if template_id_raw:
template = db.get(RequestDataTemplate, _parse_uuid_or_400(template_id_raw, "template_id"))
if template is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
if str(template.topic_code or "").strip() != topic_code:
raise HTTPException(status_code=400, detail="Шаблон не соответствует теме заявки")
else:
template = (
db.query(RequestDataTemplate)
.filter(RequestDataTemplate.topic_code == topic_code, RequestDataTemplate.name == name)
.first()
)
if template is None:
template = RequestDataTemplate(
topic_code=topic_code,
name=name,
enabled=True,
sort_order=0,
created_by_admin_id=actor_uuid,
responsible=responsible,
)
db.add(template)
db.flush()
else:
actor_role = str(admin.get("role") or "").upper()
if actor_role == "LAWYER":
owner_id = str(template.created_by_admin_id or "").strip()
actor_id = str(actor_uuid or "").strip()
if owner_id and actor_id and owner_id != actor_id:
raise HTTPException(status_code=403, detail="Юрист может изменять только свои шаблоны")
template.name = name
template.responsible = responsible
db.add(template)
db.flush()
touched_keys: set[str] = set()
normalized_items: list[tuple[int, TopicDataTemplate | None, str, str, str]] = []
for index, item in enumerate(raw_items):
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail="Элемент шаблона должен быть объектом")
catalog = None
catalog_id_raw = str(item.get("topic_data_template_id") or item.get("topic_template_id") or "").strip()
if catalog_id_raw:
catalog = db.get(TopicDataTemplate, _parse_uuid_or_400(catalog_id_raw, "topic_data_template_id"))
if catalog is None:
raise HTTPException(status_code=400, detail="Поле справочника не найдено")
label = str(item.get("label") or (catalog.label if catalog else "")).strip()
if not label:
raise HTTPException(status_code=400, detail="Укажите наименование поля")
value_type = _normalize_value_type(item.get("value_type") or item.get("field_type") or (catalog.value_type if catalog else None))
key = str(item.get("key") or (catalog.key if catalog else "")).strip()
if not key:
key = _slugify_key(label)
key = key[:80]
if key in touched_keys:
raise HTTPException(status_code=400, detail=f'Поле "{label}" добавлено дважды')
touched_keys.add(key)
if catalog is None:
catalog = (
db.query(TopicDataTemplate)
.filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.key == key)
.first()
)
if catalog is None:
catalog = TopicDataTemplate(
topic_code=topic_code,
key=key,
label=label,
value_type=value_type,
enabled=True,
required=True,
sort_order=index,
responsible=responsible,
)
db.add(catalog)
db.flush()
else:
changed = False
if str(catalog.label or "") != label:
catalog.label = label
changed = True
if str(catalog.value_type or "string") != value_type:
catalog.value_type = value_type
changed = True
if changed:
catalog.responsible = responsible
db.add(catalog)
db.flush()
normalized_items.append((index, catalog, key, label, value_type))
existing_items = (
db.query(RequestDataTemplateItem)
.filter(RequestDataTemplateItem.request_data_template_id == template.id)
.all()
)
by_key = {str(row.key): row for row in existing_items}
for index, catalog, key, label, value_type in normalized_items:
row = by_key.get(key)
if row is None:
row = RequestDataTemplateItem(
request_data_template_id=template.id,
topic_data_template_id=catalog.id if catalog else None,
key=key,
label=label,
value_type=value_type,
sort_order=index,
responsible=responsible,
)
else:
row.topic_data_template_id = catalog.id if catalog else None
row.label = label
row.value_type = value_type
row.sort_order = index
row.responsible = responsible
db.add(row)
for row in existing_items:
if str(row.key) not in touched_keys:
db.delete(row)
db.commit()
db.refresh(template)
items = (
db.query(RequestDataTemplateItem)
.filter(RequestDataTemplateItem.request_data_template_id == template.id)
.order_by(RequestDataTemplateItem.sort_order.asc(), RequestDataTemplateItem.created_at.asc(), RequestDataTemplateItem.id.asc())
.all()
)
return {"template": _serialize_request_data_template(template), "items": [_serialize_request_data_template_item(row) for row in items]}
@router.post("/requests/{request_id}/data-requests", status_code=201)
def upsert_data_request_batch(
request_id: str,
payload: dict,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_manage_request_or_403(admin, req)
actor_role = str(admin.get("role") or "").strip().upper()
body = payload or {}
raw_items = body.get("items")
if not isinstance(raw_items, list) or not raw_items:
raise HTTPException(status_code=400, detail="Нужно передать список полей запроса")
message_id_raw = str(body.get("message_id") or "").strip()
existing_message = None
existing_message_rows: list[RequestDataRequirement] = []
if message_id_raw:
msg_uuid = _parse_uuid_or_400(message_id_raw, "message_id")
existing_message = db.get(Message, msg_uuid)
if existing_message is None or existing_message.request_id != req.id:
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
existing_message_rows = (
db.query(RequestDataRequirement)
.filter(
RequestDataRequirement.request_id == req.id,
RequestDataRequirement.request_message_id == existing_message.id,
)
.all()
)
else:
role = actor_role
actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор")
actor_admin_user_id = str(admin.get("sub") or "").strip() or None
if actor_admin_user_id:
try:
actor_uuid = UUID(actor_admin_user_id)
except ValueError:
actor_uuid = None
if actor_uuid is not None:
actor_user = db.get(AdminUser, actor_uuid)
if actor_user is not None:
actor_name = str(actor_user.name or actor_user.email or actor_name)
existing_message = create_admin_or_lawyer_message(
db,
request=req,
body="Запрос",
actor_role=role,
actor_name=actor_name,
actor_admin_user_id=actor_admin_user_id,
)
message_uuid = existing_message.id
topic_code = str(req.topic_code or "").strip()
document_name_default = str(body.get("document_name") or "").strip() or None
actor_uuid = None
raw_actor = str(admin.get("sub") or "").strip()
if raw_actor:
try:
actor_uuid = UUID(raw_actor)
except ValueError:
actor_uuid = None
normalized_rows: list[RequestDataRequirement] = []
touched_keys: set[str] = set()
for index, item in enumerate(raw_items):
if not isinstance(item, dict):
raise HTTPException(status_code=400, detail="Элемент списка полей должен быть объектом")
template = None
template_id_raw = str(item.get("topic_template_id") or "").strip()
if template_id_raw:
template = db.get(TopicDataTemplate, _parse_uuid_or_400(template_id_raw, "topic_template_id"))
if template is None:
raise HTTPException(status_code=400, detail="Шаблон дополнительного поля не найден")
label = str(item.get("label") or (template.label if template else "")).strip()
if not label:
raise HTTPException(status_code=400, detail="Укажите наименование поля")
field_type = _normalize_value_type(item.get("field_type") or (template.value_type if template else None))
doc_name = str(item.get("document_name") or (template.document_name if template else "") or (document_name_default or "")).strip() or None
key = str(item.get("key") or (template.key if template else "")).strip()
if not key:
key = _slugify_key(label)
key = key[:80]
if key in touched_keys:
raise HTTPException(status_code=400, detail=f'Поле "{label}" добавлено дважды')
touched_keys.add(key)
topic_template_id = None
if template is not None:
topic_template_id = template.id
elif topic_code:
existing_template = (
db.query(TopicDataTemplate)
.filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.key == key)
.first()
)
if existing_template is None:
existing_template = TopicDataTemplate(
topic_code=topic_code,
key=key,
label=label,
value_type=field_type,
document_name=doc_name,
enabled=True,
required=True,
sort_order=index,
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
)
db.add(existing_template)
db.flush()
else:
changed = False
if str(existing_template.label or "") != label:
existing_template.label = label
changed = True
if str(existing_template.value_type or "text") != field_type:
existing_template.value_type = field_type
changed = True
if str(existing_template.document_name or "") != str(doc_name or ""):
existing_template.document_name = doc_name
changed = True
if changed:
db.add(existing_template)
db.flush()
topic_template_id = existing_template.id
req_row = (
db.query(RequestDataRequirement)
.filter(RequestDataRequirement.request_id == req.id, RequestDataRequirement.key == key)
.first()
)
if req_row is None:
req_row = RequestDataRequirement(
request_id=req.id,
request_message_id=message_uuid,
topic_template_id=topic_template_id,
key=key,
label=label,
field_type=field_type,
document_name=doc_name,
required=True,
sort_order=index,
created_by_admin_id=actor_uuid,
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
)
else:
if actor_role == "LAWYER" and str(req_row.value_text or "").strip():
current_message_id = str(req_row.request_message_id or "")
incoming_message_id = str(message_uuid or "")
current_topic_template_id = str(req_row.topic_template_id or "")
incoming_topic_template_id = str(topic_template_id or "")
current_doc_name = str(req_row.document_name or "") if req_row.document_name is not None else ""
incoming_doc_name = str(doc_name or "")
if (
str(req_row.label or "") != label
or str(req_row.field_type or "text") != field_type
or current_doc_name != incoming_doc_name
or current_topic_template_id != incoming_topic_template_id
or current_message_id != incoming_message_id
or int(req_row.sort_order or 0) != int(index)
):
raise HTTPException(status_code=403, detail="Юрист не может изменять заполненные клиентом данные")
req_row.request_message_id = message_uuid
req_row.topic_template_id = topic_template_id
req_row.label = label
req_row.field_type = field_type
req_row.document_name = doc_name
req_row.sort_order = index
req_row.responsible = str(admin.get("email") or "").strip() or "Администратор системы"
db.add(req_row)
normalized_rows.append(req_row)
if message_id_raw:
if actor_role == "LAWYER":
for row in existing_message_rows:
if row.key not in touched_keys and str(row.value_text or "").strip():
raise HTTPException(status_code=403, detail="Юрист не может удалять заполненные клиентом данные")
for row in existing_message_rows:
if row.key not in touched_keys:
db.delete(row)
db.commit()
fresh_messages = list_messages_for_request(db, req.id)
serialized = serialize_messages_for_request(db, req.id, fresh_messages)
payload_row = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None)
if payload_row is None:
raise HTTPException(status_code=500, detail="Не удалось сформировать сообщение запроса")
return payload_row

View file

@ -4,7 +4,7 @@ import importlib
import json import json
import pkgutil import pkgutil
import uuid import uuid
from datetime import date, datetime, timezone from datetime import date, datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
from functools import lru_cache from functools import lru_cache
from typing import Any from typing import Any
@ -27,6 +27,8 @@ from app.models.form_field import FormField
from app.models.client import Client from app.models.client import Client
from app.models.table_availability import TableAvailability from app.models.table_availability import TableAvailability
from app.models.request_data_requirement import RequestDataRequirement from app.models.request_data_requirement import RequestDataRequirement
from app.models.request_data_template import RequestDataTemplate
from app.models.request_data_template_item import RequestDataTemplateItem
from app.models.attachment import Attachment 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
@ -75,6 +77,7 @@ REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid
REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"} REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"}
INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"} INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"}
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"} ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
ALLOWED_REQUEST_DATA_VALUE_TYPES = {"string", "text", "date", "number", "file"}
# Per-table RBAC: table -> role -> actions. # Per-table RBAC: table -> role -> actions.
# If a table is missing here, fallback rules are used. # If a table is missing here, fallback rules are used.
@ -103,9 +106,12 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
"otp_sessions": {"ADMIN": {"query", "read"}}, "otp_sessions": {"ADMIN": {"query", "read"}},
"admin_users": {"ADMIN": set(CRUD_ACTIONS)}, "admin_users": {"ADMIN": set(CRUD_ACTIONS)},
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)}, "admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
"landing_featured_staff": {"ADMIN": set(CRUD_ACTIONS)},
"topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)}, "topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)},
"topic_required_fields": {"ADMIN": set(CRUD_ACTIONS)}, "topic_required_fields": {"ADMIN": set(CRUD_ACTIONS)},
"topic_data_templates": {"ADMIN": set(CRUD_ACTIONS)}, "topic_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
"request_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
"request_data_template_items": {"ADMIN": set(CRUD_ACTIONS)},
"request_data_requirements": {"ADMIN": set(CRUD_ACTIONS)}, "request_data_requirements": {"ADMIN": set(CRUD_ACTIONS)},
"notifications": {"ADMIN": {"query", "read", "update"}}, "notifications": {"ADMIN": {"query", "read", "update"}},
} }
@ -264,10 +270,13 @@ def _table_label(table_name: str) -> str:
"clients": "Клиенты", "clients": "Клиенты",
"table_availability": "Доступность таблиц", "table_availability": "Доступность таблиц",
"topic_required_fields": "Обязательные поля темы", "topic_required_fields": "Обязательные поля темы",
"topic_data_templates": "Шаблоны данных темы", "topic_data_templates": "Дополнительные данные",
"request_data_templates": "Шаблоны доп. данных",
"request_data_template_items": "Набор данных шаблона",
"topic_status_transitions": "Переходы статусов темы", "topic_status_transitions": "Переходы статусов темы",
"admin_users": "Пользователи", "admin_users": "Пользователи",
"admin_user_topics": "Дополнительные темы юристов", "admin_user_topics": "Дополнительные темы юристов",
"landing_featured_staff": "Карусель сотрудников лендинга",
"attachments": "Вложения", "attachments": "Вложения",
"messages": "Сообщения", "messages": "Сообщения",
"audit_log": "Журнал аудита", "audit_log": "Журнал аудита",
@ -355,8 +364,16 @@ def _column_label(table_name: str, column_name: str) -> str:
"key": "Ключ", "key": "Ключ",
"name": "Название", "name": "Название",
"label": "Метка", "label": "Метка",
"caption": "Подпись",
"value_type": "Тип значения",
"document_name": "Документ",
"request_data_template_id": "Шаблон",
"request_data_template_item_id": "Элемент шаблона",
"text": "Текст", "text": "Текст",
"description": "Описание", "description": "Описание",
"request_message_id": "ID сообщения запроса",
"field_type": "Тип поля",
"value_text": "Данные",
"author": "Автор", "author": "Автор",
"source": "Источник", "source": "Источник",
"email": "Email", "email": "Email",
@ -384,6 +401,7 @@ def _column_label(table_name: str, column_name: str) -> str:
"updated_at": "Дата обновления", "updated_at": "Дата обновления",
"responsible": "Ответственный", "responsible": "Ответственный",
"sort_order": "Порядок", "sort_order": "Порядок",
"pinned": "Закреплен",
"is_active": "Активен", "is_active": "Активен",
"enabled": "Активен", "enabled": "Активен",
"required": "Обязательное", "required": "Обязательное",
@ -396,6 +414,7 @@ def _column_label(table_name: str, column_name: str) -> str:
"primary_topic_code": "Профильная тема", "primary_topic_code": "Профильная тема",
"default_rate": "Ставка по умолчанию", "default_rate": "Ставка по умолчанию",
"effective_rate": "Ставка (фикс.)", "effective_rate": "Ставка (фикс.)",
"request_cost": "Стоимость заявки",
"salary_percent": "Процент зарплаты", "salary_percent": "Процент зарплаты",
"invoice_amount": "Сумма счета", "invoice_amount": "Сумма счета",
"paid_by_admin_id": "Оплату подтвердил", "paid_by_admin_id": "Оплату подтвердил",
@ -468,12 +487,17 @@ def _reference_override(table_name: str, column_name: str) -> tuple[str, str] |
("topic_required_fields", "topic_code"): ("topics", "code"), ("topic_required_fields", "topic_code"): ("topics", "code"),
("topic_required_fields", "field_key"): ("form_fields", "key"), ("topic_required_fields", "field_key"): ("form_fields", "key"),
("topic_data_templates", "topic_code"): ("topics", "code"), ("topic_data_templates", "topic_code"): ("topics", "code"),
("request_data_templates", "topic_code"): ("topics", "code"),
("request_data_templates", "created_by_admin_id"): ("admin_users", "id"),
("request_data_template_items", "request_data_template_id"): ("request_data_templates", "id"),
("request_data_template_items", "topic_data_template_id"): ("topic_data_templates", "id"),
("topic_status_transitions", "topic_code"): ("topics", "code"), ("topic_status_transitions", "topic_code"): ("topics", "code"),
("topic_status_transitions", "from_status"): ("statuses", "code"), ("topic_status_transitions", "from_status"): ("statuses", "code"),
("topic_status_transitions", "to_status"): ("statuses", "code"), ("topic_status_transitions", "to_status"): ("statuses", "code"),
("admin_users", "primary_topic_code"): ("topics", "code"), ("admin_users", "primary_topic_code"): ("topics", "code"),
("admin_user_topics", "admin_user_id"): ("admin_users", "id"), ("admin_user_topics", "admin_user_id"): ("admin_users", "id"),
("admin_user_topics", "topic_code"): ("topics", "code"), ("admin_user_topics", "topic_code"): ("topics", "code"),
("landing_featured_staff", "admin_user_id"): ("admin_users", "id"),
("request_data_requirements", "request_id"): ("requests", "id"), ("request_data_requirements", "request_id"): ("requests", "id"),
("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"), ("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"),
("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"), ("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"),
@ -530,6 +554,8 @@ def _reference_label_field(table_name: str, value_field: str) -> str:
"status_groups": "name", "status_groups": "name",
"form_fields": "label", "form_fields": "label",
"topic_data_templates": "label", "topic_data_templates": "label",
"request_data_templates": "name",
"request_data_template_items": "label",
"invoices": "invoice_number", "invoices": "invoice_number",
"messages": "body", "messages": "body",
"attachments": "file_name", "attachments": "file_name",
@ -784,6 +810,8 @@ def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, An
raise HTTPException(status_code=400, detail="Email обязателен") raise HTTPException(status_code=400, detail="Email обязателен")
data["email"] = email data["email"] = email
data["role"] = role data["role"] = role
if "phone" in data:
data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone")))
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url")) data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code")) data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
data["password_hash"] = hash_password(raw_password) data["password_hash"] = hash_password(raw_password)
@ -809,6 +837,8 @@ def _apply_admin_user_fields_for_update(payload: dict[str, Any]) -> dict[str, An
if not email: if not email:
raise HTTPException(status_code=400, detail="Email не может быть пустым") raise HTTPException(status_code=400, detail="Email не может быть пустым")
data["email"] = email data["email"] = email
if "phone" in data:
data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone")))
if "avatar_url" in data: if "avatar_url" in data:
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url")) data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
if "primary_topic_code" in data: if "primary_topic_code" in data:
@ -936,6 +966,88 @@ def _apply_topic_data_templates_fields(db: Session, payload: dict[str, Any]) ->
if not key: if not key:
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым') raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
data["key"] = key data["key"] = key
if "value_type" in data:
value_type = str(data.get("value_type") or "").strip().lower()
if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file')
data["value_type"] = value_type
if "document_name" in data:
data["document_name"] = _normalize_optional_string(data.get("document_name"))
return data
def _apply_request_data_templates_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if "topic_code" in data:
topic_code = str(data.get("topic_code") or "").strip()
if not topic_code:
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
_ensure_topic_exists_or_400(db, topic_code)
data["topic_code"] = topic_code
if "name" in data:
name = str(data.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail='Поле "name" не может быть пустым')
data["name"] = name
if "description" in data:
data["description"] = _normalize_optional_string(data.get("description"))
if "created_by_admin_id" in data and data.get("created_by_admin_id") is not None:
admin_id = _parse_uuid_or_400(data.get("created_by_admin_id"), "created_by_admin_id")
admin_user = db.get(AdminUser, admin_id)
if admin_user is None:
raise HTTPException(status_code=400, detail="Пользователь не найден")
data["created_by_admin_id"] = admin_id
return data
def _apply_request_data_template_items_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
template = None
if "request_data_template_id" in data:
template_id = _parse_uuid_or_400(data.get("request_data_template_id"), "request_data_template_id")
template = db.get(RequestDataTemplate, template_id)
if template is None:
raise HTTPException(status_code=400, detail="Шаблон не найден")
data["request_data_template_id"] = template_id
if "topic_data_template_id" in data and data.get("topic_data_template_id") is not None:
catalog_id = _parse_uuid_or_400(data.get("topic_data_template_id"), "topic_data_template_id")
catalog = db.get(TopicDataTemplate, catalog_id)
if catalog is None:
raise HTTPException(status_code=400, detail="Поле доп. данных не найдено")
data["topic_data_template_id"] = catalog_id
if "key" not in data or not str(data.get("key") or "").strip():
data["key"] = str(catalog.key or "").strip()
if "label" not in data or not str(data.get("label") or "").strip():
data["label"] = str(catalog.label or catalog.key or "").strip()
if "value_type" not in data or not str(data.get("value_type") or "").strip():
data["value_type"] = str(catalog.value_type or "string")
if template is not None and str(template.topic_code or "").strip() and str(catalog.topic_code or "").strip():
if str(template.topic_code) != str(catalog.topic_code):
raise HTTPException(status_code=400, detail="Поле не соответствует теме шаблона")
if "key" in data:
key = str(data.get("key") or "").strip()
if not key:
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
data["key"] = key[:80]
if "label" in data:
label = str(data.get("label") or "").strip()
if not label:
raise HTTPException(status_code=400, detail='Поле "label" не может быть пустым')
data["label"] = label
if "value_type" in data:
value_type = str(data.get("value_type") or "").strip().lower()
if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file')
data["value_type"] = value_type
if "sort_order" in data:
raw = data.get("sort_order")
if raw is None or str(raw).strip() == "":
data["sort_order"] = 0
else:
try:
data["sort_order"] = int(raw)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом')
return data return data
@ -953,11 +1065,31 @@ def _apply_request_data_requirements_fields(db: Session, payload: dict[str, Any]
if template is None: if template is None:
raise HTTPException(status_code=400, detail="Шаблон темы не найден") raise HTTPException(status_code=400, detail="Шаблон темы не найден")
data["topic_template_id"] = template_id data["topic_template_id"] = template_id
if "request_message_id" in data and data.get("request_message_id") is not None:
data["request_message_id"] = _parse_uuid_or_400(data.get("request_message_id"), "request_message_id")
if "key" in data: if "key" in data:
key = str(data.get("key") or "").strip() key = str(data.get("key") or "").strip()
if not key: if not key:
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым') raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
data["key"] = key data["key"] = key
if "field_type" in data:
field_type = str(data.get("field_type") or "").strip().lower()
if field_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
raise HTTPException(status_code=400, detail='Поле "field_type" должно быть одним из: string, text, date, number, file')
data["field_type"] = field_type
if "document_name" in data:
data["document_name"] = _normalize_optional_string(data.get("document_name"))
if "value_text" in data:
data["value_text"] = _normalize_optional_string(data.get("value_text"))
if "sort_order" in data:
raw_sort = data.get("sort_order")
if raw_sort is None or str(raw_sort).strip() == "":
data["sort_order"] = 0
else:
try:
data["sort_order"] = int(raw_sort)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом')
return data return data
@ -1416,7 +1548,20 @@ def get_row(
if normalized == "attachments" and isinstance(row, Attachment): if normalized == "attachments" and isinstance(row, Attachment):
req = _request_for_related_row_or_404(db, row) req = _request_for_related_row_or_404(db, row)
_ensure_lawyer_can_view_request_or_403(admin, req) _ensure_lawyer_can_view_request_or_403(admin, req)
return _strip_hidden_fields(normalized, _row_to_dict(row)) payload = _strip_hidden_fields(normalized, _row_to_dict(row))
if normalized == "requests" and isinstance(row, Request):
assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
if assigned_lawyer_id:
try:
lawyer_uuid = uuid.UUID(assigned_lawyer_id)
except ValueError:
lawyer_uuid = None
if lawyer_uuid is not None:
lawyer = db.get(AdminUser, lawyer_uuid)
if lawyer is not None:
payload["assigned_lawyer_name"] = lawyer.name or lawyer.email or assigned_lawyer_id
payload["assigned_lawyer_phone"] = _serialize_value(getattr(lawyer, "phone", None))
return payload
@router.post("/{table_name}", status_code=201) @router.post("/{table_name}", status_code=201)
@ -1490,6 +1635,10 @@ def create_row(
clean_payload = _apply_topic_required_fields_fields(db, clean_payload) clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
if normalized == "topic_data_templates": if normalized == "topic_data_templates":
clean_payload = _apply_topic_data_templates_fields(db, clean_payload) clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
if normalized == "request_data_templates":
clean_payload = _apply_request_data_templates_fields(db, clean_payload)
if normalized == "request_data_template_items":
clean_payload = _apply_request_data_template_items_fields(db, clean_payload)
if normalized == "request_data_requirements": if normalized == "request_data_requirements":
clean_payload = _apply_request_data_requirements_fields(db, clean_payload) clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
if normalized == "topic_status_transitions": if normalized == "topic_status_transitions":
@ -1557,6 +1706,10 @@ def update_row(
clean_payload = _apply_topic_required_fields_fields(db, clean_payload) clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
if normalized == "topic_data_templates": if normalized == "topic_data_templates":
clean_payload = _apply_topic_data_templates_fields(db, clean_payload) clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
if normalized == "request_data_templates":
clean_payload = _apply_request_data_templates_fields(db, clean_payload)
if normalized == "request_data_template_items":
clean_payload = _apply_request_data_template_items_fields(db, clean_payload)
if normalized == "request_data_requirements": if normalized == "request_data_requirements":
clean_payload = _apply_request_data_requirements_fields(db, clean_payload) clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
if normalized == "topic_status_transitions": if normalized == "topic_status_transitions":
@ -1603,23 +1756,9 @@ def update_row(
if normalized == "requests" and "status_code" in clean_payload: if normalized == "requests" and "status_code" in clean_payload:
before_status = str(before.get("status_code") or "") before_status = str(before.get("status_code") or "")
after_status = str(clean_payload.get("status_code") or "") after_status = str(clean_payload.get("status_code") or "")
topic_code = str(before.get("topic_code") or "").strip() or None
if not transition_allowed_for_topic(db, topic_code, before_status, after_status):
raise HTTPException(
status_code=400,
detail="Переход статуса не разрешен для выбранной темы",
)
if before_status != after_status and isinstance(row, Request): if before_status != after_status and isinstance(row, Request):
extra_fields_override = clean_payload.get("extra_fields") if "important_date_at" not in clean_payload or clean_payload.get("important_date_at") is None:
if not isinstance(extra_fields_override, dict): clean_payload["important_date_at"] = datetime.now(timezone.utc) + timedelta(days=3)
extra_fields_override = row.extra_fields if isinstance(row.extra_fields, dict) else None
validate_transition_requirements_or_400(
db,
row,
from_status=before_status,
to_status=after_status,
extra_fields_override=extra_fields_override,
)
billing_note = apply_billing_transition_effects( billing_note = apply_billing_transition_effects(
db, db,
req=row, req=row,
@ -1635,6 +1774,7 @@ def update_row(
from_status=before_status, from_status=before_status,
to_status=after_status, to_status=after_status,
admin=admin, admin=admin,
important_date_at=clean_payload.get("important_date_at"),
responsible=responsible, responsible=responsible,
) )
notify_request_event( notify_request_event(
@ -1643,7 +1783,15 @@ def update_row(
event_type=NOTIFICATION_EVENT_STATUS, event_type=NOTIFICATION_EVENT_STATUS,
actor_role=_actor_role(admin), actor_role=_actor_role(admin),
actor_admin_user_id=admin.get("sub"), actor_admin_user_id=admin.get("sub"),
body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")), body=(
f"{before_status} -> {after_status}"
+ (
f"\nВажная дата: {clean_payload.get('important_date_at').isoformat()}"
if isinstance(clean_payload.get("important_date_at"), datetime)
else ""
)
+ (f"\n{billing_note}" if billing_note else "")
),
responsible=responsible, responsible=responsible,
) )
for key, value in clean_payload.items(): for key, value in clean_payload.items():

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
from uuid import UUID from uuid import UUID
@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.core.deps import require_role from app.core.deps import require_role
from app.db.session import get_db from app.db.session import get_db
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.audit_log import AuditLog
from app.models.request import Request from app.models.request import Request
from app.models.status import Status from app.models.status import Status
from app.models.status_history import StatusHistory from app.models.status_history import StatusHistory
@ -59,6 +60,33 @@ def _uuid_or_none(value: str | None) -> UUID | None:
return None return None
def _extract_assigned_lawyer_from_audit(diff: dict | None, action: str | None) -> str | None:
if not isinstance(diff, dict):
return None
action_code = str(action or "").upper()
if action_code == "MANUAL_CLAIM":
value = diff.get("assigned_lawyer_id")
return str(value).strip() if value else None
if action_code == "MANUAL_REASSIGN":
value = diff.get("to_lawyer_id")
return str(value).strip() if value else None
if action_code in {"CREATE", "UPDATE"}:
after = diff.get("after")
before = diff.get("before")
if action_code == "UPDATE":
if not isinstance(after, dict) or not isinstance(before, dict):
return None
prev_value = str(before.get("assigned_lawyer_id") or "").strip()
next_value = str(after.get("assigned_lawyer_id") or "").strip()
if not next_value or next_value == prev_value:
return None
return next_value
if isinstance(after, dict):
value = str(after.get("assigned_lawyer_id") or "").strip()
return value or None
return None
@router.get("/overview") @router.get("/overview")
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
role = str(admin.get("role") or "").upper() role = str(admin.get("role") or "").upper()
@ -123,6 +151,32 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
paid_events_map = {str(lawyer_id): int(events) for lawyer_id, events, _ in paid_rows if lawyer_id} paid_events_map = {str(lawyer_id): int(events) for lawyer_id, events, _ in paid_rows if lawyer_id}
monthly_gross_map = {str(lawyer_id): _to_float(gross) for lawyer_id, _, gross in paid_rows if lawyer_id} monthly_gross_map = {str(lawyer_id): _to_float(gross) for lawyer_id, _, gross in paid_rows if lawyer_id}
monthly_completed_rows = (
db.query(Request.assigned_lawyer_id, func.count(func.distinct(StatusHistory.request_id)))
.join(StatusHistory, StatusHistory.request_id == Request.id)
.filter(Request.assigned_lawyer_id.is_not(None))
.filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start)
.filter(func.upper(StatusHistory.to_status).in_({str(code).upper() for code in terminal_codes}))
.group_by(Request.assigned_lawyer_id)
.all()
)
monthly_completed_map = {str(lawyer_id): int(count) for lawyer_id, count in monthly_completed_rows if lawyer_id}
monthly_assigned_map: dict[str, int] = {}
audit_rows = (
db.query(AuditLog.action, AuditLog.diff)
.filter(AuditLog.entity == "requests")
.filter(AuditLog.created_at >= month_start, AuditLog.created_at < next_month_start)
.all()
)
for action, diff in audit_rows:
assigned_to = _extract_assigned_lawyer_from_audit(diff, action)
if not assigned_to:
continue
monthly_assigned_map[assigned_to] = int(monthly_assigned_map.get(assigned_to, 0)) + 1
monthly_revenue = round(sum(monthly_gross_map.values()), 2)
lawyers = ( lawyers = (
db.query(AdminUser) db.query(AdminUser)
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True)) .filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
@ -146,6 +200,8 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
"active_load": active_load_map.get(lawyer_id, 0), "active_load": active_load_map.get(lawyer_id, 0),
"total_assigned": total_load_map.get(lawyer_id, 0), "total_assigned": total_load_map.get(lawyer_id, 0),
"active_amount": round(active_amount_map.get(lawyer_id, 0.0), 2), "active_amount": round(active_amount_map.get(lawyer_id, 0.0), 2),
"monthly_assigned_count": monthly_assigned_map.get(lawyer_id, 0),
"monthly_completed_count": monthly_completed_map.get(lawyer_id, 0),
"monthly_paid_events": paid_events_map.get(lawyer_id, 0), "monthly_paid_events": paid_events_map.get(lawyer_id, 0),
"monthly_paid_gross": round(monthly_paid_gross, 2), "monthly_paid_gross": round(monthly_paid_gross, 2),
"monthly_salary": round(monthly_salary, 2), "monthly_salary": round(monthly_salary, 2),
@ -221,6 +277,18 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
scoped_lawyer_loads = lawyer_loads scoped_lawyer_loads = lawyer_loads
sla_snapshot = compute_sla_snapshot(db) 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)
deadline_alert_query = (
db.query(func.count(Request.id))
.filter(Request.important_date_at.is_not(None))
.filter(Request.important_date_at < next_day_start)
.filter(Request.status_code.notin_(terminal_codes))
)
if role == "LAWYER" and actor_uuid is not None:
deadline_alert_query = deadline_alert_query.filter(Request.assigned_lawyer_id == str(actor_uuid))
elif role == "LAWYER":
deadline_alert_query = deadline_alert_query.filter(Request.id.is_(None))
deadline_alert_total = int(deadline_alert_query.scalar() or 0)
return { return {
"scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN", "scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN",
"new": int(by_status.get("NEW", 0)), "new": int(by_status.get("NEW", 0)),
@ -230,6 +298,11 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
"unassigned_total": unassigned_total, "unassigned_total": unassigned_total,
"my_unread_updates": my_unread_updates, "my_unread_updates": my_unread_updates,
"my_unread_by_event": my_unread_by_event, "my_unread_by_event": my_unread_by_event,
"deadline_alert_total": deadline_alert_total,
"month_revenue": monthly_revenue,
"month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2)
if role == "LAWYER"
else round(sum(_to_float(row.get("monthly_salary")) for row in lawyer_loads), 2),
"frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"), "frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"),
"sla_overdue": sla_snapshot.get("overdue_total", 0), "sla_overdue": sla_snapshot.get("overdue_total", 0),
"overdue_by_status": sla_snapshot.get("overdue_by_status", {}), "overdue_by_status": sla_snapshot.get("overdue_by_status", {}),
@ -239,3 +312,79 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
"unread_for_lawyers": int(unread_for_lawyers), "unread_for_lawyers": int(unread_for_lawyers),
"lawyer_loads": scoped_lawyer_loads, "lawyer_loads": scoped_lawyer_loads,
} }
@router.get("/lawyers/{lawyer_id}/active-requests")
def lawyer_active_requests(
lawyer_id: str,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
):
actor_role = str(admin.get("role") or "").upper()
actor_id = str(admin.get("sub") or "").strip()
if actor_role == "LAWYER" and str(lawyer_id) != actor_id:
return {"rows": [], "total": 0, "totals": {"amount": 0.0, "salary": 0.0}}
lawyer = db.query(AdminUser).filter(AdminUser.id == _uuid_or_none(lawyer_id), AdminUser.role == "LAWYER").first()
if lawyer is None:
return {"rows": [], "total": 0, "totals": {"amount": 0.0, "salary": 0.0}}
terminal_codes = _terminal_status_codes(db)
paid_codes = _paid_status_codes()
now_utc = datetime.now(timezone.utc)
month_start, next_month_start = _month_bounds(now_utc)
salary_percent = _to_float(lawyer.salary_percent)
paid_by_request_rows = (
db.query(StatusHistory.request_id, func.count(StatusHistory.id))
.join(Request, Request.id == StatusHistory.request_id)
.filter(Request.assigned_lawyer_id == str(lawyer.id))
.filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start)
.filter(func.upper(StatusHistory.to_status).in_(paid_codes))
.group_by(StatusHistory.request_id)
.all()
)
paid_events_per_request = {str(req_id): int(count) for req_id, count in paid_by_request_rows if req_id}
request_rows = (
db.query(Request)
.filter(Request.assigned_lawyer_id == str(lawyer.id))
.filter(Request.status_code.notin_(terminal_codes))
.order_by(Request.created_at.desc(), Request.track_number.asc())
.all()
)
rows = []
total_amount = 0.0
total_salary = 0.0
for req in request_rows:
req_id = str(req.id)
invoice_amount = _to_float(req.invoice_amount)
paid_events = int(paid_events_per_request.get(req_id, 0))
month_paid_amount = round(invoice_amount * paid_events, 2)
month_salary_amount = round(month_paid_amount * salary_percent / 100.0, 2)
total_amount += month_paid_amount
total_salary += month_salary_amount
rows.append(
{
"id": req_id,
"track_number": req.track_number,
"status_code": req.status_code,
"client_name": req.client_name,
"invoice_amount": round(invoice_amount, 2),
"month_paid_events": paid_events,
"month_paid_amount": month_paid_amount,
"month_salary_amount": month_salary_amount,
"created_at": req.created_at.isoformat() if req.created_at else None,
"paid_at": req.paid_at.isoformat() if req.paid_at else None,
}
)
return {
"rows": rows,
"total": len(rows),
"totals": {
"amount": round(total_amount, 2),
"salary": round(total_salary, 2),
},
}

View file

@ -16,16 +16,17 @@ from app.schemas.admin import (
RequestDataRequirementCreate, RequestDataRequirementCreate,
RequestDataRequirementPatch, RequestDataRequirementPatch,
RequestReassign, RequestReassign,
RequestStatusChange,
) )
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.client import Client
from app.models.request_data_requirement import RequestDataRequirement from app.models.request_data_requirement import RequestDataRequirement
from app.models.request import Request from app.models.request import Request
from app.models.status import Status from app.models.status import Status
from app.models.status_group import StatusGroup from app.models.status_group import StatusGroup
from app.models.status_history import StatusHistory from app.models.status_history import StatusHistory
from app.models.topic_data_template import TopicDataTemplate from app.models.topic_data_template import TopicDataTemplate
from app.models.topic_status_transition import TopicStatusTransition
from app.services.notifications import ( from app.services.notifications import (
EVENT_STATUS as NOTIFICATION_EVENT_STATUS, EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
mark_admin_notifications_read, mark_admin_notifications_read,
@ -33,9 +34,8 @@ from app.services.notifications import (
) )
from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client
from app.services.request_status import actor_admin_uuid, apply_status_change_effects from app.services.request_status import actor_admin_uuid, apply_status_change_effects
from app.services.status_flow import transition_allowed_for_topic
from app.services.request_templates import validate_required_topic_fields_or_400 from app.services.request_templates import validate_required_topic_fields_or_400
from app.services.status_transition_requirements import normalize_string_list, validate_transition_requirements_or_400 from app.services.status_transition_requirements import normalize_string_list
from app.services.billing_flow import apply_billing_transition_effects from app.services.billing_flow import apply_billing_transition_effects
from app.services.universal_query import apply_universal_query from app.services.universal_query import apply_universal_query
@ -106,6 +106,149 @@ def _parse_datetime_safe(value: object) -> datetime | None:
return parsed return parsed
def _normalize_important_date_or_default(raw: object, *, default_days: int = 3) -> datetime:
parsed = _parse_datetime_safe(raw)
if parsed:
return parsed
return datetime.now(timezone.utc) + timedelta(days=default_days)
def _terminal_status_codes(db: Session) -> set[str]:
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
codes = {str(code or "").strip() for (code,) in rows if str(code or "").strip()}
return codes or {"RESOLVED", "CLOSED", "REJECTED"}
def _coerce_request_bool_filter_or_400(value: object) -> bool:
if isinstance(value, bool):
return value
text = str(value or "").strip().lower()
if text in {"1", "true", "yes", "y", "да"}:
return True
if text in {"0", "false", "no", "n", "нет"}:
return False
raise HTTPException(status_code=400, detail="Значение фильтра должно быть boolean")
def _split_request_special_filters(uq: UniversalQuery) -> tuple[UniversalQuery, list[FilterClause]]:
filters = list(uq.filters or [])
special: list[FilterClause] = []
regular: list[FilterClause] = []
for clause in filters:
field = str(getattr(clause, "field", "") or "").strip()
if field in {"has_unread_updates", "deadline_alert"}:
special.append(clause)
else:
regular.append(clause)
return UniversalQuery(filters=regular, sort=list(uq.sort or []), page=uq.page), special
def _apply_request_special_filters(
base_query,
*,
db: Session,
role: str,
actor_id: str,
special_filters: list[FilterClause],
):
if not special_filters:
return base_query
terminal_codes_cache: set[str] | None = None
for clause in special_filters:
field = str(clause.field or "").strip()
op = str(clause.op or "").strip()
if op not in {"=", "!="}:
raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"')
expected = _coerce_request_bool_filter_or_400(clause.value)
if field == "has_unread_updates":
if role == "LAWYER":
expr = Request.lawyer_has_unread_updates.is_(True)
else:
expr = or_(
Request.lawyer_has_unread_updates.is_(True),
Request.client_has_unread_updates.is_(True),
)
elif field == "deadline_alert":
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)
if terminal_codes_cache is None:
terminal_codes_cache = _terminal_status_codes(db)
expr = (
Request.important_date_at.is_not(None)
& (Request.important_date_at < next_day_start)
& (Request.status_code.notin_(terminal_codes_cache))
)
if role == "LAWYER":
expr = expr & (Request.assigned_lawyer_id == actor_id)
else:
continue
base_query = base_query.filter(expr if expected else ~expr)
return base_query
def _normalize_client_phone(value: object) -> str:
text = "".join(ch for ch in str(value or "") if ch.isdigit() or ch == "+")
if not text:
return ""
if text.startswith("8") and len(text) == 11:
text = "+7" + text[1:]
if not text.startswith("+") and text.isdigit():
text = "+" + text
return text
def _client_uuid_or_none(value: object) -> UUID | None:
raw = str(value or "").strip()
if not raw:
return None
try:
return UUID(raw)
except ValueError:
raise HTTPException(status_code=400, detail='Некорректный "client_id"')
def _client_for_request_payload_or_400(
db: Session,
*,
client_id: object,
client_name: object,
client_phone: object,
responsible: str,
) -> Client:
client_uuid = _client_uuid_or_none(client_id)
if client_uuid is not None:
row = db.get(Client, client_uuid)
if row is None:
raise HTTPException(status_code=404, detail="Клиент не найден")
return row
normalized_phone = _normalize_client_phone(client_phone)
if not normalized_phone:
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно')
normalized_name = str(client_name or "").strip() or "Клиент"
row = db.query(Client).filter(Client.phone == normalized_phone).first()
if row is None:
row = Client(
full_name=normalized_name,
phone=normalized_phone,
responsible=responsible,
)
db.add(row)
db.flush()
return row
changed = False
if normalized_name and row.full_name != normalized_name:
row.full_name = normalized_name
changed = True
if changed:
row.responsible = responsible
db.add(row)
db.flush()
return row
def _extract_case_deadline(extra_fields: object) -> datetime | None: def _extract_case_deadline(extra_fields: object) -> datetime | None:
if not isinstance(extra_fields, dict): if not isinstance(extra_fields, dict):
return None return None
@ -281,8 +424,8 @@ def _request_data_requirement_row(row: RequestDataRequirement) -> dict:
def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))): def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
base_query = db.query(Request) base_query = db.query(Request)
role = str(admin.get("role") or "").upper() role = str(admin.get("role") or "").upper()
actor = str(admin.get("sub") or "").strip()
if role == "LAWYER": if role == "LAWYER":
actor = str(admin.get("sub") or "").strip()
if not actor: if not actor:
raise HTTPException(status_code=401, detail="Некорректный токен") raise HTTPException(status_code=401, detail="Некорректный токен")
base_query = base_query.filter( base_query = base_query.filter(
@ -292,7 +435,15 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
) )
) )
q = apply_universal_query(base_query, Request, uq) regular_uq, special_filters = _split_request_special_filters(uq)
base_query = _apply_request_special_filters(
base_query,
db=db,
role=role,
actor_id=actor,
special_filters=special_filters,
)
q = apply_universal_query(base_query, Request, regular_uq)
total = q.count() total = q.count()
rows = q.offset(uq.page.offset).limit(uq.page.limit).all() rows = q.offset(uq.page.offset).limit(uq.page.limit).all()
return { return {
@ -300,11 +451,14 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
{ {
"id": str(r.id), "id": str(r.id),
"track_number": r.track_number, "track_number": r.track_number,
"client_id": str(r.client_id) if r.client_id else None,
"status_code": r.status_code, "status_code": r.status_code,
"client_name": r.client_name, "client_name": r.client_name,
"client_phone": r.client_phone, "client_phone": r.client_phone,
"topic_code": r.topic_code, "topic_code": r.topic_code,
"important_date_at": r.important_date_at.isoformat() if r.important_date_at else None,
"effective_rate": float(r.effective_rate) if r.effective_rate is not None else None, "effective_rate": float(r.effective_rate) if r.effective_rate is not None else None,
"request_cost": float(r.request_cost) if r.request_cost is not None else None,
"invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None, "invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None,
"paid_at": r.paid_at.isoformat() if r.paid_at else None, "paid_at": r.paid_at.isoformat() if r.paid_at else None,
"paid_by_admin_id": r.paid_by_admin_id, "paid_by_admin_id": r.paid_by_admin_id,
@ -360,33 +514,8 @@ def get_requests_kanban(
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]
topic_codes = {str(row.topic_code or "").strip() for row in request_rows if str(row.topic_code or "").strip()}
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()}
transition_rows: list[TopicStatusTransition] = []
if topic_codes:
transition_rows = (
db.query(TopicStatusTransition)
.filter(
TopicStatusTransition.enabled.is_(True),
TopicStatusTransition.topic_code.in_(list(topic_codes)),
)
.order_by(
TopicStatusTransition.topic_code.asc(),
TopicStatusTransition.from_status.asc(),
TopicStatusTransition.sort_order.asc(),
TopicStatusTransition.to_status.asc(),
)
.all()
)
for row in transition_rows:
from_code = str(row.from_status or "").strip()
to_code = str(row.to_status or "").strip()
if from_code:
status_codes.add(from_code)
if to_code:
status_codes.add(to_code)
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 = (
@ -448,16 +577,30 @@ def get_requests_kanban(
current_status_changed_at[request_id] = row.created_at current_status_changed_at[request_id] = row.created_at
previous_status_by_request[request_id] = str(row.from_status or "").strip() previous_status_by_request[request_id] = str(row.from_status or "").strip()
transitions_by_key: dict[tuple[str, str], list[TopicStatusTransition]] = {} all_enabled_status_rows = (
transitions_to_key: dict[tuple[str, str], list[TopicStatusTransition]] = {} db.query(Status, StatusGroup)
for row in transition_rows: .outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
topic_code = str(row.topic_code or "").strip() .filter(Status.enabled.is_(True))
from_status = str(row.from_status or "").strip() .order_by(Status.sort_order.asc(), Status.name.asc(), Status.code.asc())
to_status = str(row.to_status or "").strip() .all()
if not topic_code or not from_status or not to_status: )
all_enabled_statuses: list[dict[str, object]] = []
for status_row, group_row in all_enabled_status_rows:
code = str(status_row.code or "").strip()
if not code:
continue continue
transitions_by_key.setdefault((topic_code, from_status), []).append(row) meta = {
transitions_to_key.setdefault((topic_code, to_status), []).append(row) "code": code,
"name": str(status_row.name or code),
"kind": str(status_row.kind or "DEFAULT"),
"is_terminal": bool(status_row.is_terminal),
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
"status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None),
"sort_order": int(status_row.sort_order or 0),
}
status_meta_map.setdefault(code, meta)
all_enabled_statuses.append(meta)
status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all() status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all()
columns_catalog = [ columns_catalog = [
@ -474,7 +617,6 @@ def get_requests_kanban(
group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog} group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog}
for row in request_rows: for row in request_rows:
request_id = str(row.id) request_id = str(row.id)
topic_code = str(row.topic_code or "").strip()
status_code = str(row.status_code or "").strip() status_code = str(row.status_code or "").strip()
status_meta = _status_meta_or_default(status_meta_map, status_code) status_meta = _status_meta_or_default(status_meta_map, status_code)
status_group = str(status_meta.get("status_group_id") or "").strip() status_group = str(status_meta.get("status_group_id") or "").strip()
@ -496,9 +638,9 @@ def get_requests_kanban(
} }
columns_catalog.append(columns_by_key[status_group]) columns_catalog.append(columns_by_key[status_group])
available_transitions = [] available_transitions = []
for transition in transitions_by_key.get((topic_code, status_code), []): for status_def in all_enabled_statuses:
to_status = str(transition.to_status or "").strip() to_status = str(status_def.get("code") or "").strip()
if not to_status: if not to_status or to_status == status_code:
continue continue
to_meta = _status_meta_or_default(status_meta_map, to_status) to_meta = _status_meta_or_default(status_meta_map, to_status)
target_group = str(to_meta.get("status_group_id") or "").strip() target_group = str(to_meta.get("status_group_id") or "").strip()
@ -514,26 +656,12 @@ def get_requests_kanban(
"to_status": to_status, "to_status": to_status,
"to_status_name": str(to_meta.get("name") or to_status), "to_status_name": str(to_meta.get("name") or to_status),
"target_group": target_group, "target_group": target_group,
"sla_hours": transition.sla_hours, "is_terminal": bool(to_meta.get("is_terminal")),
} }
) )
case_deadline = _extract_case_deadline(row.extra_fields) case_deadline = row.important_date_at or _extract_case_deadline(row.extra_fields)
entered_at = current_status_changed_at.get(request_id) or row.created_at
previous_status = previous_status_by_request.get(request_id)
transition_candidates = transitions_to_key.get((topic_code, status_code), [])
matched_transition = None
if previous_status:
for transition in transition_candidates:
if str(transition.from_status or "").strip() == previous_status:
matched_transition = transition
break
if matched_transition is None and transition_candidates:
matched_transition = transition_candidates[0]
sla_deadline = None sla_deadline = None
if entered_at and matched_transition and matched_transition.sla_hours is not None:
sla_deadline = entered_at + timedelta(hours=int(matched_transition.sla_hours))
assigned_id = str(row.assigned_lawyer_id or "").strip() or None assigned_id = str(row.assigned_lawyer_id or "").strip() or None
items.append( items.append(
@ -544,6 +672,7 @@ def get_requests_kanban(
"client_phone": row.client_phone, "client_phone": row.client_phone,
"topic_code": row.topic_code, "topic_code": row.topic_code,
"status_code": status_code, "status_code": status_code,
"important_date_at": row.important_date_at.isoformat() if row.important_date_at else None,
"status_name": str(status_meta.get("name") or status_code), "status_name": str(status_meta.get("name") or status_code),
"status_group": status_group, "status_group": status_group,
"status_group_name": status_group_name or None, "status_group_name": status_group_name or None,
@ -618,6 +747,13 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields) validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}" track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
responsible = str(admin.get("email") or "").strip() or "Администратор системы" responsible = str(admin.get("email") or "").strip() or "Администратор системы"
client = _client_for_request_payload_or_400(
db,
client_id=payload.client_id,
client_name=payload.client_name,
client_phone=payload.client_phone,
responsible=responsible,
)
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
effective_rate = payload.effective_rate effective_rate = payload.effective_rate
if assigned_lawyer_id: if assigned_lawyer_id:
@ -627,14 +763,17 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a
effective_rate = assigned_lawyer.default_rate effective_rate = assigned_lawyer.default_rate
row = Request( row = Request(
track_number=track, track_number=track,
client_name=payload.client_name, client_id=client.id,
client_phone=payload.client_phone, client_name=client.full_name,
client_phone=client.phone,
topic_code=payload.topic_code, topic_code=payload.topic_code,
status_code=payload.status_code, status_code=payload.status_code,
important_date_at=payload.important_date_at,
description=payload.description, description=payload.description,
extra_fields=payload.extra_fields, extra_fields=payload.extra_fields,
assigned_lawyer_id=assigned_lawyer_id, assigned_lawyer_id=assigned_lawyer_id,
effective_rate=effective_rate, effective_rate=effective_rate,
request_cost=payload.request_cost,
invoice_amount=payload.invoice_amount, invoice_amount=payload.invoice_amount,
paid_at=payload.paid_at, paid_at=payload.paid_at,
paid_by_admin_id=payload.paid_by_admin_id, paid_by_admin_id=payload.paid_by_admin_id,
@ -682,30 +821,32 @@ def update_request(
changes["effective_rate"] = assigned_lawyer.default_rate changes["effective_rate"] = assigned_lawyer.default_rate
old_status = str(row.status_code or "") old_status = str(row.status_code or "")
responsible = str(admin.get("email") or "").strip() or "Администратор системы" responsible = str(admin.get("email") or "").strip() or "Администратор системы"
if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())):
client = _client_for_request_payload_or_400(
db,
client_id=changes.get("client_id", row.client_id),
client_name=changes.get("client_name", row.client_name),
client_phone=changes.get("client_phone", row.client_phone),
responsible=responsible,
)
changes["client_id"] = client.id
changes["client_name"] = client.full_name
changes["client_phone"] = client.phone
status_changed = "status_code" in changes and str(changes.get("status_code") or "") != old_status
if status_changed and ("important_date_at" not in changes or changes.get("important_date_at") is None):
changes["important_date_at"] = _normalize_important_date_or_default(None)
for key, value in changes.items(): for key, value in changes.items():
setattr(row, key, value) setattr(row, key, value)
if "status_code" in changes and str(changes.get("status_code") or "") != old_status: if status_changed:
next_status = str(changes.get("status_code") or "") next_status = str(changes.get("status_code") or "")
if not transition_allowed_for_topic( important_date_at = row.important_date_at
db,
str(row.topic_code or "").strip() or None,
old_status,
next_status,
):
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
validate_transition_requirements_or_400(
db,
row,
from_status=old_status,
to_status=next_status,
extra_fields_override=row.extra_fields if isinstance(row.extra_fields, dict) else None,
)
billing_note = apply_billing_transition_effects( billing_note = apply_billing_transition_effects(
db, db,
req=row, req=row,
from_status=old_status, from_status=old_status,
to_status=next_status, to_status=next_status,
admin=admin, admin=admin,
important_date_at=important_date_at,
responsible=responsible, responsible=responsible,
) )
mark_unread_for_client(row, EVENT_STATUS) mark_unread_for_client(row, EVENT_STATUS)
@ -723,7 +864,11 @@ def update_request(
event_type=NOTIFICATION_EVENT_STATUS, event_type=NOTIFICATION_EVENT_STATUS,
actor_role=str(admin.get("role") or "").upper() or "ADMIN", actor_role=str(admin.get("role") or "").upper() or "ADMIN",
actor_admin_user_id=admin.get("sub"), actor_admin_user_id=admin.get("sub"),
body=(f"{old_status} -> {next_status}" + (f"\n{billing_note}" if billing_note else "")), body=(
f"{old_status} -> {next_status}"
+ (f"\nВажная дата: {important_date_at.isoformat()}" if important_date_at else "")
+ (f"\n{billing_note}" if billing_note else "")
),
responsible=responsible, responsible=responsible,
) )
try: try:
@ -772,14 +917,17 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
return { return {
"id": str(req.id), "id": str(req.id),
"track_number": req.track_number, "track_number": req.track_number,
"client_id": str(req.client_id) if req.client_id else None,
"client_name": req.client_name, "client_name": req.client_name,
"client_phone": req.client_phone, "client_phone": req.client_phone,
"topic_code": req.topic_code, "topic_code": req.topic_code,
"status_code": req.status_code, "status_code": req.status_code,
"important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
"description": req.description, "description": req.description,
"extra_fields": req.extra_fields, "extra_fields": req.extra_fields,
"assigned_lawyer_id": req.assigned_lawyer_id, "assigned_lawyer_id": req.assigned_lawyer_id,
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None, "effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
"request_cost": float(req.request_cost) if req.request_cost is not None else None,
"invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None, "invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None,
"paid_at": req.paid_at.isoformat() if req.paid_at else None, "paid_at": req.paid_at.isoformat() if req.paid_at else None,
"paid_by_admin_id": req.paid_by_admin_id, "paid_by_admin_id": req.paid_by_admin_id,
@ -793,6 +941,86 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
} }
@router.post("/{request_id}/status-change")
def change_request_status(
request_id: str,
payload: RequestStatusChange,
db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER")),
):
request_uuid = _request_uuid_or_400(request_id)
req = db.get(Request, request_uuid)
if not req:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_can_manage_request_or_403(admin, req)
next_status = str(payload.status_code or "").strip()
if not next_status:
raise HTTPException(status_code=400, detail='Поле "status_code" обязательно')
status_row = db.query(Status).filter(Status.code == next_status, Status.enabled.is_(True)).first()
if status_row is None:
raise HTTPException(status_code=400, detail="Указан несуществующий или неактивный статус")
old_status = str(req.status_code or "").strip()
if old_status == next_status:
raise HTTPException(status_code=400, detail="Выберите новый статус")
important_date_at = _normalize_important_date_or_default(payload.important_date_at)
comment = str(payload.comment or "").strip() or None
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
req.status_code = next_status
req.important_date_at = important_date_at
req.responsible = responsible
billing_note = apply_billing_transition_effects(
db,
req=req,
from_status=old_status,
to_status=next_status,
admin=admin,
responsible=responsible,
)
mark_unread_for_client(req, EVENT_STATUS)
apply_status_change_effects(
db,
req,
from_status=old_status,
to_status=next_status,
admin=admin,
comment=comment,
important_date_at=important_date_at,
responsible=responsible,
)
notify_request_event(
db,
request=req,
event_type=NOTIFICATION_EVENT_STATUS,
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
actor_admin_user_id=admin.get("sub"),
body=(
f"{old_status} -> {next_status}"
+ f"\nВажная дата: {important_date_at.isoformat()}"
+ (f"\n{comment}" if comment else "")
+ (f"\n{billing_note}" if billing_note else "")
),
responsible=responsible,
)
db.add(req)
db.commit()
db.refresh(req)
return {
"status": "ok",
"request_id": str(req.id),
"track_number": req.track_number,
"from_status": old_status or None,
"to_status": next_status,
"important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
}
@router.get("/{request_id}/status-route") @router.get("/{request_id}/status-route")
def get_request_status_route( def get_request_status_route(
request_id: str, request_id: str,
@ -808,22 +1036,6 @@ def get_request_status_route(
topic_code = str(req.topic_code or "").strip() topic_code = str(req.topic_code or "").strip()
current_status = str(req.status_code or "").strip() current_status = str(req.status_code or "").strip()
transitions: list[TopicStatusTransition] = []
if topic_code:
transitions = (
db.query(TopicStatusTransition)
.filter(
TopicStatusTransition.topic_code == topic_code,
TopicStatusTransition.enabled.is_(True),
)
.order_by(
TopicStatusTransition.sort_order.asc(),
TopicStatusTransition.from_status.asc(),
TopicStatusTransition.to_status.asc(),
)
.all()
)
history_rows = ( history_rows = (
db.query(StatusHistory) db.query(StatusHistory)
.filter(StatusHistory.request_id == req.id) .filter(StatusHistory.request_id == req.id)
@ -841,23 +1053,28 @@ def get_request_status_route(
known_codes.add(from_code) known_codes.add(from_code)
if to_code: if to_code:
known_codes.add(to_code) known_codes.add(to_code)
for row in transitions:
from_code = str(row.from_status or "").strip()
to_code = str(row.to_status or "").strip()
if from_code:
known_codes.add(from_code)
if to_code:
known_codes.add(to_code)
statuses_map: dict[str, dict[str, str]] = {} statuses_map: dict[str, dict[str, str]] = {}
all_enabled_status_rows = db.query(Status, StatusGroup).outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id).filter(Status.enabled.is_(True)).all()
for status_row, _group_row in all_enabled_status_rows:
code = str(status_row.code or "").strip()
if code:
known_codes.add(code)
if known_codes: if known_codes:
status_rows = db.query(Status).filter(Status.code.in_(list(known_codes))).all() status_rows = (
db.query(Status, StatusGroup)
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
.filter(Status.code.in_(list(known_codes)))
.all()
)
statuses_map = { statuses_map = {
str(row.code): { str(status_row.code): {
"name": str(row.name or row.code), "name": str(status_row.name or status_row.code),
"kind": str(row.kind or "DEFAULT"), "kind": str(status_row.kind or "DEFAULT"),
"is_terminal": bool(status_row.is_terminal),
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
} }
for row in status_rows for status_row, group_row in status_rows
} }
sequence_from_history: list[str] = [] sequence_from_history: list[str] = []
@ -885,27 +1102,8 @@ def get_request_status_route(
for code in sequence_from_history: for code in sequence_from_history:
add_code(code) add_code(code)
for row in transitions:
add_code(str(row.from_status or ""))
add_code(str(row.to_status or ""))
add_code(current_status) add_code(current_status)
transition_by_to_status: dict[str, dict[str, object]] = {}
for row in transitions:
to_code = str(row.to_status or "").strip()
if not to_code:
continue
current = transition_by_to_status.get(to_code)
if current is None or int(row.sort_order or 0) < int(current.get("sort_order") or 0):
transition_by_to_status[to_code] = {
"from_status": str(row.from_status or "").strip() or None,
"sla_hours": row.sla_hours,
"required_data_keys": normalize_string_list(row.required_data_keys),
"required_mime_types": normalize_string_list(row.required_mime_types),
"sort_order": int(row.sort_order or 0),
}
changed_at_by_status: dict[str, str] = {} changed_at_by_status: dict[str, str] = {}
for row in history_rows: for row in history_rows:
to_code = str(row.to_status or "").strip() to_code = str(row.to_status or "").strip()
@ -922,7 +1120,6 @@ def get_request_status_route(
nodes: list[dict[str, str | int | None]] = [] nodes: list[dict[str, str | int | None]] = []
for index, code in enumerate(ordered_codes): for index, code in enumerate(ordered_codes):
meta = statuses_map.get(code) or {} meta = statuses_map.get(code) or {}
transition_meta = transition_by_to_status.get(code) or {}
state = "pending" state = "pending"
if code == current_status: if code == current_status:
state = "current" state = "current"
@ -930,18 +1127,6 @@ def get_request_status_route(
state = "completed" state = "completed"
note_parts: list[str] = [] note_parts: list[str] = []
from_status = transition_meta.get("from_status")
if from_status:
note_parts.append(f"Переход из статуса «{status_name(str(from_status))}»")
sla_hours = transition_meta.get("sla_hours")
if sla_hours is not None:
note_parts.append(f"SLA: {sla_hours} ч")
required_data_keys = transition_meta.get("required_data_keys") or []
if required_data_keys:
note_parts.append("Данные: " + ", ".join(str(item) for item in required_data_keys))
required_mime_types = transition_meta.get("required_mime_types") or []
if required_mime_types:
note_parts.append("Файлы: " + ", ".join(str(item) for item in required_mime_types))
kind = str(meta.get("kind") or "DEFAULT") kind = str(meta.get("kind") or "DEFAULT")
if kind == "INVOICE": if kind == "INVOICE":
note_parts.append("Этап выставления счета") note_parts.append("Этап выставления счета")
@ -954,19 +1139,88 @@ def get_request_status_route(
"name": status_name(code), "name": status_name(code),
"kind": kind, "kind": kind,
"state": state, "state": state,
"sla_hours": sla_hours,
"required_data_keys": required_data_keys,
"required_mime_types": required_mime_types,
"changed_at": changed_at_by_status.get(code), "changed_at": changed_at_by_status.get(code),
"note": "".join(note_parts), "note": "".join(note_parts),
} }
) )
history_entries: list[dict[str, object]] = []
timeline: list[dict[str, object]] = []
for row in history_rows:
timeline.append(
{
"id": str(row.id),
"from_status": str(row.from_status or "").strip() or None,
"to_status": str(row.to_status or "").strip() or None,
"to_status_name": status_name(str(row.to_status or "").strip()) if str(row.to_status or "").strip() else None,
"created_at": row.created_at,
"important_date_at": row.important_date_at,
"comment": row.comment,
}
)
if not timeline:
timeline.append(
{
"id": "current",
"from_status": None,
"to_status": current_status or None,
"to_status_name": status_name(current_status) if current_status else None,
"created_at": req.updated_at or req.created_at,
"important_date_at": req.important_date_at,
"comment": None,
}
)
for index, item in enumerate(timeline):
current_at = item.get("created_at")
next_at = timeline[index + 1].get("created_at") if index + 1 < len(timeline) else datetime.now(timezone.utc)
duration_seconds = None
if isinstance(current_at, datetime) and isinstance(next_at, datetime):
delta = next_at - current_at
duration_seconds = max(0, int(delta.total_seconds()))
history_entries.append(
{
"id": item.get("id"),
"from_status": item.get("from_status"),
"to_status": item.get("to_status"),
"to_status_name": item.get("to_status_name"),
"changed_at": current_at.isoformat() if isinstance(current_at, datetime) else None,
"important_date_at": item.get("important_date_at").isoformat() if isinstance(item.get("important_date_at"), datetime) else None,
"comment": item.get("comment"),
"duration_seconds": duration_seconds,
}
)
available_statuses: list[dict[str, object]] = []
for status_row, group_row in sorted(
all_enabled_status_rows,
key=lambda pair: (
int(pair[1].sort_order or 0) if pair[1] is not None else 999,
int(pair[0].sort_order or 0),
str(pair[0].name or pair[0].code).lower(),
),
):
code = str(status_row.code or "").strip()
if not code:
continue
available_statuses.append(
{
"code": code,
"name": str(status_row.name or code),
"kind": str(status_row.kind or "DEFAULT"),
"is_terminal": bool(status_row.is_terminal),
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
}
)
return { return {
"request_id": str(req.id), "request_id": str(req.id),
"track_number": req.track_number, "track_number": req.track_number,
"topic_code": req.topic_code, "topic_code": req.topic_code,
"current_status": current_status or None, "current_status": current_status or None,
"current_important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
"available_statuses": available_statuses,
"history": list(reversed(history_entries)),
"nodes": nodes, "nodes": nodes,
} }

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat, test_utils
router = APIRouter() router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"]) router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
@ -13,3 +13,4 @@ router.include_router(notifications.router, prefix="/notifications", tags=["Admi
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"]) router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
router.include_router(chat.router, prefix="/chat", tags=["AdminChat"]) router.include_router(chat.router, prefix="/chat", tags=["AdminChat"])
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"]) router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
router.include_router(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"])

View file

@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import require_role
from app.db.session import get_db
from app.services.test_data_cleanup import CleanupSpec, cleanup_test_data
router = APIRouter()
class TestDataCleanupPayload(BaseModel):
track_numbers: list[str] = Field(default_factory=list)
phones: list[str] = Field(default_factory=list)
emails: list[str] = Field(default_factory=list)
include_default_e2e_patterns: bool = True
def _guard_local_only() -> None:
env = str(settings.APP_ENV or "").strip().lower()
if env in {"prod", "production"}:
raise HTTPException(status_code=404, detail="Not found")
@router.post("/cleanup-test-data")
def cleanup_test_data_endpoint(
payload: TestDataCleanupPayload,
db: Session = Depends(get_db),
admin: dict[str, Any] = Depends(require_role("ADMIN")),
):
_guard_local_only()
counts = cleanup_test_data(
db,
CleanupSpec(
track_numbers=payload.track_numbers,
phones=payload.phones,
emails=payload.emails,
include_default_e2e_patterns=bool(payload.include_default_e2e_patterns),
),
)
return {
"status": "ok",
"environment": settings.APP_ENV,
"requested_by": str(admin.get("email") or admin.get("sub") or ""),
"deleted": counts,
}

View file

@ -1,17 +1,42 @@
from __future__ import annotations from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
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
from app.db.session import get_db from app.db.session import get_db
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.request import Request from app.models.request import Request
from app.models.request_data_requirement import RequestDataRequirement
from app.schemas.public import PublicMessageCreate from app.schemas.public import PublicMessageCreate
from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message, serialize_messages_for_request
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_lawyer
router = APIRouter() router = APIRouter()
def _attachment_meta_for_public(req: Request, value_text: str | None, db: Session) -> dict | None:
raw = str(value_text or "").strip()
if not raw:
return None
try:
attachment_uuid = UUID(raw)
except ValueError:
return None
attachment = db.get(Attachment, attachment_uuid)
if attachment is None or attachment.request_id != req.id:
return None
return {
"attachment_id": str(attachment.id),
"file_name": attachment.file_name,
"mime_type": attachment.mime_type,
"size_bytes": int(attachment.size_bytes or 0),
"download_url": f"/api/public/uploads/object/{attachment.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:
@ -60,7 +85,7 @@ def list_messages_by_track(
req = _request_for_track_or_404(db, track_number) req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req) _ensure_view_access_or_403(session, req)
rows = list_messages_for_request(db, req.id) rows = list_messages_for_request(db, req.id)
return [serialize_message(row) for row in rows] return serialize_messages_for_request(db, req.id, rows)
@router.post("/requests/{track_number}/messages", status_code=201) @router.post("/requests/{track_number}/messages", status_code=201)
@ -74,3 +99,131 @@ def create_message_by_track(
_ensure_view_access_or_403(session, req) _ensure_view_access_or_403(session, req)
row = create_client_message(db, request=req, body=payload.body) row = create_client_message(db, request=req, body=payload.body)
return serialize_message(row) return serialize_message(row)
@router.get("/requests/{track_number}/data-requests/{message_id}")
def get_data_request_by_message(
track_number: str,
message_id: str,
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)
try:
message_uuid = UUID(str(message_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный идентификатор сообщения")
message = db.get(Message, message_uuid)
if message is None or message.request_id != req.id:
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
rows = (
db.query(RequestDataRequirement)
.filter(
RequestDataRequirement.request_id == req.id,
RequestDataRequirement.request_message_id == message_uuid,
)
.order_by(RequestDataRequirement.sort_order.asc(), RequestDataRequirement.created_at.asc(), RequestDataRequirement.id.asc())
.all()
)
if not rows:
raise HTTPException(status_code=404, detail="Запрос данных не найден")
return {
"message_id": str(message.id),
"request_id": str(req.id),
"track_number": req.track_number,
"items": [
{
"id": str(row.id),
"key": row.key,
"label": row.label,
"field_type": str(row.field_type or "text"),
"value_text": row.value_text,
"value_file": _attachment_meta_for_public(req, row.value_text, db) if str(row.field_type or "").lower() == "file" else None,
"is_filled": bool(str(row.value_text or "").strip()),
"sort_order": int(row.sort_order or 0),
}
for row in rows
],
}
@router.post("/requests/{track_number}/data-requests/{message_id}")
def save_data_request_values(
track_number: str,
message_id: str,
payload: dict,
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)
try:
message_uuid = UUID(str(message_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный идентификатор сообщения")
message = db.get(Message, message_uuid)
if message is None or message.request_id != req.id:
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
raw_items = (payload or {}).get("items")
if not isinstance(raw_items, list):
raise HTTPException(status_code=400, detail="Ожидается список items")
rows = (
db.query(RequestDataRequirement)
.filter(
RequestDataRequirement.request_id == req.id,
RequestDataRequirement.request_message_id == message_uuid,
)
.all()
)
by_id = {str(row.id): row for row in rows}
by_key = {str(row.key): row for row in rows}
updated = 0
for item in raw_items:
if not isinstance(item, dict):
continue
target = None
raw_id = str(item.get("id") or "").strip()
if raw_id:
target = by_id.get(raw_id)
if target is None:
raw_key = str(item.get("key") or "").strip()
if raw_key:
target = by_key.get(raw_key)
if target is None:
continue
if str(target.field_type or "").lower() == "file":
attachment_id_raw = str(item.get("attachment_id") or item.get("value_text") or "").strip()
if attachment_id_raw:
try:
attachment_uuid = UUID(attachment_id_raw)
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный attachment_id для файла")
attachment = db.get(Attachment, attachment_uuid)
if attachment is None or attachment.request_id != req.id:
raise HTTPException(status_code=400, detail="Файл для поля не найден или недоступен")
normalized = str(attachment.id)
else:
normalized = ""
else:
value_text = item.get("value_text")
normalized = str(value_text).strip() if value_text is not None else ""
target.value_text = normalized or None
target.responsible = "Клиент"
db.add(target)
updated += 1
if updated:
mark_unread_for_lawyer(req, EVENT_MESSAGE)
req.responsible = "Клиент"
db.add(req)
db.commit()
else:
db.rollback()
messages = list_messages_for_request(db, req.id)
serialized = serialize_messages_for_request(db, req.id, messages)
current = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None)
return {"updated": updated, "message": current}

View file

@ -0,0 +1,64 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.landing_featured_staff import LandingFeaturedStaff
from app.models.topic import Topic
router = APIRouter()
@router.get("")
def list_featured_staff(
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
):
topic_names = {
str(row.code): str(row.name)
for row in db.query(Topic).filter(Topic.enabled.is_(True)).all()
}
rows = (
db.query(LandingFeaturedStaff, AdminUser)
.join(AdminUser, AdminUser.id == LandingFeaturedStaff.admin_user_id)
.filter(
LandingFeaturedStaff.enabled.is_(True),
AdminUser.is_active.is_(True),
AdminUser.role.in_(("ADMIN", "LAWYER")),
AdminUser.avatar_url.is_not(None),
and_(AdminUser.avatar_url != ""),
)
.order_by(
LandingFeaturedStaff.pinned.desc(),
LandingFeaturedStaff.sort_order.asc(),
LandingFeaturedStaff.created_at.asc(),
)
.limit(limit)
.all()
)
result = []
for slot, user in rows:
role_code = str(user.role or "").upper()
role_label = "Администратор" if role_code == "ADMIN" else "Юрист"
primary_topic_code = str(user.primary_topic_code or "").strip() or None
result.append(
{
"id": str(slot.id),
"admin_user_id": str(user.id),
"name": user.name,
"role": role_code,
"role_label": role_label,
"avatar_url": user.avatar_url,
"caption": str(slot.caption or "").strip() or None,
"pinned": bool(slot.pinned),
"sort_order": int(slot.sort_order or 0),
"primary_topic_code": primary_topic_code,
"primary_topic_name": topic_names.get(primary_topic_code or "", primary_topic_code),
}
)
return {"items": result, "total": len(result)}

View file

@ -1,9 +1,10 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.public import requests, otp, quotes, uploads, chat from app.api.public import requests, otp, quotes, uploads, chat, featured_staff
router = APIRouter() router = APIRouter()
router.include_router(requests.router, prefix="/requests", tags=["Public"]) router.include_router(requests.router, prefix="/requests", tags=["Public"])
router.include_router(otp.router, prefix="/otp", tags=["Public"]) router.include_router(otp.router, prefix="/otp", tags=["Public"])
router.include_router(quotes.router, prefix="/quotes", tags=["Public"]) router.include_router(quotes.router, prefix="/quotes", tags=["Public"])
router.include_router(featured_staff.router, prefix="/featured-staff", tags=["Public"])
router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"]) router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"])
router.include_router(chat.router, prefix="/chat", tags=["PublicChat"]) router.include_router(chat.router, prefix="/chat", tags=["PublicChat"])

View file

@ -23,6 +23,19 @@ SECURITY_HEADERS = {
"Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", "Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
} }
FRAMEABLE_FILE_SECURITY_HEADERS = {
**SECURITY_HEADERS,
"X-Frame-Options": "SAMEORIGIN",
"Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'",
}
_FRAMEABLE_PATH_PATTERNS = (
re.compile(r"^/api/public/uploads/object/"),
re.compile(r"^/api/admin/uploads/object/"),
re.compile(r"^/api/public/requests/[^/]+/invoices/[^/]+/pdf$"),
re.compile(r"^/api/admin/invoices/[^/]+/pdf$"),
)
def _request_id_from_header(raw: str | None) -> str: def _request_id_from_header(raw: str | None) -> str:
value = str(raw or "").strip() value = str(raw or "").strip()
@ -33,6 +46,14 @@ def _request_id_from_header(raw: str | None) -> str:
return value return value
def _response_security_headers(request: Request) -> dict[str, str]:
method = str(request.method or "").upper()
path = str(request.url.path or "")
if method in {"GET", "HEAD"} and any(pattern.search(path) for pattern in _FRAMEABLE_PATH_PATTERNS):
return FRAMEABLE_FILE_SECURITY_HEADERS
return SECURITY_HEADERS
def install_http_hardening(app: FastAPI) -> None: def install_http_hardening(app: FastAPI) -> None:
@app.middleware("http") @app.middleware("http")
async def _http_hardening_middleware(request: Request, call_next): async def _http_hardening_middleware(request: Request, call_next):
@ -42,7 +63,7 @@ def install_http_hardening(app: FastAPI) -> None:
response = await call_next(request) response = await call_next(request)
for key, value in SECURITY_HEADERS.items(): for key, value in _response_security_headers(request).items():
response.headers[key] = value response.headers[key] = value
# Backend serves application data and operational endpoints only. # Backend serves application data and operational endpoints only.
# Keep responses non-cacheable to avoid stale or sensitive data reuse. # Keep responses non-cacheable to avoid stale or sensitive data reuse.

View file

@ -0,0 +1,38 @@
from __future__ import annotations
import argparse
from app.db.session import SessionLocal
from app.services.test_data_cleanup import CleanupSpec, cleanup_test_data
def main() -> None:
parser = argparse.ArgumentParser(description="Cleanup E2E / test artifacts from development DB")
parser.add_argument("--track", action="append", dest="tracks", default=[], help="Track number to cleanup (repeatable)")
parser.add_argument("--phone", action="append", dest="phones", default=[], help="Phone to cleanup (repeatable)")
parser.add_argument("--email", action="append", dest="emails", default=[], help="Email to cleanup (repeatable)")
parser.add_argument(
"--no-default-patterns",
action="store_true",
help="Disable cleanup by default E2E patterns (use only explicit track/phone/email values)",
)
args = parser.parse_args()
db = SessionLocal()
try:
result = cleanup_test_data(
db,
CleanupSpec(
track_numbers=args.tracks,
phones=args.phones,
emails=args.emails,
include_default_e2e_patterns=not bool(args.no_default_patterns),
),
)
print(result)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,694 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
from typing import Iterable
from uuid import uuid4
from sqlalchemy import or_
from app.core.config import settings
from app.core.security import hash_password
from app.db.session import SessionLocal
from app.models.admin_user import AdminUser
from app.models.admin_user_topic import AdminUserTopic
from app.models.attachment import Attachment
from app.models.audit_log import AuditLog
from app.models.client import Client
from app.models.invoice import Invoice
from app.models.message import Message
from app.models.notification import Notification
from app.models.request import Request
from app.models.request_data_requirement import RequestDataRequirement
from app.models.security_audit_log import SecurityAuditLog
from app.models.status import Status
from app.models.status_group import StatusGroup
from app.models.status_history import StatusHistory
from app.models.topic import Topic
from app.models.topic_data_template import TopicDataTemplate
from app.services.admin_bootstrap import ensure_bootstrap_admin_for_login
from app.services.s3_storage import get_s3_storage
UTC = timezone.utc
NOW = datetime.now(UTC).replace(second=0, microsecond=0)
REQUEST_PREFIX = "TRK-MAN-"
CLIENT_PHONE_PREFIX = "+79001000"
LAWYER_PHONE_PREFIX = "+79002000"
LAWYER_EMAIL_DOMAIN = "example.com"
LAWYER_PASSWORD = "LawyerManual-123!"
PROJECT_ROOT = Path(__file__).resolve().parents[2]
ACCESS_FILE_PATH = PROJECT_ROOT / "context" / "15_manual_test_access.md"
STATUS_GROUPS = [
("Новые", 10),
("В работе", 20),
("Ожидание", 30),
("Завершены", 40),
]
STATUSES = [
{"code": "NEW", "name": "Новая", "group": "Новые", "sort_order": 10, "is_terminal": False},
{"code": "ASSIGNED", "name": "Назначена", "group": "Новые", "sort_order": 20, "is_terminal": False},
{"code": "IN_PROGRESS", "name": "В работе", "group": "В работе", "sort_order": 30, "is_terminal": False},
{"code": "WAITING_CLIENT", "name": "Ожидание клиента", "group": "Ожидание", "sort_order": 40, "is_terminal": False},
{"code": "WAITING_DOCUMENTS", "name": "Ожидание документов", "group": "Ожидание", "sort_order": 50, "is_terminal": False},
{"code": "PAUSED", "name": "Пауза", "group": "Ожидание", "sort_order": 60, "is_terminal": False},
{"code": "RESOLVED", "name": "Решена", "group": "Завершены", "sort_order": 70, "is_terminal": True},
{"code": "CLOSED", "name": "Закрыта", "group": "Завершены", "sort_order": 80, "is_terminal": True},
]
TOPICS = [
("manual-civil", "Гражданские споры", 10),
("manual-family", "Семейное право", 20),
("manual-labor", "Трудовые споры", 30),
("manual-tax", "Налоговые вопросы", 40),
("manual-contract", "Договорная работа", 50),
]
LAWYERS = [
{
"email": f"lawyer1.manual@{LAWYER_EMAIL_DOMAIN}",
"name": "Иван Волков",
"phone": f"{LAWYER_PHONE_PREFIX}01",
"primary_topic_code": "manual-civil",
"extra_topics": ["manual-contract"],
"default_rate": Decimal("5000.00"),
"salary_percent": Decimal("35.00"),
},
{
"email": f"lawyer2.manual@{LAWYER_EMAIL_DOMAIN}",
"name": "Мария Егорова",
"phone": f"{LAWYER_PHONE_PREFIX}02",
"primary_topic_code": "manual-family",
"extra_topics": ["manual-labor"],
"default_rate": Decimal("4500.00"),
"salary_percent": Decimal("32.00"),
},
{
"email": f"lawyer3.manual@{LAWYER_EMAIL_DOMAIN}",
"name": "Павел Климов",
"phone": f"{LAWYER_PHONE_PREFIX}03",
"primary_topic_code": "manual-tax",
"extra_topics": ["manual-contract"],
"default_rate": Decimal("6500.00"),
"salary_percent": Decimal("40.00"),
},
{
"email": f"lawyer4.manual@{LAWYER_EMAIL_DOMAIN}",
"name": "Ольга Смирнова",
"phone": f"{LAWYER_PHONE_PREFIX}04",
"primary_topic_code": "manual-contract",
"extra_topics": ["manual-civil", "manual-tax"],
"default_rate": Decimal("5500.00"),
"salary_percent": Decimal("38.00"),
},
]
CLIENTS = [
("Ручной Клиент 01", f"{CLIENT_PHONE_PREFIX}01"),
("Ручной Клиент 02", f"{CLIENT_PHONE_PREFIX}02"),
("Ручной Клиент 03", f"{CLIENT_PHONE_PREFIX}03"),
("Ручной Клиент 04", f"{CLIENT_PHONE_PREFIX}04"),
("Ручной Клиент 05", f"{CLIENT_PHONE_PREFIX}05"),
("Ручной Клиент 06", f"{CLIENT_PHONE_PREFIX}06"),
("Ручной Клиент 07", f"{CLIENT_PHONE_PREFIX}07"),
("Ручной Клиент 08", f"{CLIENT_PHONE_PREFIX}08"),
("Ручной Клиент 09", f"{CLIENT_PHONE_PREFIX}09"),
("Ручной Клиент 10", f"{CLIENT_PHONE_PREFIX}10"),
]
@dataclass
class SeedRequestSpec:
idx: int
client_phone: str
topic_code: str
status_code: str
assigned_lawyer_email: str | None
created_days_ago: int
last_update_hours_ago: int
important_in_days: int | None
request_cost: Decimal
invoice_amount: Decimal | None
invoice_status: str | None
paid_days_ago: int | None
chat_pairs: int
client_unread: bool
lawyer_unread: bool
extra_note: str
REQUEST_SPECS: list[SeedRequestSpec] = [
SeedRequestSpec(1, CLIENTS[0][1], "manual-civil", "NEW", None, 1, 2, 2, Decimal("15000"), None, None, None, 2, False, False, "Первичная консультация"),
SeedRequestSpec(2, CLIENTS[0][1], "manual-contract", "IN_PROGRESS", LAWYERS[0]["email"], 9, 4, 1, Decimal("80000"), Decimal("30000"), "PAID", 3, 4, True, False, "Договор и претензия"),
SeedRequestSpec(3, CLIENTS[0][1], "manual-tax", "WAITING_CLIENT", LAWYERS[2]["email"], 16, 8, -1, Decimal("120000"), Decimal("50000"), "WAITING_PAYMENT", None, 3, False, True, "Запрошены акты"),
SeedRequestSpec(4, CLIENTS[0][1], "manual-tax", "RESOLVED", LAWYERS[2]["email"], 34, 72, None, Decimal("95000"), Decimal("95000"), "PAID", 10, 2, False, False, "Решено через досудебное"),
SeedRequestSpec(5, CLIENTS[0][1], "manual-family", "CLOSED", LAWYERS[1]["email"], 64, 240, None, Decimal("60000"), Decimal("60000"), "PAID", 28, 2, False, False, "Завершено мировым соглашением"),
SeedRequestSpec(6, CLIENTS[1][1], "manual-family", "IN_PROGRESS", LAWYERS[1]["email"], 4, 3, 0, Decimal("45000"), Decimal("20000"), "WAITING_PAYMENT", None, 4, False, True, "Подготовка иска"),
SeedRequestSpec(7, CLIENTS[1][1], "manual-labor", "WAITING_DOCUMENTS", LAWYERS[1]["email"], 7, 12, 3, Decimal("38000"), None, None, None, 3, True, False, "Ожидаем трудовой договор"),
SeedRequestSpec(8, CLIENTS[1][1], "manual-labor", "NEW", None, 0, 1, 3, Decimal("25000"), None, None, None, 1, False, False, "Новая заявка без назначения"),
SeedRequestSpec(9, CLIENTS[1][1], "manual-contract", "ASSIGNED", LAWYERS[3]["email"], 2, 5, 2, Decimal("70000"), None, None, None, 2, True, False, "Назначена, ожидает старта"),
SeedRequestSpec(10, CLIENTS[2][1], "manual-civil", "IN_PROGRESS", LAWYERS[0]["email"], 11, 6, 4, Decimal("110000"), Decimal("40000"), "PAID", 5, 5, False, True, "Судебное представительство"),
SeedRequestSpec(11, CLIENTS[2][1], "manual-contract", "WAITING_CLIENT", LAWYERS[3]["email"], 14, 20, 1, Decimal("52000"), Decimal("15000"), "WAITING_PAYMENT", None, 3, False, True, "Дозапрос доверенности"),
SeedRequestSpec(12, CLIENTS[2][1], "manual-tax", "PAUSED", LAWYERS[2]["email"], 21, 48, 7, Decimal("135000"), None, None, None, 2, True, False, "Пауза до ответа инспекции"),
SeedRequestSpec(13, CLIENTS[3][1], "manual-civil", "WAITING_DOCUMENTS", LAWYERS[0]["email"], 6, 9, -2, Decimal("50000"), None, None, None, 3, True, False, "Просрочен дедлайн по документам"),
SeedRequestSpec(14, CLIENTS[3][1], "manual-family", "RESOLVED", LAWYERS[1]["email"], 27, 96, None, Decimal("42000"), Decimal("42000"), "PAID", 12, 2, False, False, "Закрытие по соглашению"),
SeedRequestSpec(15, CLIENTS[4][1], "manual-tax", "IN_PROGRESS", LAWYERS[2]["email"], 3, 2, 5, Decimal("210000"), Decimal("90000"), "PAID", 1, 4, False, True, "Налоговая проверка"),
SeedRequestSpec(16, CLIENTS[4][1], "manual-contract", "WAITING_CLIENT", LAWYERS[3]["email"], 5, 10, 2, Decimal("65000"), None, None, None, 3, True, False, "Ждем подписанный акт"),
SeedRequestSpec(17, CLIENTS[5][1], "manual-labor", "NEW", None, 1, 1, 3, Decimal("18000"), None, None, None, 1, False, False, "Консультация по увольнению"),
SeedRequestSpec(18, CLIENTS[6][1], "manual-civil", "IN_PROGRESS", LAWYERS[0]["email"], 8, 7, 2, Decimal("92000"), Decimal("30000"), "WAITING_PAYMENT", None, 4, False, True, "Исполнительное производство"),
SeedRequestSpec(19, CLIENTS[7][1], "manual-family", "ASSIGNED", LAWYERS[1]["email"], 2, 6, 3, Decimal("55000"), None, None, None, 2, True, False, "Раздел имущества"),
SeedRequestSpec(20, CLIENTS[8][1], "manual-tax", "WAITING_DOCUMENTS", LAWYERS[2]["email"], 10, 18, 1, Decimal("175000"), None, None, None, 3, True, False, "Требуются книги учета"),
SeedRequestSpec(21, CLIENTS[9][1], "manual-contract", "NEW", None, 0, 0, 3, Decimal("30000"), None, None, None, 1, False, False, "Проверка оферты"),
]
def _money(value: Decimal | None) -> Decimal | None:
if value is None:
return None
return Decimal(value).quantize(Decimal("0.01"))
def _track_number(idx: int) -> str:
return f"{REQUEST_PREFIX}{idx:04d}"
def _ensure_status_groups(db) -> dict[str, StatusGroup]:
out: dict[str, StatusGroup] = {}
for name, sort_order in STATUS_GROUPS:
row = db.query(StatusGroup).filter(StatusGroup.name == name).first()
if row is None:
row = StatusGroup(name=name, sort_order=sort_order, responsible="Сид ручных тестов")
db.add(row)
db.flush()
else:
row.sort_order = sort_order
row.responsible = "Сид ручных тестов"
out[name] = row
return out
def _ensure_statuses(db, groups: dict[str, StatusGroup]) -> dict[str, Status]:
out: dict[str, Status] = {}
for item in STATUSES:
row = db.query(Status).filter(Status.code == item["code"]).first()
if row is None:
row = Status(
code=item["code"],
name=item["name"],
status_group_id=groups[item["group"]].id,
enabled=True,
sort_order=int(item["sort_order"]),
is_terminal=bool(item["is_terminal"]),
responsible="Сид ручных тестов",
)
db.add(row)
else:
row.name = str(item["name"])
row.status_group_id = groups[item["group"]].id
row.enabled = True
row.sort_order = int(item["sort_order"])
row.is_terminal = bool(item["is_terminal"])
row.responsible = "Сид ручных тестов"
out[item["code"]] = row
return out
def _ensure_topics(db) -> dict[str, Topic]:
out: dict[str, Topic] = {}
for code, name, sort_order in TOPICS:
row = db.query(Topic).filter(Topic.code == code).first()
if row is None:
row = Topic(code=code, name=name, enabled=True, sort_order=sort_order, responsible="Сид ручных тестов")
db.add(row)
else:
row.name = name
row.enabled = True
row.sort_order = sort_order
row.responsible = "Сид ручных тестов"
out[code] = row
return out
def _ensure_bootstrap_admin(db) -> AdminUser:
admin = ensure_bootstrap_admin_for_login(db, settings.ADMIN_BOOTSTRAP_EMAIL, settings.ADMIN_BOOTSTRAP_PASSWORD)
if admin is None:
row = db.query(AdminUser).filter(AdminUser.email == settings.ADMIN_BOOTSTRAP_EMAIL).first()
if row:
return row
raise RuntimeError("Не удалось обеспечить bootstrap-администратора")
if not str(admin.phone or "").strip():
admin.phone = "+79009999999"
admin.responsible = "Сид ручных тестов"
db.add(admin)
db.commit()
db.refresh(admin)
return admin
def _ensure_lawyers(db) -> dict[str, AdminUser]:
out: dict[str, AdminUser] = {}
for idx, item in enumerate(LAWYERS, start=1):
row = db.query(AdminUser).filter(AdminUser.email == item["email"]).first()
if row is None:
row = AdminUser(
role="LAWYER",
name=item["name"],
email=item["email"],
phone=item["phone"],
password_hash=hash_password(LAWYER_PASSWORD),
primary_topic_code=item["primary_topic_code"],
default_rate=item["default_rate"],
salary_percent=item["salary_percent"],
is_active=True,
responsible="Сид ручных тестов",
)
db.add(row)
db.flush()
else:
row.role = "LAWYER"
row.name = item["name"]
row.phone = item["phone"]
row.primary_topic_code = item["primary_topic_code"]
row.default_rate = item["default_rate"]
row.salary_percent = item["salary_percent"]
row.is_active = True
row.responsible = "Сид ручных тестов"
if not str(row.password_hash or "").strip():
row.password_hash = hash_password(LAWYER_PASSWORD)
out[item["email"]] = row
db.flush()
for item in LAWYERS:
row = out[item["email"]]
db.query(AdminUserTopic).filter(AdminUserTopic.admin_user_id == row.id).delete(synchronize_session=False)
for topic_code in item["extra_topics"]:
db.add(
AdminUserTopic(
admin_user_id=row.id,
topic_code=topic_code,
responsible="Сид ручных тестов",
)
)
return out
def _ensure_clients(db) -> dict[str, Client]:
out: dict[str, Client] = {}
for full_name, phone in CLIENTS:
row = db.query(Client).filter(Client.phone == phone).first()
if row is None:
row = Client(full_name=full_name, phone=phone, responsible="Сид ручных тестов")
db.add(row)
else:
row.full_name = full_name
row.responsible = "Сид ручных тестов"
out[phone] = row
return out
def _seed_request_cleanup(db, request_ids: list, request_tracks: list[str]) -> dict[str, int]:
counts = {
"attachments": 0,
"messages": 0,
"status_history": 0,
"invoices": 0,
"notifications": 0,
"request_data_requirements": 0,
"audit_log": 0,
"security_audit_log": 0,
"requests": 0,
}
if not request_ids:
return counts
attachment_rows = db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).all()
attachment_ids = [row.id for row in attachment_rows]
try:
storage = get_s3_storage()
storage.ensure_bucket()
for row in attachment_rows:
try:
storage.client.delete_object(Bucket=storage.bucket, Key=row.s3_key)
except Exception:
pass
except Exception:
pass
counts["notifications"] += db.query(Notification).filter(Notification.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
counts["request_data_requirements"] += (
db.query(RequestDataRequirement).filter(RequestDataRequirement.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
)
counts["status_history"] += db.query(StatusHistory).filter(StatusHistory.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
counts["invoices"] += db.query(Invoice).filter(Invoice.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
counts["messages"] += db.query(Message).filter(Message.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
counts["attachments"] += db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
counts["security_audit_log"] += (
db.query(SecurityAuditLog).filter(SecurityAuditLog.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
)
if attachment_ids:
counts["security_audit_log"] += (
db.query(SecurityAuditLog).filter(SecurityAuditLog.attachment_id.in_(attachment_ids)).delete(synchronize_session=False) or 0
)
counts["audit_log"] += (
db.query(AuditLog)
.filter(AuditLog.entity == "requests", AuditLog.entity_id.in_([str(v) for v in request_ids]))
.delete(synchronize_session=False)
or 0
)
if request_tracks:
counts["notifications"] += (
db.query(Notification)
.filter(Notification.recipient_track_number.in_(request_tracks))
.delete(synchronize_session=False)
or 0
)
counts["requests"] += db.query(Request).filter(Request.id.in_(request_ids)).delete(synchronize_session=False) or 0
return counts
def _rebuild_manual_requests(db, clients_by_phone: dict[str, Client], lawyers_by_email: dict[str, AdminUser]) -> list[Request]:
manual_existing = db.query(Request).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).all()
cleanup_counts = _seed_request_cleanup(
db,
[row.id for row in manual_existing],
[str(row.track_number or "") for row in manual_existing],
)
created_requests: list[Request] = []
for spec in REQUEST_SPECS:
client = clients_by_phone[spec.client_phone]
lawyer = lawyers_by_email.get(spec.assigned_lawyer_email or "") if spec.assigned_lawyer_email else None
created_at = NOW - timedelta(days=spec.created_days_ago)
updated_at = NOW - timedelta(hours=spec.last_update_hours_ago)
important_date = None if spec.important_in_days is None else (NOW + timedelta(days=spec.important_in_days))
status_timeline = _build_status_timeline(spec.status_code, created_at, important_date)
row = Request(
track_number=_track_number(spec.idx),
client_id=client.id,
client_name=client.full_name,
client_phone=client.phone,
topic_code=spec.topic_code,
status_code=spec.status_code,
important_date_at=important_date,
description=(
f"Тестовая заявка для ручной проверки платформы. {spec.extra_note}. "
f"Клиент: {client.full_name}. Стадия: {spec.status_code}."
),
extra_fields={
"номероговора": f"MAN-{spec.idx:04d}",
"канал": "manual_seed",
"приоритет": "средний" if spec.idx % 3 else "высокий",
},
assigned_lawyer_id=str(lawyer.id) if lawyer else None,
effective_rate=_money(lawyer.default_rate if lawyer else Decimal("0.00")) if lawyer else None,
request_cost=_money(spec.request_cost),
invoice_amount=_money(spec.invoice_amount),
paid_at=(NOW - timedelta(days=spec.paid_days_ago)) if spec.paid_days_ago is not None else None,
paid_by_admin_id=None,
total_attachments_bytes=0,
client_has_unread_updates=spec.client_unread,
client_unread_event_type="MESSAGE" if spec.client_unread else None,
lawyer_has_unread_updates=spec.lawyer_unread,
lawyer_unread_event_type="MESSAGE" if spec.lawyer_unread else None,
responsible=(lawyer.name if lawyer else "Не назначено"),
created_at=created_at,
updated_at=updated_at,
)
db.add(row)
db.flush()
created_requests.append(row)
_seed_status_history(db, row, status_timeline, lawyers_by_email, lawyer)
_seed_chat_messages(db, row, lawyer, spec.chat_pairs)
_seed_notifications(db, row, lawyer, spec.client_unread, spec.lawyer_unread)
if spec.invoice_status and spec.invoice_amount is not None:
_seed_invoice(db, row, client, lawyer, spec.invoice_status, spec.invoice_amount, spec.paid_days_ago)
print(
"manual seed cleanup:",
", ".join(f"{k}={v}" for k, v in cleanup_counts.items() if v),
)
return created_requests
def _build_status_timeline(final_status: str, created_at: datetime, important_date: datetime | None):
chain_by_final = {
"NEW": ["NEW"],
"ASSIGNED": ["NEW", "ASSIGNED"],
"IN_PROGRESS": ["NEW", "ASSIGNED", "IN_PROGRESS"],
"WAITING_CLIENT": ["NEW", "ASSIGNED", "IN_PROGRESS", "WAITING_CLIENT"],
"WAITING_DOCUMENTS": ["NEW", "ASSIGNED", "IN_PROGRESS", "WAITING_DOCUMENTS"],
"PAUSED": ["NEW", "ASSIGNED", "IN_PROGRESS", "PAUSED"],
"RESOLVED": ["NEW", "ASSIGNED", "IN_PROGRESS", "RESOLVED"],
"CLOSED": ["NEW", "ASSIGNED", "IN_PROGRESS", "WAITING_CLIENT", "CLOSED"],
}
chain = chain_by_final.get(final_status, ["NEW", final_status])
steps = []
base = created_at
for i, status_code in enumerate(chain):
step_at = base + timedelta(hours=max(1, i * 18))
if i == len(chain) - 1:
imp = important_date
else:
imp = step_at + timedelta(days=3)
steps.append({"to_status": status_code, "at": step_at, "important_date_at": imp})
return steps
def _seed_status_history(db, request: Request, timeline: list[dict], lawyers_by_email: dict[str, AdminUser], assigned_lawyer: AdminUser | None):
actor = assigned_lawyer
for idx, item in enumerate(timeline):
from_status = timeline[idx - 1]["to_status"] if idx > 0 else None
db.add(
StatusHistory(
request_id=request.id,
from_status=from_status,
to_status=item["to_status"],
changed_by_admin_id=actor.id if actor else None,
comment=(
"Создание заявки" if idx == 0 else f"Переход в статус {item['to_status']} (сценарий ручного теста)"
),
important_date_at=item["important_date_at"],
responsible=(actor.name if actor else "Сид ручных тестов"),
created_at=item["at"],
updated_at=item["at"],
)
)
def _seed_chat_messages(db, request: Request, lawyer: AdminUser | None, chat_pairs: int):
start_time = (request.created_at or NOW) + timedelta(hours=2)
for i in range(chat_pairs):
client_time = start_time + timedelta(hours=i * 8)
db.add(
Message(
request_id=request.id,
author_type="CLIENT",
author_name=request.client_name,
body=f"Клиент: сообщение #{i + 1} по заявке {request.track_number}",
immutable=False,
responsible="Клиент",
created_at=client_time,
updated_at=client_time,
)
)
if lawyer:
lawyer_time = client_time + timedelta(hours=1)
db.add(
Message(
request_id=request.id,
author_type="LAWYER",
author_name=lawyer.name,
body=f"Юрист: ответ #{i + 1} по заявке {request.track_number}",
immutable=False,
responsible=lawyer.name,
created_at=lawyer_time,
updated_at=lawyer_time,
)
)
def _seed_notifications(db, request: Request, lawyer: AdminUser | None, client_unread: bool, lawyer_unread: bool):
base_payload = {
"request_id": str(request.id),
"track_number": request.track_number,
"status_code": request.status_code,
"topic_code": request.topic_code,
}
if client_unread:
db.add(
Notification(
request_id=request.id,
recipient_type="CLIENT",
recipient_track_number=request.track_number,
event_type="MESSAGE",
title=f"Новое сообщение по заявке {request.track_number}",
body="Есть непрочитанный ответ юриста",
payload=base_payload,
is_read=False,
responsible="Сид ручных тестов",
dedupe_key=f"manual-seed:client:{request.track_number}",
created_at=(request.updated_at or NOW) - timedelta(minutes=5),
updated_at=(request.updated_at or NOW) - timedelta(minutes=5),
)
)
if lawyer_unread and lawyer:
db.add(
Notification(
request_id=request.id,
recipient_type="ADMIN_USER",
recipient_admin_user_id=lawyer.id,
event_type="MESSAGE",
title=f"Новое сообщение по заявке {request.track_number}",
body="Есть непрочитанное сообщение клиента",
payload=base_payload,
is_read=False,
responsible="Сид ручных тестов",
dedupe_key=f"manual-seed:lawyer:{lawyer.id}:{request.track_number}",
created_at=(request.updated_at or NOW) - timedelta(minutes=4),
updated_at=(request.updated_at or NOW) - timedelta(minutes=4),
)
)
def _seed_invoice(
db,
request: Request,
client: Client,
lawyer: AdminUser | None,
status: str,
amount: Decimal,
paid_days_ago: int | None,
):
issued_at = (request.created_at or NOW) + timedelta(days=1)
paid_at = (NOW - timedelta(days=paid_days_ago)) if (status == "PAID" and paid_days_ago is not None) else None
db.add(
Invoice(
request_id=request.id,
client_id=client.id,
invoice_number=f"INV-MAN-{request.track_number[-4:]}",
status=status,
amount=_money(amount) or Decimal("0.00"),
currency="RUB",
payer_display_name=client.full_name,
payer_details_encrypted=None,
issued_by_admin_user_id=lawyer.id if lawyer else None,
issued_by_role=("LAWYER" if lawyer else "ADMIN"),
issued_at=issued_at,
paid_at=paid_at,
responsible=(lawyer.name if lawyer else "Администратор системы"),
created_at=issued_at,
updated_at=(paid_at or issued_at),
)
)
def _write_access_file(
admin: AdminUser,
lawyers_by_email: dict[str, AdminUser],
clients_by_phone: dict[str, Client],
requests: list[Request],
) -> None:
requests_by_phone: dict[str, list[Request]] = {}
for row in requests:
requests_by_phone.setdefault(str(row.client_phone), []).append(row)
for rows in requests_by_phone.values():
rows.sort(key=lambda r: str(r.track_number))
topic_name_by_code = {code: name for code, name, _ in TOPICS}
lawyer_by_id = {str(row.id): row for row in lawyers_by_email.values()}
lines: list[str] = []
lines.append("# Тестовые доступы для ручной проверки")
lines.append("")
lines.append("Сид: `app/data/manual_test_seed.py` (идемпотентный, пересоздает заявки `TRK-MAN-*`).")
lines.append(f"Обновлено: `{datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S %Z')}`")
lines.append("")
lines.append("## Администратор")
lines.append(f"- Email: `{admin.email}`")
lines.append(f"- Пароль: `{settings.ADMIN_BOOTSTRAP_PASSWORD}`")
lines.append(f"- Телефон: `{admin.phone or '-'}`")
lines.append("")
lines.append("## Юристы (4)")
for item in LAWYERS:
row = lawyers_by_email[item["email"]]
lines.append(
f"- {row.name}: `{row.email}` / `{LAWYER_PASSWORD}` | тел.: `{row.phone or '-'}` | "
f"основная тема: `{topic_name_by_code.get(str(row.primary_topic_code or ''), row.primary_topic_code or '-')}`"
)
lines.append("")
lines.append("## Клиенты (10) и заявки")
lines.append("Для клиента вход через OTP (код выводится в backend-консоль в mock-режиме).")
for full_name, phone in CLIENTS:
client_rows = requests_by_phone.get(phone, [])
lines.append(f"- {full_name} | тел.: `{phone}` | заявок: `{len(client_rows)}`")
for req in client_rows:
lawyer_name = "-"
if req.assigned_lawyer_id and str(req.assigned_lawyer_id) in lawyer_by_id:
lawyer_name = lawyer_by_id[str(req.assigned_lawyer_id)].name
important = req.important_date_at.astimezone(UTC).strftime("%d.%m.%y %H:%M") if req.important_date_at else "-"
lines.append(
f" - `{req.track_number}` | статус: `{req.status_code}` | тема: `{topic_name_by_code.get(str(req.topic_code or ''), req.topic_code or '-')}` "
f"| юрист: `{lawyer_name}` | важная дата: `{important}`"
)
lines.append("")
lines.append("## Примечания")
lines.append("- В выборке есть неназначенные заявки, активные, ожидающие и терминальные статусы.")
lines.append("- Есть заявки с оплаченными и ожидающими оплату счетами для проверки dashboard/финансов.")
lines.append("- В активных заявках есть переписка и непрочитанные уведомления.")
ACCESS_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
ACCESS_FILE_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
def seed_manual_test_data() -> dict[str, int]:
db = SessionLocal()
try:
_ensure_bootstrap_admin(db)
_ensure_topics(db)
groups = _ensure_status_groups(db)
_ensure_statuses(db, groups)
lawyers_by_email = _ensure_lawyers(db)
clients_by_phone = _ensure_clients(db)
db.flush()
created_requests = _rebuild_manual_requests(db, clients_by_phone, lawyers_by_email)
db.commit()
admin = db.query(AdminUser).filter(AdminUser.email == settings.ADMIN_BOOTSTRAP_EMAIL).first()
if admin is None:
raise RuntimeError("Bootstrap admin not found after seed")
# refresh rows for final access file output
final_requests = (
db.query(Request)
.filter(Request.track_number.like(f"{REQUEST_PREFIX}%"))
.order_by(Request.track_number.asc())
.all()
)
_write_access_file(admin, lawyers_by_email, clients_by_phone, final_requests)
summary = {
"clients": db.query(Client).filter(Client.phone.like(f"{CLIENT_PHONE_PREFIX}%")).count(),
"lawyers": db.query(AdminUser).filter(AdminUser.email.like("lawyer%.manual@%")).count(),
"requests": len(final_requests),
"messages": db.query(Message).join(Request, Message.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(),
"status_history": db.query(StatusHistory).join(Request, StatusHistory.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(),
"invoices": db.query(Invoice).join(Request, Invoice.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(),
"notifications": db.query(Notification).join(Request, Notification.request_id == Request.id).filter(Request.track_number.like(f"{REQUEST_PREFIX}%")).count(),
}
print("manual seed summary:", summary)
print("manual access file:", str(ACCESS_FILE_PATH))
return summary
finally:
db.close()
if __name__ == "__main__":
seed_manual_test_data()

View file

@ -8,6 +8,7 @@ class AdminUser(Base, UUIDMixin, TimestampMixin):
role: Mapped[str] = mapped_column(String(20), nullable=False) # ADMIN|LAWYER role: Mapped[str] = mapped_column(String(20), nullable=False) # ADMIN|LAWYER
name: Mapped[str] = mapped_column(String(200), nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False)
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False) email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
phone: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)

View file

@ -0,0 +1,18 @@
import uuid
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import TimestampMixin, UUIDMixin
class LandingFeaturedStaff(Base, UUIDMixin, TimestampMixin):
__tablename__ = "landing_featured_staff"
admin_user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, unique=True, index=True)
caption: Mapped[str | None] = mapped_column(String(300), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)

View file

@ -15,10 +15,12 @@ class Request(Base, UUIDMixin, TimestampMixin):
client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True) client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW") status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW")
important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False) extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True) assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
effective_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True) effective_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
request_cost: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
invoice_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) invoice_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paid_by_admin_id: Mapped[str | None] = mapped_column(String(64), nullable=True) paid_by_admin_id: Mapped[str | None] = mapped_column(String(64), nullable=True)

View file

@ -1,6 +1,6 @@
import uuid import uuid
from sqlalchemy import String, Boolean, Text, UniqueConstraint from sqlalchemy import String, Boolean, Text, UniqueConstraint, Integer
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
@ -19,9 +19,14 @@ class RequestDataRequirement(Base, UUIDMixin, TimestampMixin):
) )
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True) request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
request_message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
topic_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True) topic_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True) key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(200), nullable=False) label: Mapped[str] = mapped_column(String(200), nullable=False)
field_type: Mapped[str] = mapped_column(String(20), nullable=False, default="text")
document_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
value_text: Mapped[str | None] = mapped_column(String(500), nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)

View file

@ -0,0 +1,24 @@
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import TimestampMixin, UUIDMixin
class RequestDataTemplate(Base, UUIDMixin, TimestampMixin):
__tablename__ = "request_data_templates"
__table_args__ = (
UniqueConstraint("topic_code", "name", name="uq_request_data_templates_topic_name"),
)
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)

View file

@ -0,0 +1,24 @@
from __future__ import annotations
import uuid
from sqlalchemy import Integer, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
from app.models.common import TimestampMixin, UUIDMixin
class RequestDataTemplateItem(Base, UUIDMixin, TimestampMixin):
__tablename__ = "request_data_template_items"
__table_args__ = (
UniqueConstraint("request_data_template_id", "key", name="uq_request_data_template_items_template_key"),
)
request_data_template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
topic_data_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(200), nullable=False)
value_type: Mapped[str] = mapped_column(String(20), nullable=False, default="string")
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)

View file

@ -1,5 +1,6 @@
import uuid import uuid
from sqlalchemy import String from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from app.db.session import Base from app.db.session import Base
@ -12,3 +13,4 @@ class StatusHistory(Base, UUIDMixin, TimestampMixin):
to_status: Mapped[str] = mapped_column(String(50), nullable=False) to_status: Mapped[str] = mapped_column(String(50), nullable=False)
changed_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) changed_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
comment: Mapped[str | None] = mapped_column(String(400), nullable=True) comment: Mapped[str | None] = mapped_column(String(400), nullable=True)
important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)

View file

@ -18,6 +18,8 @@ class TopicDataTemplate(Base, UUIDMixin, TimestampMixin):
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True) key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
label: Mapped[str] = mapped_column(String(200), nullable=False) label: Mapped[str] = mapped_column(String(200), nullable=False)
value_type: Mapped[str] = mapped_column(String(20), nullable=False, default="text")
document_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)

View file

@ -63,14 +63,17 @@ class FormFieldUpsert(BaseModel):
class RequestAdminCreate(BaseModel): class RequestAdminCreate(BaseModel):
track_number: Optional[str] = None track_number: Optional[str] = None
client_id: Optional[str] = None
client_name: str client_name: str
client_phone: str client_phone: str
topic_code: Optional[str] = None topic_code: Optional[str] = None
status_code: str = "NEW" status_code: str = "NEW"
important_date_at: Optional[datetime] = None
description: Optional[str] = None description: Optional[str] = None
extra_fields: dict = Field(default_factory=dict) extra_fields: dict = Field(default_factory=dict)
assigned_lawyer_id: Optional[str] = None assigned_lawyer_id: Optional[str] = None
effective_rate: Optional[float] = None effective_rate: Optional[float] = None
request_cost: Optional[float] = None
invoice_amount: Optional[float] = None invoice_amount: Optional[float] = None
paid_at: Optional[datetime] = None paid_at: Optional[datetime] = None
paid_by_admin_id: Optional[str] = None paid_by_admin_id: Optional[str] = None
@ -79,14 +82,17 @@ class RequestAdminCreate(BaseModel):
class RequestAdminPatch(BaseModel): class RequestAdminPatch(BaseModel):
track_number: Optional[str] = None track_number: Optional[str] = None
client_id: Optional[str] = None
client_name: Optional[str] = None client_name: Optional[str] = None
client_phone: Optional[str] = None client_phone: Optional[str] = None
topic_code: Optional[str] = None topic_code: Optional[str] = None
status_code: Optional[str] = None status_code: Optional[str] = None
important_date_at: Optional[datetime] = None
description: Optional[str] = None description: Optional[str] = None
extra_fields: Optional[dict] = None extra_fields: Optional[dict] = None
assigned_lawyer_id: Optional[str] = None assigned_lawyer_id: Optional[str] = None
effective_rate: Optional[float] = None effective_rate: Optional[float] = None
request_cost: Optional[float] = None
invoice_amount: Optional[float] = None invoice_amount: Optional[float] = None
paid_at: Optional[datetime] = None paid_at: Optional[datetime] = None
paid_by_admin_id: Optional[str] = None paid_by_admin_id: Optional[str] = None
@ -97,6 +103,12 @@ class RequestReassign(BaseModel):
lawyer_id: str lawyer_id: str
class RequestStatusChange(BaseModel):
status_code: str
important_date_at: Optional[datetime] = None
comment: Optional[str] = None
class RequestDataRequirementCreate(BaseModel): class RequestDataRequirementCreate(BaseModel):
key: str key: str
label: str label: str

View file

@ -6,7 +6,9 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.message import Message from app.models.message import Message
from app.models.attachment import Attachment
from app.models.request import Request from app.models.request import Request
from app.models.request_data_requirement import RequestDataRequirement
from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event
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
@ -32,6 +34,113 @@ def serialize_message(row: Message) -> dict[str, Any]:
} }
def _truncate_request_data_label(label: str, limit: int = 18) -> str:
text = str(label or "").strip()
if len(text) <= limit:
return text
return text[: max(3, limit - 3)].rstrip() + "..."
def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Message]) -> list[dict[str, Any]]:
message_ids = []
for row in rows:
try:
message_ids.append(row.id)
except Exception:
continue
requirements = (
db.query(RequestDataRequirement)
.filter(
RequestDataRequirement.request_id == request_id,
RequestDataRequirement.request_message_id.in_(message_ids) if message_ids else False,
)
.order_by(
RequestDataRequirement.request_message_id.asc(),
RequestDataRequirement.sort_order.asc(),
RequestDataRequirement.created_at.asc(),
RequestDataRequirement.id.asc(),
)
.all()
if message_ids
else []
)
by_message_id: dict[str, list[RequestDataRequirement]] = {}
for item in requirements:
mid = str(item.request_message_id or "").strip()
if not mid:
continue
by_message_id.setdefault(mid, []).append(item)
file_attachment_ids = []
for item in requirements:
if str(item.field_type or "").lower() != "file":
continue
raw = str(item.value_text or "").strip()
if not raw:
continue
try:
file_attachment_ids.append(raw)
except Exception:
continue
attachment_map: dict[str, Attachment] = {}
if file_attachment_ids:
attachment_rows = db.query(Attachment).filter(Attachment.id.in_(file_attachment_ids)).all()
attachment_map = {str(row.id): row for row in attachment_rows}
out: list[dict[str, Any]] = []
for row in rows:
payload = serialize_message(row)
linked = by_message_id.get(str(row.id), [])
if linked:
linked_sorted = sorted(
linked,
key=lambda req: (
1 if str(req.value_text or "").strip() else 0,
int(req.sort_order or 0),
req.created_at.timestamp() if getattr(req, "created_at", None) else 0,
str(req.id),
),
)
items = []
all_filled = True
for idx, req in enumerate(linked_sorted, start=1):
value_text = str(req.value_text or "").strip()
is_filled = bool(value_text)
if not is_filled:
all_filled = False
items.append(
{
"id": str(req.id),
"index": idx,
"key": req.key,
"label": req.label,
"label_short": _truncate_request_data_label(str(req.label or "")),
"field_type": str(req.field_type or "text"),
"document_name": req.document_name,
"value_text": req.value_text,
"value_file": (
{
"attachment_id": str(attachment_map[value_text].id),
"file_name": attachment_map[value_text].file_name,
"mime_type": attachment_map[value_text].mime_type,
"size_bytes": int(attachment_map[value_text].size_bytes or 0),
"download_url": None,
}
if str(req.field_type or "").lower() == "file" and value_text in attachment_map
else None
),
"is_filled": is_filled,
}
)
payload["message_kind"] = "REQUEST_DATA"
payload["request_data_items"] = items
payload["request_data_all_filled"] = all_filled and bool(items)
payload["body"] = "Запрос"
else:
payload["message_kind"] = "TEXT"
out.append(payload)
return out
def create_client_message(db: Session, *, request: Request, body: str) -> Message: def create_client_message(db: Session, *, request: Request, body: str) -> Message:
message_body = str(body or "").strip() message_body = str(body or "").strip()
if not message_body: if not message_body:

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import uuid import uuid
from typing import Any from typing import Any
from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -42,6 +43,7 @@ def register_status_history(
*, *,
admin: dict[str, Any] | None = None, admin: dict[str, Any] | None = None,
comment: str | None = None, comment: str | None = None,
important_date_at: datetime | None = None,
responsible: str = "Администратор системы", responsible: str = "Администратор системы",
) -> None: ) -> None:
db.add( db.add(
@ -51,6 +53,7 @@ def register_status_history(
to_status=str(to_status or "").strip(), to_status=str(to_status or "").strip(),
changed_by_admin_id=actor_admin_uuid(admin), changed_by_admin_id=actor_admin_uuid(admin),
comment=comment, comment=comment,
important_date_at=important_date_at,
responsible=responsible, responsible=responsible,
) )
) )
@ -64,6 +67,7 @@ def apply_status_change_effects(
to_status: str, to_status: str,
admin: dict[str, Any] | None = None, admin: dict[str, Any] | None = None,
comment: str | None = None, comment: str | None = None,
important_date_at: datetime | None = None,
responsible: str = "Администратор системы", responsible: str = "Администратор системы",
) -> None: ) -> None:
old_code = str(from_status or "").strip() old_code = str(from_status or "").strip()
@ -78,5 +82,6 @@ def apply_status_change_effects(
new_code, new_code,
admin=admin, admin=admin,
comment=comment, comment=comment,
important_date_at=important_date_at,
responsible=responsible, responsible=responsible,
) )

View file

@ -0,0 +1,249 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable
from uuid import UUID
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.models.admin_user import AdminUser
from app.models.admin_user_topic import AdminUserTopic
from app.models.attachment import Attachment
from app.models.audit_log import AuditLog
from app.models.client import Client
from app.models.invoice import Invoice
from app.models.message import Message
from app.models.notification import Notification
from app.models.otp_session import OtpSession
from app.models.request import Request
from app.models.request_data_requirement import RequestDataRequirement
from app.models.request_data_template import RequestDataTemplate
from app.models.request_data_template_item import RequestDataTemplateItem
from app.models.security_audit_log import SecurityAuditLog
from app.models.status_history import StatusHistory
from app.models.topic import Topic
from app.models.topic_data_template import TopicDataTemplate
from app.services.s3_storage import get_s3_storage
@dataclass
class CleanupSpec:
track_numbers: list[str] = field(default_factory=list)
phones: list[str] = field(default_factory=list)
emails: list[str] = field(default_factory=list)
include_default_e2e_patterns: bool = True
def _normalize_list(values: Iterable[object] | None) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for raw in values or []:
text = str(raw or "").strip()
if not text or text in seen:
continue
seen.add(text)
out.append(text)
return out
def _safe_delete_s3_objects(rows: list[Attachment]) -> int:
if not rows:
return 0
deleted = 0
try:
storage = get_s3_storage()
for row in rows:
key = str(row.s3_key or "").strip()
if not key:
continue
try:
storage.ensure_bucket()
storage.client.delete_object(Bucket=storage.bucket, Key=key)
deleted += 1
except Exception:
continue
except Exception:
return 0
return deleted
def cleanup_test_data(db: Session, spec: CleanupSpec | None = None) -> dict[str, int]:
payload = spec or CleanupSpec()
track_numbers = _normalize_list(payload.track_numbers)
phones = _normalize_list(payload.phones)
emails = [item.lower() for item in _normalize_list(payload.emails)]
request_query = db.query(Request)
request_filters = []
if track_numbers:
request_filters.append(Request.track_number.in_(track_numbers))
if phones:
request_filters.append(Request.client_phone.in_(phones))
if payload.include_default_e2e_patterns:
request_filters.extend(
[
Request.client_name.ilike("Клиент E2E %"),
Request.description.ilike("%e2e%"),
Request.description.ilike("%E2E%"),
]
)
request_rows = request_query.filter(or_(*request_filters)).all() if request_filters else []
request_ids = [row.id for row in request_rows]
request_tracks = [str(row.track_number or "") for row in request_rows]
client_ids_from_requests = [row.client_id for row in request_rows if row.client_id]
attachment_rows = db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).all() if request_ids else []
attachment_ids = [row.id for row in attachment_rows]
s3_deleted = _safe_delete_s3_objects(attachment_rows)
deleted_counts: dict[str, int] = {
"requests": 0,
"messages": 0,
"attachments": 0,
"status_history": 0,
"invoices": 0,
"notifications": 0,
"request_data_requirements": 0,
"security_audit_log": 0,
"audit_log": 0,
"otp_sessions": 0,
"clients": 0,
"admin_users": 0,
"admin_user_topics": 0,
"topics": 0,
"topic_data_templates": 0,
"request_data_templates": 0,
"request_data_template_items": 0,
"s3_objects": s3_deleted,
}
if request_ids:
deleted_counts["notifications"] += (
db.query(Notification).filter(Notification.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
)
deleted_counts["request_data_requirements"] += (
db.query(RequestDataRequirement).filter(RequestDataRequirement.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
)
deleted_counts["status_history"] += (
db.query(StatusHistory).filter(StatusHistory.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
)
deleted_counts["invoices"] += (
db.query(Invoice).filter(Invoice.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
)
deleted_counts["messages"] += (
db.query(Message).filter(Message.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
)
deleted_counts["attachments"] += (
db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
)
deleted_counts["security_audit_log"] += (
db.query(SecurityAuditLog).filter(SecurityAuditLog.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
)
if attachment_ids:
deleted_counts["security_audit_log"] += (
db.query(SecurityAuditLog).filter(SecurityAuditLog.attachment_id.in_(attachment_ids)).delete(synchronize_session=False) or 0
)
request_id_strs = {str(item) for item in request_ids}
deleted_counts["audit_log"] += (
db.query(AuditLog)
.filter(AuditLog.entity == "requests", AuditLog.entity_id.in_(list(request_id_strs)))
.delete(synchronize_session=False)
or 0
)
deleted_counts["requests"] += (
db.query(Request).filter(Request.id.in_(request_ids)).delete(synchronize_session=False) or 0
)
otp_filters = []
if phones:
otp_filters.append(OtpSession.phone.in_(phones))
if request_tracks:
otp_filters.append(OtpSession.track_number.in_(request_tracks))
if otp_filters:
deleted_counts["otp_sessions"] += db.query(OtpSession).filter(or_(*otp_filters)).delete(synchronize_session=False) or 0
client_filters = []
if client_ids_from_requests:
client_filters.append(Client.id.in_(client_ids_from_requests))
if phones:
client_filters.append(Client.phone.in_(phones))
if payload.include_default_e2e_patterns:
client_filters.append(Client.full_name.ilike("Клиент E2E %"))
if client_filters:
deleted_counts["clients"] += db.query(Client).filter(or_(*client_filters)).delete(synchronize_session=False) or 0
user_rows: list[AdminUser] = []
user_filters = []
if emails:
user_filters.append(AdminUser.email.in_(emails))
if payload.include_default_e2e_patterns:
user_filters.extend([AdminUser.email.ilike("ui-lawyer-%@example.com"), AdminUser.name.ilike("Юрист UI %")])
if user_filters:
user_rows = db.query(AdminUser).filter(or_(*user_filters)).all()
user_ids = [row.id for row in user_rows]
if user_ids:
deleted_counts["admin_user_topics"] += (
db.query(AdminUserTopic).filter(AdminUserTopic.admin_user_id.in_(user_ids)).delete(synchronize_session=False) or 0
)
deleted_counts["notifications"] += (
db.query(Notification).filter(Notification.recipient_admin_user_id.in_(user_ids)).delete(synchronize_session=False) or 0
)
deleted_counts["audit_log"] += (
db.query(AuditLog).filter(AuditLog.actor_admin_id.in_(user_ids)).delete(synchronize_session=False) or 0
)
deleted_counts["admin_users"] += (
db.query(AdminUser).filter(AdminUser.id.in_(user_ids)).delete(synchronize_session=False) or 0
)
topic_rows: list[Topic] = []
topic_filters = []
if payload.include_default_e2e_patterns:
topic_filters.append(Topic.name.ilike("Тема UI %"))
if topic_filters:
topic_rows = db.query(Topic).filter(or_(*topic_filters)).all()
topic_codes = [str(row.code or "").strip() for row in topic_rows if str(row.code or "").strip()]
if topic_codes:
deleted_counts["topic_data_templates"] += (
db.query(TopicDataTemplate).filter(TopicDataTemplate.topic_code.in_(topic_codes)).delete(synchronize_session=False) or 0
)
template_rows = db.query(RequestDataTemplate).filter(RequestDataTemplate.topic_code.in_(topic_codes)).all()
template_ids = [row.id for row in template_rows]
if template_ids:
deleted_counts["request_data_template_items"] += (
db.query(RequestDataTemplateItem)
.filter(RequestDataTemplateItem.request_data_template_id.in_(template_ids))
.delete(synchronize_session=False)
or 0
)
deleted_counts["request_data_templates"] += (
db.query(RequestDataTemplate).filter(RequestDataTemplate.id.in_(template_ids)).delete(synchronize_session=False) or 0
)
deleted_counts["topics"] += db.query(Topic).filter(Topic.code.in_(topic_codes)).delete(synchronize_session=False) or 0
if payload.include_default_e2e_patterns:
deleted_counts["topic_data_templates"] += (
db.query(TopicDataTemplate)
.filter(or_(TopicDataTemplate.label.ilike("%E2E%"), TopicDataTemplate.label.ilike("%e2e%")))
.delete(synchronize_session=False)
or 0
)
template_rows = (
db.query(RequestDataTemplate)
.filter(or_(RequestDataTemplate.name.ilike("%E2E%"), RequestDataTemplate.name.ilike("%e2e%")))
.all()
)
template_ids = [row.id for row in template_rows]
if template_ids:
deleted_counts["request_data_template_items"] += (
db.query(RequestDataTemplateItem)
.filter(RequestDataTemplateItem.request_data_template_id.in_(template_ids))
.delete(synchronize_session=False)
or 0
)
deleted_counts["request_data_templates"] += (
db.query(RequestDataTemplate).filter(RequestDataTemplate.id.in_(template_ids)).delete(synchronize_session=False) or 0
)
db.commit()
return deleted_counts

View file

@ -1,31 +1,146 @@
import uuid import uuid
from datetime import date, datetime, timezone
from datetime import timedelta
from decimal import Decimal, InvalidOperation
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Query
from sqlalchemy import asc, desc from sqlalchemy import asc, desc
from sqlalchemy.orm import Query
from app.schemas.universal import UniversalQuery from app.schemas.universal import UniversalQuery
def _bad_filter_value(column_key: str, kind: str) -> HTTPException:
return HTTPException(status_code=400, detail=f'Некорректное значение фильтра для поля "{column_key}" ({kind})')
def _coerce_bool_filter_value(column_key: str, value):
if isinstance(value, bool):
return value
text = str(value or "").strip().lower()
if text in {"1", "true", "yes", "y", "да"}:
return True
if text in {"0", "false", "no", "n", "нет"}:
return False
raise _bad_filter_value(column_key, "boolean")
def _coerce_number_filter_value(column_key: str, value, python_type):
if value is None:
return None
if python_type in {int, float} and isinstance(value, (int, float)):
return python_type(value)
if python_type is Decimal and isinstance(value, Decimal):
return value
text = str(value).strip()
if not text:
raise _bad_filter_value(column_key, "number")
normalized = text.replace(",", ".")
try:
if python_type is int:
return int(normalized)
if python_type is float:
return float(normalized)
if python_type is Decimal:
return Decimal(normalized)
return python_type(normalized)
except (ValueError, TypeError, InvalidOperation):
raise _bad_filter_value(column_key, "number")
def _coerce_date_filter_value(column_key: str, value):
if isinstance(value, date) and not isinstance(value, datetime):
return value
text = str(value or "").strip()
if not text:
raise _bad_filter_value(column_key, "date")
try:
# Accept either YYYY-MM-DD or full ISO datetime and take its date part.
if "T" in text or " " in text:
return datetime.fromisoformat(text.replace("Z", "+00:00")).date()
return date.fromisoformat(text)
except ValueError:
raise _bad_filter_value(column_key, "date")
def _coerce_datetime_filter_value(column_key: str, value):
if isinstance(value, datetime):
parsed = value
else:
text = str(value or "").strip()
if not text:
raise _bad_filter_value(column_key, "datetime")
try:
if "T" not in text and " " not in text and len(text) == 10:
# Date-only filter value for timestamp columns -> start of the day.
parsed = datetime.combine(date.fromisoformat(text), datetime.min.time())
else:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
raise _bad_filter_value(column_key, "datetime")
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _coerce_filter_value(column, value): def _coerce_filter_value(column, value):
try: try:
python_type = column.property.columns[0].type.python_type python_type = column.property.columns[0].type.python_type
except Exception: except Exception:
return value return value
if python_type is uuid.UUID and isinstance(value, str): if python_type is uuid.UUID:
if isinstance(value, uuid.UUID):
return value
try: try:
return uuid.UUID(value) return uuid.UUID(str(value or "").strip())
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail=f'Некорректный UUID в фильтре поля "{column.key}"') raise HTTPException(status_code=400, detail=f'Некорректный UUID в фильтре поля "{column.key}"')
if python_type is bool:
return _coerce_bool_filter_value(column.key, value)
if python_type in {int, float, Decimal}:
return _coerce_number_filter_value(column.key, value, python_type)
if python_type is date:
return _coerce_date_filter_value(column.key, value)
if python_type is datetime:
return _coerce_datetime_filter_value(column.key, value)
return value return value
def _column_python_type(column):
try:
return column.property.columns[0].type.python_type
except Exception:
return None
def _is_date_only_filter_literal(raw_value) -> bool:
if isinstance(raw_value, date) and not isinstance(raw_value, datetime):
return True
if not isinstance(raw_value, str):
return False
text = raw_value.strip()
if not text or "T" in text or " " in text:
return False
try:
date.fromisoformat(text)
return True
except ValueError:
return False
def apply_universal_query(q: Query, model, uq: UniversalQuery) -> Query: def apply_universal_query(q: Query, model, uq: UniversalQuery) -> Query:
for f in uq.filters: for f in uq.filters:
col = getattr(model, f.field, None) col = getattr(model, f.field, None)
if col is None: if col is None:
continue continue
col_python_type = _column_python_type(col)
value = _coerce_filter_value(col, f.value) value = _coerce_filter_value(col, f.value)
if col_python_type is datetime and f.op in {"=", "!="} and _is_date_only_filter_literal(f.value):
day_start = value
day_end = day_start + timedelta(days=1)
day_expr = (col >= day_start) & (col < day_end)
q = q.filter(day_expr if f.op == "=" else ~day_expr)
continue
if f.op == "=": if f.op == "=":
q = q.filter(col == value) q = q.filter(col == value)
elif f.op == "!=": elif f.op == "!=":

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,569 @@
import { KNOWN_CONFIG_TABLE_KEYS, OPERATOR_LABELS, TABLE_SERVER_CONFIG } from "../../shared/constants.js";
import { boolLabel, fmtDate, listPreview, normalizeReferenceMeta, roleLabel, statusKindLabel, statusLabel } from "../../shared/utils.js";
export function ConfigSection(props) {
const {
token,
tables,
dictionaries,
configActiveKey,
activeConfigTableState,
activeConfigMeta,
genericConfigHeaders,
canCreateInConfig,
canUpdateInConfig,
canDeleteInConfig,
statusDesignerTopicCode,
statusDesignerCards,
getTableLabel,
getFieldDef,
getFilterValuePreview,
resolveReferenceLabel,
resolveTableConfig,
getStatus,
loadCurrentConfigTable,
openCreateRecordModal,
openFilterModal,
removeFilterChip,
openFilterEditModal,
toggleTableSort,
openEditRecordModal,
deleteRecord,
loadStatusDesignerTopic,
openCreateStatusTransitionForTopic,
loadPrevPage,
loadNextPage,
loadAllRows,
FilterToolbarComponent,
DataTableComponent,
TablePagerComponent,
StatusLineComponent,
IconButtonComponent,
UserAvatarComponent,
} = props;
const FilterToolbar = FilterToolbarComponent;
const DataTable = DataTableComponent;
const TablePager = TablePagerComponent;
const StatusLine = StatusLineComponent;
const IconButton = IconButtonComponent;
const UserAvatar = UserAvatarComponent;
return (
<>
<div className="section-head">
<div>
<h2>Справочники</h2>
<p className="breadcrumbs">{"Справочники -> " + (configActiveKey ? getTableLabel(configActiveKey) : "Справочник не выбран")}</p>
<p className="muted">Выберите справочник в дереве слева.</p>
</div>
<button className="btn secondary" type="button" onClick={() => loadCurrentConfigTable(true)}>
Обновить
</button>
</div>
<div className="config-layout">
<div className="config-panel">
<div className="block">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }}>
<h3 style={{ margin: 0 }}>{configActiveKey ? getTableLabel(configActiveKey) : "Справочник не выбран"}</h3>
{canCreateInConfig && configActiveKey ? (
<button className="btn" type="button" onClick={() => openCreateRecordModal(configActiveKey)}>
Добавить
</button>
) : null}
</div>
<FilterToolbar
filters={activeConfigTableState.filters}
onOpen={() => openFilterModal(configActiveKey)}
onRemove={(index) => removeFilterChip(configActiveKey, index)}
onEdit={(index) => openFilterEditModal(configActiveKey, index)}
getChipLabel={(clause) => {
const fieldDef = getFieldDef(configActiveKey, clause.field);
return (
(fieldDef ? fieldDef.label : clause.field) +
" " +
OPERATOR_LABELS[clause.op] +
" " +
getFilterValuePreview(configActiveKey, clause)
);
}}
/>
{configActiveKey === "topics" ? (
<DataTable
headers={[
{ key: "code", label: "Код", sortable: true, field: "code" },
{ key: "name", label: "Название", sortable: true, field: "name" },
{ key: "enabled", label: "Активна", sortable: true, field: "enabled" },
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
{ key: "actions", label: "Действия" },
]}
rows={tables.topics.rows}
emptyColspan={5}
onSort={(field) => toggleTableSort("topics", field)}
sortClause={(tables.topics.sort && tables.topics.sort[0]) || TABLE_SERVER_CONFIG.topics.sort[0]}
renderRow={(row) => (
<tr key={row.id}>
<td>
<code>{row.code || "-"}</code>
</td>
<td>{row.name || "-"}</td>
<td>{boolLabel(row.enabled)}</td>
<td>{String(row.sort_order ?? 0)}</td>
<td>
<div className="table-actions">
<IconButton icon="✎" tooltip="Редактировать тему" onClick={() => openEditRecordModal("topics", row)} />
<IconButton icon="🗑" tooltip="Удалить тему" onClick={() => deleteRecord("topics", row.id)} tone="danger" />
</div>
</td>
</tr>
)}
/>
) : null}
{configActiveKey === "quotes" ? (
<DataTable
headers={[
{ key: "author", label: "Автор", sortable: true, field: "author" },
{ key: "text", label: "Текст", sortable: true, field: "text" },
{ key: "source", label: "Источник", sortable: true, field: "source" },
{ key: "is_active", label: "Активна", sortable: true, field: "is_active" },
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
{ key: "created_at", label: "Создана", sortable: true, field: "created_at" },
{ key: "actions", label: "Действия" },
]}
rows={tables.quotes.rows}
emptyColspan={7}
onSort={(field) => toggleTableSort("quotes", field)}
sortClause={(tables.quotes.sort && tables.quotes.sort[0]) || TABLE_SERVER_CONFIG.quotes.sort[0]}
renderRow={(row) => (
<tr key={row.id}>
<td>{row.author || "-"}</td>
<td>{row.text || "-"}</td>
<td>{row.source || "-"}</td>
<td>{boolLabel(row.is_active)}</td>
<td>{String(row.sort_order ?? 0)}</td>
<td>{fmtDate(row.created_at)}</td>
<td>
<div className="table-actions">
<IconButton icon="✎" tooltip="Редактировать цитату" onClick={() => openEditRecordModal("quotes", row)} />
<IconButton icon="🗑" tooltip="Удалить цитату" onClick={() => deleteRecord("quotes", row.id)} tone="danger" />
</div>
</td>
</tr>
)}
/>
) : null}
{configActiveKey === "statuses" ? (
<DataTable
headers={[
{ key: "code", label: "Код", sortable: true, field: "code" },
{ key: "name", label: "Название", sortable: true, field: "name" },
{ key: "status_group_id", label: "Группа", sortable: true, field: "status_group_id" },
{ key: "kind", label: "Тип", sortable: true, field: "kind" },
{ key: "enabled", label: "Активен", sortable: true, field: "enabled" },
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
{ key: "is_terminal", label: "Терминальный", sortable: true, field: "is_terminal" },
{ key: "invoice_template", label: "Шаблон счета" },
{ key: "actions", label: "Действия" },
]}
rows={tables.statuses.rows}
emptyColspan={9}
onSort={(field) => toggleTableSort("statuses", field)}
sortClause={(tables.statuses.sort && tables.statuses.sort[0]) || TABLE_SERVER_CONFIG.statuses.sort[0]}
renderRow={(row) => (
<tr key={row.id}>
<td>
<code>{row.code || "-"}</code>
</td>
<td>{row.name || "-"}</td>
<td>{resolveReferenceLabel({ table: "status_groups", value_field: "id", label_field: "name" }, row.status_group_id)}</td>
<td>{statusKindLabel(row.kind)}</td>
<td>{boolLabel(row.enabled)}</td>
<td>{String(row.sort_order ?? 0)}</td>
<td>{boolLabel(row.is_terminal)}</td>
<td>{row.invoice_template || "-"}</td>
<td>
<div className="table-actions">
<IconButton icon="✎" tooltip="Редактировать статус" onClick={() => openEditRecordModal("statuses", row)} />
<IconButton icon="🗑" tooltip="Удалить статус" onClick={() => deleteRecord("statuses", row.id)} tone="danger" />
</div>
</td>
</tr>
)}
/>
) : null}
{configActiveKey === "formFields" ? (
<DataTable
headers={[
{ key: "key", label: "Ключ", sortable: true, field: "key" },
{ key: "label", label: "Метка", sortable: true, field: "label" },
{ key: "type", label: "Тип", sortable: true, field: "type" },
{ key: "required", label: "Обязательное", sortable: true, field: "required" },
{ key: "enabled", label: "Активно", sortable: true, field: "enabled" },
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
{ key: "actions", label: "Действия" },
]}
rows={tables.formFields.rows}
emptyColspan={7}
onSort={(field) => toggleTableSort("formFields", field)}
sortClause={(tables.formFields.sort && tables.formFields.sort[0]) || TABLE_SERVER_CONFIG.formFields.sort[0]}
renderRow={(row) => (
<tr key={row.id}>
<td>
<code>{row.key || "-"}</code>
</td>
<td>{row.label || "-"}</td>
<td>{row.type || "-"}</td>
<td>{boolLabel(row.required)}</td>
<td>{boolLabel(row.enabled)}</td>
<td>{String(row.sort_order ?? 0)}</td>
<td>
<div className="table-actions">
<IconButton icon="✎" tooltip="Редактировать поле формы" onClick={() => openEditRecordModal("formFields", row)} />
<IconButton icon="🗑" tooltip="Удалить поле формы" onClick={() => deleteRecord("formFields", row.id)} tone="danger" />
</div>
</td>
</tr>
)}
/>
) : null}
{configActiveKey === "topicRequiredFields" ? (
<DataTable
headers={[
{ key: "topic_code", label: "Тема", sortable: true, field: "topic_code" },
{ key: "field_key", label: "Поле формы", sortable: true, field: "field_key" },
{ key: "required", label: "Обязательное", sortable: true, field: "required" },
{ key: "enabled", label: "Активно", sortable: true, field: "enabled" },
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
{ key: "created_at", label: "Создано", sortable: true, field: "created_at" },
{ key: "actions", label: "Действия" },
]}
rows={tables.topicRequiredFields.rows}
emptyColspan={7}
onSort={(field) => toggleTableSort("topicRequiredFields", field)}
sortClause={
(tables.topicRequiredFields.sort && tables.topicRequiredFields.sort[0]) ||
TABLE_SERVER_CONFIG.topicRequiredFields.sort[0]
}
renderRow={(row) => (
<tr key={row.id}>
<td>{row.topic_code || "-"}</td>
<td>
<code>{row.field_key || "-"}</code>
</td>
<td>{boolLabel(row.required)}</td>
<td>{boolLabel(row.enabled)}</td>
<td>{String(row.sort_order ?? 0)}</td>
<td>{fmtDate(row.created_at)}</td>
<td>
<div className="table-actions">
<IconButton
icon="✎"
tooltip="Редактировать обязательное поле"
onClick={() => openEditRecordModal("topicRequiredFields", row)}
/>
<IconButton
icon="🗑"
tooltip="Удалить обязательное поле"
onClick={() => deleteRecord("topicRequiredFields", row.id)}
tone="danger"
/>
</div>
</td>
</tr>
)}
/>
) : null}
{configActiveKey === "topicDataTemplates" ? (
<DataTable
headers={[
{ key: "topic_code", label: "Тема", sortable: true, field: "topic_code" },
{ key: "key", label: "Ключ", sortable: true, field: "key" },
{ key: "label", label: "Метка", sortable: true, field: "label" },
{ key: "description", label: "Описание", sortable: true, field: "description" },
{ key: "required", label: "Обязательное", sortable: true, field: "required" },
{ key: "enabled", label: "Активно", sortable: true, field: "enabled" },
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
{ key: "created_at", label: "Создано", sortable: true, field: "created_at" },
{ key: "actions", label: "Действия" },
]}
rows={tables.topicDataTemplates.rows}
emptyColspan={9}
onSort={(field) => toggleTableSort("topicDataTemplates", field)}
sortClause={
(tables.topicDataTemplates.sort && tables.topicDataTemplates.sort[0]) ||
TABLE_SERVER_CONFIG.topicDataTemplates.sort[0]
}
renderRow={(row) => (
<tr key={row.id}>
<td>{row.topic_code || "-"}</td>
<td>
<code>{row.key || "-"}</code>
</td>
<td>{row.label || "-"}</td>
<td>{row.description || "-"}</td>
<td>{boolLabel(row.required)}</td>
<td>{boolLabel(row.enabled)}</td>
<td>{String(row.sort_order ?? 0)}</td>
<td>{fmtDate(row.created_at)}</td>
<td>
<div className="table-actions">
<IconButton icon="✎" tooltip="Редактировать шаблон" onClick={() => openEditRecordModal("topicDataTemplates", row)} />
<IconButton icon="🗑" tooltip="Удалить шаблон" onClick={() => deleteRecord("topicDataTemplates", row.id)} tone="danger" />
</div>
</td>
</tr>
)}
/>
) : null}
{configActiveKey === "statusTransitions" ? (
<>
<div className="status-designer">
<div className="status-designer-head">
<div>
<h4>Конструктор маршрута статусов</h4>
<p className="muted">Ветвления, возвраты, SLA и требования к данным/файлам на каждом переходе.</p>
</div>
<div className="status-designer-controls">
<select
id="status-designer-topic"
value={statusDesignerTopicCode}
onChange={(event) => loadStatusDesignerTopic(event.target.value)}
>
<option value="">Выберите тему</option>
{(dictionaries.topics || []).map((topic) => (
<option key={topic.code} value={topic.code}>
{(topic.name || topic.code) + " (" + topic.code + ")"}
</option>
))}
</select>
<button className="btn secondary btn-sm" type="button" onClick={() => loadStatusDesignerTopic(statusDesignerTopicCode)}>
Обновить тему
</button>
<button className="btn btn-sm" type="button" onClick={openCreateStatusTransitionForTopic}>
Добавить переход
</button>
</div>
</div>
{statusDesignerCards.length ? (
<div className="status-designer-grid" id="status-designer-cards">
{statusDesignerCards.map((card) => (
<div className="status-node-card" key={card.code}>
<div className="status-node-head">
<div>
<b>{card.name}</b>
<code>{card.code}</code>
</div>
{card.isTerminal ? <span className="status-node-terminal">Терминальный</span> : null}
</div>
{card.outgoing.length ? (
<ul className="simple-list status-node-links">
{card.outgoing.map((link) => (
<li key={String(link.id)}>
<button
className="status-link-chip"
type="button"
onClick={() => openEditRecordModal("statusTransitions", link)}
>
<span>{statusLabel(link.to_status) + " (" + String(link.to_status || "-") + ")"}</span>
<small>
{"SLA: " +
(link.sla_hours == null ? "-" : String(link.sla_hours) + " ч") +
" • Данные: " +
listPreview(link.required_data_keys, "-") +
" • Файлы: " +
listPreview(link.required_mime_types, "-")}
</small>
</button>
</li>
))}
</ul>
) : (
<p className="muted">Нет исходящих переходов</p>
)}
</div>
))}
</div>
) : (
<p className="muted">Для выбранной темы переходы пока не настроены.</p>
)}
</div>
<DataTable
headers={[
{ key: "topic_code", label: "Тема", sortable: true, field: "topic_code" },
{ key: "from_status", label: "Из статуса", sortable: true, field: "from_status" },
{ key: "to_status", label: "В статус", sortable: true, field: "to_status" },
{ key: "sla_hours", label: "SLA (часы)", sortable: true, field: "sla_hours" },
{ key: "required_data_keys", label: "Обязательные данные" },
{ key: "required_mime_types", label: "Обязательные файлы" },
{ key: "enabled", label: "Активен", sortable: true, field: "enabled" },
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
{ key: "actions", label: "Действия" },
]}
rows={tables.statusTransitions.rows}
emptyColspan={9}
onSort={(field) => toggleTableSort("statusTransitions", field)}
sortClause={
(tables.statusTransitions.sort && tables.statusTransitions.sort[0]) || TABLE_SERVER_CONFIG.statusTransitions.sort[0]
}
renderRow={(row) => (
<tr key={row.id}>
<td>{row.topic_code || "-"}</td>
<td>{statusLabel(row.from_status)}</td>
<td>{statusLabel(row.to_status)}</td>
<td>{row.sla_hours == null ? "-" : String(row.sla_hours)}</td>
<td>{listPreview(row.required_data_keys, "-")}</td>
<td>{listPreview(row.required_mime_types, "-")}</td>
<td>{boolLabel(row.enabled)}</td>
<td>{String(row.sort_order ?? 0)}</td>
<td>
<div className="table-actions">
<IconButton
icon="✎"
tooltip="Редактировать переход"
onClick={() => openEditRecordModal("statusTransitions", row)}
/>
<IconButton
icon="🗑"
tooltip="Удалить переход"
onClick={() => deleteRecord("statusTransitions", row.id)}
tone="danger"
/>
</div>
</td>
</tr>
)}
/>
</>
) : null}
{configActiveKey === "users" ? (
<DataTable
headers={[
{ key: "name", label: "Пользователь", sortable: true, field: "name" },
{ key: "email", label: "Email", sortable: true, field: "email" },
{ key: "role", label: "Роль", sortable: true, field: "role" },
{ key: "primary_topic_code", label: "Профиль (тема)", sortable: true, field: "primary_topic_code" },
{ key: "default_rate", label: "Ставка", sortable: true, field: "default_rate" },
{ key: "salary_percent", label: "Процент", sortable: true, field: "salary_percent" },
{ key: "is_active", label: "Активен", sortable: true, field: "is_active" },
{ key: "responsible", label: "Ответственный", sortable: true, field: "responsible" },
{ key: "created_at", label: "Создан", sortable: true, field: "created_at" },
{ key: "actions", label: "Действия" },
]}
rows={tables.users.rows}
emptyColspan={10}
onSort={(field) => toggleTableSort("users", field)}
sortClause={(tables.users.sort && tables.users.sort[0]) || TABLE_SERVER_CONFIG.users.sort[0]}
renderRow={(row) => (
<tr key={row.id}>
<td>
<div className="user-identity">
<UserAvatar name={row.name} email={row.email} avatarUrl={row.avatar_url} accessToken={token} size={32} />
<div className="user-identity-text">
<b>{row.name || "-"}</b>
</div>
</div>
</td>
<td>{row.email || "-"}</td>
<td>{roleLabel(row.role)}</td>
<td>{resolveReferenceLabel({ table: "topics", value_field: "code", label_field: "name" }, row.primary_topic_code)}</td>
<td>{row.default_rate == null ? "-" : String(row.default_rate)}</td>
<td>{row.salary_percent == null ? "-" : String(row.salary_percent)}</td>
<td>{boolLabel(row.is_active)}</td>
<td>{row.responsible || "-"}</td>
<td>{fmtDate(row.created_at)}</td>
<td>
<div className="table-actions">
<IconButton icon="✎" tooltip="Редактировать пользователя" onClick={() => openEditRecordModal("users", row)} />
<IconButton icon="🗑" tooltip="Удалить пользователя" onClick={() => deleteRecord("users", row.id)} tone="danger" />
</div>
</td>
</tr>
)}
/>
) : null}
{configActiveKey === "userTopics" ? (
<DataTable
headers={[
{ key: "admin_user_id", label: "Юрист", sortable: true, field: "admin_user_id" },
{ key: "topic_code", label: "Доп. тема", sortable: true, field: "topic_code" },
{ key: "responsible", label: "Ответственный", sortable: true, field: "responsible" },
{ key: "created_at", label: "Создано", sortable: true, field: "created_at" },
{ key: "actions", label: "Действия" },
]}
rows={tables.userTopics.rows}
emptyColspan={5}
onSort={(field) => toggleTableSort("userTopics", field)}
sortClause={(tables.userTopics.sort && tables.userTopics.sort[0]) || TABLE_SERVER_CONFIG.userTopics.sort[0]}
renderRow={(row) => {
const lawyer = (dictionaries.users || []).find((item) => String(item.id) === String(row.admin_user_id));
const lawyerLabel = lawyer ? (lawyer.name || lawyer.email || row.admin_user_id) : row.admin_user_id || "-";
return (
<tr key={row.id}>
<td>{lawyerLabel}</td>
<td>{row.topic_code || "-"}</td>
<td>{row.responsible || "-"}</td>
<td>{fmtDate(row.created_at)}</td>
<td>
<div className="table-actions">
<IconButton icon="✎" tooltip="Редактировать связь" onClick={() => openEditRecordModal("userTopics", row)} />
<IconButton icon="🗑" tooltip="Удалить связь" onClick={() => deleteRecord("userTopics", row.id)} tone="danger" />
</div>
</td>
</tr>
);
}}
/>
) : null}
{configActiveKey && !KNOWN_CONFIG_TABLE_KEYS.has(configActiveKey) ? (
<DataTable
headers={genericConfigHeaders}
rows={activeConfigTableState.rows}
emptyColspan={Math.max(1, genericConfigHeaders.length)}
onSort={(field) => toggleTableSort(configActiveKey, field)}
sortClause={
(activeConfigTableState.sort && activeConfigTableState.sort[0]) ||
((resolveTableConfig(configActiveKey)?.sort || [])[0])
}
renderRow={(row) => (
<tr key={row.id || JSON.stringify(row)}>
{(activeConfigMeta?.columns || []).map((column) => {
const key = String(column.name || "");
const value = row[key];
if (column.kind === "boolean") return <td key={key}>{boolLabel(Boolean(value))}</td>;
if (column.kind === "date" || column.kind === "datetime") return <td key={key}>{fmtDate(value)}</td>;
if (column.kind === "json") return <td key={key}>{value == null ? "-" : JSON.stringify(value)}</td>;
const reference = normalizeReferenceMeta(column.reference);
if (reference) return <td key={key}>{resolveReferenceLabel(reference, value)}</td>;
return <td key={key}>{value == null || value === "" ? "-" : String(value)}</td>;
})}
{canUpdateInConfig || canDeleteInConfig ? (
<td>
<div className="table-actions">
{canUpdateInConfig ? (
<IconButton icon="✎" tooltip="Редактировать запись" onClick={() => openEditRecordModal(configActiveKey, row)} />
) : null}
{canDeleteInConfig ? (
<IconButton icon="🗑" tooltip="Удалить запись" onClick={() => deleteRecord(configActiveKey, row.id)} tone="danger" />
) : null}
</div>
</td>
) : null}
</tr>
)}
/>
) : null}
<TablePager
tableState={activeConfigTableState}
onPrev={() => loadPrevPage(configActiveKey)}
onNext={() => loadNextPage(configActiveKey)}
onLoadAll={() => loadAllRows(configActiveKey)}
/>
<StatusLine status={getStatus(configActiveKey)} />
</div>
</div>
</div>
</>
);
}
export default ConfigSection;

View file

@ -0,0 +1,253 @@
import { fmtAmount, fmtDate, statusLabel } from "../../shared/utils.js";
export function DashboardSection({
dashboardData,
token,
status,
apiCall,
onOpenRequest,
DataTableComponent,
StatusLineComponent,
UserAvatarComponent,
}) {
const { useMemo, useState } = React;
const DataTable = DataTableComponent;
const StatusLine = StatusLineComponent;
const UserAvatar = UserAvatarComponent;
const [lawyerModal, setLawyerModal] = useState({
open: false,
loading: false,
error: "",
lawyer: null,
rows: [],
totals: { amount: 0, salary: 0 },
});
const statusCards = useMemo(() => {
return Object.entries(dashboardData?.byStatus || {})
.map(([label, value]) => ({ label, value }))
.sort((a, b) => String(a.label).localeCompare(String(b.label), "ru"));
}, [dashboardData?.byStatus]);
const fmtThousandsCompact = (value) => {
const amount = Number(value || 0);
if (!Number.isFinite(amount)) return "0";
return new Intl.NumberFormat("ru-RU", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(amount / 1000);
};
const openLawyerModal = async (lawyerRow) => {
if (!lawyerRow?.lawyer_id || typeof apiCall !== "function") return;
setLawyerModal({
open: true,
loading: true,
error: "",
lawyer: lawyerRow,
rows: [],
totals: { amount: 0, salary: 0 },
});
try {
const data = await apiCall("/api/admin/metrics/lawyers/" + encodeURIComponent(String(lawyerRow.lawyer_id)) + "/active-requests");
setLawyerModal((prev) => ({
...prev,
loading: false,
error: "",
rows: Array.isArray(data?.rows) ? data.rows : [],
totals: {
amount: Number(data?.totals?.amount || 0),
salary: Number(data?.totals?.salary || 0),
},
}));
} catch (error) {
setLawyerModal((prev) => ({ ...prev, loading: false, error: error.message || "Ошибка загрузки" }));
}
};
const closeLawyerModal = () => {
setLawyerModal({ open: false, loading: false, error: "", lawyer: null, rows: [], totals: { amount: 0, salary: 0 } });
};
const lawyerCards = Array.isArray(dashboardData?.lawyerLoads) ? dashboardData.lawyerLoads : [];
return (
<>
<div className="section-head">
<div>
<h2>Обзор метрик</h2>
<p className="muted">Состояние заявок, финансы месяца и загрузка юристов.</p>
</div>
</div>
<div className="cards">
{(dashboardData?.cards || []).map((card) => (
<div className="card" key={card.label}>
<p>{card.label}</p>
<b>{card.value}</b>
</div>
))}
</div>
{statusCards.length ? (
<div style={{ marginTop: "0.8rem" }}>
<div className="section-head" style={{ marginBottom: "0.5rem" }}>
<div>
<h3 style={{ margin: 0 }}>Статусы заявок</h3>
<p className="muted" style={{ marginTop: "0.2rem" }}>Текущая раскладка по всем статусам.</p>
</div>
</div>
<div className="cards">
{statusCards.map((card) => (
<div className="card" key={"status-" + card.label}>
<p>{card.label}</p>
<b>{String(card.value ?? 0)}</b>
</div>
))}
</div>
</div>
) : null}
{dashboardData?.scope === "LAWYER" ? (
<div className="json" style={{ marginTop: "0.5rem" }}>
{JSON.stringify(dashboardData?.myUnreadByEvent || {}, null, 2)}
</div>
) : null}
<div style={{ marginTop: "0.9rem" }}>
<h3 style={{ margin: "0 0 0.55rem" }}>Загрузка юристов</h3>
<div className="lawyer-dashboard-grid">
{lawyerCards.length ? (
lawyerCards.map((row) => (
<button
key={row.lawyer_id}
type="button"
className="lawyer-dashboard-card"
onClick={() => openLawyerModal(row)}
title="Открыть детали юриста"
>
<div className="lawyer-dashboard-left">
<div className="lawyer-dashboard-avatar">
<UserAvatar name={row.name} email={row.email} avatarUrl={row.avatar_url} accessToken={token} size={72} />
</div>
<b className="lawyer-dashboard-name">{row.name || row.email || "-"}</b>
<span className="lawyer-dashboard-topic">{row.primary_topic_code || "Тема не указана"}</span>
</div>
<div className="lawyer-dashboard-right">
<div className="lawyer-metric-pair"><span>В работе</span><b>{String(row.active_load ?? 0)}</b></div>
<div className="lawyer-metric-pair"><span>Новые</span><b>{String(row.monthly_assigned_count ?? 0)}</b></div>
<div className="lawyer-metric-pair"><span>Закрыто</span><b>{String(row.monthly_completed_count ?? 0)}</b></div>
<div className="lawyer-metric-pair"><span>Сумма, тыс.</span><b>{fmtThousandsCompact(row.monthly_paid_gross)}</b></div>
<div className="lawyer-metric-pair"><span>ЗП, тыс.</span><b>{fmtThousandsCompact(row.monthly_salary)}</b></div>
</div>
</button>
))
) : (
<div className="card">
<p>Юристы</p>
<b>Нет данных</b>
</div>
)}
</div>
</div>
<StatusLine status={status} />
<div className={"overlay" + (lawyerModal.open ? " open" : "")} onClick={closeLawyerModal}>
<div className="modal lawyer-dashboard-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>{lawyerModal.lawyer ? "Юрист: " + (lawyerModal.lawyer.name || lawyerModal.lawyer.email || "-") : "Юрист"}</h3>
{lawyerModal.lawyer ? (
<p className="muted" style={{ margin: "0.2rem 0 0" }}>
{(lawyerModal.lawyer.primary_topic_code || "Тема не указана") + " • " + (lawyerModal.lawyer.email || "")}
</p>
) : null}
</div>
<button className="close" type="button" onClick={closeLawyerModal} aria-label="Закрыть">
×
</button>
</div>
{lawyerModal.lawyer ? (
<div className="lawyer-dashboard-modal-summary">
<div className="lawyer-dashboard-modal-avatar">
<UserAvatar
name={lawyerModal.lawyer.name}
email={lawyerModal.lawyer.email}
avatarUrl={lawyerModal.lawyer.avatar_url}
accessToken={token}
size={84}
/>
</div>
<div className="lawyer-dashboard-modal-metrics">
<div className="lawyer-metric-pair"><span>В работе</span><b>{String(lawyerModal.lawyer.active_load ?? 0)}</b></div>
<div className="lawyer-metric-pair"><span>Новые</span><b>{String(lawyerModal.lawyer.monthly_assigned_count ?? 0)}</b></div>
<div className="lawyer-metric-pair"><span>Завершенные</span><b>{String(lawyerModal.lawyer.monthly_completed_count ?? 0)}</b></div>
<div className="lawyer-metric-pair"><span>Сумма</span><b>{fmtAmount(lawyerModal.lawyer.monthly_paid_gross)}</b></div>
<div className="lawyer-metric-pair"><span>Зарплата</span><b>{fmtAmount(lawyerModal.lawyer.monthly_salary)}</b></div>
</div>
</div>
) : null}
<div className="lawyer-dashboard-modal-scroll">
{lawyerModal.loading ? <p className="muted">Загрузка активных заявок...</p> : null}
{lawyerModal.error ? <p className="status error">{lawyerModal.error}</p> : null}
{!lawyerModal.loading ? (
<>
<div className="lawyer-dashboard-modal-table-area">
<DataTable
headers={[
{ key: "track_number", label: "Номер" },
{ key: "status_code", label: "Статус" },
{ key: "client_name", label: "Клиент" },
{ key: "created_at", label: "Создана" },
{ key: "invoice_amount", label: "Сумма по заявке" },
{ key: "month_paid_amount", label: "Оплаты" },
{ key: "month_salary_amount", label: "Зарплата" },
]}
rows={lawyerModal.rows || []}
emptyColspan={7}
renderRow={(row) => (
<tr key={row.id}>
<td>
<button
type="button"
className="request-track-link"
onClick={(event) => {
if (typeof onOpenRequest === "function") onOpenRequest(row.id, event);
closeLawyerModal();
}}
title="Открыть заявку"
>
<code>{row.track_number || "-"}</code>
</button>
</td>
<td>{statusLabel(row.status_code)}</td>
<td>{row.client_name || "-"}</td>
<td>{fmtDate(row.created_at)}</td>
<td>{fmtAmount(row.invoice_amount)}</td>
<td>{fmtAmount(row.month_paid_amount)}</td>
<td>{fmtAmount(row.month_salary_amount)}</td>
</tr>
)}
/>
</div>
</>
) : null}
</div>
{!lawyerModal.loading ? (
<div className="lawyer-dashboard-modal-footer">
<div className="lawyer-dashboard-total-chip">Активных: <b>{String((lawyerModal.rows || []).length)}</b></div>
<div className="lawyer-dashboard-total-chip">Оплаты: <b>{fmtAmount(lawyerModal.totals.amount)}</b></div>
<div className="lawyer-dashboard-total-chip">Зарплата: <b>{fmtAmount(lawyerModal.totals.salary)}</b></div>
</div>
) : null}
</div>
</div>
</>
);
}
export default DashboardSection;

View file

@ -0,0 +1,121 @@
import { OPERATOR_LABELS, TABLE_SERVER_CONFIG } from "../../shared/constants.js";
import { fmtDate, invoiceStatusLabel } from "../../shared/utils.js";
export function InvoicesSection({
role,
tables,
status,
getFieldDef,
getFilterValuePreview,
onRefresh,
onCreate,
onOpenFilter,
onRemoveFilter,
onEditFilter,
onSort,
onPrev,
onNext,
onLoadAll,
onOpenRequest,
onDownloadPdf,
onEditRecord,
onDeleteRecord,
FilterToolbarComponent,
DataTableComponent,
TablePagerComponent,
StatusLineComponent,
IconButtonComponent,
}) {
const tableState = tables?.invoices || { rows: [], filters: [], sort: [] };
const FilterToolbar = FilterToolbarComponent;
const DataTable = DataTableComponent;
const TablePager = TablePagerComponent;
const StatusLine = StatusLineComponent;
const IconButton = IconButtonComponent;
return (
<>
<div className="section-head">
<div>
<h2>Счета</h2>
<p className="muted">Выставленные счета клиентам, статусы оплаты и выгрузка PDF.</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button className="btn secondary" type="button" onClick={onRefresh}>
Обновить
</button>
<button className="btn" type="button" onClick={onCreate}>
Новый счет
</button>
</div>
</div>
<FilterToolbar
filters={tableState.filters}
onOpen={onOpenFilter}
onRemove={onRemoveFilter}
onEdit={onEditFilter}
getChipLabel={(clause) => {
const fieldDef = getFieldDef("invoices", clause.field);
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("invoices", clause);
}}
/>
<DataTable
headers={[
{ key: "invoice_number", label: "Номер", sortable: true, field: "invoice_number" },
{ key: "status", label: "Статус", sortable: true, field: "status" },
{ key: "amount", label: "Сумма", sortable: true, field: "amount" },
{ key: "payer_display_name", label: "Плательщик", sortable: true, field: "payer_display_name" },
{ key: "request_track_number", label: "Заявка" },
{ key: "issued_by_name", label: "Выставил", sortable: true, field: "issued_by_admin_user_id" },
{ key: "issued_at", label: "Сформирован", sortable: true, field: "issued_at" },
{ key: "paid_at", label: "Оплачен", sortable: true, field: "paid_at" },
{ key: "actions", label: "Действия" },
]}
rows={tableState.rows}
emptyColspan={9}
onSort={onSort}
sortClause={(tableState.sort && tableState.sort[0]) || TABLE_SERVER_CONFIG.invoices.sort[0]}
renderRow={(row) => (
<tr key={row.id}>
<td>
<code>{row.invoice_number || "-"}</code>
</td>
<td>{row.status_label || invoiceStatusLabel(row.status)}</td>
<td>{row.amount == null ? "-" : String(row.amount) + " " + String(row.currency || "RUB")}</td>
<td>{row.payer_display_name || "-"}</td>
<td>
{row.request_id ? (
<button
type="button"
className="request-track-link"
onClick={(event) => onOpenRequest(row, event)}
title="Открыть заявку"
>
<code>{row.request_track_number || row.request_id || "-"}</code>
</button>
) : (
<code>{row.request_track_number || row.request_id || "-"}</code>
)}
</td>
<td>{row.issued_by_name || "-"}</td>
<td>{fmtDate(row.issued_at)}</td>
<td>{fmtDate(row.paid_at)}</td>
<td>
<div className="table-actions">
<IconButton icon="⬇" tooltip="Скачать PDF" onClick={() => onDownloadPdf(row)} />
<IconButton icon="✎" tooltip="Редактировать счет" onClick={() => onEditRecord(row)} />
{role === "ADMIN" ? (
<IconButton icon="🗑" tooltip="Удалить счет" onClick={() => onDeleteRecord(row.id)} tone="danger" />
) : null}
</div>
</td>
</tr>
)}
/>
<TablePager tableState={tableState} onPrev={onPrev} onNext={onNext} onLoadAll={onLoadAll} />
<StatusLine status={status} />
</>
);
}
export default InvoicesSection;

View file

@ -0,0 +1,246 @@
import { KANBAN_GROUPS } from "../../shared/constants.js";
import { fallbackStatusGroup, fmtKanbanDate, resolveDeadlineTone, statusLabel } from "../../shared/utils.js";
export function KanbanBoard({
loading,
columns,
rows,
role,
actorId,
filters,
onRefresh,
onOpenFilter,
onRemoveFilter,
onEditFilter,
getFilterChipLabel,
onOpenSort,
sortActive,
onOpenRequest,
onClaimRequest,
onMoveRequest,
status,
FilterToolbarComponent,
StatusLineComponent,
}) {
const { useMemo, useState } = React;
const [draggingId, setDraggingId] = useState("");
const [dragOverGroup, setDragOverGroup] = useState("");
const safeColumns = Array.isArray(columns) && columns.length ? columns : KANBAN_GROUPS;
const grouped = useMemo(() => {
const map = {};
safeColumns.forEach((column) => {
map[String(column.key)] = [];
});
(rows || []).forEach((row) => {
const group = String(row?.status_group || fallbackStatusGroup(row?.status_code));
if (!map[group]) map[group] = [];
map[group].push(row);
});
return map;
}, [rows, safeColumns]);
const rowMap = useMemo(() => {
const map = new Map();
(rows || []).forEach((row) => {
if (!row?.id) return;
map.set(String(row.id), row);
});
return map;
}, [rows]);
const onDropToGroup = (event, groupKey) => {
event.preventDefault();
const requestId = String(event.dataTransfer.getData("text/plain") || draggingId || "");
setDragOverGroup("");
setDraggingId("");
if (!requestId) return;
const row = rowMap.get(requestId);
if (!row) return;
onMoveRequest(row, String(groupKey || ""));
};
const FilterToolbar = FilterToolbarComponent;
const StatusLine = StatusLineComponent;
return (
<div className="kanban-wrap">
<div className="section-head">
<div>
<h2>Канбан заявок</h2>
<p className="muted">Группировка по группам статусов и серверная фильтрация карточек.</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button className={"btn secondary" + (sortActive ? " active-success" : "")} type="button" onClick={onOpenSort}>
Сортировка
</button>
<button className="btn secondary" type="button" onClick={onRefresh} disabled={loading}>
Обновить
</button>
</div>
</div>
{FilterToolbar ? (
<FilterToolbar
filters={filters || []}
onOpen={onOpenFilter}
onRemove={onRemoveFilter}
onEdit={onEditFilter}
getChipLabel={getFilterChipLabel}
/>
) : null}
<div className="kanban-board" id="kanban-board">
{safeColumns.map((column) => {
const key = String(column.key || "");
const cards = grouped[key] || [];
const isOver = dragOverGroup === key;
return (
<div
key={key}
className={"kanban-column" + (isOver ? " drag-over" : "")}
onDragOver={(event) => {
event.preventDefault();
setDragOverGroup(key);
}}
onDragLeave={(event) => {
if (event.currentTarget.contains(event.relatedTarget)) return;
setDragOverGroup((prev) => (prev === key ? "" : prev));
}}
onDrop={(event) => onDropToGroup(event, key)}
>
<div className="kanban-column-head">
<b>{column.label || key}</b>
<span>{Number(column.total ?? cards.length)}</span>
</div>
<div className="kanban-column-body">
{cards.length ? (
cards.map((row) => {
const requestId = String(row.id || "");
const isUnassigned = !String(row.assigned_lawyer_id || "").trim();
const canClaim = role === "LAWYER" && isUnassigned;
const canMove =
role === "ADMIN" ||
(!isUnassigned && String(row.assigned_lawyer_id || "").trim() === String(actorId || "").trim());
const transitionOptions = Array.isArray(row.available_transitions) ? row.available_transitions : [];
const deadline = row.sla_deadline_at || row.case_deadline_at || "";
const deadlineTone = resolveDeadlineTone(deadline);
const unreadTypes = new Set();
if (role === "LAWYER") {
if (row.lawyer_has_unread_updates && row.lawyer_unread_event_type) unreadTypes.add(String(row.lawyer_unread_event_type).toUpperCase());
} else {
if (row.client_has_unread_updates && row.client_unread_event_type) unreadTypes.add(String(row.client_unread_event_type).toUpperCase());
if (row.lawyer_has_unread_updates && row.lawyer_unread_event_type) unreadTypes.add(String(row.lawyer_unread_event_type).toUpperCase());
}
const hasUnreadMessage = unreadTypes.has("MESSAGE");
const hasUnreadAttachment = unreadTypes.has("ATTACHMENT");
return (
<article
key={requestId}
className={"kanban-card" + (canMove ? " draggable" : "")}
draggable={canMove}
role="button"
tabIndex={0}
onClick={(event) => onOpenRequest(requestId, event)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenRequest(requestId, event);
}
}}
onDragStart={(event) => {
if (!canMove) {
event.preventDefault();
return;
}
setDraggingId(requestId);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", requestId);
}}
onDragEnd={() => {
setDraggingId("");
setDragOverGroup("");
}}
>
<div className="kanban-card-head">
<button
type="button"
className="request-track-link"
onClick={(event) => {
event.stopPropagation();
onOpenRequest(requestId, event);
}}
title="Открыть заявку"
>
<code>{row.track_number || "-"}</code>
</button>
<span className={"kanban-status-badge group-" + String(row.status_group || "").toLowerCase()}>
{row.status_name || statusLabel(row.status_code)}
</span>
</div>
<p className="kanban-card-desc">{String(row.description || "Описание не заполнено")}</p>
<div className="kanban-card-meta">
<span>{row.client_name || "-"}</span>
<span>{fmtKanbanDate(row.created_at)}</span>
</div>
<div className="kanban-card-meta">
<span>{row.topic_code || "-"}</span>
<span>{row.assigned_lawyer_name || (isUnassigned ? "Не назначено" : row.assigned_lawyer_id || "-")}</span>
</div>
<div className="kanban-card-meta">
<div className="kanban-update-icons">
<span className={"kanban-update-icon" + (hasUnreadMessage ? " is-unread" : "")} title="Непрочитанные сообщения">
💬
</span>
<span className={"kanban-update-icon" + (hasUnreadAttachment ? " is-unread" : "")} title="Непрочитанные файлы">
📎
</span>
</div>
<span className={"kanban-deadline-chip tone-" + deadlineTone}>{deadline ? fmtKanbanDate(deadline) : "—"}</span>
</div>
<div
className="kanban-card-actions"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
{canClaim ? (
<button className="btn secondary btn-sm" type="button" onClick={() => onClaimRequest(requestId)}>
Взять в работу
</button>
) : null}
{canMove && transitionOptions.length ? (
<select
className="kanban-transition-select"
defaultValue=""
onClick={(event) => event.stopPropagation()}
onChange={(event) => {
const targetStatus = String(event.target.value || "");
if (!targetStatus) return;
onMoveRequest(row, "", targetStatus);
event.target.value = "";
}}
>
<option value="">Перевести</option>
{transitionOptions.map((transition) => (
<option key={String(transition.to_status)} value={String(transition.to_status)}>
{String(transition.to_status_name || transition.to_status)}
</option>
))}
</select>
) : null}
</div>
</article>
);
})
) : (
<p className="muted kanban-empty">Пусто</p>
)}
</div>
</div>
);
})}
</div>
{StatusLine ? <StatusLine status={status} /> : null}
</div>
);
}
export default KanbanBoard;

View file

@ -0,0 +1,29 @@
export function MetaSection({ metaEntity, metaJson, status, onEntityChange, onLoad, StatusLineComponent }) {
const StatusLine = StatusLineComponent;
return (
<>
<div className="section-head">
<div>
<h2>Схема метаданных</h2>
<p className="muted">Поля сущностей для meta-driven форм.</p>
</div>
</div>
<div className="filters" style={{ gridTemplateColumns: "1fr auto" }}>
<div className="field">
<label htmlFor="meta-entity">Сущность</label>
<input id="meta-entity" value={metaEntity} placeholder="quotes" onChange={onEntityChange} />
</div>
<div style={{ display: "flex", alignItems: "end" }}>
<button className="btn secondary" type="button" onClick={onLoad}>
Загрузить
</button>
</div>
</div>
<div className="json">{metaJson}</div>
<StatusLine status={status} />
</>
);
}
export default MetaSection;

View file

@ -0,0 +1,96 @@
import { OPERATOR_LABELS, TABLE_SERVER_CONFIG } from "../../shared/constants.js";
import { boolLabel, fmtDate } from "../../shared/utils.js";
export function QuotesSection({
tables,
status,
getFieldDef,
getFilterValuePreview,
onRefresh,
onCreate,
onOpenFilter,
onRemoveFilter,
onEditFilter,
onSort,
onPrev,
onNext,
onLoadAll,
onEditRecord,
onDeleteRecord,
FilterToolbarComponent,
DataTableComponent,
TablePagerComponent,
StatusLineComponent,
IconButtonComponent,
}) {
const tableState = tables?.quotes || { rows: [], filters: [], sort: [] };
const FilterToolbar = FilterToolbarComponent;
const DataTable = DataTableComponent;
const TablePager = TablePagerComponent;
const StatusLine = StatusLineComponent;
const IconButton = IconButtonComponent;
return (
<>
<div className="section-head">
<div>
<h2>Цитаты</h2>
<p className="muted">Управление публичной лентой цитат с серверными фильтрами.</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button className="btn secondary" type="button" onClick={onRefresh}>
Обновить
</button>
<button className="btn" type="button" onClick={onCreate}>
Новая цитата
</button>
</div>
</div>
<FilterToolbar
filters={tableState.filters}
onOpen={onOpenFilter}
onRemove={onRemoveFilter}
onEdit={onEditFilter}
getChipLabel={(clause) => {
const fieldDef = getFieldDef("quotes", clause.field);
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("quotes", clause);
}}
/>
<DataTable
headers={[
{ key: "author", label: "Автор", sortable: true, field: "author" },
{ key: "text", label: "Текст", sortable: true, field: "text" },
{ key: "source", label: "Источник", sortable: true, field: "source" },
{ key: "is_active", label: "Активна", sortable: true, field: "is_active" },
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
{ key: "created_at", label: "Создана", sortable: true, field: "created_at" },
{ key: "actions", label: "Действия" },
]}
rows={tableState.rows}
emptyColspan={7}
onSort={onSort}
sortClause={(tableState.sort && tableState.sort[0]) || TABLE_SERVER_CONFIG.quotes.sort[0]}
renderRow={(row) => (
<tr key={row.id}>
<td>{row.author || "-"}</td>
<td>{row.text || "-"}</td>
<td>{row.source || "-"}</td>
<td>{boolLabel(row.is_active)}</td>
<td>{String(row.sort_order ?? 0)}</td>
<td>{fmtDate(row.created_at)}</td>
<td>
<div className="table-actions">
<IconButton icon="✎" tooltip="Редактировать цитату" onClick={() => onEditRecord(row)} />
<IconButton icon="🗑" tooltip="Удалить цитату" onClick={() => onDeleteRecord(row.id)} tone="danger" />
</div>
</td>
</tr>
)}
/>
<TablePager tableState={tableState} onPrev={onPrev} onNext={onNext} onLoadAll={onLoadAll} />
<StatusLine status={status} />
</>
);
}
export default QuotesSection;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,163 @@
import { OPERATOR_LABELS, REQUEST_UPDATE_EVENT_LABELS, TABLE_SERVER_CONFIG } from "../../shared/constants.js";
import { fmtDate, statusLabel } from "../../shared/utils.js";
function renderRequestUpdatesCell(row, role) {
if (role === "LAWYER") {
const has = Boolean(row.lawyer_has_unread_updates);
const eventType = String(row.lawyer_unread_event_type || "").toUpperCase();
return has ? (
<span className="request-update-chip" title={"Есть непрочитанное обновление: " + (REQUEST_UPDATE_EVENT_LABELS[eventType] || eventType.toLowerCase())}>
<span className="request-update-dot" />
{REQUEST_UPDATE_EVENT_LABELS[eventType] || "обновление"}
</span>
) : (
<span className="request-update-empty">нет</span>
);
}
const clientHas = Boolean(row.client_has_unread_updates);
const clientType = String(row.client_unread_event_type || "").toUpperCase();
const lawyerHas = Boolean(row.lawyer_has_unread_updates);
const lawyerType = String(row.lawyer_unread_event_type || "").toUpperCase();
if (!clientHas && !lawyerHas) return <span className="request-update-empty">нет</span>;
return (
<span className="request-updates-stack">
{clientHas ? (
<span className="request-update-chip" title={"Клиенту: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || clientType.toLowerCase())}>
<span className="request-update-dot" />
{"Клиент: " + (REQUEST_UPDATE_EVENT_LABELS[clientType] || "обновление")}
</span>
) : null}
{lawyerHas ? (
<span className="request-update-chip" title={"Юристу: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || lawyerType.toLowerCase())}>
<span className="request-update-dot" />
{"Юрист: " + (REQUEST_UPDATE_EVENT_LABELS[lawyerType] || "обновление")}
</span>
) : null}
</span>
);
}
export function RequestsSection({
role,
tables,
status,
getStatus,
getFieldDef,
getFilterValuePreview,
resolveReferenceLabel,
onRefresh,
onCreate,
onOpenFilter,
onRemoveFilter,
onEditFilter,
onSort,
onPrev,
onNext,
onLoadAll,
onClaimRequest,
onOpenReassign,
onOpenRequest,
onEditRecord,
onDeleteRecord,
FilterToolbarComponent,
DataTableComponent,
TablePagerComponent,
StatusLineComponent,
IconButtonComponent,
}) {
const tableState = tables?.requests || { rows: [], filters: [], sort: [] };
const FilterToolbar = FilterToolbarComponent;
const DataTable = DataTableComponent;
const TablePager = TablePagerComponent;
const StatusLine = StatusLineComponent;
const IconButton = IconButtonComponent;
return (
<>
<div className="section-head">
<div>
<h2>Заявки</h2>
<p className="muted">Серверная фильтрация и просмотр клиентских заявок.</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button className="btn secondary" type="button" onClick={onRefresh}>
Обновить
</button>
<button className="btn" type="button" onClick={onCreate}>
Новая заявка
</button>
</div>
</div>
<FilterToolbar
filters={tableState.filters}
onOpen={onOpenFilter}
onRemove={onRemoveFilter}
onEdit={onEditFilter}
getChipLabel={(clause) => {
const fieldDef = getFieldDef("requests", clause.field);
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("requests", clause);
}}
/>
<DataTable
headers={[
{ key: "track_number", label: "Номер", sortable: true, field: "track_number" },
{ key: "client_name", label: "Клиент", sortable: true, field: "client_name" },
{ key: "client_phone", label: "Телефон", sortable: true, field: "client_phone" },
{ key: "status_code", label: "Статус", sortable: true, field: "status_code" },
{ key: "topic_code", label: "Тема", sortable: true, field: "topic_code" },
{ key: "assigned_lawyer_id", label: "Назначен", sortable: true, field: "assigned_lawyer_id" },
{ key: "invoice_amount", label: "Счет", sortable: true, field: "invoice_amount" },
{ key: "paid_at", label: "Оплачено", sortable: true, field: "paid_at" },
{ key: "updates", label: "Обновления" },
{ key: "created_at", label: "Создана", sortable: true, field: "created_at" },
{ key: "actions", label: "Действия" },
]}
rows={tableState.rows}
emptyColspan={11}
onSort={onSort}
sortClause={(tableState.sort && tableState.sort[0]) || TABLE_SERVER_CONFIG.requests.sort[0]}
renderRow={(row) => (
<tr key={row.id}>
<td>
<button
type="button"
className="request-track-link"
onClick={(event) => onOpenRequest(row.id, event)}
title="Открыть заявку"
>
<code>{row.track_number || "-"}</code>
</button>
</td>
<td>{row.client_name || "-"}</td>
<td>{row.client_phone || "-"}</td>
<td>{statusLabel(row.status_code)}</td>
<td>{row.topic_code || "-"}</td>
<td>{resolveReferenceLabel({ table: "admin_users", value_field: "id", label_field: "name" }, row.assigned_lawyer_id)}</td>
<td>{row.invoice_amount == null ? "-" : String(row.invoice_amount)}</td>
<td>{fmtDate(row.paid_at)}</td>
<td>{renderRequestUpdatesCell(row, role)}</td>
<td>{fmtDate(row.created_at)}</td>
<td>
<div className="table-actions">
{role === "LAWYER" && !row.assigned_lawyer_id ? (
<IconButton icon="📥" tooltip="Взять в работу" onClick={() => onClaimRequest(row.id)} />
) : null}
{role === "ADMIN" && row.assigned_lawyer_id ? (
<IconButton icon="⇄" tooltip="Переназначить" onClick={() => onOpenReassign(row)} />
) : null}
<IconButton icon="✎" tooltip="Редактировать заявку" onClick={() => onEditRecord(row)} />
<IconButton icon="🗑" tooltip="Удалить заявку" onClick={() => onDeleteRecord(row.id)} tone="danger" />
</div>
</td>
</tr>
)}
/>
<TablePager tableState={tableState} onPrev={onPrev} onNext={onNext} onLoadAll={onLoadAll} />
<StatusLine status={status || (typeof getStatus === "function" ? getStatus("requests") : null)} />
</>
);
}
export default RequestsSection;

View file

@ -0,0 +1,67 @@
import { boolLabel, fmtDate } from "../../shared/utils.js";
export function AvailableTablesSection({
tables,
status,
onRefresh,
onToggleActive,
DataTableComponent,
StatusLineComponent,
IconButtonComponent,
}) {
const tableState = tables?.availableTables || { rows: [] };
const DataTable = DataTableComponent;
const StatusLine = StatusLineComponent;
const IconButton = IconButtonComponent;
return (
<>
<div className="section-head">
<div>
<h2>Доступность таблиц</h2>
<p className="muted">Скрытая служебная вкладка. Доступ только для администратора по прямой ссылке.</p>
</div>
<button className="btn secondary" type="button" onClick={onRefresh}>
Обновить
</button>
</div>
<DataTable
headers={[
{ key: "label", label: "Таблица" },
{ key: "table", label: "Код" },
{ key: "section", label: "Раздел" },
{ key: "is_active", label: "Активна" },
{ key: "updated_at", label: "Обновлена" },
{ key: "responsible", label: "Ответственный" },
{ key: "actions", label: "Действия" },
]}
rows={tableState.rows}
emptyColspan={7}
renderRow={(row) => (
<tr key={String(row.table || row.label)}>
<td>{row.label || "-"}</td>
<td>
<code>{row.table || "-"}</code>
</td>
<td>{row.section || "-"}</td>
<td>{boolLabel(Boolean(row.is_active))}</td>
<td>{fmtDate(row.updated_at)}</td>
<td>{row.responsible || "-"}</td>
<td>
<div className="table-actions">
<IconButton
icon={row.is_active ? "⏸" : "▶"}
tooltip={row.is_active ? "Деактивировать таблицу" : "Активировать таблицу"}
onClick={() => onToggleActive(row.table, !Boolean(row.is_active))}
/>
</div>
</td>
</tr>
)}
/>
<StatusLine status={status} />
</>
);
}
export default AvailableTablesSection;

View file

@ -0,0 +1,42 @@
import { translateApiError } from "../shared/utils.js";
export function useAdminApi(token) {
const { useCallback } = React;
return useCallback(
async (path, options, tokenOverride) => {
const opts = options || {};
const authToken = tokenOverride !== undefined ? tokenOverride : token;
const headers = { "Content-Type": "application/json", ...(opts.headers || {}) };
if (opts.auth !== false) {
if (!authToken) throw new Error("Отсутствует токен авторизации");
headers.Authorization = "Bearer " + authToken;
}
const response = await fetch(path, {
method: opts.method || "GET",
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
const text = await response.text();
let payload;
try {
payload = text ? JSON.parse(text) : {};
} catch (_) {
payload = { raw: text };
}
if (!response.ok) {
const message = (payload && (payload.detail || payload.error || payload.raw)) || "HTTP " + response.status;
throw new Error(translateApiError(String(message)));
}
return payload;
},
[token]
);
}
export default useAdminApi;

View file

@ -0,0 +1,82 @@
import { normalizeReferenceMeta } from "../shared/utils.js";
export function useAdminCatalogLoaders({ api, setStatus, setTableState, setReferenceRowsMap, buildUniversalQuery }) {
const { useCallback } = React;
const loadAvailableTables = useCallback(
async (tokenOverride) => {
setStatus("availableTables", "Загрузка...", "");
try {
const data = await api("/api/admin/crud/meta/available-tables", {}, tokenOverride);
const rows = Array.isArray(data.rows) ? data.rows : [];
setTableState("availableTables", {
filters: [],
sort: null,
offset: 0,
total: rows.length,
showAll: true,
rows,
});
setStatus("availableTables", "Список обновлен", "ok");
return true;
} catch (error) {
setStatus("availableTables", "Ошибка: " + error.message, "error");
return false;
}
},
[api, setStatus, setTableState]
);
const loadReferenceRows = useCallback(
async (catalogRows, tokenOverride) => {
const rows = Array.isArray(catalogRows) ? catalogRows : [];
const byTable = {};
rows.forEach((item) => {
const table = String(item?.table || "");
if (!table) return;
byTable[table] = item;
});
const references = new Set();
rows.forEach((item) => {
(item?.columns || []).forEach((column) => {
const meta = normalizeReferenceMeta(column?.reference);
if (meta?.table) references.add(meta.table);
});
});
if (!references.size) {
setReferenceRowsMap({});
return;
}
const nextMap = {};
await Promise.all(
Array.from(references.values()).map(async (table) => {
const meta = byTable[table];
const endpoint = String(meta?.query_endpoint || ("/api/admin/crud/" + table + "/query"));
const sort = Array.isArray(meta?.default_sort) && meta.default_sort.length ? meta.default_sort : [{ field: "created_at", dir: "desc" }];
try {
const data = await api(
endpoint,
{
method: "POST",
body: buildUniversalQuery([], sort, 500, 0),
},
tokenOverride
);
nextMap[table] = Array.isArray(data?.rows) ? data.rows : [];
} catch (_) {
nextMap[table] = [];
}
})
);
setReferenceRowsMap(nextMap);
},
[api, buildUniversalQuery, setReferenceRowsMap]
);
return {
loadAvailableTables,
loadReferenceRows,
};
}
export default useAdminCatalogLoaders;

View file

@ -0,0 +1,120 @@
import { KANBAN_GROUPS } from "../shared/constants.js";
import { createTableState } from "../shared/state.js";
export function useKanban({ api, setStatus, setTableState, tablesRef }) {
const { useCallback, useState } = React;
const [kanbanData, setKanbanData] = useState({
rows: [],
columns: KANBAN_GROUPS,
total: 0,
truncated: false,
});
const [kanbanLoading, setKanbanLoading] = useState(false);
const [kanbanSortModal, setKanbanSortModal] = useState({
open: false,
value: "created_newest",
});
const [kanbanSortApplied, setKanbanSortApplied] = useState(false);
const loadKanban = useCallback(
async (tokenOverride, options) => {
const opts = options || {};
const currentKanbanState = tablesRef.current.kanban || createTableState();
const activeFilters = Array.isArray(opts.filtersOverride) ? [...opts.filtersOverride] : [...(currentKanbanState.filters || [])];
const currentSortMode = Array.isArray(currentKanbanState.sort) && currentKanbanState.sort[0] ? String(currentKanbanState.sort[0].field || "") : "";
const activeSortMode = String(opts.sortModeOverride || currentSortMode || kanbanSortModal.value || "created_newest").trim() || "created_newest";
const params = new URLSearchParams({ limit: "400", sort_mode: activeSortMode });
if (activeFilters.length) params.set("filters", JSON.stringify(activeFilters));
setKanbanLoading(true);
setStatus("kanban", "Загрузка...", "");
try {
const data = await api("/api/admin/requests/kanban?" + params.toString(), {}, tokenOverride);
const rows = Array.isArray(data.rows) ? data.rows : [];
const columns = Array.isArray(data.columns) && data.columns.length ? data.columns : KANBAN_GROUPS;
setKanbanData({
rows,
columns,
total: Number(data.total || rows.length),
truncated: Boolean(data.truncated),
});
setTableState("kanban", {
...currentKanbanState,
filters: activeFilters,
sort: [{ field: activeSortMode, dir: "asc" }],
rows,
total: Number(data.total || rows.length),
offset: 0,
showAll: false,
});
const tail = Boolean(data.truncated) ? " Показана ограниченная выборка." : "";
setStatus("kanban", "Канбан обновлен." + tail, "ok");
} catch (error) {
setStatus("kanban", "Ошибка: " + error.message, "error");
} finally {
setKanbanLoading(false);
}
},
[api, kanbanSortModal.value, setStatus, setTableState, tablesRef]
);
const openKanbanSortModal = useCallback(() => {
const tableState = tablesRef.current.kanban || createTableState();
const currentMode = Array.isArray(tableState.sort) && tableState.sort[0] ? String(tableState.sort[0].field || "") : "";
setKanbanSortModal({
open: true,
value: currentMode || "created_newest",
});
setStatus("kanbanSort", "", "");
}, [setStatus, tablesRef]);
const closeKanbanSortModal = useCallback(() => {
setKanbanSortModal((prev) => ({ ...prev, open: false }));
setStatus("kanbanSort", "", "");
}, [setStatus]);
const updateKanbanSortMode = useCallback((event) => {
setKanbanSortModal((prev) => ({ ...prev, value: String(event.target.value || "created_newest") }));
}, []);
const submitKanbanSortModal = useCallback(
async (event) => {
event.preventDefault();
const nextMode = String(kanbanSortModal.value || "created_newest");
const tableState = tablesRef.current.kanban || createTableState();
setTableState("kanban", {
...tableState,
sort: [{ field: nextMode, dir: "asc" }],
offset: 0,
showAll: false,
});
setKanbanSortApplied(true);
closeKanbanSortModal();
await loadKanban(undefined, { sortModeOverride: nextMode });
},
[closeKanbanSortModal, kanbanSortModal.value, loadKanban, setTableState, tablesRef]
);
const resetKanbanState = useCallback(() => {
setKanbanSortModal({ open: false, value: "created_newest" });
setKanbanSortApplied(false);
setKanbanData({ rows: [], columns: KANBAN_GROUPS, total: 0, truncated: false });
setKanbanLoading(false);
}, []);
return {
kanbanData,
kanbanLoading,
kanbanSortModal,
kanbanSortApplied,
loadKanban,
openKanbanSortModal,
closeKanbanSortModal,
updateKanbanSortMode,
submitKanbanSortModal,
resetKanbanState,
};
}
export default useKanban;

View file

@ -0,0 +1,436 @@
import { createRequestModalState } from "../shared/state.js";
export function useRequestWorkspace(options) {
const { useCallback, useRef, useState } = React;
const opts = options || {};
const api = opts.api;
const setStatus = opts.setStatus;
const setActiveSection = opts.setActiveSection;
const token = opts.token || "";
const users = Array.isArray(opts.users) ? opts.users : [];
const buildUniversalQuery = opts.buildUniversalQuery;
const resolveAdminObjectSrc = opts.resolveAdminObjectSrc;
const [requestModal, setRequestModal] = useState(createRequestModalState());
const requestOpenGuardRef = useRef({ requestId: "", ts: 0 });
const resetRequestWorkspaceState = useCallback(() => {
setRequestModal(createRequestModalState());
requestOpenGuardRef.current = { requestId: "", ts: 0 };
}, []);
const updateRequestModalMessageDraft = useCallback((event) => {
const value = event.target.value;
setRequestModal((prev) => ({ ...prev, messageDraft: value }));
}, []);
const appendRequestModalFiles = useCallback((files) => {
const list = Array.isArray(files) ? files.filter(Boolean) : [];
if (!list.length) return;
setRequestModal((prev) => {
const existing = Array.isArray(prev.selectedFiles) ? prev.selectedFiles : [];
const next = [...existing];
list.forEach((file) => {
const duplicate = next.some(
(item) =>
item &&
item.name === file.name &&
Number(item.size || 0) === Number(file.size || 0) &&
Number(item.lastModified || 0) === Number(file.lastModified || 0)
);
if (!duplicate) next.push(file);
});
return { ...prev, selectedFiles: next };
});
}, []);
const removeRequestModalFile = useCallback((index) => {
setRequestModal((prev) => {
const existing = Array.isArray(prev.selectedFiles) ? [...prev.selectedFiles] : [];
existing.splice(index, 1);
return { ...prev, selectedFiles: existing };
});
}, []);
const clearRequestModalFiles = useCallback(() => {
setRequestModal((prev) => ({ ...prev, selectedFiles: [] }));
}, []);
const loadRequestModalData = useCallback(
async (requestId, loadOptions) => {
if (!api || !requestId) return;
const localOpts = loadOptions || {};
const showLoading = localOpts.showLoading !== false;
if (showLoading) {
setRequestModal((prev) => ({
...prev,
loading: true,
requestId,
requestData: null,
financeSummary: null,
statusRouteNodes: [],
}));
}
const requestFilter = [{ field: "request_id", op: "=", value: String(requestId) }];
try {
const [row, messagesData, attachmentsData, statusRouteData, invoicesData] = await Promise.all([
api("/api/admin/crud/requests/" + requestId),
api("/api/admin/chat/requests/" + requestId + "/messages"),
api("/api/admin/crud/attachments/query", {
method: "POST",
body: buildUniversalQuery(requestFilter, [{ field: "created_at", dir: "asc" }], 500, 0),
}),
api("/api/admin/requests/" + requestId + "/status-route").catch(() => ({ nodes: [] })),
api("/api/admin/invoices/query", {
method: "POST",
body: buildUniversalQuery(requestFilter, [{ field: "paid_at", dir: "desc" }], 500, 0),
}).catch(() => ({ rows: [] })),
]);
const usersById = new Map(users.filter((user) => user && user.id).map((user) => [String(user.id), user]));
const rowData = row && typeof row === "object" ? { ...row } : row;
if (rowData && typeof rowData === "object") {
const assignedLawyerId = String(rowData.assigned_lawyer_id || "").trim();
if (assignedLawyerId) {
const lawyer = usersById.get(assignedLawyerId);
if (lawyer) {
rowData.assigned_lawyer_name = rowData.assigned_lawyer_name || lawyer.name || lawyer.email || assignedLawyerId;
rowData.assigned_lawyer_phone = rowData.assigned_lawyer_phone || lawyer.phone || null;
}
}
}
const attachments = (attachmentsData.rows || []).map((item) => ({
...item,
download_url: resolveAdminObjectSrc(item.s3_key, token),
}));
const usersByEmail = new Map(
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 paidInvoices = (invoicesData?.rows || []).filter(
(item) => String(item?.status || "").toUpperCase() === "PAID"
);
const paidTotal = paidInvoices.reduce((acc, item) => {
const amount = Number(item?.amount || 0);
return Number.isFinite(amount) ? acc + amount : acc;
}, 0);
const latestPaidAt = paidInvoices.reduce((latest, item) => {
const raw = item?.paid_at;
const ts = raw ? new Date(raw).getTime() : Number.NaN;
if (!Number.isFinite(ts)) return latest;
if (!latest) return String(raw);
const latestTs = new Date(latest).getTime();
return ts > latestTs ? String(raw) : latest;
}, "");
setRequestModal((prev) => ({
...prev,
loading: false,
requestId: rowData?.id || requestId,
trackNumber: String(rowData?.track_number || ""),
requestData: rowData,
financeSummary: {
request_cost: rowData?.request_cost ?? null,
effective_rate: rowData?.effective_rate ?? null,
paid_total: Math.round((paidTotal + Number.EPSILON) * 100) / 100,
last_paid_at: latestPaidAt || rowData?.paid_at || null,
},
statusRouteNodes: Array.isArray(statusRouteData?.nodes) ? statusRouteData.nodes : [],
statusHistory: Array.isArray(statusRouteData?.history) ? statusRouteData.history : [],
availableStatuses: Array.isArray(statusRouteData?.available_statuses) ? statusRouteData.available_statuses : [],
currentImportantDateAt: String(statusRouteData?.current_important_date_at || rowData?.important_date_at || ""),
messages: normalizedMessages,
attachments,
selectedFiles: [],
fileUploading: false,
}));
if (showLoading && typeof setStatus === "function") setStatus("requestModal", "", "");
} catch (error) {
setRequestModal((prev) => ({
...prev,
loading: false,
requestId,
requestData: null,
financeSummary: null,
statusRouteNodes: [],
statusHistory: [],
availableStatuses: [],
currentImportantDateAt: "",
messages: [],
attachments: [],
selectedFiles: [],
fileUploading: false,
}));
if (typeof setStatus === "function") setStatus("requestModal", "Ошибка: " + error.message, "error");
}
},
[api, buildUniversalQuery, resolveAdminObjectSrc, setStatus, token, users]
);
const refreshRequestModal = useCallback(async () => {
if (!requestModal.requestId) return;
await loadRequestModalData(requestModal.requestId, { showLoading: true });
}, [loadRequestModalData, requestModal.requestId]);
const openRequestDetails = useCallback(
async (requestId, event, options) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!requestId) return;
const normalizedRequestId = String(requestId);
const now = Date.now();
const prev = requestOpenGuardRef.current;
if (prev.requestId === normalizedRequestId && now - prev.ts < 900) return;
requestOpenGuardRef.current = { requestId: normalizedRequestId, ts: now };
if (window.location.pathname !== "/admin.html" || window.location.search) {
window.history.replaceState(null, "", "/admin.html");
}
if (typeof setStatus === "function") setStatus("requestModal", "", "");
if (typeof setActiveSection === "function") setActiveSection("requestWorkspace");
await loadRequestModalData(normalizedRequestId, { showLoading: true });
const preset = options && typeof options === "object" ? options.statusChangePreset : null;
if (preset) {
setRequestModal((prev) => ({ ...prev, pendingStatusChangePreset: preset }));
}
},
[loadRequestModalData, setActiveSection, setStatus]
);
const submitRequestModalMessage = useCallback(
async (event) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
if (!api) return;
const requestId = requestModal.requestId;
const body = String(requestModal.messageDraft || "").trim();
const files = Array.isArray(requestModal.selectedFiles) ? requestModal.selectedFiles : [];
if (!requestId || (!body && !files.length)) return;
try {
setRequestModal((prev) => ({ ...prev, fileUploading: true }));
if (typeof setStatus === "function") {
setStatus("requestModal", files.length ? "Отправка сообщения и файлов..." : "Отправка сообщения...", "");
}
let messageId = null;
if (body) {
const message = await api("/api/admin/chat/requests/" + requestId + "/messages", {
method: "POST",
body: { body },
});
messageId = String(message?.id || "").trim() || null;
}
for (const file of files) {
const mimeType = String(file.type || "application/octet-stream");
const init = await api("/api/admin/uploads/init", {
method: "POST",
body: {
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
},
});
const putResp = await fetch(init.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file,
});
if (!putResp.ok) throw new Error("Не удалось загрузить файл в хранилище");
await api("/api/admin/uploads/complete", {
method: "POST",
body: {
key: init.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
message_id: messageId,
},
});
}
setRequestModal((prev) => ({ ...prev, messageDraft: "", selectedFiles: [], fileUploading: false }));
const successMessage = body && files.length ? "Сообщение и файлы отправлены" : files.length ? "Файлы отправлены" : "Сообщение отправлено";
if (typeof setStatus === "function") setStatus("requestModal", successMessage, "ok");
await loadRequestModalData(requestId, { showLoading: false });
} catch (error) {
setRequestModal((prev) => ({ ...prev, fileUploading: false }));
if (typeof setStatus === "function") setStatus("requestModal", "Ошибка отправки: " + error.message, "error");
}
},
[api, loadRequestModalData, requestModal.messageDraft, requestModal.requestId, requestModal.selectedFiles, setStatus]
);
const loadRequestDataTemplates = useCallback(
async (documentName) => {
const requestId = requestModal.requestId;
if (!api || !requestId) return { rows: [], documents: [] };
const query = documentName ? "?document=" + encodeURIComponent(String(documentName)) : "";
return api("/api/admin/chat/requests/" + requestId + "/data-request-templates" + query);
},
[api, requestModal.requestId]
);
const loadRequestDataBatch = useCallback(
async (messageId) => {
const requestId = requestModal.requestId;
if (!api || !requestId || !messageId) throw new Error("Не выбрана заявка");
return api("/api/admin/chat/requests/" + requestId + "/data-requests/" + encodeURIComponent(String(messageId)));
},
[api, requestModal.requestId]
);
const loadRequestDataTemplateDetails = useCallback(
async (templateId) => {
const requestId = requestModal.requestId;
if (!api || !requestId || !templateId) throw new Error("Не выбран шаблон");
return api(
"/api/admin/chat/requests/" +
requestId +
"/data-request-templates/" +
encodeURIComponent(String(templateId))
);
},
[api, requestModal.requestId]
);
const saveRequestDataTemplate = useCallback(
async (payload) => {
const requestId = requestModal.requestId;
if (!api || !requestId) throw new Error("Не выбрана заявка");
return api("/api/admin/chat/requests/" + requestId + "/data-request-templates", {
method: "POST",
body: payload || {},
});
},
[api, requestModal.requestId]
);
const saveRequestDataBatch = useCallback(
async (payload) => {
const requestId = requestModal.requestId;
if (!api || !requestId) throw new Error("Не выбрана заявка");
const result = await api("/api/admin/chat/requests/" + requestId + "/data-requests", {
method: "POST",
body: payload || {},
});
await loadRequestModalData(requestId, { showLoading: false });
return result;
},
[api, loadRequestModalData, requestModal.requestId]
);
const clearPendingStatusChangePreset = useCallback(() => {
setRequestModal((prev) => ({ ...prev, pendingStatusChangePreset: null }));
}, []);
const submitRequestStatusChange = useCallback(
async ({ requestId, statusCode, importantDateAt, comment, files } = {}) => {
if (!api) throw new Error("API недоступен");
const targetRequestId = String(requestId || requestModal.requestId || "").trim();
if (!targetRequestId) throw new Error("Не выбрана заявка");
const nextStatus = String(statusCode || "").trim();
if (!nextStatus) throw new Error("Выберите статус");
const body = {
status_code: nextStatus,
important_date_at: importantDateAt || null,
comment: String(comment || "").trim() || null,
};
if (typeof setStatus === "function") setStatus("requestModal", "Смена статуса...", "");
const result = await api("/api/admin/requests/" + targetRequestId + "/status-change", {
method: "POST",
body,
});
const attachedFiles = Array.isArray(files) ? files.filter(Boolean) : [];
const commentText = String(comment || "").trim();
if (commentText || attachedFiles.length) {
let messageId = null;
const statusLine = "Смена статуса: " + String(result?.from_status || "—") + " -> " + String(result?.to_status || nextStatus);
const messageBody = [statusLine, commentText].filter(Boolean).join("\n");
if (messageBody) {
const message = await api("/api/admin/chat/requests/" + targetRequestId + "/messages", {
method: "POST",
body: { body: messageBody },
});
messageId = String(message?.id || "").trim() || null;
}
for (const file of attachedFiles) {
const mimeType = String(file.type || "application/octet-stream");
const init = await api("/api/admin/uploads/init", {
method: "POST",
body: {
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: targetRequestId,
},
});
const putResp = await fetch(init.presigned_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: file,
});
if (!putResp.ok) throw new Error("Не удалось загрузить файл в хранилище");
await api("/api/admin/uploads/complete", {
method: "POST",
body: {
key: init.key,
file_name: file.name,
mime_type: mimeType,
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: targetRequestId,
message_id: messageId,
},
});
}
}
if (typeof setStatus === "function") setStatus("requestModal", "Статус заявки обновлен", "ok");
await loadRequestModalData(targetRequestId, { showLoading: false });
return result;
},
[api, loadRequestModalData, requestModal.requestId, setStatus]
);
return {
requestModal,
setRequestModal,
requestOpenGuardRef,
resetRequestWorkspaceState,
updateRequestModalMessageDraft,
appendRequestModalFiles,
removeRequestModalFile,
clearRequestModalFiles,
loadRequestModalData,
refreshRequestModal,
openRequestDetails,
clearPendingStatusChangePreset,
submitRequestStatusChange,
submitRequestModalMessage,
loadRequestDataTemplates,
loadRequestDataBatch,
loadRequestDataTemplateDetails,
saveRequestDataTemplate,
saveRequestDataBatch,
};
}
export default useRequestWorkspace;

View file

@ -0,0 +1,199 @@
import { DEFAULT_FORM_FIELD_TYPES, PAGE_SIZE, STATUS_LABELS } from "../shared/constants.js";
import { createTableState } from "../shared/state.js";
import { sortByName, statusLabel } from "../shared/utils.js";
export function useTableActions({ api, setStatus, resolveTableConfig, tablesRef, setTableState, setDictionaries, buildUniversalQuery }) {
const { useCallback } = React;
const loadTable = useCallback(
async (tableKey, options, tokenOverride) => {
const opts = options || {};
const config = resolveTableConfig(tableKey);
if (!config) return false;
const current = tablesRef.current[tableKey] || createTableState();
const next = {
...current,
filters: Array.isArray(opts.filtersOverride) ? [...opts.filtersOverride] : [...(current.filters || [])],
sort: Array.isArray(opts.sortOverride) ? [...opts.sortOverride] : Array.isArray(current.sort) ? [...current.sort] : null,
rows: [...(current.rows || [])],
};
if (opts.resetOffset) {
next.offset = 0;
next.showAll = false;
}
if (opts.loadAll) {
next.offset = 0;
next.showAll = true;
}
const statusKey = tableKey;
setStatus(statusKey, "Загрузка...", "");
try {
const activeSort = next.sort && next.sort.length ? next.sort : config.sort;
let limit = next.showAll ? Math.max(next.total || PAGE_SIZE, PAGE_SIZE) : PAGE_SIZE;
const offset = next.showAll ? 0 : next.offset;
let data = await api(
config.endpoint,
{
method: "POST",
body: buildUniversalQuery(next.filters, activeSort, limit, offset),
},
tokenOverride
);
next.total = Number(data.total || 0);
next.rows = data.rows || [];
if (next.showAll && next.total > next.rows.length) {
limit = next.total;
data = await api(
config.endpoint,
{
method: "POST",
body: buildUniversalQuery(next.filters, activeSort, limit, 0),
},
tokenOverride
);
next.total = Number(data.total || next.total);
next.rows = data.rows || [];
}
if (!next.showAll && next.total > 0 && next.offset >= next.total) {
next.offset = Math.floor((next.total - 1) / PAGE_SIZE) * PAGE_SIZE;
setTableState(tableKey, next);
return loadTable(tableKey, {}, tokenOverride);
}
setTableState(tableKey, next);
if (tableKey === "requests") {
setDictionaries((prev) => {
const map = new Map((prev.topics || []).map((topic) => [topic.code, topic]));
(next.rows || []).forEach((row) => {
if (!row.topic_code || map.has(row.topic_code)) return;
map.set(row.topic_code, { code: row.topic_code, name: row.topic_code });
});
return { ...prev, topics: sortByName(Array.from(map.values())) };
});
}
if (tableKey === "topics") {
setDictionaries((prev) => ({
...prev,
topics: sortByName((next.rows || []).map((row) => ({ code: row.code, name: row.name || row.code }))),
}));
}
if (tableKey === "statuses") {
setDictionaries((prev) => {
const map = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }]));
(next.rows || []).forEach((row) => {
if (!row.code) return;
map.set(row.code, { code: row.code, name: row.name || statusLabel(row.code) });
});
return { ...prev, statuses: sortByName(Array.from(map.values())) };
});
}
if (tableKey === "formFields" || tableKey === "form_fields") {
setDictionaries((prev) => {
const set = new Set(DEFAULT_FORM_FIELD_TYPES);
(next.rows || []).forEach((row) => {
if (row?.type) set.add(row.type);
});
const fieldKeys = (next.rows || [])
.filter((row) => row && row.key)
.map((row) => ({ key: row.key, label: row.label || row.key }))
.sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru"));
return {
...prev,
formFieldTypes: Array.from(set.values()).sort((a, b) => String(a).localeCompare(String(b), "ru")),
formFieldKeys: fieldKeys,
};
});
}
if (tableKey === "users" || tableKey === "admin_users") {
setDictionaries((prev) => {
const map = new Map((prev.users || []).map((user) => [user.id, user]));
(next.rows || []).forEach((row) => {
map.set(row.id, {
id: row.id,
name: row.name || "",
email: row.email || "",
role: row.role || "",
is_active: Boolean(row.is_active),
});
});
return { ...prev, users: Array.from(map.values()) };
});
}
setStatus(statusKey, "Список обновлен", "ok");
return true;
} catch (error) {
setStatus(statusKey, "Ошибка: " + error.message, "error");
return false;
}
},
[api, buildUniversalQuery, resolveTableConfig, setDictionaries, setStatus, setTableState, tablesRef]
);
const loadPrevPage = useCallback(
(tableKey) => {
const tableState = tablesRef.current[tableKey] || createTableState();
const next = { ...tableState, offset: Math.max(0, tableState.offset - PAGE_SIZE), showAll: false };
setTableState(tableKey, next);
loadTable(tableKey, {});
},
[loadTable, setTableState, tablesRef]
);
const loadNextPage = useCallback(
(tableKey) => {
const tableState = tablesRef.current[tableKey] || createTableState();
if (tableState.offset + PAGE_SIZE >= tableState.total) return;
const next = { ...tableState, offset: tableState.offset + PAGE_SIZE, showAll: false };
setTableState(tableKey, next);
loadTable(tableKey, {});
},
[loadTable, setTableState, tablesRef]
);
const loadAllRows = useCallback(
(tableKey) => {
const tableState = tablesRef.current[tableKey] || createTableState();
if (!tableState.total) return;
const next = { ...tableState, offset: 0, showAll: true };
setTableState(tableKey, next);
loadTable(tableKey, { loadAll: true });
},
[loadTable, setTableState, tablesRef]
);
const toggleTableSort = useCallback(
(tableKey, field) => {
const tableState = tablesRef.current[tableKey] || createTableState();
const currentSort = Array.isArray(tableState.sort) ? tableState.sort[0] : null;
const dir = currentSort && currentSort.field === field ? (currentSort.dir === "asc" ? "desc" : "asc") : "asc";
const sortOverride = [{ field, dir }];
const next = { ...tableState, sort: sortOverride, offset: 0, showAll: false };
setTableState(tableKey, next);
loadTable(tableKey, { resetOffset: true, sortOverride });
},
[loadTable, setTableState, tablesRef]
);
return {
loadTable,
loadPrevPage,
loadNextPage,
loadAllRows,
toggleTableSort,
};
}
export default useTableActions;

View file

@ -0,0 +1,120 @@
import { createTableState } from "../shared/state.js";
export function useTableFilterActions({
filterModal,
closeFilterModal,
getFieldDef,
loadKanban,
loadTable,
setStatus,
setTableState,
tablesRef,
}) {
const { useCallback } = React;
const applyFilterModal = useCallback(
async (event) => {
if (event && typeof event.preventDefault === "function") event.preventDefault();
if (!filterModal.tableKey) return;
const fieldDef = getFieldDef(filterModal.tableKey, filterModal.field);
if (!fieldDef) {
setStatus("filter", "Поле фильтра не выбрано", "error");
return;
}
let value;
if (fieldDef.type === "boolean") {
value = filterModal.rawValue === "true";
} else if (fieldDef.type === "number") {
if (String(filterModal.rawValue || "").trim() === "") {
setStatus("filter", "Введите число", "error");
return;
}
value = Number(filterModal.rawValue);
if (Number.isNaN(value)) {
setStatus("filter", "Некорректное число", "error");
return;
}
} else {
value = String(filterModal.rawValue || "").trim();
if (!value) {
setStatus("filter", "Введите значение фильтра", "error");
return;
}
}
const tableState = tablesRef.current[filterModal.tableKey] || createTableState();
const nextFilters = [...(tableState.filters || [])];
const nextClause = { field: fieldDef.field, op: filterModal.op, value };
if (Number.isInteger(filterModal.editIndex) && filterModal.editIndex >= 0 && filterModal.editIndex < nextFilters.length) {
nextFilters[filterModal.editIndex] = nextClause;
} else {
const existingIndex = nextFilters.findIndex((item) => item.field === nextClause.field && item.op === nextClause.op);
if (existingIndex >= 0) nextFilters[existingIndex] = nextClause;
else nextFilters.push(nextClause);
}
setTableState(filterModal.tableKey, {
...tableState,
filters: nextFilters,
offset: 0,
showAll: false,
});
closeFilterModal();
if (filterModal.tableKey === "kanban") {
await loadKanban(undefined, { filtersOverride: nextFilters });
} else {
await loadTable(filterModal.tableKey, { resetOffset: true, filtersOverride: nextFilters });
}
},
[closeFilterModal, filterModal, getFieldDef, loadKanban, loadTable, setStatus, setTableState, tablesRef]
);
const clearFiltersFromModal = useCallback(async () => {
if (!filterModal.tableKey) return;
const tableState = tablesRef.current[filterModal.tableKey] || createTableState();
setTableState(filterModal.tableKey, {
...tableState,
filters: [],
offset: 0,
showAll: false,
});
closeFilterModal();
if (filterModal.tableKey === "kanban") {
await loadKanban(undefined, { filtersOverride: [] });
} else {
await loadTable(filterModal.tableKey, { resetOffset: true, filtersOverride: [] });
}
}, [closeFilterModal, filterModal.tableKey, loadKanban, loadTable, setTableState, tablesRef]);
const removeFilterChip = useCallback(
async (tableKey, index) => {
const tableState = tablesRef.current[tableKey] || createTableState();
const nextFilters = [...(tableState.filters || [])];
nextFilters.splice(index, 1);
setTableState(tableKey, {
...tableState,
filters: nextFilters,
offset: 0,
showAll: false,
});
if (tableKey === "kanban") {
await loadKanban(undefined, { filtersOverride: nextFilters });
} else {
await loadTable(tableKey, { resetOffset: true, filtersOverride: nextFilters });
}
},
[loadKanban, loadTable, setTableState, tablesRef]
);
return {
applyFilterModal,
clearFiltersFromModal,
removeFilterChip,
};
}
export default useTableFilterActions;

View file

@ -0,0 +1,56 @@
import { createTableState } from "../shared/state.js";
function createInitialTablesState() {
return {
kanban: createTableState(),
requests: createTableState(),
invoices: createTableState(),
quotes: createTableState(),
topics: createTableState(),
statuses: createTableState(),
formFields: createTableState(),
topicRequiredFields: createTableState(),
topicDataTemplates: createTableState(),
statusTransitions: createTableState(),
users: createTableState(),
userTopics: createTableState(),
availableTables: createTableState(),
};
}
export function useTablesState() {
const { useCallback, useEffect, useRef, useState } = React;
const [tables, setTables] = useState(createInitialTablesState);
const [tableCatalog, setTableCatalog] = useState([]);
const [referenceRowsMap, setReferenceRowsMap] = useState({});
const tablesRef = useRef(tables);
useEffect(() => {
tablesRef.current = tables;
}, [tables]);
const setTableState = useCallback((tableKey, next) => {
setTables((prev) => ({ ...prev, [tableKey]: next }));
}, []);
const resetTablesState = useCallback(() => {
setTables(createInitialTablesState());
setTableCatalog([]);
setReferenceRowsMap({});
}, []);
return {
tables,
setTables,
tablesRef,
setTableState,
resetTablesState,
tableCatalog,
setTableCatalog,
referenceRowsMap,
setReferenceRowsMap,
};
}
export default useTablesState;

1
app/web/admin/index.jsx Normal file
View file

@ -0,0 +1 @@
import "../admin.jsx";

View file

@ -0,0 +1,155 @@
export const LS_TOKEN = "admin_access_token";
export const PAGE_SIZE = 50;
export const DEFAULT_FORM_FIELD_TYPES = ["string", "text", "number", "boolean", "date"];
export const ALL_OPERATORS = ["=", "!=", ">", "<", ">=", "<=", "~"];
export const OPERATOR_LABELS = {
"=": "=",
"!=": "!=",
">": ">",
"<": "<",
">=": ">=",
"<=": "<=",
"~": "~",
};
export const ROLE_LABELS = {
ADMIN: "Администратор",
LAWYER: "Юрист",
};
export const STATUS_LABELS = {
NEW: "Новая",
IN_PROGRESS: "В работе",
WAITING_CLIENT: "Ожидание клиента",
WAITING_COURT: "Ожидание суда",
RESOLVED: "Решена",
CLOSED: "Закрыта",
REJECTED: "Отклонена",
};
export const INVOICE_STATUS_LABELS = {
WAITING_PAYMENT: "Ожидает оплату",
PAID: "Оплачен",
CANCELED: "Отменен",
};
export const STATUS_KIND_LABELS = {
DEFAULT: "Обычный",
INVOICE: "Выставление счета",
PAID: "Оплачено",
};
export const REQUEST_UPDATE_EVENT_LABELS = {
MESSAGE: "сообщение",
ATTACHMENT: "файл",
STATUS: "статус",
};
export const KANBAN_GROUPS = [
{ key: "NEW", label: "Новые" },
{ key: "IN_PROGRESS", label: "В работе" },
{ key: "WAITING", label: "Ожидание" },
{ key: "DONE", label: "Завершены" },
];
export const TABLE_SERVER_CONFIG = {
requests: {
table: "requests",
// Requests use a specialized endpoint because it supports virtual/server-side filters
// (e.g. deadline alerts and unread notifications) that are not plain table columns.
endpoint: "/api/admin/requests/query",
sort: [{ field: "created_at", dir: "desc" }],
},
invoices: {
table: "invoices",
endpoint: "/api/admin/invoices/query",
sort: [{ field: "issued_at", dir: "desc" }],
},
quotes: {
table: "quotes",
endpoint: "/api/admin/crud/quotes/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
topics: {
table: "topics",
endpoint: "/api/admin/crud/topics/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
statuses: {
table: "statuses",
endpoint: "/api/admin/crud/statuses/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
formFields: {
table: "form_fields",
endpoint: "/api/admin/crud/form_fields/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
topicRequiredFields: {
table: "topic_required_fields",
endpoint: "/api/admin/crud/topic_required_fields/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
topicDataTemplates: {
table: "topic_data_templates",
endpoint: "/api/admin/crud/topic_data_templates/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
statusTransitions: {
table: "topic_status_transitions",
endpoint: "/api/admin/crud/topic_status_transitions/query",
sort: [{ field: "sort_order", dir: "asc" }],
},
users: {
table: "admin_users",
endpoint: "/api/admin/crud/admin_users/query",
sort: [{ field: "created_at", dir: "desc" }],
},
userTopics: {
table: "admin_user_topics",
endpoint: "/api/admin/crud/admin_user_topics/query",
sort: [{ field: "created_at", dir: "desc" }],
},
};
export const TABLE_MUTATION_CONFIG = Object.fromEntries(
Object.entries(TABLE_SERVER_CONFIG).map(([tableKey, config]) => [
tableKey,
{
create: "/api/admin/crud/" + config.table,
update: (id) => "/api/admin/crud/" + config.table + "/" + id,
delete: (id) => "/api/admin/crud/" + config.table + "/" + id,
},
])
);
TABLE_MUTATION_CONFIG.invoices = {
create: "/api/admin/invoices",
update: (id) => "/api/admin/invoices/" + id,
delete: (id) => "/api/admin/invoices/" + id,
};
export const TABLE_KEY_ALIASES = {
form_fields: "formFields",
status_groups: "statusGroups",
topic_required_fields: "topicRequiredFields",
topic_data_templates: "topicDataTemplates",
topic_status_transitions: "statusTransitions",
admin_users: "users",
admin_user_topics: "userTopics",
};
export const TABLE_UNALIASES = Object.fromEntries(Object.entries(TABLE_KEY_ALIASES).map(([table, alias]) => [alias, table]));
export const KNOWN_CONFIG_TABLE_KEYS = new Set([
"quotes",
"topics",
"statuses",
"formFields",
"topicRequiredFields",
"topicDataTemplates",
"statusTransitions",
"users",
"userTopics",
]);

View file

@ -0,0 +1,30 @@
export function createTableState() {
return {
filters: [],
sort: null,
offset: 0,
total: 0,
showAll: false,
rows: [],
};
}
export function createRequestModalState() {
return {
loading: false,
requestId: null,
trackNumber: "",
requestData: null,
financeSummary: null,
statusRouteNodes: [],
statusHistory: [],
availableStatuses: [],
currentImportantDateAt: "",
pendingStatusChangePreset: null,
messages: [],
attachments: [],
messageDraft: "",
selectedFiles: [],
fileUploading: false,
};
}

View file

@ -0,0 +1,348 @@
import {
ALL_OPERATORS,
INVOICE_STATUS_LABELS,
REQUEST_UPDATE_EVENT_LABELS,
ROLE_LABELS,
STATUS_KIND_LABELS,
STATUS_LABELS,
} from "./constants.js";
export function resolveAdminRoute(search) {
const params = new URLSearchParams(String(search || ""));
const section = String(params.get("section") || "").trim();
const view = String(params.get("view") || "").trim();
const requestId = String(params.get("requestId") || "").trim();
return { section, view, requestId };
}
export function humanizeKey(value) {
const text = String(value || "")
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!text) return "-";
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function metaKindToFilterType(kind) {
if (kind === "boolean") return "boolean";
if (kind === "number") return "number";
if (kind === "date" || kind === "datetime") return "date";
return "text";
}
export function metaKindToRecordType(kind) {
if (kind === "boolean") return "boolean";
if (kind === "number") return "number";
if (kind === "json") return "json";
return "text";
}
export function decodeJwtPayload(token) {
try {
const payload = token.split(".")[1] || "";
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join("")
);
return JSON.parse(json);
} catch (_) {
return null;
}
}
export function sortByName(items) {
return [...items].sort((a, b) => String(a.name || a.code || "").localeCompare(String(b.name || b.code || ""), "ru"));
}
export function roleLabel(role) {
return ROLE_LABELS[role] || role || "-";
}
export function statusLabel(code) {
return STATUS_LABELS[code] || code || "-";
}
export function invoiceStatusLabel(code) {
return INVOICE_STATUS_LABELS[code] || code || "-";
}
export function statusKindLabel(code) {
return STATUS_KIND_LABELS[code] || code || "-";
}
export function fallbackStatusGroup(statusCode) {
const code = String(statusCode || "").toUpperCase();
if (!code) return "NEW";
if (code.startsWith("NEW")) return "NEW";
if (code.includes("WAIT") || code.includes("PEND") || code.includes("HOLD")) return "WAITING";
if (code.includes("CLOSE") || code.includes("RESOLV") || code.includes("REJECT") || code.includes("DONE") || code.includes("PAID")) return "DONE";
return "IN_PROGRESS";
}
export function boolLabel(value) {
return value ? "Да" : "Нет";
}
export function boolFilterLabel(value) {
return value ? "True" : "False";
}
export function fmtDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = String(date.getFullYear()).slice(-2);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${day}.${month}.${year} ${hours}:${minutes}`;
}
export function fmtDateOnly(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = String(date.getFullYear()).slice(-2);
return `${day}.${month}.${year}`;
}
export function fmtTimeOnly(value) {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime())
? String(value)
: date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
export function fmtKanbanDate(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = String(date.getFullYear()).slice(-2);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${day}.${month}.${year} ${hours}:${minutes}`;
}
export function fmtShortDateTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = String(date.getFullYear()).slice(-2);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${day}.${month}.${year} ${hours}:${minutes}`;
}
export function resolveDeadlineTone(value) {
if (!value) return "ok";
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return "ok";
const delta = time - Date.now();
const fourDaysMs = 4 * 24 * 60 * 60 * 1000;
const oneDayMs = 24 * 60 * 60 * 1000;
if (delta > fourDaysMs) return "ok";
if (delta > oneDayMs) return "warn";
return "danger";
}
export function fmtAmount(value) {
if (value == null || value === "") return "-";
const number = Number(value);
if (Number.isNaN(number)) return String(value);
return number.toLocaleString("ru-RU");
}
export function fmtBytes(value) {
const size = Number(value || 0);
if (!Number.isFinite(size) || size <= 0) return "0 Б";
const units = ["Б", "КБ", "МБ", "ГБ"];
let normalized = size;
let index = 0;
while (normalized >= 1024 && index < units.length - 1) {
normalized /= 1024;
index += 1;
}
return normalized.toLocaleString("ru-RU", { maximumFractionDigits: index === 0 ? 0 : 1 }) + " " + units[index];
}
export function normalizeStringList(value) {
if (!Array.isArray(value)) return [];
const out = [];
const seen = new Set();
value.forEach((item) => {
const text = String(item || "").trim();
if (!text) return;
const key = text.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(text);
});
return out;
}
export function listPreview(value, emptyLabel) {
const items = normalizeStringList(value);
return items.length ? items.join(", ") : emptyLabel;
}
export function normalizeReferenceMeta(raw) {
if (!raw || typeof raw !== "object") return null;
const table = String(raw.table || "").trim();
const valueField = String(raw.value_field || "id").trim() || "id";
const labelField = String(raw.label_field || valueField).trim() || valueField;
if (!table) return null;
return { table, value_field: valueField, label_field: labelField };
}
export function userInitials(name, email) {
const source = String(name || "").trim();
if (source) {
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return source.slice(0, 2).toUpperCase();
}
const mail = String(email || "").trim();
return (mail.slice(0, 2) || "U").toUpperCase();
}
export function avatarColor(seed) {
const palette = ["#6f8fa9", "#568f7d", "#a07a5c", "#7d6ea9", "#8f6f8f", "#7f8c5a"];
const text = String(seed || "");
let hash = 0;
for (let i = 0; i < text.length; i += 1) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return palette[hash % palette.length];
}
export function resolveAvatarSrc(avatarUrl, accessToken) {
const raw = String(avatarUrl || "").trim();
if (!raw) return "";
if (raw.startsWith("s3://")) {
const key = raw.slice("s3://".length);
if (!key || !accessToken) return "";
return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken);
}
return raw;
}
export function resolveAdminObjectSrc(s3Key, accessToken) {
const key = String(s3Key || "").trim();
if (!key || !accessToken) return "";
return "/api/admin/uploads/object/" + encodeURIComponent(key) + "?token=" + encodeURIComponent(accessToken);
}
export function detectAttachmentPreviewKind(fileName, mimeType) {
const name = String(fileName || "").toLowerCase();
const mime = String(mimeType || "").toLowerCase();
if (/\.(txt|md|csv|json|log|xml|ya?ml|ini|cfg)$/i.test(name)) return "text";
if (
mime.startsWith("text/") ||
mime === "application/json" ||
mime === "application/xml" ||
mime === "text/xml"
) {
return "text";
}
if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image";
if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video";
if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf";
return "none";
}
export function buildUniversalQuery(filters, sort, limit, offset) {
return {
filters: filters || [],
sort: sort || [],
page: { limit: limit ?? 50, offset: offset ?? 0 },
};
}
export function canAccessSection(role, section) {
const allowed = new Set(["dashboard", "kanban", "requests", "requestWorkspace", "invoices", "meta", "quotes", "config", "availableTables"]);
if (!allowed.has(section)) return false;
if (section === "quotes" || section === "config" || section === "availableTables") return role === "ADMIN";
return true;
}
export function translateApiError(message) {
const direct = {
"Missing auth token": "Отсутствует токен авторизации",
"Missing bearer token": "Отсутствует токен авторизации",
"Invalid token": "Некорректный токен",
Forbidden: "Недостаточно прав",
"Invalid credentials": "Неверный логин или пароль",
"Request not found": "Заявка не найдена",
"Quote not found": "Цитата не найдена",
not_found: "Запись не найдена",
};
if (direct[message]) return direct[message];
if (String(message).startsWith("HTTP ")) return "Ошибка сервера (" + message + ")";
return message;
}
export function getOperatorsForType(type) {
if (type === "number" || type === "date" || type === "datetime") return ["=", "!=", ">", "<", ">=", "<="];
if (type === "boolean" || type === "reference" || type === "enum") return ["=", "!="];
return [...ALL_OPERATORS];
}
export function localizeRequestDetails(row) {
return {
ID: row.id || null,
"Номер заявки": row.track_number || null,
Клиент: row.client_name || null,
Телефон: row.client_phone || null,
"Тема (код)": row.topic_code || null,
Статус: statusLabel(row.status_code),
Описание: row.description || null,
"Дополнительные поля": row.extra_fields || {},
"Назначенный юрист (ID)": row.assigned_lawyer_id || null,
"Ставка (фикс.)": row.effective_rate ?? null,
"Сумма счета": row.invoice_amount ?? null,
"Оплачено": row.paid_at ? fmtDate(row.paid_at) : null,
"Оплату подтвердил (ID)": row.paid_by_admin_id || null,
"Непрочитано клиентом": boolLabel(Boolean(row.client_has_unread_updates)),
"Тип обновления для клиента": row.client_unread_event_type
? (REQUEST_UPDATE_EVENT_LABELS[row.client_unread_event_type] || row.client_unread_event_type)
: null,
"Непрочитано юристом": boolLabel(Boolean(row.lawyer_has_unread_updates)),
"Тип обновления для юриста": row.lawyer_unread_event_type
? (REQUEST_UPDATE_EVENT_LABELS[row.lawyer_unread_event_type] || row.lawyer_unread_event_type)
: null,
"Общий размер вложений (байт)": row.total_attachments_bytes ?? 0,
Создано: fmtDate(row.created_at),
Обновлено: fmtDate(row.updated_at),
};
}
export function localizeMeta(data) {
const fieldTypeMap = {
string: "строка",
text: "текст",
boolean: "булево",
number: "число",
date: "дата",
};
return {
Сущность: data.entity,
Поля: (data.fields || []).map((field) => ({
"Код поля": field.field_name,
Название: field.label,
Тип: fieldTypeMap[field.type] || field.type,
Обязательное: boolLabel(field.required),
"Только чтение": boolLabel(field.read_only),
"Редактируемые роли": (field.editable_roles || []).map(roleLabel),
})),
};
}

View file

@ -219,6 +219,99 @@ textarea {
font-size: 0.78rem; font-size: 0.78rem;
} }
.request-data-item {
border-color: rgba(212, 168, 106, 0.35);
background: linear-gradient(160deg, rgba(76, 56, 20, 0.28), rgba(39, 29, 14, 0.34));
}
.request-data-item.done {
border-color: rgba(73, 182, 142, 0.35);
background: linear-gradient(160deg, rgba(40, 86, 66, 0.26), rgba(26, 55, 43, 0.32));
}
.request-data-item-author {
color: #a7b8cf;
font-size: 0.78rem;
}
.request-data-message-btn {
width: 100%;
margin-top: 0.35rem;
text-align: left;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
color: #eef3fb;
padding: 0.55rem 0.65rem;
cursor: pointer;
}
.request-data-message-btn:hover {
border-color: rgba(212, 168, 106, 0.42);
background: rgba(212, 168, 106, 0.1);
}
.request-data-item.done .request-data-message-btn:hover {
border-color: rgba(73, 182, 142, 0.42);
background: rgba(73, 182, 142, 0.09);
}
.request-data-message-title {
font-weight: 800;
color: #ffe0ac;
}
.request-data-item.done .request-data-message-title {
color: #c8eed8;
}
.request-data-message-list {
margin-top: 0.35rem;
display: grid;
gap: 0.16rem;
max-height: 11.6rem;
overflow: hidden;
}
.request-data-message-row {
display: flex;
gap: 0.3rem;
align-items: baseline;
color: #e0e9f7;
font-size: 0.84rem;
}
.request-data-message-row.filled .request-data-message-row-label {
text-decoration: line-through;
color: #b8c4d6;
}
.request-data-message-row-index {
min-width: 1.9rem;
color: #ffd5a1;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 0.18rem;
}
.request-data-message-row-check {
color: #59d182;
font-weight: 800;
}
.request-data-message-more {
color: #bac7da;
font-size: 0.8rem;
font-weight: 700;
padding-left: 1.95rem;
}
.muted-inline {
margin: 0;
color: var(--muted);
}
.file-actions { .file-actions {
margin-top: 0.45rem; margin-top: 0.45rem;
display: flex; display: flex;
@ -250,6 +343,63 @@ textarea {
margin: 0.7rem; margin: 0.7rem;
} }
.data-request-modal {
width: min(760px, 100%);
}
.data-request-body {
width: 100%;
min-height: 280px;
max-height: calc(92vh - 76px);
overflow: auto;
border: 1px solid var(--line);
border-radius: 12px;
background: #0f1722;
padding: 0.8rem;
display: block;
}
.data-request-form {
display: grid;
gap: 0.65rem;
}
.data-request-form-row {
display: grid;
grid-template-columns: 28px minmax(180px, 0.9fr) minmax(0, 1.4fr);
gap: 0.55rem;
align-items: start;
padding: 0.45rem 0;
border-bottom: 1px solid rgba(207, 217, 231, 0.08);
}
.data-request-form-row:last-child {
border-bottom: none;
}
.data-request-form-index {
color: #9fb0c6;
font-weight: 700;
padding-top: 0.8rem;
text-align: center;
}
.data-request-form-label {
color: #e9f1fe;
line-height: 1.4;
padding-top: 0.72rem;
overflow-wrap: anywhere;
}
.data-request-form textarea {
min-height: 92px;
}
.data-request-actions {
display: flex;
justify-content: flex-end;
}
.preview-overlay { .preview-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -338,6 +488,21 @@ textarea {
text-align: center; text-align: center;
} }
.preview-text {
width: 100%;
min-height: min(60vh, 520px);
margin: 0;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(10, 16, 24, 0.88);
color: #dbe7f8;
padding: 0.7rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font: 500 0.86rem/1.45 "JetBrains Mono", "Fira Code", monospace;
}
.chat-form { .chat-form {
margin-top: 0.7rem; margin-top: 0.7rem;
display: grid; display: grid;
@ -375,6 +540,17 @@ textarea {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.data-request-form-row {
grid-template-columns: 1fr;
gap: 0.35rem;
}
.data-request-form-index,
.data-request-form-label {
padding-top: 0;
text-align: left;
}
} }
@media (max-width: 520px) { @media (max-width: 520px) {

View file

@ -99,6 +99,24 @@
</div> </div>
</div> </div>
<div class="preview-overlay" id="data-request-overlay" aria-hidden="true">
<div class="preview-modal data-request-modal" role="dialog" aria-modal="true" aria-labelledby="data-request-title">
<div class="preview-head">
<h3 id="data-request-title">Запрос данных</h3>
<button class="close-btn" id="data-request-close" type="button" aria-label="Закрыть">×</button>
</div>
<div class="preview-body data-request-body">
<form id="data-request-form" class="data-request-form">
<div id="data-request-items"></div>
<div class="data-request-actions">
<button class="btn btn-ghost" id="data-request-save" type="submit">Сохранить</button>
</div>
</form>
<p class="status" id="data-request-status"></p>
</div>
</div>
</div>
<script src="/client.js"></script> <script src="/client.js"></script>
</body> </body>
</html> </html>

View file

@ -23,16 +23,29 @@
const previewTitle = document.getElementById("file-preview-title"); const previewTitle = document.getElementById("file-preview-title");
const previewClose = document.getElementById("file-preview-close"); const previewClose = document.getElementById("file-preview-close");
const previewBody = document.getElementById("file-preview-body"); const previewBody = document.getElementById("file-preview-body");
const dataRequestOverlay = document.getElementById("data-request-overlay");
const dataRequestClose = document.getElementById("data-request-close");
const dataRequestForm = document.getElementById("data-request-form");
const dataRequestItems = document.getElementById("data-request-items");
const dataRequestStatus = document.getElementById("data-request-status");
const dataRequestTitle = document.getElementById("data-request-title");
let previewObjectUrl = "";
let activeTrack = ""; let activeTrack = "";
let activeRequestId = ""; let activeRequestId = "";
let activeDataRequestMessageId = "";
function formatDate(value) { function formatDate(value) {
if (!value) return "-"; if (!value) return "-";
try { try {
const dt = new Date(value); const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return value; if (Number.isNaN(dt.getTime())) return value;
return dt.toLocaleString("ru-RU"); const day = String(dt.getDate()).padStart(2, "0");
const month = String(dt.getMonth() + 1).padStart(2, "0");
const year = String(dt.getFullYear()).slice(-2);
const hours = String(dt.getHours()).padStart(2, "0");
const minutes = String(dt.getMinutes()).padStart(2, "0");
return `${day}.${month}.${year} ${hours}:${minutes}`;
} catch (_) { } catch (_) {
return value; return value;
} }
@ -45,6 +58,50 @@
el.textContent = message; el.textContent = message;
} }
function setDataRequestStatus(message, kind) {
if (!dataRequestStatus) return;
setStatus(dataRequestStatus, message || "", kind || null);
}
async function uploadPublicRequestAttachment(file, requestId) {
const initResponse = await fetch("/api/public/uploads/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
}),
});
const initData = await parseJsonSafe(initResponse);
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку файла"));
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
const completeResponse = await fetch("/api/public/uploads/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: requestId,
}),
});
const completeData = await parseJsonSafe(completeResponse);
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку файла"));
return completeData;
}
async function parseJsonSafe(response) { async function parseJsonSafe(response) {
try { try {
return await response.json(); return await response.json();
@ -79,21 +136,171 @@
function detectPreviewKind(fileName, mimeType) { function detectPreviewKind(fileName, mimeType) {
const name = String(fileName || "").toLowerCase(); const name = String(fileName || "").toLowerCase();
const mime = String(mimeType || "").toLowerCase(); const mime = String(mimeType || "").toLowerCase();
if (/\.(txt|md|csv|json|log|xml|ya?ml|ini|cfg)$/i.test(name)) return "text";
if (mime.startsWith("text/") || mime === "application/json" || mime === "application/xml" || mime === "text/xml") return "text";
if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image"; if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image";
if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video"; if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video";
if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf"; if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf";
return "none"; return "none";
} }
function revokePreviewObjectUrl() {
if (!previewObjectUrl) return;
try {
URL.revokeObjectURL(previewObjectUrl);
} catch (_) {}
previewObjectUrl = "";
}
function decodeTextPreview(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0));
const sampleLength = Math.min(bytes.length, 4096);
let suspicious = 0;
for (let i = 0; i < sampleLength; i += 1) {
const byte = bytes[i];
if (byte === 0) suspicious += 4;
else if (byte < 9 || (byte > 13 && byte < 32)) suspicious += 1;
}
if (sampleLength && suspicious / sampleLength > 0.08) return null;
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes).replace(/\u0000/g, "");
return text.length > 200000 ? text.slice(0, 200000) + "\n\n[Текст обрезан для предпросмотра]" : text;
}
function closePreview() { function closePreview() {
if (!previewOverlay || !previewBody) return; if (!previewOverlay || !previewBody) return;
revokePreviewObjectUrl();
previewOverlay.classList.remove("open"); previewOverlay.classList.remove("open");
previewOverlay.setAttribute("aria-hidden", "true"); previewOverlay.setAttribute("aria-hidden", "true");
previewBody.innerHTML = ""; previewBody.innerHTML = "";
} }
function openPreview(item) { function closeDataRequestModal() {
if (!dataRequestOverlay || !dataRequestItems) return;
activeDataRequestMessageId = "";
dataRequestItems.innerHTML = "";
dataRequestOverlay.classList.remove("open");
dataRequestOverlay.setAttribute("aria-hidden", "true");
setDataRequestStatus("", null);
}
function dataRequestInputType(fieldType) {
const type = String(fieldType || "").toLowerCase();
if (type === "date") return "date";
if (type === "number") return "number";
if (type === "file") return "file";
return "text";
}
function renderDataRequestItemsForm(items) {
if (!dataRequestItems) return;
dataRequestItems.innerHTML = "";
if (!Array.isArray(items) || !items.length) {
const p = document.createElement("p");
p.className = "muted-inline";
p.textContent = "Нет полей для заполнения.";
dataRequestItems.appendChild(p);
return;
}
items
.slice()
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0))
.forEach((item, index) => {
const row = document.createElement("div");
row.className = "data-request-form-row";
const indexNode = document.createElement("div");
indexNode.className = "data-request-form-index";
indexNode.textContent = String(index + 1) + ".";
row.appendChild(indexNode);
const labelNode = document.createElement("div");
labelNode.className = "data-request-form-label";
labelNode.textContent = String(item.label || item.key || "Поле");
row.appendChild(labelNode);
const inputWrap = document.createElement("div");
inputWrap.className = "field";
let input;
const normalizedFieldType = String(item.field_type || "").toLowerCase();
if (normalizedFieldType === "text") {
input = document.createElement("textarea");
input.rows = 3;
} else {
input = document.createElement("input");
input.type = dataRequestInputType(normalizedFieldType);
if (normalizedFieldType === "number") input.step = "any";
}
if (normalizedFieldType === "file") {
const currentFile = String(item.value_text || "").trim();
if (currentFile) {
const existing = document.createElement("div");
existing.className = "muted-inline";
existing.textContent =
"Текущее значение: " + String((item.value_file && item.value_file.file_name) || currentFile);
inputWrap.appendChild(existing);
}
if (item.value_file && item.value_file.download_url) {
const fileActions = document.createElement("div");
fileActions.className = "file-actions";
if (detectPreviewKind(item.value_file.file_name, item.value_file.mime_type) !== "none") {
const previewBtn = document.createElement("button");
previewBtn.type = "button";
previewBtn.className = "file-link-btn";
previewBtn.textContent = "Предпросмотр";
previewBtn.addEventListener("click", () => openPreview(item.value_file));
fileActions.appendChild(previewBtn);
}
const link = document.createElement("a");
link.className = "file-link-btn";
link.href = item.value_file.download_url;
link.textContent = "Открыть / скачать";
link.target = "_blank";
link.rel = "noopener noreferrer";
fileActions.appendChild(link);
inputWrap.appendChild(fileActions);
}
const hint = document.createElement("div");
hint.className = "muted-inline";
hint.textContent = "Выберите файл. Он будет загружен и привязан к полю запроса.";
inputWrap.appendChild(hint);
input.dataset.currentValue = currentFile;
} else {
input.value = item.value_text == null ? "" : String(item.value_text);
}
input.dataset.reqId = String(item.id || "");
input.dataset.reqKey = String(item.key || "");
input.dataset.reqFieldType = normalizedFieldType;
inputWrap.appendChild(input);
row.appendChild(inputWrap);
dataRequestItems.appendChild(row);
});
}
async function openDataRequestModal(message) {
if (!activeTrack || !message?.id || !dataRequestOverlay) return;
activeDataRequestMessageId = String(message.id);
dataRequestOverlay.classList.add("open");
dataRequestOverlay.setAttribute("aria-hidden", "false");
if (dataRequestTitle) dataRequestTitle.textContent = "Запрос данных";
setDataRequestStatus("Загрузка...", null);
try {
const response = await fetch(
"/api/public/chat/requests/" + encodeURIComponent(activeTrack) + "/data-requests/" + encodeURIComponent(activeDataRequestMessageId)
);
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось открыть запрос данных"));
renderDataRequestItemsForm(data?.items || []);
setDataRequestStatus("Заполните нужные поля и сохраните.", null);
} catch (error) {
setDataRequestStatus(error?.message || "Не удалось открыть запрос данных", "error");
renderDataRequestItemsForm([]);
}
}
async function openPreview(item) {
if (!previewOverlay || !previewBody || !previewTitle || !item?.download_url) return; if (!previewOverlay || !previewBody || !previewTitle || !item?.download_url) return;
revokePreviewObjectUrl();
previewBody.innerHTML = ""; previewBody.innerHTML = "";
previewTitle.textContent = item.file_name || "Предпросмотр файла"; previewTitle.textContent = item.file_name || "Предпросмотр файла";
const kind = detectPreviewKind(item.file_name, item.mime_type); const kind = detectPreviewKind(item.file_name, item.mime_type);
@ -111,12 +318,62 @@
video.controls = true; video.controls = true;
video.preload = "metadata"; video.preload = "metadata";
previewBody.appendChild(video); previewBody.appendChild(video);
} else if (kind === "pdf") { } else if (kind === "pdf" || kind === "text") {
const frame = document.createElement("iframe"); const loading = document.createElement("p");
frame.className = "preview-frame"; loading.className = "preview-note";
frame.src = item.download_url; loading.textContent = "Загрузка предпросмотра...";
frame.title = item.file_name || "PDF"; previewBody.appendChild(loading);
previewBody.appendChild(frame); try {
const response = await fetch(item.download_url, { credentials: "same-origin" });
if (!response.ok) throw new Error("Не удалось загрузить файл для предпросмотра.");
const buffer = await response.arrayBuffer();
previewBody.innerHTML = "";
if (kind === "pdf") {
const header = new Uint8Array(buffer.slice(0, 5));
const isPdf =
header.length >= 5 &&
header[0] === 0x25 &&
header[1] === 0x50 &&
header[2] === 0x44 &&
header[3] === 0x46 &&
header[4] === 0x2d;
if (isPdf) {
const frame = document.createElement("iframe");
frame.className = "preview-frame";
frame.src = item.download_url;
frame.title = item.file_name || "PDF";
previewBody.appendChild(frame);
} else {
const text = decodeTextPreview(buffer);
if (text != null) {
const note = document.createElement("p");
note.className = "preview-note";
note.textContent = "Файл помечен как PDF, но не является валидным PDF. Показан текстовый предпросмотр.";
previewBody.appendChild(note);
const pre = document.createElement("pre");
pre.className = "preview-text";
pre.textContent = text || "Файл пуст.";
previewBody.appendChild(pre);
} else {
throw new Error("Файл помечен как PDF, но не является валидным PDF-документом.");
}
}
} else {
const text = decodeTextPreview(buffer);
if (text == null) throw new Error("Не удалось распознать текстовый файл для предпросмотра.");
const pre = document.createElement("pre");
pre.className = "preview-text";
pre.textContent = text || "Файл пуст.";
previewBody.appendChild(pre);
}
} catch (error) {
previewBody.innerHTML = "";
const note = document.createElement("p");
note.className = "preview-note";
note.textContent = error instanceof Error ? error.message : "Не удалось открыть предпросмотр.";
previewBody.appendChild(note);
}
} else { } else {
const note = document.createElement("p"); const note = document.createElement("p");
note.className = "preview-note"; note.className = "preview-note";
@ -150,10 +407,78 @@
time.textContent = formatDate(item.created_at); time.textContent = formatDate(item.created_at);
li.appendChild(time); li.appendChild(time);
const p = document.createElement("p"); if (String(item.message_kind || "") === "REQUEST_DATA") {
const author = item.author_name || item.author_type || "Участник"; li.classList.add("request-data-item");
p.textContent = author + ": " + (item.body || ""); if (item.request_data_all_filled) li.classList.add("done");
li.appendChild(p);
const author = document.createElement("div");
author.className = "request-data-item-author";
author.textContent = String(item.author_name || item.author_type || "Юрист");
li.appendChild(author);
const button = document.createElement("button");
button.type = "button";
button.className = "request-data-message-btn";
button.addEventListener("click", () => openDataRequestModal(item));
const title = document.createElement("div");
title.className = "request-data-message-title";
if (
item.request_data_all_filled &&
Array.isArray(item.request_data_items) &&
item.request_data_items.length === 1 &&
String(item.request_data_items[0]?.field_type || "").toLowerCase() === "file"
) {
title.textContent = "Файл";
} else {
title.textContent = "Запрос";
}
button.appendChild(title);
if (!item.request_data_all_filled && Array.isArray(item.request_data_items) && item.request_data_items.length) {
const list = document.createElement("div");
list.className = "request-data-message-list";
const visibleItems = item.request_data_items.slice(0, 7);
visibleItems.forEach((req, idx) => {
const row = document.createElement("div");
row.className = "request-data-message-row";
if (req.is_filled) row.classList.add("filled");
const idxNode = document.createElement("span");
idxNode.className = "request-data-message-row-index";
idxNode.textContent = String(req.index || idx + 1) + ".";
row.appendChild(idxNode);
if (req.is_filled) {
const check = document.createElement("span");
check.className = "request-data-message-row-check";
check.textContent = "✓";
idxNode.prepend(check);
}
const labelNode = document.createElement("span");
labelNode.className = "request-data-message-row-label";
labelNode.textContent = String(req.label_short || req.label || "Поле");
row.appendChild(labelNode);
list.appendChild(row);
});
if (item.request_data_items.length > visibleItems.length) {
const more = document.createElement("div");
more.className = "request-data-message-more";
more.textContent = "... еще " + String(item.request_data_items.length - visibleItems.length);
list.appendChild(more);
}
button.appendChild(list);
}
li.appendChild(button);
} else {
const p = document.createElement("p");
const author = item.author_name || item.author_type || "Участник";
p.textContent = author + ": " + (item.body || "");
li.appendChild(p);
}
cabinetMessages.appendChild(li); cabinetMessages.appendChild(li);
}); });
} }
@ -397,8 +722,75 @@
if (event.key === "Escape" && previewOverlay?.classList.contains("open")) { if (event.key === "Escape" && previewOverlay?.classList.contains("open")) {
closePreview(); closePreview();
} }
if (event.key === "Escape" && dataRequestOverlay?.classList.contains("open")) {
closeDataRequestModal();
}
}); });
if (dataRequestClose) {
dataRequestClose.addEventListener("click", closeDataRequestModal);
}
if (dataRequestOverlay) {
dataRequestOverlay.addEventListener("click", (event) => {
if (event.target === dataRequestOverlay) closeDataRequestModal();
});
}
if (dataRequestForm) {
dataRequestForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!activeTrack || !activeDataRequestMessageId || !activeRequestId) return;
const inputs = Array.from(dataRequestForm.querySelectorAll("input[data-req-id], textarea[data-req-id]"));
try {
setDataRequestStatus("Сохраняем...", null);
const items = [];
for (const input of inputs) {
const fieldType = String(input.dataset.reqFieldType || "").toLowerCase();
if (fieldType === "file") {
let attachmentId = "";
if (input.files && input.files[0]) {
setDataRequestStatus("Загружаем файл для поля...", null);
const completeData = await uploadPublicRequestAttachment(input.files[0], activeRequestId);
attachmentId = String((completeData && completeData.attachment_id) || "");
input.dataset.currentValue = attachmentId;
} else {
attachmentId = String(input.dataset.currentValue || "");
}
items.push({
id: String(input.dataset.reqId || ""),
key: String(input.dataset.reqKey || ""),
attachment_id: attachmentId,
value_text: attachmentId,
});
continue;
}
items.push({
id: String(input.dataset.reqId || ""),
key: String(input.dataset.reqKey || ""),
value_text: String(input.value || ""),
});
}
setDataRequestStatus("Сохраняем...", null);
const response = await fetch(
"/api/public/chat/requests/" +
encodeURIComponent(activeTrack) +
"/data-requests/" +
encodeURIComponent(activeDataRequestMessageId),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
}
);
const data = await parseJsonSafe(response);
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось сохранить данные"));
setDataRequestStatus("Данные сохранены.", "ok");
await refreshCabinetData();
} catch (error) {
setDataRequestStatus(error?.message || "Не удалось сохранить данные", "error");
}
});
}
cabinetChatForm.addEventListener("submit", async (event) => { cabinetChatForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
if (!activeTrack) { if (!activeTrack) {
@ -439,41 +831,7 @@
try { try {
setStatus(pageStatus, "Подготавливаем загрузку файла...", null); setStatus(pageStatus, "Подготавливаем загрузку файла...", null);
const initResponse = await fetch("/api/public/uploads/init", { await uploadPublicRequestAttachment(file, activeRequestId);
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId,
}),
});
const initData = await parseJsonSafe(initResponse);
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку"));
const putResponse = await fetch(initData.presigned_url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: file,
});
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
const completeResponse = await fetch("/api/public/uploads/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: initData.key,
file_name: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
scope: "REQUEST_ATTACHMENT",
request_id: activeRequestId,
}),
});
const completeData = await parseJsonSafe(completeResponse);
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку"));
cabinetFileInput.value = ""; cabinetFileInput.value = "";
await refreshCabinetData(); await refreshCabinetData();

View file

@ -650,6 +650,151 @@
max-width: 100%; max-width: 100%;
} }
.featured-team-section[hidden] {
display: none !important;
}
.featured-team-shell {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.6rem;
align-items: center;
}
.featured-team-track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(280px, 34%);
gap: 0.85rem;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
scrollbar-width: thin;
padding: 0.15rem 0.1rem 0.4rem;
}
.featured-card {
scroll-snap-align: start;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(165deg, rgba(32, 43, 57, 0.95), rgba(17, 24, 32, 0.96));
display: grid;
grid-template-columns: 88px 1fr;
gap: 0.8rem;
padding: 0.85rem;
min-height: 146px;
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18);
animation: rise 0.45s ease forwards;
opacity: 0;
transform: translateY(8px);
}
.featured-avatar {
width: 88px;
height: 88px;
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(212, 169, 104, 0.35);
background: rgba(255, 255, 255, 0.04);
align-self: start;
}
.featured-card-body {
min-width: 0;
display: grid;
align-content: start;
gap: 0.35rem;
}
.featured-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.featured-card-top h3 {
margin: 0;
font-size: 1rem;
line-height: 1.25;
color: #eef4ff;
overflow-wrap: anywhere;
}
.featured-chip {
flex: 0 0 auto;
border-radius: 999px;
padding: 0.18rem 0.5rem;
border: 1px solid rgba(212, 169, 104, 0.35);
background: rgba(212, 169, 104, 0.12);
color: #f4d7a8;
font-size: 0.72rem;
font-weight: 700;
white-space: nowrap;
}
.featured-meta {
margin: 0;
color: #a6b5c8;
font-size: 0.84rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.featured-caption {
margin: 0.1rem 0 0;
color: #dde8f6;
font-size: 0.9rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
.carousel-nav {
width: 40px;
height: 40px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04);
color: #e8eff8;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
}
.carousel-nav:hover {
transform: translateY(-1px);
border-color: rgba(212, 169, 104, 0.3);
background: rgba(212, 169, 104, 0.08);
}
.carousel-dots {
margin-top: 0.55rem;
display: flex;
gap: 0.4rem;
justify-content: center;
min-height: 10px;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
background: rgba(207, 217, 231, 0.28);
transition: transform 0.15s ease, background 0.15s ease;
}
.carousel-dot.active {
background: rgba(212, 169, 104, 0.9);
transform: scale(1.15);
}
.brand, .brand,
.meta-row b { .meta-row b {
overflow-wrap: anywhere; overflow-wrap: anywhere;
@ -668,6 +813,10 @@
.practices { .practices {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.featured-team-track {
grid-auto-columns: minmax(300px, 52%);
}
} }
@media (max-width: 740px) { @media (max-width: 740px) {
@ -714,6 +863,18 @@
width: 100%; width: 100%;
} }
.featured-team-shell {
grid-template-columns: 1fr;
}
.carousel-nav {
display: none;
}
.featured-team-track {
grid-auto-columns: 86%;
}
.simple-list { .simple-list {
max-height: 220px; max-height: 220px;
} }
@ -741,6 +902,15 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.featured-card {
grid-template-columns: 1fr;
}
.featured-avatar {
width: 76px;
height: 76px;
}
.hero { .hero {
padding-top: 1.4rem; padding-top: 1.4rem;
} }

View file

@ -15,6 +15,7 @@
<a href="#practices">Компетенции</a> <a href="#practices">Компетенции</a>
<a href="#approach">Подход</a> <a href="#approach">Подход</a>
<a href="#expert">Эксперт</a> <a href="#expert">Эксперт</a>
<a href="#team">Команда</a>
<button class="btn btn-ghost" type="button" data-open-access>Мои заявки</button> <button class="btn btn-ghost" type="button" data-open-access>Мои заявки</button>
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button> <button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
</nav> </nav>
@ -147,6 +148,21 @@
</article> </article>
</section> </section>
<section id="team" class="featured-team-section" hidden>
<div class="section-head">
<div>
<h2>Выдающиеся юристы</h2>
<p class="subtitle">Команда специалистов, которых администратор рекомендует для знакомства с нашим подходом и практикой.</p>
</div>
</div>
<div class="featured-team-shell">
<button class="carousel-nav prev" type="button" id="featured-team-prev" aria-label="Прокрутить влево"></button>
<div class="featured-team-track" id="featured-team-track" aria-live="polite"></div>
<button class="carousel-nav next" type="button" id="featured-team-next" aria-label="Прокрутить вправо"></button>
</div>
<div class="carousel-dots" id="featured-team-dots" aria-label="Навигация по карточкам"></div>
</section>
<section class="cta-band"> <section class="cta-band">
<p>Создайте заявку и получите номер обращения. По нему вы сможете отслеживать статус, чат и документы в отдельной странице клиента.</p> <p>Создайте заявку и получите номер обращения. По нему вы сможете отслеживать статус, чат и документы в отдельной странице клиента.</p>
<button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button> <button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button>

View file

@ -18,6 +18,11 @@
const quoteText = document.getElementById("quote-text"); const quoteText = document.getElementById("quote-text");
const quoteMeta = document.getElementById("quote-meta"); const quoteMeta = document.getElementById("quote-meta");
const featuredTeamSection = document.getElementById("team");
const featuredTeamTrack = document.getElementById("featured-team-track");
const featuredTeamDots = document.getElementById("featured-team-dots");
const featuredTeamPrev = document.getElementById("featured-team-prev");
const featuredTeamNext = document.getElementById("featured-team-next");
function setStatus(el, message, kind) { function setStatus(el, message, kind) {
if (!el) return; if (!el) return;
@ -135,6 +140,120 @@
} }
} }
function renderFeaturedDots(count, activeIndex) {
if (!featuredTeamDots) return;
featuredTeamDots.innerHTML = "";
if (count <= 1) return;
for (let index = 0; index < count; index += 1) {
const button = document.createElement("button");
button.type = "button";
button.className = "carousel-dot" + (index === activeIndex ? " active" : "");
button.setAttribute("aria-label", "Карточка " + (index + 1));
button.addEventListener("click", () => {
const card = featuredTeamTrack?.children?.[index];
if (card && typeof card.scrollIntoView === "function") {
card.scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
}
});
featuredTeamDots.appendChild(button);
}
}
function initFeaturedCarouselControls() {
if (!featuredTeamTrack) return;
const scrollByCards = (dir) => {
const card = featuredTeamTrack.querySelector(".featured-card");
const step = card ? card.getBoundingClientRect().width + 14 : 320;
featuredTeamTrack.scrollBy({ left: dir * step, behavior: "smooth" });
};
if (featuredTeamPrev) featuredTeamPrev.addEventListener("click", () => scrollByCards(-1));
if (featuredTeamNext) featuredTeamNext.addEventListener("click", () => scrollByCards(1));
let rafId = 0;
const syncDots = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const cards = Array.from(featuredTeamTrack.children || []);
if (!cards.length) return renderFeaturedDots(0, 0);
const trackLeft = featuredTeamTrack.getBoundingClientRect().left;
let bestIndex = 0;
let bestDistance = Number.POSITIVE_INFINITY;
cards.forEach((card, index) => {
const distance = Math.abs(card.getBoundingClientRect().left - trackLeft);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
});
renderFeaturedDots(cards.length, bestIndex);
});
};
featuredTeamTrack.addEventListener("scroll", syncDots, { passive: true });
window.addEventListener("resize", syncDots);
syncDots();
}
async function loadFeaturedStaff() {
if (!featuredTeamSection || !featuredTeamTrack) return;
try {
const response = await fetch("/api/public/featured-staff?limit=24");
const data = await parseJsonSafe(response);
const items = Array.isArray(data?.items) ? data.items : [];
if (!response.ok || items.length === 0) {
featuredTeamSection.hidden = true;
return;
}
featuredTeamTrack.innerHTML = "";
items.forEach((item) => {
const card = document.createElement("article");
card.className = "featured-card";
const avatar = document.createElement("img");
avatar.className = "featured-avatar";
avatar.src = String(item.avatar_url || "");
avatar.alt = String(item.name || "Сотрудник");
avatar.loading = "lazy";
card.appendChild(avatar);
const body = document.createElement("div");
body.className = "featured-card-body";
const top = document.createElement("div");
top.className = "featured-card-top";
const name = document.createElement("h3");
name.textContent = String(item.name || "Сотрудник");
top.appendChild(name);
if (item.pinned) {
const chip = document.createElement("span");
chip.className = "featured-chip";
chip.textContent = "Рекомендуем";
top.appendChild(chip);
}
body.appendChild(top);
const meta = document.createElement("p");
meta.className = "featured-meta";
meta.textContent = [item.role_label, item.primary_topic_name].filter(Boolean).join(" • ");
body.appendChild(meta);
const caption = document.createElement("p");
caption.className = "featured-caption";
caption.textContent = String(item.caption || "Практический опыт в сложных юридических делах и сопровождении споров.");
body.appendChild(caption);
card.appendChild(body);
featuredTeamTrack.appendChild(card);
});
featuredTeamSection.hidden = false;
initFeaturedCarouselControls();
} catch (_) {
featuredTeamSection.hidden = true;
}
}
accessSendOtpButton.addEventListener("click", async () => { accessSendOtpButton.addEventListener("click", async () => {
const phone = String(accessPhoneInput.value || "").trim(); const phone = String(accessPhoneInput.value || "").trim();
if (!phone) { if (!phone) {
@ -254,4 +373,5 @@
loadTopics(); loadTopics();
loadQuotes(); loadQuotes();
loadFeaturedStaff();
})(); })();

Binary file not shown.

View file

@ -58,6 +58,20 @@
| P37 | сделано | Админ-авторизация и креды | Привести к единому правилу bootstrap-креды администратора (`admin@example.com` + согласованный пароль), обновить документацию/контекст и smoke-проверки логина | Реализован bootstrap-login с автосозданием администратора `admin@example.com` / `admin123`; добавлены автотесты `tests/test_admin_auth.py` | | P37 | сделано | Админ-авторизация и креды | Привести к единому правилу bootstrap-креды администратора (`admin@example.com` + согласованный пароль), обновить документацию/контекст и smoke-проверки логина | Реализован bootstrap-login с автосозданием администратора `admin@example.com` / `admin123`; добавлены автотесты `tests/test_admin_auth.py` |
| P38 | сделано | Конструктор маршрутов статусов | Реализовать для администратора визуальный конструктор маршрутов статусов по каждой теме: вариативные переходы (в т.ч. возврат на предыдущий статус, переход в завершение и альтернативные ветки), SLA на переход, список обязательных документов/данных для закрытия шага | Добавлен UI-конструктор в справочнике переходов статусов (выбор темы, визуальные карточки статусов и исходящих переходов), расширены поля перехода (`required_data_keys`, `required_mime_types`), переходы валидируются API по требованиям шага (данные заявки + MIME вложений), добавлены backend/e2e тесты | | P38 | сделано | Конструктор маршрутов статусов | Реализовать для администратора визуальный конструктор маршрутов статусов по каждой теме: вариативные переходы (в т.ч. возврат на предыдущий статус, переход в завершение и альтернативные ветки), SLA на переход, список обязательных документов/данных для закрытия шага | Добавлен UI-конструктор в справочнике переходов статусов (выбор темы, визуальные карточки статусов и исходящих переходов), расширены поля перехода (`required_data_keys`, `required_mime_types`), переходы валидируются API по требованиям шага (данные заявки + MIME вложений), добавлены backend/e2e тесты |
| P39 | сделано | Канбан по заявкам (LAWYER/ADMIN) | Реализовать канбан-доску заявок с унификацией разных статусных флоу через группы колонок (например: `Новые`, `В работе`, `Ожидание`, `Завершены`) + карточки заявок с ключевыми данными (дата создания, клиент, описание, новые сообщения/файлы, SLA deadline/дедлайн дела) | Добавлен backend endpoint `/api/admin/requests/kanban` (role-scope, группы статусов, SLA/case deadline, допустимые переходы); в `admin.jsx` добавлена секция `Канбан` с карточками, claim для юриста, drag&drop/быстрый перевод по допустимым переходам, open-in-place; покрыто unittest и e2e (`kanban_role_flow`) | | P39 | сделано | Канбан по заявкам (LAWYER/ADMIN) | Реализовать канбан-доску заявок с унификацией разных статусных флоу через группы колонок (например: `Новые`, `В работе`, `Ожидание`, `Завершены`) + карточки заявок с ключевыми данными (дата создания, клиент, описание, новые сообщения/файлы, SLA deadline/дедлайн дела) | Добавлен backend endpoint `/api/admin/requests/kanban` (role-scope, группы статусов, SLA/case deadline, допустимые переходы); в `admin.jsx` добавлена секция `Канбан` с карточками, claim для юриста, drag&drop/быстрый перевод по допустимым переходам, open-in-place; покрыто unittest и e2e (`kanban_role_flow`) |
| P40 | сделано | Декомпозиция: подготовка сборки фронта | Подготовить модульную декомпозицию фронта: перевести entrypoint `admin.jsx` -> `admin/index.jsx`, включить `esbuild --bundle` в `frontend/Dockerfile`, зафиксировать совместимость `admin.html` и Docker Compose | Реализовано: добавлен `app/web/admin/index.jsx`, сборка переведена на `esbuild admin/index.jsx --bundle`, smoke e2e входа/навигации (`admin_entry_flow`) и сборка в контейнере проходят |
| P41 | сделано | Декомпозиция `admin.jsx`: shared-слой | Вынести из `admin.jsx` константы/маппинги/табличные конфиги и pure-utils (`format`, `filters`, `route`, `reference`) в отдельные модули | Реализовано: добавлены `app/web/admin/shared/constants.js`, `app/web/admin/shared/utils.js`, `app/web/admin/shared/state.js`; `admin.jsx` сокращен до ~4800 строк и использует shared-импорты; e2e smoke `admin_entry_flow`, `admin_role_flow`, `kanban_role_flow` зеленые |
| P42 | сделано | Декомпозиция `admin.jsx`: feature-слой | Разделить UI и логику на feature-модули (`kanban`, `request-workspace`, `config-dictionaries`, `invoices`, `dashboard`) + вынести кастомные hooks/services (`useAdminApi`, `useTablesState`, `useRequestWorkspace`, `useKanban`) | Корневой `App` выполняет orchestration/layout, feature-код изолирован по папкам, сценарии ADMIN/LAWYER/CLIENT не деградировали |
| P43 | к разработке | Декомпозиция backend CRUD | Разбить `app/api/admin/crud.py` на модули: `router`, `access`, `meta`, `payloads`, `service`, `audit` без изменения API-контракта и RBAC | Эндпоинты CRUD/meta работают как раньше, покрытие тестами сохранено/расширено, файл-монолит устранен |
| P44 | к разработке | Декомпозиция backend Requests | Разбить `app/api/admin/requests.py` на модули: `router`, `kanban`, `status_flow`, `data_templates`, `permissions`, `service` с сохранением текущего поведения | Эндпоинты заявок/канбана/маршрутов статусов проходят текущие тесты, ролевые ограничения и SLA-логика без регрессий |
| P45 | к разработке | Декомпозиция тестового слоя | Разделить `tests/test_admin_universal_crud.py` на тематические пакеты (`tests/admin/*`) + вынести общие фикстуры/фабрики | Тесты запускаются пакетно и по подмодулям, время/диагностика прогонов улучшаются, покрытие не снижается |
| P46 | к разработке | Финализация декомпозиции | Обновить runbook/контекст по новым путям модулей и тестов, выполнить полный регрессионный прогон (unittest + e2e) и закрыть технический долг по монолитам | `context/11_test_runbook.md` и связанные контексты актуальны, полный прогон тестов зеленый, декомпозиция завершена |
| P47 | к разработке | Запросы клиента по заявке (модель/миграции) | Добавить отдельную таблицу клиентских обращений по заявке (рабочее имя таблицы: `request_service_requests`, чтобы не конфликтовать с `requests`): тип `enum` (`CURATOR_CONTACT`, `LAWYER_CHANGE_REQUEST`), статус обработки, текст обращения, ссылки на заявку/клиента/назначенного юриста, read/unread флаги для ADMIN/LAWYER/CURATOR, аудит | Миграция применена, таблица доступна в БД, API/модели позволяют создать оба типа запросов, read/unread и аудит фиксируются |
| P48 | к разработке | RBAC и видимость запросов (куратор/смена юриста) | Реализовать правила видимости и доступа: запрос к куратору видят ADMIN (и будущий `CURATOR`) + назначенный юрист; запрос о смене юриста не видит назначенный юрист, видит ADMIN (и будущий `CURATOR` при включении роли); предусмотреть доступ к чату заявки для куратора и отправку сообщений от его имени | Правила видимости соблюдаются серверно, назначенный юрист не видит `LAWYER_CHANGE_REQUEST`, кураторский доступ к чату и чтение/запись работают по RBAC |
| P49 | к разработке | Клиентский UI: запрос к куратору / смена юриста | Добавить в клиентском контуре действия: (1) запрос консультации к администратору/куратору по делу; (2) запрос о смене юриста; показывать статус обработки и связанные уведомления по заявке, не раскрывая служебные поля | Клиент может создать оба типа запросов из UI заявки, видит подтверждение и статус, запросы связываются с конкретной заявкой |
| P50 | к разработке | Админ-панель: вкладка «Запросы» + индикатор в topbar | Добавить отдельную вкладку `Запросы` наравне с `Заявки` и `Счета`; таблица в общем стиле (фильтры/сортировка/пагинация), а также отдельную topbar-иконку (левее `!` и конверта), которая подсвечивается красным при непрочитанных запросах и открывает таблицу с фильтром по непрочитанным | Вкладка `Запросы` доступна ADMIN (и CURATOR при появлении роли), topbar-иконка показывает unread и открывает отфильтрованный список, визуально согласовано с текущими индикаторами |
| P51 | к разработке | Тесты: запросы к куратору / смена юриста | Добавить backend + e2e покрытия: создание запросов клиентом, RBAC-изоляция по типам, подсветка заявок/иконки в админке, видимость для юриста/админа/куратора, доступ к чату от куратора | Автотесты покрывают оба типа запросов и corner cases (невидимость запроса о смене юриста назначенному юристу, unread/reset, фильтрация в таблице `Запросы`) |
| P52 | сделано | Лендинг: карусель выдающихся юристов | Добавить на лендинг карусель сотрудников (выдающиеся юристы) с фотографиями; в выдачу попадают только пользователи ролей `LAWYER`/`ADMIN`, у которых заполнен `avatar_url` (фото в профиле) | На лендинге отображается карусель карточек сотрудников с фото, именем и подписью; без фото сотрудник в карусель не попадает |
| P53 | сделано | Справочник карусели сотрудников | Добавить отдельную таблицу/справочник для управления каруселью на лендинге: ссылка на сотрудника, порядок, активность, подпись, признак закрепления (`pinned`) и CRUD в админке | Администратор может добавлять/убирать сотрудников, менять порядок, задавать подпись и `pinned`; лендинг использует этот справочник для выдачи карусели |
## Критический маршрут (обязательный порядок) ## Критический маршрут (обязательный порядок)
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения). 1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
@ -67,6 +81,9 @@
5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце). 5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце).
6. `P28 -> P29 -> P30 -> P31 -> P32 -> P33 -> P35 -> P34 -> P36 -> P37` (итерация UX/клиентского входа/чат-сервиса/навигации/доступов). 6. `P28 -> P29 -> P30 -> P31 -> P32 -> P33 -> P35 -> P34 -> P36 -> P37` (итерация UX/клиентского входа/чат-сервиса/навигации/доступов).
7. `P38 -> P39` (конструктор маршрутов и канбан-представление заявок для ролей). 7. `P38 -> P39` (конструктор маршрутов и канбан-представление заявок для ролей).
8. `P40 -> P41 -> P42 -> P43 -> P44 -> P45 -> P46` (декомпозиция фронта/бэка/тестов и финальная стабилизация).
9. `P47 -> P48 -> P49 -> P50 -> P51` (контур клиентских запросов к куратору/админу и запросов на смену юриста).
10. `P52 -> P53` (карусель сотрудников на лендинге и админское управление составом/порядком).
## Детализация P38-P39 (новый контур) ## Детализация P38-P39 (новый контур)
### P38. Конструктор маршрутов статусов ### P38. Конструктор маршрутов статусов
@ -93,6 +110,99 @@
5. Действия: 5. Действия:
перевод карточки между колонками только по допустимым серверным переходам. перевод карточки между колонками только по допустимым серверным переходам.
## Детализация P40-P46 (декомпозиция монолитов)
### P40-P42. Фронтенд (admin)
1. Инфраструктурный шаг:
сначала подготовить сборку под модули (`index.jsx`, `--bundle`), затем приступать к разбиению.
2. Shared-слой:
вынести константы, конфиги таблиц/лейблов и pure-utils в `app/web/admin/*`.
3. Feature-слой:
выделить отдельные модули и hooks для Kanban, Workspace заявки, Справочников, Счетов, Dashboard.
4. Ограничение:
декомпозиция без изменения пользовательского поведения и API-контрактов.
5. Итог `P42`:
вынесены feature-секции (`kanban`, `request-workspace`, `dashboard`, `requests`, `invoices`, `config-dictionaries`, `quotes`, `availableTables`, `meta`) и hooks/services (`useAdminApi`, `useKanban`, `useRequestWorkspace`, `useTablesState`, `useTableActions`, `useTableFilterActions`, `useAdminCatalogLoaders`); `admin.jsx` сокращен до orchestration/layout уровня, role e2e-регресс подтвержден.
### P43-P44. Backend (admin API)
1. Разделение `crud.py`:
разнести права, метаданные, нормализацию payload, сервис CRUD и аудит по отдельным файлам.
2. Разделение `requests.py`:
выделить канбан, workflow статусов, шаблоны данных, проверки прав и сервисный слой.
3. Ограничение:
сохранить текущие URL/контракты эндпоинтов и существующую RBAC/SLA-логику.
### P45-P46. Тесты и контекст
1. Разделение тестов:
разнести большой `test_admin_universal_crud.py` по тематическим модулям и общим helper/fixture.
2. Финальная верификация:
обновить runbook, затем выполнить полный прогон backend + e2e перед переводом блока в `сделано`.
## Детализация P47-P51 (запросы к куратору / смена юриста)
### P47. Таблица клиентских запросов по заявке
1. Новая сущность:
добавить отдельную таблицу (рабочее имя `request_service_requests`, чтобы не конфликтовать с `requests`).
2. Поля:
`request_id`, `client_id`, `type(enum)`, `status(enum)`, `body`, `created_by_client`, `assigned_lawyer_id`, `resolved_by_admin_id`, `read/unread` маркеры по ролям, системные поля.
3. Типы запросов:
`CURATOR_CONTACT`, `LAWYER_CHANGE_REQUEST`.
4. Аудит:
создание/изменение статуса/прочтение логировать в `audit_log`.
### P48. Видимость и RBAC
1. `CURATOR_CONTACT`:
видят ADMIN (и будущий `CURATOR`) + назначенный юрист; подсветка заявки для администратора/куратора и ведущего юриста.
2. `LAWYER_CHANGE_REQUEST`:
не видит назначенный юрист; видит ADMIN (и будущий `CURATOR`, если роль будет добавлена).
3. Куратор:
предусмотреть серверные точки расширения под роль `CURATOR` (даже если пока используется ADMIN как куратор).
4. Чат:
куратор получает доступ к чтению/записи в чат заявки от своего имени по RBAC.
### P49. Клиентский UI
1. Кнопки в карточке заявки:
«Обратиться к куратору/администратору» и «Запросить смену юриста».
2. Формы:
модальные окна с текстом обращения, валидацией длины и подтверждением отправки.
3. Отображение:
клиент видит только свои запросы и их статус обработки.
### P50. Админский UI
1. Новая вкладка `Запросы`:
таблица в стиле `Заявки/Счета` (фильтр, сортировка, пагинация, server-side).
2. Topbar-иконка:
отдельный индикатор (левее `!` и конверта), красный `unread` + переход в `Запросы` с фильтром по непрочитанным.
3. Подсветка заявок:
заявка с открытыми клиентскими запросами должна визуально отмечаться в списке `Заявки` и/или карточке.
### P51. Тестирование
1. Backend:
позитивные/негативные тесты по типам запросов и RBAC.
2. E2E:
клиент создает оба типа запросов; админ видит их в новой вкладке и через topbar-иконку; назначенный юрист видит только `CURATOR_CONTACT`.
3. Corner cases:
unread/read reset, повторные запросы, пустой/слишком длинный текст, доступ к чужой заявке.
## Детализация P52-P53 (карусель сотрудников на лендинге)
### P52. Лендинг-карусель
1. Отображение:
карусель карточек сотрудников в блоке лендинга (UI/UX в стиле проекта, адаптивная для mobile/desktop).
2. Ограничение выборки:
в выдачу попадают только `LAWYER`/`ADMIN` с заполненным `avatar_url`.
3. Контент карточки:
фото, имя, подпись (из справочника карусели), при необходимости роль.
4. Сортировка:
сначала `pinned`, затем `sort_order`.
### P53. Справочник управления каруселью
1. Новая таблица:
отдельный справочник (например, `landing_featured_staff`) с полями: `admin_user_id`, `caption`, `sort_order`, `enabled`, `pinned` + системные поля.
2. Админский CRUD:
универсальная таблица/форма в справочниках для добавления, удаления, редактирования и сортировки.
3. Валидация:
серверно разрешать только сотрудников ролей `LAWYER`/`ADMIN`; при отсутствии фото элемент не попадет в публичную выдачу.
4. Публичный endpoint/выдача:
лендинг получает отдельную публичную выборку по активным элементам карусели.
## Правила выполнения для ИИ-агента ## Правила выполнения для ИИ-агента
1. Не менять бизнес-правила без обновления `context/*.md`. 1. Не менять бизнес-правила без обновления `context/*.md`.
2. Любую новую таблицу добавлять только через миграции + тест миграций. 2. Любую новую таблицу добавлять только через миграции + тест миграций.

View file

@ -1,8 +1,10 @@
# Runbook Проверок (Тесты и Валидация по Плану) # Runbook Проверок (Тесты и Валидация по Плану)
## Назначение ## Назначение
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P39` и как их запускать. Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P46` и как их запускать.
Использовать перед переводом пункта в статус `сделано`. Использовать перед переводом пункта в статус `сделано`.
Детальная role-based матрица пользовательских сценариев (UI + corner cases): `/Users/tronosfera/Develop/Law/context/13_role_flows_test_matrix.md`.
Приоритизированный e2e backlog (P0/P1/P2 + покрытие): `/Users/tronosfera/Develop/Law/context/14_e2e_backlog_prioritized.md`.
## Базовые команды ## Базовые команды
1. Применить миграции: 1. Применить миграции:
@ -17,10 +19,10 @@ docker compose exec -T backend python -m unittest discover -s tests -p 'test_*.p
```bash ```bash
docker compose exec -T backend python -m compileall app tests alembic docker compose exec -T backend python -m compileall app tests alembic
``` ```
4. Проверка сборки `admin.jsx` через Docker Compose (на образе `frontend`): 4. Проверка сборки admin фронта через Docker Compose (на образе `frontend`, entrypoint `admin/index.jsx`):
```bash ```bash
docker compose build frontend docker compose build frontend
docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js" docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin/index.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"
``` ```
5. Браузерный E2E (Playwright) для ролевых UI-флоу (PUBLIC / LAWYER / ADMIN) через фиксированный образ `law-e2e-playwright:1.58.2`: 5. Браузерный E2E (Playwright) для ролевых UI-флоу (PUBLIC / LAWYER / ADMIN) через фиксированный образ `law-e2e-playwright:1.58.2`:
```bash ```bash
@ -35,6 +37,15 @@ docker compose run --rm --no-deps \
e2e playwright test --config=playwright.config.js e2e playwright test --config=playwright.config.js
``` ```
Примечание: образ `e2e` собирается один раз и переиспользуется, браузеры/Playwright не скачиваются при каждом запуске. Примечание: образ `e2e` собирается один раз и переиспользуется, браузеры/Playwright не скачиваются при каждом запуске.
6. Очистка e2e/тестовых артефактов в dev-БД (ручной запуск, если нужен вне e2e):
```bash
docker compose exec -T backend python -m app.data.cleanup_test_artifacts
```
7. Сид ручных тестовых данных (10 клиентов, 4 юриста, заявки/чаты/счета):
```bash
docker compose exec -T backend python -m app.data.manual_test_seed
```
Доступы и список тестовых заявок сохраняются в `/Users/tronosfera/Develop/Law/context/15_manual_test_access.md`.
## Матрица проверок по задачам ## Матрица проверок по задачам
| ID | Что проверяем | Где тесты | Как запускать | | ID | Что проверяем | Где тесты | Как запускать |
@ -44,7 +55,7 @@ docker compose run --rm --no-deps \
| P03 | Universal CRUD + RBAC + audit | `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests -v` | | P03 | Universal CRUD + RBAC + audit | `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests -v` |
| P04 | Пользователи, роли, пароли | `tests/test_admin_universal_crud.py` (тесты про `admin_users`) | команда как для `P03` | | P04 | Пользователи, роли, пароли | `tests/test_admin_universal_crud.py` (тесты про `admin_users`) | команда как для `P03` |
| P05 | Базовый auto-assign | `tests/test_auto_assign.py` | `docker compose exec -T backend python -m unittest tests.test_auto_assign -v` | | P05 | Базовый auto-assign | `tests/test_auto_assign.py` | `docker compose exec -T backend python -m unittest tests.test_auto_assign -v` |
| P06 | Админка `admin.jsx` + базовый UI контур | сборка `admin.jsx` + CRUD/API тесты | базовая команда 4 + тесты `P03` | | P06 | Админка `admin.jsx` + базовый UI контур | сборка admin фронта + CRUD/API тесты | базовая команда 4 + тесты `P03` |
| P07 | Доп. темы юристов (`admin_user_topics`) | `tests/test_admin_universal_crud.py` | команда как для `P03` | | P07 | Доп. темы юристов (`admin_user_topics`) | `tests/test_admin_universal_crud.py` | команда как для `P03` |
| P08 | Ручной claim (без гонок) | `tests/test_admin_universal_crud.py` (claim-тесты) | команда как для `P03` | | P08 | Ручной claim (без гонок) | `tests/test_admin_universal_crud.py` (claim-тесты) | команда как для `P03` |
| P09 | ADMIN-only переназначение | `tests/test_admin_universal_crud.py` (reassign-тесты) | команда как для `P03` | | P09 | ADMIN-only переназначение | `tests/test_admin_universal_crud.py` (reassign-тесты) | команда как для `P03` |
@ -61,7 +72,7 @@ docker compose run --rm --no-deps \
| P20 | Уведомления | `tests/test_notifications.py`, а также регрессии `tests/test_public_cabinet.py`, `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_notifications tests.test_public_cabinet tests.test_uploads_s3 tests.test_worker_maintenance -v`; затем полный прогон | | P20 | Уведомления | `tests/test_notifications.py`, а также регрессии `tests/test_public_cabinet.py`, `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_notifications tests.test_public_cabinet tests.test_uploads_s3 tests.test_worker_maintenance -v`; затем полный прогон |
| P21 | Dashboard ADMIN/LAWYER | `tests/test_admin_universal_crud.py` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance tests.test_admin_universal_crud -v`; проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц | | P21 | Dashboard ADMIN/LAWYER | `tests/test_admin_universal_crud.py` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance tests.test_admin_universal_crud -v`; проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц |
| P22 | Hardening/release | `tests/test_http_hardening.py` + весь regression + compile + миграции + UI build | `docker compose exec -T backend python -m unittest tests.test_http_hardening -v`; затем базовые команды 1-4 | | P22 | Hardening/release | `tests/test_http_hardening.py` + весь regression + compile + миграции + UI build | `docker compose exec -T backend python -m unittest tests.test_http_hardening -v`; затем базовые команды 1-4 |
| P23 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать `admin.jsx` при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла | | P23 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать admin фронт при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла |
| P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_rates tests.test_admin_universal_crud -v`; проверка что public API не отдает поля ставок/процентов | | P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_rates tests.test_admin_universal_crud -v`; проверка что public API не отдает поля ставок/процентов |
| P25 | Billing-статус и шаблон счета | `tests/test_billing_flow.py`, `tests/test_invoices.py` + e2e статусных переходов | `docker compose exec -T backend python -m unittest tests.test_billing_flow tests.test_invoices tests.test_admin_universal_crud -v`; валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) | | P25 | Billing-статус и шаблон счета | `tests/test_billing_flow.py`, `tests/test_invoices.py` + e2e статусных переходов | `docker compose exec -T backend python -m unittest tests.test_billing_flow tests.test_invoices tests.test_admin_universal_crud -v`; валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) |
| P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` | | P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` |
@ -71,13 +82,20 @@ docker compose run --rm --no-deps \
| P30 | Отдельная страница работы с заявкой клиента | новые e2e для client workspace route + `tests/test_public_cabinet.py` | добавить e2e route-flow + прогон `test_public_cabinet` | | P30 | Отдельная страница работы с заявкой клиента | новые e2e для client workspace route + `tests/test_public_cabinet.py` | добавить e2e route-flow + прогон `test_public_cabinet` |
| P31 | Вход клиента через phone+OTP модалку | новые e2e OTP modal flow + `tests/test_otp_rate_limit.py`, `tests/test_public_requests.py` | e2e + backend OTP тесты | | P31 | Вход клиента через phone+OTP модалку | новые e2e OTP modal flow + `tests/test_otp_rate_limit.py`, `tests/test_public_requests.py` | e2e + backend OTP тесты |
| P32 | Переключение между заявками клиента | новые e2e multi-request flow + `tests/test_public_cabinet.py` | e2e multi-request + backend regression | | P32 | Переключение между заявками клиента | новые e2e multi-request flow + `tests/test_public_cabinet.py` | e2e multi-request + backend regression |
| P33 | Чат в отдельном сервисе | `tests/test_public_cabinet.py`, `tests/test_admin_universal_crud.py` (chat service cases) + UI smoke (`client.js`, `admin.jsx`) | `docker compose run --rm backend python -m unittest tests.test_public_cabinet tests.test_admin_universal_crud -v` + фронт-сборка `admin.jsx` | | P33 | Чат в отдельном сервисе | `tests/test_public_cabinet.py`, `tests/test_admin_universal_crud.py` (chat service cases) + UI smoke (`client.js`, `admin.jsx`) | `docker compose run --rm backend python -m unittest tests.test_public_cabinet tests.test_admin_universal_crud -v` + фронт-сборка admin entrypoint |
| P34 | Ненавязчивые цитаты в блоке «Первая консультация» | UI e2e/smoke лендинга | визуальная регрессия лендинга + Playwright public smoke | | P34 | Ненавязчивые цитаты в блоке «Первая консультация» | UI e2e/smoke лендинга | визуальная регрессия лендинга + Playwright public smoke |
| P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` | | P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` |
| P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` | | P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` |
| P37 | Единые bootstrap-креды админа | `tests/test_admin_auth.py` + auth smoke (`/api/admin/auth/login`) + docs consistency check | `docker compose run --rm backend python -m unittest tests.test_admin_auth -v` + UI/API login smoke с `admin@example.com` / `admin123` | | P37 | Единые bootstrap-креды админа | `tests/test_admin_auth.py` + auth smoke (`/api/admin/auth/login`) + docs consistency check | `docker compose run --rm backend python -m unittest tests.test_admin_auth -v` + UI/API login smoke с `admin@example.com` / `admin123` |
| P38 | Конструктор маршрутов статусов (темы) | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py` + e2e `e2e/tests/admin_status_designer_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_worker_maintenance -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/admin_status_designer_flow.spec.js` | | P38 | Конструктор маршрутов статусов (темы) | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py` + e2e `e2e/tests/admin_status_designer_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_worker_maintenance -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/admin_status_designer_flow.spec.js` |
| P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py` (`test_requests_kanban_returns_grouped_cards_and_role_scope`) + e2e `e2e/tests/kanban_role_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests.test_requests_kanban_returns_grouped_cards_and_role_scope -v` и `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/kanban_role_flow.spec.js`; дополнительно регресс `admin_role_flow`, `lawyer_role_flow` | | P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py` (`test_requests_kanban_returns_grouped_cards_and_role_scope`) + e2e `e2e/tests/kanban_role_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests.test_requests_kanban_returns_grouped_cards_and_role_scope -v` и `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/kanban_role_flow.spec.js`; дополнительно регресс `admin_role_flow`, `lawyer_role_flow` |
| P40 | Подготовка модульной сборки admin фронта | `frontend/Dockerfile`, `app/web/admin/index.jsx`, smoke e2e | базовая команда 4 + `e2e/tests/admin_entry_flow.spec.js` |
| P41 | Декомпозиция shared-слоя admin | сборка admin фронта + role e2e smoke | базовая команда 4 + `e2e/tests/admin_role_flow.spec.js`, `e2e/tests/kanban_role_flow.spec.js` |
| P42 | Декомпозиция feature-слоя admin | сборка admin фронта + role e2e regression | базовая команда 4 + полный e2e через сервис `e2e` |
| P43 | Декомпозиция backend CRUD | `tests/test_admin_universal_crud.py`, `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_migrations -v` |
| P44 | Декомпозиция backend Requests | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`, e2e kanban | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_worker_maintenance -v` + `e2e/tests/kanban_role_flow.spec.js` |
| P45 | Декомпозиция тестового слоя | пакетный запуск `tests/admin/*` + discovery | целевые команды по новым модулям + `python -m unittest discover -s tests -p 'test_*.py' -v` |
| P46 | Финализация декомпозиции | полный backend + frontend + e2e регресс | базовые команды 1-5 |
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN) ## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
### PUBLIC (клиент) ### PUBLIC (клиент)
@ -90,6 +108,7 @@ docker compose run --rm --no-deps \
### LAWYER (юрист) ### LAWYER (юрист)
- UI e2e: `e2e/tests/lawyer_role_flow.spec.js` (вход, claim неназначенной заявки, новая вкладка работы с заявкой, чтение обновлений, смена статуса). - UI e2e: `e2e/tests/lawyer_role_flow.spec.js` (вход, claim неназначенной заявки, новая вкладка работы с заявкой, чтение обновлений, смена статуса).
- UI e2e: `e2e/tests/request_data_file_flow.spec.js` (юрист создает `Запрос` с `file`-полем, клиент загружает файл, юрист видит заполнение запроса).
- Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`. - Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`.
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`. - Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`.
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`. - Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`.
@ -114,12 +133,26 @@ docker compose run --rm --no-deps \
2. Выполнить целевые тесты пункта по матрице выше. 2. Выполнить целевые тесты пункта по матрице выше.
3. Выполнить полный прогон `unittest discover`. 3. Выполнить полный прогон `unittest discover`.
4. Выполнить `compileall`. 4. Выполнить `compileall`.
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose. 5. Для изменений админ-фронта выполнить сборку entrypoint `admin/index.jsx` через Docker Compose.
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`. 6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
## Последние подтвержденные прогоны ## Последние подтвержденные прогоны
- `docker compose run --rm backend python -m unittest -v tests.test_admin_auth``3 passed`. - `docker compose run --rm backend python -m unittest -v tests.test_admin_auth``3 passed`.
- `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v``105 passed`. - `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v``105 passed`.
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно. - `docker compose run --rm backend python -m compileall app tests alembic` — успешно.
- `docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin/index.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"` — успешно (`admin.bundle.js` собран).
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test tests/admin_entry_flow.spec.js --config=playwright.config.js``1 passed`. - `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test tests/admin_entry_flow.spec.js --config=playwright.config.js``1 passed`.
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/admin_entry_flow.spec.js e2e/tests/admin_role_flow.spec.js``2 passed`.
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/kanban_role_flow.spec.js``1 passed`.
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line``2 passed` (после выноса `dashboard/requests/invoices` из `admin.jsx`).
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/admin_status_designer_flow.spec.js --reporter=line``2 passed` (после выноса `config`-секции в `ConfigSection`).
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js --reporter=line``1 passed` (после выноса `quotes/availableTables/meta` секций).
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/kanban_role_flow.spec.js --reporter=line``2 passed` (после выноса `useKanban`).
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/lawyer_role_flow.spec.js --reporter=line``1 passed` (после выноса `useRequestWorkspace` state/actions).
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/request_data_file_flow.spec.js --reporter=line``1 passed` (E2E: `REQUEST_DATA` с типом `file`, загрузка клиентом через S3, отметка выполнения у юриста).
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/kanban_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line``3 passed` (после внедрения `useTablesState` и переноса registry state таблиц).
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line``2 passed` (после переноса `loadRequestModalData`/`refreshRequestModal` в `useRequestWorkspace`).
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line``2 passed` (после переноса `openRequestDetails` и `submitRequestModalMessage` в `useRequestWorkspace`).
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/admin_status_designer_flow.spec.js e2e/tests/kanban_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line``4 passed` (после выноса `useTableActions`: `loadTable` + paging/sort).
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/admin_status_designer_flow.spec.js e2e/tests/kanban_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line``4 passed` (после выноса `useTableFilterActions` и `useAdminCatalogLoaders`, закрытие `P42`).
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js``6 passed` (рольовые e2e + конструктор статусов + канбан: `admin_entry_flow`, `admin_role_flow`, `admin_status_designer_flow`, `kanban_role_flow`, `lawyer_role_flow`, `public_client_flow`). - `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js``6 passed` (рольовые e2e + конструктор статусов + канбан: `admin_entry_flow`, `admin_role_flow`, `admin_status_designer_flow`, `kanban_role_flow`, `lawyer_role_flow`, `public_client_flow`).

View file

@ -0,0 +1,470 @@
# Матрица User Flow для Тестирования Платформы (CLIENT / LAWYER / ADMIN)
## Назначение
Этот файл описывает полный набор пользовательских сценариев для тестирования платформы через веб-интерфейс.
Используется как:
1. чеклист ручной приемки,
2. источник для e2e/интеграционных сценариев,
3. карта corner cases по ролям.
## Общие правила проверки
1. Все ключевые сценарии проверять через UI (не только по API).
2. Для каждого сценария проверять:
- видимость данных по роли,
- корректность уведомлений/непрочитанного,
- записи в истории статусов/чата,
- отсутствие утечки служебной/финансовой информации клиенту.
3. Для статусов:
- `важная дата` видна клиенту, юристу и администратору,
- для юриста/администратора это дедлайн текущего статуса,
- смена статуса логируется вместе с `важной датой` и комментарием.
4. Для файлов:
- проверять и успешный сценарий, и ошибки (размер, MIME, предпросмотр, поврежденный файл).
## Тестовые роли и данные (рекомендуемые)
1. `ADMIN`: `admin@example.com` / `admin123`
2. `LAWYER #1`: основной юрист (назначенные заявки)
3. `LAWYER #2`: другой юрист (для проверок запрета доступа к чужим данным)
4. `CLIENT #1`: новый клиент (новый номер телефона)
5. `CLIENT #2`: второй клиент (для проверки изоляции JWT/заявок)
## CLIENT (Пользователь / Заказчик)
### C01. Просмотр лендинга и создание заявки (happy path)
1. Открыть лендинг.
2. Открыть модальную форму создания заявки.
3. Заполнить:
- ФИО,
- телефон,
- тему,
- описание.
4. Пройти OTP-подтверждение телефона.
5. Создать заявку.
6. Проверить:
- выдан уникальный номер заявки,
- создается запись клиента (`client`) и связь с заявкой,
- клиент не видит служебные поля (ставка, стоимость, внутренние ID, ответственные ID, финансовые поля).
### C02. Повторный вход в клиентский контур по номеру/OTP
1. На лендинге нажать кнопку перехода к работе с заявкой.
2. Если нет JWT (новое устройство/браузер), пройти OTP.
3. Открыть страницу работы с заявкой.
4. Проверить:
- доступ только к своим заявкам,
- после авторизации создается 7-дневный JWT/сессия устройства,
- повторный вход на том же устройстве не требует OTP до истечения срока.
### C03. Переключение между заявками клиента
1. Создать 2+ заявки на один телефон.
2. Открыть клиентский кабинет.
3. Переключаться между заявками в UI.
4. Проверить:
- отображаются только заявки этого клиента,
- корректно обновляются статус, чат, файлы, важная дата, уведомления,
- переход не ломает состояние JWT/авторизации.
### C04. Просмотр карточки заявки (видимость данных)
1. Открыть заявку в клиентском кабинете.
2. Проверить, что видны:
- номер заявки,
- тема,
- статус,
- важная дата (дедлайн),
- история/маршрут статусов,
- чат,
- файлы,
- запросы дополнительных данных.
3. Проверить, что НЕ видны:
- ставка юриста,
- внутренние финансовые поля,
- служебные ID,
- служебные комментарии/системная информация, не предназначенная клиенту.
### C05. Чат клиент <-> юрист (happy path)
1. Клиент отправляет сообщение юристу.
2. Юрист отвечает.
3. Клиент обновляет/переоткрывает заявку.
4. Проверить:
- сообщения отображаются в стиле чата,
- метки времени/даты корректны,
- непрочитанные индикаторы появляются и сбрасываются после открытия заявки,
- история сообщений сохраняется.
### C06. Загрузка файлов клиентом (happy path)
1. В карточке заявки/чате прикрепить файл.
2. Отправить сообщение с файлом.
3. Проверить:
- файл загружается,
- появляется в чате и во вкладке файлов,
- доступен предпросмотр/скачивание,
- размер учитывается в лимитах заявки.
### C07. Запрос дополнительных данных от юриста (частичное заполнение)
1. Юрист создает `Запрос` с несколькими полями.
2. Клиент открывает сообщение `Запрос`.
3. Заполняет часть полей, сохраняет.
4. Повторно открывает запрос и дозаполняет остальное.
5. Проверить:
- заполненные поля отмечаются и зачеркиваются в чате,
- незаполненные остаются активными,
- после полного заполнения запрос сворачивается и меняет цвет/состояние.
### C08. `file`-поле в запросе дополнительных данных
1. Юрист создает запрос с полем типа `file`.
2. Клиент загружает файл в это поле.
3. Проверить:
- файл реально загружается как attachment,
- поле считается заполненным,
- файл доступен в файлах заявки и в контексте запроса,
- юрист видит, что запрос выполнен.
### C09. Оповещения клиента
1. Юрист меняет статус.
2. Юрист отправляет сообщение.
3. Юрист загружает файл.
4. Клиент открывает кабинет.
5. Проверить:
- есть визуальные индикаторы новых событий,
- открытие заявки сбрасывает непрочитанное состояние,
- важная дата обновляется при смене статуса.
### C10. Терминальный статус (завершение заявки)
1. Юрист/админ переводит заявку в терминальный статус.
2. Клиент открывает заявку.
3. Проверить:
- терминальный статус отображается корректно,
- маршрут/история статусов содержит финальную запись,
- важная дата по финальному статусу отображается.
### C11. Ошибка загрузки файла (корнер-кейсы)
Проверить UI-реакцию и корректный текст ошибки:
1. Слишком большой файл (>25MB).
2. Превышение суммарного лимита по заявке (>250MB).
3. Обрыв сети/ошибка presigned upload.
4. Неподдерживаемый или некорректный MIME.
5. Проверить:
- пользователь видит понятную ошибку,
- UI не зависает,
- частично неуспешная отправка не ломает чат,
- дубликаты/битые attachments не создаются.
### C12. Слишком длинное сообщение / некорректное сообщение
1. Отправить очень длинный текст (выше backend-лимита, если установлен).
2. Отправить пустое сообщение.
3. Отправить сообщение из пробелов.
4. Проверить:
- понятная ошибка в UI,
- ничего лишнего в чат не попадает,
- состояние формы остается консистентным.
### C13. Предпросмотр файлов (валидный / невалидный)
1. Валидный `pdf`, `jpg`, `mp4`, `txt`.
2. Невалидный PDF (файл с MIME `application/pdf`, но без `%PDF-`).
3. `.txt` с кривым MIME (`application/pdf`).
4. Проверить:
- валидные файлы открываются,
- невалидный PDF дает fallback (понятное сообщение / текстовый предпросмотр),
- `.txt` открывается текстом даже при кривом MIME.
### C14. Безопасность доступа клиента (изоляция)
1. Открыть чужой номер заявки.
2. Использовать JWT клиента #1 для заявки клиента #2.
3. Попробовать открыть прямые URL файлов чужой заявки.
4. Проверить:
- доступ запрещен,
- нет утечки метаданных.
## LAWYER (Юрист)
### L01. Вход и видимость заявок
1. Войти как юрист.
2. Открыть список заявок.
3. Проверить видимость:
- свои заявки,
- неназначенные заявки,
- отсутствие чужих назначенных заявок.
### L02. Claim неназначенной заявки
1. В списке или в канбане выбрать неназначенную заявку.
2. Нажать `Взять в работу`.
3. Проверить:
- заявка назначается текущему юристу,
- в канбане/таблице обновляется исполнитель,
- claim логируется (audit),
- другой юрист больше не может ее claim'ить.
### L03. Канбан: просмотр и работа с карточками
1. Открыть `Канбан`.
2. Проверить:
- группировка по группам статусов,
- карточки содержат ключевые поля (номер, клиент, тема, дедлайн/важная дата, индикаторы новых сообщений/файлов),
- горизонтальная прокрутка внутри блока, без растягивания страницы.
### L04. Канбан: drag&drop смена статуса (однозначная группа)
1. Перетащить карточку в колонку, где целевая группа сопоставляется с одним статусом.
2. Проверить:
- статус меняется,
- важная дата проставляется по умолчанию (`+3 дня`), если не задана явно,
- изменение отображается в карточке и в истории статусов.
### L05. Канбан: drag&drop в группу с несколькими статусами
1. Перетащить карточку в колонку, где в группе >1 статуса.
2. Проверить:
- открывается карточка заявки / модалка смены статуса,
- доступны только статусы соответствующей группы,
- можно выбрать конкретный статус и важную дату.
### L06. Смена статуса из карточки заявки (happy path)
1. Открыть карточку заявки.
2. Нажать кнопку смены статуса.
3. Выбрать новый статус, указать важную дату, добавить комментарий и файл.
4. Отправить.
5. Проверить:
- статус заявки обновился,
- важная дата изменилась,
- запись появилась в истории статусов,
- комментарий/файл попали в чат.
### L07. Терминальный статус (закрытие заявки юристом)
1. Юрист переводит заявку в терминальный статус.
2. Проверить:
- статус обновлен,
- заявка считается завершенной в dashboard/метриках,
- история статусов содержит терминальный этап.
### L08. История статусов в модалке смены статуса
1. Открыть модалку смены статуса для заявки с несколькими сменами статусов.
2. Проверить:
- список прокручивается внутри фиксированного блока,
- новые записи сверху, старые снизу,
- видны дата назначения, важная дата, длительность нахождения, комментарий (если есть).
### L09. Важные даты (дедлайн) у юриста
1. Сменить статус без указания даты.
2. Проверить автоподстановку `+3 дня`.
3. Сменить статус с ручной датой.
4. Проверить:
- дедлайн отображается в карточке заявки,
- дедлайн отображается в канбане,
- цвет дедлайна в канбане соответствует сроку.
### L10. Чат и файлы юриста
1. Отправка сообщений клиенту.
2. Отправка файлов.
3. Отправка файла+сообщения.
4. Drag&drop файлов в чат.
5. Проверить:
- сообщения/файлы видит клиент,
- непрочитанные индикаторы работают,
- предпросмотр/скачивание работают.
### L11. Запрос дополнительных данных (шаблоны)
1. Открыть модалку `Запросить`.
2. Выбрать существующий шаблон.
3. Проверить автозагрузку полей шаблона в таблицу (без дубликатов).
4. Добавить поле из справочника.
5. Создать новое поле через тот же инпут.
6. Сохранить шаблон:
- новый,
- перезапись своего.
7. Проверить:
- чужой шаблон нельзя перезаписать (UI + backend),
- badge/tooltip соответствуют статусу шаблона.
### L12. Ограничения редактирования заполненных клиентом доп.данных
1. После заполнения клиентом открыть запрос в чате.
2. Проверить, что юрист НЕ может:
- менять название поля,
- тип,
- порядок,
- удалять заполненное поле.
3. Проверить backend-защиту (через UI негативный сценарий / при попытке сохранить).
### L13. Ограничения по правам (чужая заявка)
1. Попробовать открыть/изменить чужую назначенную заявку.
2. Попробовать сменить статус чужой заявки.
3. Попробовать загрузить файл/написать сообщение в чужую заявку.
4. Ожидание: запрет доступа/операции.
### L14. Финансовые ограничения
1. Юрист не должен иметь возможность менять запрещенные финансовые поля через CRUD заявки.
2. Юрист не может подтверждать оплату счета (`PAID`).
3. Проверить UI/API сообщения об ошибке.
## ADMIN (Администратор)
### A01. Вход и доступ ко всем разделам
1. Войти как администратор.
2. Проверить доступ:
- Обзор,
- Канбан,
- Заявки,
- Счета,
- Справочники,
- `availableTables` по прямой ссылке.
### A02. Справочник пользователей (CRUD)
1. Создать юриста:
- имя, email, пароль,
- роль,
- основная тема,
- дополнительные темы,
- ставка по умолчанию,
- процент,
- телефон,
- аватар.
2. Отредактировать пользователя.
3. Деактивировать/активировать.
4. Проверить:
- данные сохраняются,
- аватар/инициалы отображаются корректно,
- роль и RBAC применяются.
### A03. Справочники статусов и групп статусов
1. Создать/редактировать группы статусов.
2. Создать/редактировать статусы:
- группа,
- терминальность,
- kind (обычный / счет / оплачено).
3. Проверить:
- канбан строится по группам,
- статусы появляются в свободном выборе смены статуса.
### A04. Таблицы в справочниках и `availableTables`
1. Открыть `/admin.html?section=availableTables`.
2. Включать/выключать таблицы.
3. Проверить:
- неактивные таблицы исчезают из списка справочников,
- активные появляются без фронтовой доработки (универсальное отображение),
- `clients` видны в справочниках, если таблица активна.
### A05. Заявки: полный CRUD и ручное управление
1. Создать заявку вручную.
2. Привязать существующего клиента.
3. Создать нового клиента через форму заявки (если нет в списке).
4. Изменить тему/описание/исполнителя/стоимость.
5. Проверить:
- `client_id` корректно проставляется,
- стоимость заявки видна админу/юристу, скрыта клиенту.
### A06. Смена статуса заявки (с важной датой)
1. Открыть карточку заявки.
2. Через модалку смены статуса:
- выбрать любой статус,
- указать важную дату,
- добавить комментарий/файл.
3. Проверить:
- изменения видны клиенту/юристу/админу,
- история статусов содержит важную дату и комментарий.
### A07. Администратор может корректировать заполненные доп.данные
1. После заполнения клиентом доп.данных открыть запрос.
2. Удалить/изменить заполненную строку (если требуется).
3. Проверить:
- операция разрешена админу,
- изменения консистентны.
### A08. Счета и оплаты (happy path)
1. Создать счет вручную.
2. Проверить формирование PDF.
3. Перевести в `Оплачен`.
4. Проверить:
- дата оплаты проставляется,
- данные видны в списке счетов,
- юрист видит свои счета, админ — все.
### A09. Billing через статусы
1. Перевести заявку в billing-статус (если используется `INVOICE` / `PAID`).
2. Проверить:
- срабатывает логика счетов/оплаты,
- сумма/зарплата отражается в дашборде.
### A10. Dashboard администратора
Проверить плитки и метрики:
1. Новые / в работе / и др. статусы.
2. Выручка за месяц.
3. Расходы (зарплата юристов) за месяц.
4. Карточки загрузки юристов:
- активные,
- новые,
- закрыто,
- сумма,
- зарплата.
5. Модалка статистики юриста:
- фиксированные хедеры,
- таблица активных заявок,
- сумматоры внизу,
- корректные переходы в карточки заявок.
### A11. Безопасность / аудит / доступы
1. Проверка доступа к файлам S3 по ролям.
2. Предпросмотр PDF/файлов через iframe/same-origin.
3. Проверка аудита действий (security audit / audit log).
4. Проверка, что ПДн и финансовые данные не уходят в публичный контур.
### A12. Corner cases администратора
1. Ошибки сохранения (невалидные поля, несуществующие ссылки).
2. Одновременное редактирование (refresh после сохранения).
3. Попытка удалить сущность, на которую есть ссылки.
4. Некорректные даты/числа в универсальных формах.
5. Проверить понятные ошибки и отсутствие “битого” состояния UI.
## Межролевые интеграционные сценарии (сквозные)
### X01. Полный цикл заявки (client -> lawyer -> terminal)
1. Клиент создает заявку.
2. Юрист берет в работу.
3. Юрист ведет чат, запрашивает данные/файлы.
4. Клиент заполняет запрос.
5. Юрист меняет статусы, выставляет важные даты.
6. Юрист завершает заявку терминальным статусом.
7. Проверить весь таймлайн и видимость по ролям.
### X02. Цикл со счетом и оплатой
1. Клиент создает заявку.
2. Юрист/админ переводит в статус выставления счета.
3. Админ формирует/подтверждает оплату счета.
4. Проверить:
- счет виден в заявке и в списке счетов,
- статус оплаты влияет на финансовые метрики,
- зарплата юриста считается после `Оплачено`.
### X03. Непрочитанные события и уведомления
1. Создать набор событий: статус, сообщение, файл.
2. Проверить отображение индикаторов:
- в списках,
- в канбане,
- в карточке заявки.
3. Проверить сброс после открытия заявки соответствующей ролью.
### X04. Ограничения прав и изоляция
1. CLIENT не видит служебные/финансовые поля.
2. LAWYER не видит/не меняет чужие заявки.
3. LAWYER не подтверждает оплату.
4. LAWYER не редактирует заполненные клиентом доп.данные.
5. ADMIN имеет доступ ко всем данным и операциям.
## Corner Cases (общий список для обязательного покрытия)
1. Пустые поля / пробельные значения.
2. Слишком длинные тексты сообщений / комментариев.
3. Файлы:
- >25MB,
- превышение 250MB на заявку,
- невалидный PDF,
- `.txt` с кривым MIME.
4. Повторные клики / double-submit.
5. Потеря сети во время upload/send/status-change.
6. Истекший JWT / отсутствие OTP.
7. Конфликт назначения заявки (claim race).
8. Удаление/изменение связанной сущности (клиент/статус/пользователь).
9. Переключение между заявками/разделами при открытых модалках.
## Рекомендация по автоматизации (e2e backlog)
Приоритетно выделить в отдельные Playwright-сценарии:
1. `public_client_notifications_flow`
2. `lawyer_status_change_modal_flow` (важная дата + комментарий + файл)
3. `kanban_multi_status_group_flow` (drag&drop -> выбор статуса)
4. `admin_finance_billing_dashboard_flow`
5. `client_file_upload_errors_flow`
6. `rbac_negative_flows` (client/lawyer/admin)

View file

@ -0,0 +1,92 @@
# E2E Backlog (P0 / P1 / P2) и Текущее Покрытие
## Назначение
Файл раскладывает role-based матрицу `/Users/tronosfera/Develop/Law/context/13_role_flows_test_matrix.md`
в конкретный backlog Playwright-сценариев:
1. приоритет (`P0`, `P1`, `P2`);
2. статус покрытия (`Покрыто`, `Частично`, `Не покрыто`);
3. что уже проверяется текущими e2e-спеками;
4. что нужно дописать.
## Текущее покрытие (сводно)
### Уже есть e2e-спеки
1. `/Users/tronosfera/Develop/Law/e2e/tests/admin_entry_flow.spec.js`
2. `/Users/tronosfera/Develop/Law/e2e/tests/admin_role_flow.spec.js`
3. `/Users/tronosfera/Develop/Law/e2e/tests/kanban_role_flow.spec.js`
4. `/Users/tronosfera/Develop/Law/e2e/tests/lawyer_role_flow.spec.js`
5. `/Users/tronosfera/Develop/Law/e2e/tests/public_client_flow.spec.js`
6. `/Users/tronosfera/Develop/Law/e2e/tests/request_data_file_flow.spec.js`
### Legacy / к замене
1. `/Users/tronosfera/Develop/Law/e2e/tests/admin_status_designer_flow.spec.js`
- относится к скрытому UI-конструктору переходов статусов;
- держать как legacy до физической зачистки backend/UI;
- в новом плане не расширять.
## P0 (критические сквозные роли и деньги)
| ID | Сценарий | Роль | Покрытие | Текущий e2e | Что дописать |
|---|---|---|---|---|---|
| E2E-P0-01 | Лендинг -> создание заявки -> кабинет клиента | CLIENT | `Покрыто` | `public_client_flow` | добавить проверки важной даты и скрытия финансовых полей |
| E2E-P0-02 | Клиент чат + файл + предпросмотр + fallback невалидного PDF/TXT | CLIENT | `Частично` | `public_client_flow` | добавить отдельный spec на error/fallback preview и лимиты |
| E2E-P0-03 | Юрист: claim -> карточка заявки -> чат -> файл -> смена статуса | LAWYER | `Частично` | `lawyer_role_flow` | перевести смену статуса на новую модалку (статус, важная дата, комментарий, файл) |
| E2E-P0-04 | Юрист: запрос доп.данных (`file`) -> клиент загружает -> юрист видит выполнение | LAWYER + CLIENT | `Покрыто` | `request_data_file_flow` | расширить на частичное заполнение и повторное дозаполнение |
| E2E-P0-05 | Канбан юриста: фильтр/сортировка + claim + переход в карточку | LAWYER | `Покрыто` | `kanban_role_flow` | добавить drag&drop смену статуса с важной датой |
| E2E-P0-06 | Админ: пользователи/темы/счета/availableTables | ADMIN | `Частично` | `admin_role_flow` | добавить статус-модалку, стоимость заявки, клиентский селектор |
| E2E-P0-07 | Полный цикл: клиент -> юрист -> терминальный статус -> клиент видит завершение | CLIENT + LAWYER | `Не покрыто` | - | новый сквозной сценарий |
| E2E-P0-08 | Платежный цикл: счет -> оплата админом -> dashboard/выручка/зарплата | ADMIN | `Не покрыто` | - | новый сценарий по счетам и дашборду |
| E2E-P0-09 | RBAC UI: клиент не видит служебные/финансовые поля | CLIENT | `Частично` | косвенно в `public_client_flow` | явные ассерт-проверки отсутствия элементов |
## P1 (операционные сценарии и corner cases по ролям)
| ID | Сценарий | Роль | Покрытие | Текущий e2e | Что дописать |
|---|---|---|---|---|---|
| E2E-P1-01 | Клиент: 2-5 заявок на один телефон, переключение между заявками | CLIENT | `Не покрыто` | - | новый spec multi-request switch |
| E2E-P1-02 | Клиент: OTP вход через модалку на лендинге (без JWT) | CLIENT | `Не покрыто` | - | новый spec, без bypass verify-route или с controlled bypass |
| E2E-P1-03 | Клиент: ошибки загрузки файла (25MB/250MB/обрыв) | CLIENT | `Не покрыто` | - | негативный spec (mock network + oversized fixture) |
| E2E-P1-04 | Клиент: слишком длинное/пустое сообщение | CLIENT | `Не покрыто` | - | негативный spec по чату |
| E2E-P1-05 | Юрист: drag&drop в группу с несколькими статусами -> модалка выбора статуса | LAWYER | `Не покрыто` | - | новый spec на канбан + модалку |
| E2E-P1-06 | Юрист: статус-модалка с историей статусов и важной датой | LAWYER | `Не покрыто` | - | новый spec на карточку заявки |
| E2E-P1-07 | Юрист: терминальный статус (завершение) | LAWYER | `Не покрыто` | - | добавить в `lawyer_role_flow` или отдельный spec |
| E2E-P1-08 | Юрист: не может редактировать заполненные клиентом доп.данные | LAWYER | `Частично` | косвенно `request_data_file_flow` | добавить явный запрет в UI |
| E2E-P1-09 | Админ: дашборд (выручка/расходы/плитки юристов/модалка статистики) | ADMIN | `Частично` | `admin_role_flow` (только наличие секции) | новый spec на метрики и модалку юриста |
| E2E-P1-10 | Админ: редактирование заявки (выбор клиента/создание нового, стоимость заявки) | ADMIN | `Не покрыто` | - | новый spec на форму заявки |
| E2E-P1-11 | Админ: смена статуса заявки через новую модалку + важная дата | ADMIN | `Не покрыто` | - | новый spec |
| E2E-P1-12 | Админ/LAWYER: просмотр PDF счета/вложения в iframe preview | ADMIN + LAWYER | `Частично` | `lawyer_role_flow`, `public_client_flow` | отдельный стабильный preview spec |
## P2 (расширенный UX/regression/edge flows)
| ID | Сценарий | Роль | Покрытие | Текущий e2e | Что дописать |
|---|---|---|---|---|---|
| E2E-P2-01 | Клиент/юрист: unread индикаторы сообщений/файлов сбрасываются при открытии заявки | CLIENT + LAWYER | `Частично` | `lawyer_role_flow` | детальный spec на оба типа индикаторов |
| E2E-P2-02 | Канбан: фильтр по полям + сортировка + сохранение визуального состояния кнопок | LAWYER/ADMIN | `Частично` | `kanban_role_flow` | расширить перечень фильтров и сортировку |
| E2E-P2-03 | Universal dictionaries UI (CRUD справочников) после `availableTables` переключений | ADMIN | `Частично` | `admin_role_flow` | вынести в отдельный regression spec |
| E2E-P2-04 | Tooltip/модалки/overflow layering regression | ADMIN | `Не покрыто` | - | visual/smoke spec по tooltip overlay и scroll containers |
| E2E-P2-05 | Форма запроса данных: шаблон (создать/перезаписать/чужой readonly badge) | LAWYER | `Частично` | `request_data_file_flow` (без шаблонов) | отдельный spec на шаблоны |
| E2E-P2-06 | Клиент: невалидный PDF и `.txt` с кривым MIME -> fallback preview | CLIENT | `Не покрыто` | - | отдельный preview-fallback spec |
## Приоритет реализации (рекомендуемый порядок)
### Волна 1 (P0)
1. `E2E-P0-03` (обновить `lawyer_role_flow` под новую статус-модалку)
2. `E2E-P0-07` (полный цикл клиент -> юрист -> завершение)
3. `E2E-P0-08` (платежный цикл и dashboard)
4. `E2E-P0-09` (явный RBAC UI check клиента)
### Волна 2 (P1)
1. `E2E-P1-05`, `E2E-P1-06`, `E2E-P1-07` (канбан + статусы)
2. `E2E-P1-09`, `E2E-P1-10`, `E2E-P1-11` (админские сценарии)
3. `E2E-P1-01`, `E2E-P1-03`, `E2E-P1-04` (клиентские corner cases)
### Волна 3 (P2)
1. Preview fallback, tooltip/overflow regressions, шаблоны запросов данных
## Политика чистки данных после тестов (важно)
1. Все e2e-спеки должны регистрировать созданные `track/phone/email` и выполнять cleanup в `afterEach`.
2. Cleanup идет через локальный endpoint `/api/admin/test-utils/cleanup-test-data` (только `APP_ENV != production`).
3. Для не-e2e прогонов на рабочем dev-стенде использовать CLI:
```bash
docker compose exec -T backend python -m app.data.cleanup_test_artifacts
```
4. Ручной сид (`TRK-MAN-*`, `lawyer*.manual@example.com`) не подпадает под e2e cleanup-паттерны и должен сохраняться для приемки.

View file

@ -0,0 +1,54 @@
# Тестовые доступы для ручной проверки
Сид: `app/data/manual_test_seed.py` (идемпотентный, пересоздает заявки `TRK-MAN-*`).
Обновлено: `2026-02-26 12:49:51 UTC`
## Администратор
- Email: `admin@example.com`
- Пароль: `admin123`
- Телефон: `+79009999999`
## Юристы (4)
- Иван Волков: `lawyer1.manual@example.com` / `LawyerManual-123!` | тел.: `+7900200001` | основная тема: `Гражданские споры`
- Мария Егорова: `lawyer2.manual@example.com` / `LawyerManual-123!` | тел.: `+7900200002` | основная тема: `Семейное право`
- Павел Климов: `lawyer3.manual@example.com` / `LawyerManual-123!` | тел.: `+7900200003` | основная тема: `Налоговые вопросы`
- Ольга Смирнова: `lawyer4.manual@example.com` / `LawyerManual-123!` | тел.: `+7900200004` | основная тема: `Договорная работа`
## Клиенты (10) и заявки
Для клиента вход через OTP (код выводится в backend-консоль в mock-режиме).
- Ручной Клиент 01 | тел.: `+7900100001` | заявок: `5`
- `TRK-MAN-0001` | статус: `NEW` | тема: `Гражданские споры` | юрист: `-` | важная дата: `28.02.26 12:49`
- `TRK-MAN-0002` | статус: `IN_PROGRESS` | тема: `Договорная работа` | юрист: `Иван Волков` | важная дата: `27.02.26 12:49`
- `TRK-MAN-0003` | статус: `WAITING_CLIENT` | тема: `Налоговые вопросы` | юрист: `Павел Климов` | важная дата: `25.02.26 12:49`
- `TRK-MAN-0004` | статус: `RESOLVED` | тема: `Налоговые вопросы` | юрист: `Павел Климов` | важная дата: `-`
- `TRK-MAN-0005` | статус: `CLOSED` | тема: `Семейное право` | юрист: `Мария Егорова` | важная дата: `-`
- Ручной Клиент 02 | тел.: `+7900100002` | заявок: `4`
- `TRK-MAN-0006` | статус: `IN_PROGRESS` | тема: `Семейное право` | юрист: `Мария Егорова` | важная дата: `26.02.26 12:49`
- `TRK-MAN-0007` | статус: `WAITING_DOCUMENTS` | тема: `Трудовые споры` | юрист: `Мария Егорова` | важная дата: `01.03.26 12:49`
- `TRK-MAN-0008` | статус: `NEW` | тема: `Трудовые споры` | юрист: `-` | важная дата: `01.03.26 12:49`
- `TRK-MAN-0009` | статус: `ASSIGNED` | тема: `Договорная работа` | юрист: `Ольга Смирнова` | важная дата: `28.02.26 12:49`
- Ручной Клиент 03 | тел.: `+7900100003` | заявок: `3`
- `TRK-MAN-0010` | статус: `IN_PROGRESS` | тема: `Гражданские споры` | юрист: `Иван Волков` | важная дата: `02.03.26 12:49`
- `TRK-MAN-0011` | статус: `WAITING_CLIENT` | тема: `Договорная работа` | юрист: `Ольга Смирнова` | важная дата: `27.02.26 12:49`
- `TRK-MAN-0012` | статус: `PAUSED` | тема: `Налоговые вопросы` | юрист: `Павел Климов` | важная дата: `05.03.26 12:49`
- Ручной Клиент 04 | тел.: `+7900100004` | заявок: `2`
- `TRK-MAN-0013` | статус: `WAITING_DOCUMENTS` | тема: `Гражданские споры` | юрист: `Иван Волков` | важная дата: `24.02.26 12:49`
- `TRK-MAN-0014` | статус: `RESOLVED` | тема: `Семейное право` | юрист: `Мария Егорова` | важная дата: `-`
- Ручной Клиент 05 | тел.: `+7900100005` | заявок: `2`
- `TRK-MAN-0015` | статус: `IN_PROGRESS` | тема: `Налоговые вопросы` | юрист: `Павел Климов` | важная дата: `03.03.26 12:49`
- `TRK-MAN-0016` | статус: `WAITING_CLIENT` | тема: `Договорная работа` | юрист: `Ольга Смирнова` | важная дата: `28.02.26 12:49`
- Ручной Клиент 06 | тел.: `+7900100006` | заявок: `1`
- `TRK-MAN-0017` | статус: `NEW` | тема: `Трудовые споры` | юрист: `-` | важная дата: `01.03.26 12:49`
- Ручной Клиент 07 | тел.: `+7900100007` | заявок: `1`
- `TRK-MAN-0018` | статус: `IN_PROGRESS` | тема: `Гражданские споры` | юрист: `Иван Волков` | важная дата: `28.02.26 12:49`
- Ручной Клиент 08 | тел.: `+7900100008` | заявок: `1`
- `TRK-MAN-0019` | статус: `ASSIGNED` | тема: `Семейное право` | юрист: `Мария Егорова` | важная дата: `01.03.26 12:49`
- Ручной Клиент 09 | тел.: `+7900100009` | заявок: `1`
- `TRK-MAN-0020` | статус: `WAITING_DOCUMENTS` | тема: `Налоговые вопросы` | юрист: `Павел Климов` | важная дата: `27.02.26 12:49`
- Ручной Клиент 10 | тел.: `+7900100010` | заявок: `1`
- `TRK-MAN-0021` | статус: `NEW` | тема: `Договорная работа` | юрист: `-` | важная дата: `01.03.26 12:49`
## Примечания
- В выборке есть неназначенные заявки, активные, ожидающие и терминальные статусы.
- Есть заявки с оплаченными и ожидающими оплату счетами для проверки dashboard/финансов.
- В активных заявках есть переписка и непрочитанные уведомления.

View file

@ -1,4 +1,9 @@
const { test, expect } = require("@playwright/test"); const { test, expect } = require("@playwright/test");
const { cleanupTrackedTestData } = require("./helpers");
test.afterEach(async ({ page }, testInfo) => {
await cleanupTrackedTestData(page, testInfo);
});
test("admin entry via route only: landing has no admin CTA and /admin opens panel", async ({ page }) => { test("admin entry via route only: landing has no admin CTA and /admin opens panel", async ({ page }) => {
await page.goto("/"); await page.goto("/");

View file

@ -7,20 +7,30 @@ const {
openDictionaryTree, openDictionaryTree,
selectDictionaryNode, selectDictionaryNode,
rowByTrack, rowByTrack,
trackCleanupPhone,
trackCleanupTrack,
trackCleanupEmail,
cleanupTrackedTestData,
} = require("./helpers"); } = require("./helpers");
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com"; const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123"; const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
test("admin flow via UI: dictionaries + users + topics + invoices", async ({ context, page }) => { test.afterEach(async ({ page }, testInfo) => {
await cleanupTrackedTestData(page, testInfo);
});
test("admin flow via UI: dictionaries + users + topics + invoices", async ({ context, page }, testInfo) => {
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081"; const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
const phone = randomPhone(); const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
await preparePublicSession(context, page, appUrl, phone); await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, { const { trackNumber } = await createRequestViaLanding(page, {
phone, phone,
description: "Заявка для проверки админского UI-флоу", description: "Заявка для проверки админского UI-флоу",
}); });
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }); await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
await expect(page.locator(".badge")).toContainText("роль: Администратор"); await expect(page.locator(".badge")).toContainText("роль: Администратор");
@ -30,12 +40,12 @@ test("admin flow via UI: dictionaries + users + topics + invoices", async ({ con
await openDictionaryTree(page); await openDictionaryTree(page);
await expect(page.locator("aside .menu .menu-tree")).toContainText("Темы"); await expect(page.locator("aside .menu .menu-tree")).toContainText("Темы");
await expect(page.locator("aside .menu .menu-tree")).toContainText("Статусы"); await expect(page.locator("aside .menu .menu-tree")).toContainText("Статусы");
await expect(page.locator("aside .menu .menu-tree")).toContainText("Переходы статусов");
await expect(page.locator("aside .menu .menu-tree")).toContainText("Пользователи"); await expect(page.locator("aside .menu .menu-tree")).toContainText("Пользователи");
await expect(page.locator("aside .menu .menu-tree")).toContainText("Цитаты"); await expect(page.locator("aside .menu .menu-tree")).toContainText("Цитаты");
const unique = Date.now(); const unique = Date.now();
const lawyerEmail = `ui-lawyer-${unique}@example.com`; const lawyerEmail = `ui-lawyer-${unique}@example.com`;
trackCleanupEmail(testInfo, lawyerEmail);
const topicName = `Тема UI ${unique}`; const topicName = `Тема UI ${unique}`;
await selectDictionaryNode(page, "Пользователи"); await selectDictionaryNode(page, "Пользователи");

View file

@ -1,9 +1,13 @@
const { test, expect } = require("@playwright/test"); const { test, expect } = require("@playwright/test");
const { loginAdminPanel, openDictionaryTree } = require("./helpers"); const { loginAdminPanel, openDictionaryTree, cleanupTrackedTestData } = require("./helpers");
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com"; const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123"; const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
test.afterEach(async ({ page }, testInfo) => {
await cleanupTrackedTestData(page, testInfo);
});
test("admin status designer: open transitions dictionary and prefill topic in create modal", async ({ page }) => { test("admin status designer: open transitions dictionary and prefill topic in create modal", async ({ page }) => {
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }); await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });

View file

@ -7,6 +7,8 @@ dotenv.config({ path: path.resolve(__dirname, "../../.env") });
const PUBLIC_SECRET = process.env.PUBLIC_JWT_SECRET || "change_me_public"; const PUBLIC_SECRET = process.env.PUBLIC_JWT_SECRET || "change_me_public";
const PUBLIC_COOKIE_NAME = process.env.PUBLIC_COOKIE_NAME || "public_jwt"; const PUBLIC_COOKIE_NAME = process.env.PUBLIC_COOKIE_NAME || "public_jwt";
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
function randomDigits(length) { function randomDigits(length) {
let value = ""; let value = "";
@ -20,6 +22,42 @@ function randomPhone() {
return `+79${randomDigits(9)}`; return `+79${randomDigits(9)}`;
} }
function buildTinyPdfBuffer(label = "E2E PDF") {
const safe = String(label || "E2E PDF").replace(/[()\\]/g, " ");
const stream = `BT /F1 16 Tf 24 96 Td (${safe}) Tj ET`;
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Count 1 /Kids [3 0 R] >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
`<< /Length ${Buffer.byteLength(stream, "utf-8")} >>\nstream\n${stream}\nendstream`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
let pdf = "%PDF-1.4\n";
const offsets = [0];
for (let i = 0; i < objects.length; i += 1) {
offsets.push(Buffer.byteLength(pdf, "utf-8"));
pdf += `${i + 1} 0 obj\n${objects[i]}\nendobj\n`;
}
const xrefOffset = Buffer.byteLength(pdf, "utf-8");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (let i = 1; i < offsets.length; i += 1) {
pdf += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Root 1 0 R /Size ${objects.length + 1} >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "utf-8");
}
function detectMimeForFixture(fileName) {
const lower = String(fileName || "").toLowerCase();
if (lower.endsWith(".pdf")) return "application/pdf";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".json")) return "application/json";
return "application/octet-stream";
}
function createPublicCookieToken(phone) { function createPublicCookieToken(phone) {
return jwt.sign({ sub: phone, purpose: "CREATE_REQUEST" }, PUBLIC_SECRET, { return jwt.sign({ sub: phone, purpose: "CREATE_REQUEST" }, PUBLIC_SECRET, {
algorithm: "HS256", algorithm: "HS256",
@ -27,6 +65,100 @@ function createPublicCookieToken(phone) {
}); });
} }
function createCleanupTracker() {
const state = {
track_numbers: new Set(),
phones: new Set(),
emails: new Set(),
hasArtifacts: false,
};
return {
addTrack(value) {
const text = String(value || "").trim();
if (!text) return;
state.track_numbers.add(text);
state.hasArtifacts = true;
},
addPhone(value) {
const text = String(value || "").trim();
if (!text) return;
state.phones.add(text);
state.hasArtifacts = true;
},
addEmail(value) {
const text = String(value || "").trim().toLowerCase();
if (!text) return;
state.emails.add(text);
state.hasArtifacts = true;
},
hasArtifacts() {
return state.hasArtifacts;
},
toPayload() {
return {
track_numbers: Array.from(state.track_numbers),
phones: Array.from(state.phones),
emails: Array.from(state.emails),
include_default_e2e_patterns: true,
};
},
};
}
function _getCleanupTracker(testInfo) {
if (!testInfo) return null;
if (!testInfo._cleanupTracker) {
testInfo._cleanupTracker = createCleanupTracker();
}
return testInfo._cleanupTracker;
}
function trackCleanupPhone(testInfo, phone) {
const tracker = _getCleanupTracker(testInfo);
if (tracker) tracker.addPhone(phone);
}
function trackCleanupTrack(testInfo, trackNumber) {
const tracker = _getCleanupTracker(testInfo);
if (tracker) tracker.addTrack(trackNumber);
}
function trackCleanupEmail(testInfo, email) {
const tracker = _getCleanupTracker(testInfo);
if (tracker) tracker.addEmail(email);
}
async function cleanupTrackedTestData(page, testInfo) {
const tracker = testInfo && testInfo._cleanupTracker;
if (!tracker || !tracker.hasArtifacts()) {
return;
}
const baseUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
let token = "";
const loginResponse = await page.request.post(`${baseUrl}/api/admin/auth/login`, {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
failOnStatusCode: false,
});
if (loginResponse.ok()) {
const body = await loginResponse.json().catch(() => ({}));
token = String(body?.access_token || "");
}
if (!token) {
throw new Error(`E2E cleanup failed: admin login ${loginResponse.status()}`);
}
const cleanupResponse = await page.request.post(`${baseUrl}/api/admin/test-utils/cleanup-test-data`, {
headers: { Authorization: `Bearer ${token}` },
data: tracker.toPayload(),
failOnStatusCode: false,
});
if (!cleanupResponse.ok()) {
const text = await cleanupResponse.text().catch(() => "");
throw new Error(`E2E cleanup failed: ${cleanupResponse.status()} ${text}`);
}
}
async function installPromptAutoAccept(page, code = "000000") { async function installPromptAutoAccept(page, code = "000000") {
page.on("dialog", async (dialog) => { page.on("dialog", async (dialog) => {
if (dialog.type() === "prompt") { if (dialog.type() === "prompt") {
@ -130,11 +262,13 @@ async function sendCabinetMessage(page, text) {
async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E file") { async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E file") {
let lastError = null; let lastError = null;
const mimeType = detectMimeForFixture(fileName);
const buffer = mimeType === "application/pdf" ? buildTinyPdfBuffer(bodyText) : Buffer.from(bodyText, "utf-8");
for (let attempt = 1; attempt <= 2; attempt += 1) { for (let attempt = 1; attempt <= 2; attempt += 1) {
await page.locator("#cabinet-file-input").setInputFiles({ await page.locator("#cabinet-file-input").setInputFiles({
name: fileName, name: fileName,
mimeType: "application/pdf", mimeType,
buffer: Buffer.from(bodyText, "utf-8"), buffer,
}); });
await page.locator("#cabinet-file-upload").click(); await page.locator("#cabinet-file-upload").click();
@ -202,6 +336,11 @@ async function selectDictionaryNode(page, label) {
module.exports = { module.exports = {
randomPhone, randomPhone,
createCleanupTracker,
trackCleanupPhone,
trackCleanupTrack,
trackCleanupEmail,
cleanupTrackedTestData,
preparePublicSession, preparePublicSession,
createRequestViaLanding, createRequestViaLanding,
openPublicCabinet, openPublicCabinet,
@ -212,4 +351,5 @@ module.exports = {
rowByTrack, rowByTrack,
openDictionaryTree, openDictionaryTree,
selectDictionaryNode, selectDictionaryNode,
buildTinyPdfBuffer,
}; };

View file

@ -4,20 +4,29 @@ const {
createRequestViaLanding, createRequestViaLanding,
randomPhone, randomPhone,
loginAdminPanel, loginAdminPanel,
trackCleanupPhone,
trackCleanupTrack,
cleanupTrackedTestData,
} = require("./helpers"); } = require("./helpers");
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru"; const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!"; const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
test("kanban flow via UI: lawyer sees unassigned card, claims and opens request in same tab", async ({ context, page }) => { test.afterEach(async ({ page }, testInfo) => {
await cleanupTrackedTestData(page, testInfo);
});
test("kanban flow via UI: lawyer sees unassigned card, claims and opens request in same tab", async ({ context, page }, testInfo) => {
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081"; const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
const phone = randomPhone(); const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
await preparePublicSession(context, page, appUrl, phone); await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, { const { trackNumber } = await createRequestViaLanding(page, {
phone, phone,
description: "Заявка для проверки канбана юриста", description: "Заявка для проверки канбана юриста",
}); });
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD }); await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await page.locator("aside .menu button[data-section='kanban']").click(); await page.locator("aside .menu button[data-section='kanban']").click();

View file

@ -9,14 +9,23 @@ const {
loginAdminPanel, loginAdminPanel,
openRequestsSection, openRequestsSection,
rowByTrack, rowByTrack,
buildTinyPdfBuffer,
trackCleanupPhone,
trackCleanupTrack,
cleanupTrackedTestData,
} = require("./helpers"); } = require("./helpers");
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru"; const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!"; const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
test("lawyer flow via UI: claim request -> chat and files in request workspace tab -> change status", async ({ context, page }) => { test.afterEach(async ({ page }, testInfo) => {
await cleanupTrackedTestData(page, testInfo);
});
test("lawyer flow via UI: claim request -> chat and files in request workspace tab -> change status", async ({ context, page }, testInfo) => {
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081"; const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
const phone = randomPhone(); const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
await preparePublicSession(context, page, appUrl, phone); await preparePublicSession(context, page, appUrl, phone);
@ -24,6 +33,7 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
phone, phone,
description: "Заявка для проверки флоу юриста через UI", description: "Заявка для проверки флоу юриста через UI",
}); });
trackCleanupTrack(testInfo, trackNumber);
await openPublicCabinet(page, trackNumber); await openPublicCabinet(page, trackNumber);
await sendCabinetMessage(page, `Сообщение юристу ${Date.now()}`); await sendCabinetMessage(page, `Сообщение юристу ${Date.now()}`);
@ -45,7 +55,7 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/); await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
const pagesBeforeOpen = context.pages().length; const pagesBeforeOpen = context.pages().length;
await row.first().getByRole("button", { name: "Открыть заявку" }).click(); await row.first().locator(".request-track-link").click();
await page.waitForTimeout(250); await page.waitForTimeout(250);
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen); await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
const requestPage = page; const requestPage = page;
@ -57,7 +67,8 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
const clientFileRow = requestPage.locator("#request-modal-files li").filter({ hasText: clientFileName }).first(); const clientFileRow = requestPage.locator("#request-modal-files li").filter({ hasText: clientFileName }).first();
await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click(); await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click();
await expect(requestPage.locator("#request-file-preview-overlay")).toBeVisible(); await expect(requestPage.locator("#request-file-preview-overlay")).toBeVisible();
await expect(requestPage.locator("#request-file-preview-overlay .request-preview-frame")).toBeVisible(); await expect(requestPage.locator("#request-file-preview-overlay .request-preview-text")).toBeVisible();
await expect(requestPage.locator("#request-file-preview-overlay .request-preview-text")).toContainText("lawyer unread marker");
await requestPage.locator("#request-file-preview-overlay .close").click(); await requestPage.locator("#request-file-preview-overlay .close").click();
await requestPage.getByRole("tab", { name: "Чат" }).click(); await requestPage.getByRole("tab", { name: "Чат" }).click();
@ -73,7 +84,7 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
{ {
name: lawyerFileName, name: lawyerFileName,
mimeType: "application/pdf", mimeType: "application/pdf",
buffer: Buffer.from("lawyer file from admin modal", "utf-8"), buffer: buildTinyPdfBuffer("lawyer file from admin modal"),
}, },
{ {
name: droppedFileName, name: droppedFileName,

View file

@ -6,11 +6,19 @@ const {
sendCabinetMessage, sendCabinetMessage,
uploadCabinetFile, uploadCabinetFile,
randomPhone, randomPhone,
trackCleanupPhone,
trackCleanupTrack,
cleanupTrackedTestData,
} = require("./helpers"); } = require("./helpers");
test("public flow via UI: landing -> create request -> cabinet -> chat -> upload file", async ({ context, page }) => { test.afterEach(async ({ page }, testInfo) => {
await cleanupTrackedTestData(page, testInfo);
});
test("public flow via UI: landing -> create request -> cabinet -> chat -> upload file", async ({ context, page }, testInfo) => {
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081"; const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
const phone = randomPhone(); const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
await preparePublicSession(context, page, appUrl, phone); await preparePublicSession(context, page, appUrl, phone);
@ -18,6 +26,7 @@ test("public flow via UI: landing -> create request -> cabinet -> chat -> upload
phone, phone,
description: "Проверка публичного E2E флоу через UI.", description: "Проверка публичного E2E флоу через UI.",
}); });
trackCleanupTrack(testInfo, trackNumber);
await openPublicCabinet(page, trackNumber); await openPublicCabinet(page, trackNumber);

View file

@ -0,0 +1,109 @@
const { test, expect } = require("@playwright/test");
const {
preparePublicSession,
createRequestViaLanding,
openPublicCabinet,
randomPhone,
loginAdminPanel,
openRequestsSection,
rowByTrack,
trackCleanupPhone,
trackCleanupTrack,
cleanupTrackedTestData,
} = require("./helpers");
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
test.afterEach(async ({ page }, testInfo) => {
await cleanupTrackedTestData(page, testInfo);
});
test("request data file field flow via UI: lawyer requests file -> client uploads -> lawyer sees completed request", async ({ context, page }, testInfo) => {
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
const phone = randomPhone();
trackCleanupPhone(testInfo, phone);
await preparePublicSession(context, page, appUrl, phone);
const { trackNumber } = await createRequestViaLanding(page, {
phone,
description: "E2E проверка file-поля в запросе дополнительных данных",
});
trackCleanupTrack(testInfo, trackNumber);
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
await expect(page.locator(".badge")).toContainText("роль: Юрист");
await openRequestsSection(page);
const row = rowByTrack(page, "#section-requests", trackNumber);
await expect(row).toHaveCount(1);
const claimBtn = row.first().getByRole("button", { name: "Взять в работу" });
await expect(claimBtn).toBeVisible();
await claimBtn.click();
await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
await row.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
await expect(page.locator("#request-modal-messages")).toBeVisible();
await page.getByRole("button", { name: "Запросить" }).click();
await expect(page.getByRole("heading", { name: /Запрос дополнительных данных|Редактирование запроса данных/ })).toBeVisible();
const catalogFieldInput = page.locator("#request-data-template-select");
const fileFieldLabel = `Файл для проверки E2E ${Date.now()}`;
await catalogFieldInput.fill(fileFieldLabel);
await page.locator(".request-data-modal-grid").filter({ hasText: "Поле данных" }).getByRole("button").click();
await expect(page.locator(".request-data-rows .request-data-row").first().locator("input").first()).toHaveValue(fileFieldLabel);
await page.locator(".request-data-rows .request-data-row").first().locator("select").selectOption("file");
await page.locator(".request-data-modal .modal-actions").getByRole("button", { name: "Отправить" }).click();
const requestDataModal = page.locator(".request-data-modal");
try {
await expect(requestDataModal).toBeHidden({ timeout: 20_000 });
} catch (error) {
const modalError = ((await page.locator(".request-data-modal .status.error").textContent().catch(() => "")) || "").trim();
throw new Error(`Не удалось отправить запрос данных: ${modalError || "неизвестная ошибка"}`);
}
await page.getByRole("button", { name: "Обновить" }).first().click();
await expect(page.locator("#request-modal-messages")).toContainText("Запрос");
await expect(page.locator("#request-modal-messages .chat-request-data-bubble")).toContainText("Файл для провер");
await page.goto("/");
await openPublicCabinet(page, trackNumber);
const requestMessageButton = page.locator("#cabinet-messages .request-data-message-btn").last();
await expect(requestMessageButton).toBeVisible();
await requestMessageButton.click();
await expect(page.locator("#data-request-overlay")).toHaveClass(/open/);
await expect(page.locator("#data-request-items")).toContainText("Файл для проверки");
const requestFileInput = page.locator("#data-request-items input[type='file']").first();
const requestFileName = `request-data-file-${Date.now()}.txt`;
await requestFileInput.setInputFiles({
name: requestFileName,
mimeType: "text/plain",
buffer: Buffer.from("request data file payload", "utf-8"),
});
await page.locator("#data-request-save").click();
await expect(page.locator("#data-request-status")).toContainText("Данные сохранены.");
await expect(page.locator("#cabinet-messages .request-data-item.done").last()).toBeVisible();
await page.goto("/admin");
await expect(page.getByRole("heading", { name: "Панель администратора" })).toBeVisible();
await openRequestsSection(page);
const rowAfterClientUpload = rowByTrack(page, "#section-requests", trackNumber);
await expect(rowAfterClientUpload).toHaveCount(1);
await rowAfterClientUpload.first().locator(".request-track-link").click();
await expect(page.getByRole("heading", { name: /Карточка заявки/ })).toBeVisible();
const refreshBtn = page.getByRole("button", { name: "Обновить" }).first();
await refreshBtn.click();
await expect(page.locator("#request-modal-messages .chat-request-data-bubble.all-filled").last()).toBeVisible();
const filesTab = page.getByRole("tab", { name: /Файлы/ });
await filesTab.click();
await expect(page.locator("#request-modal-files")).toContainText(requestFileName);
});

View file

@ -1,9 +1,10 @@
FROM node:22-alpine AS admin-build FROM node:22-alpine AS admin-build
WORKDIR /build WORKDIR /build
COPY app/web/admin ./admin
COPY app/web/admin.jsx ./admin.jsx COPY app/web/admin.jsx ./admin.jsx
RUN npm init -y >/dev/null 2>&1 \ RUN npm init -y >/dev/null 2>&1 \
&& npm install --silent esbuild@0.25.10 \ && npm install --silent esbuild@0.25.10 \
&& npx esbuild admin.jsx --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js && npx esbuild admin/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf

View file

@ -7,27 +7,42 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
location = /admin { location = /admin {
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
expires 10m; expires 10m;
return 302 /admin.html; return 302 /admin.html;
} }
location ~* \.jsx$ { location ~* \.jsx$ {
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
expires 10m; expires 10m;
default_type application/javascript; default_type application/javascript;
try_files $uri =404; try_files $uri =404;
} }
location / { location / {
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
expires 10m; expires 10m;
try_files $uri /index.html; try_files $uri /index.html;
} }

View file

@ -20,6 +20,7 @@ from app.core.security import create_jwt
from app.db.session import get_db from app.db.session import get_db
from app.main import app from app.main import app
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.audit_log import AuditLog
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.models.status import Status from app.models.status import Status
@ -37,6 +38,7 @@ class DashboardFinanceTests(unittest.TestCase):
) )
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine) AdminUser.__table__.create(bind=cls.engine)
AuditLog.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine) Request.__table__.create(bind=cls.engine)
Status.__table__.create(bind=cls.engine) Status.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine) Message.__table__.create(bind=cls.engine)
@ -50,6 +52,7 @@ class DashboardFinanceTests(unittest.TestCase):
Message.__table__.drop(bind=cls.engine) Message.__table__.drop(bind=cls.engine)
Status.__table__.drop(bind=cls.engine) Status.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine) Request.__table__.drop(bind=cls.engine)
AuditLog.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine) AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose() cls.engine.dispose()
@ -60,6 +63,7 @@ class DashboardFinanceTests(unittest.TestCase):
db.execute(delete(Message)) db.execute(delete(Message))
db.execute(delete(Request)) db.execute(delete(Request))
db.execute(delete(Status)) db.execute(delete(Status))
db.execute(delete(AuditLog))
db.execute(delete(AdminUser)) db.execute(delete(AdminUser))
db.commit() db.commit()
@ -185,6 +189,45 @@ class DashboardFinanceTests(unittest.TestCase):
created_at=now - timedelta(days=40), created_at=now - timedelta(days=40),
updated_at=now - timedelta(days=40), updated_at=now - timedelta(days=40),
), ),
StatusHistory(
request_id=req_a_closed.id,
from_status="IN_PROGRESS",
to_status="CLOSED",
changed_by_admin_id=None,
created_at=current_month_event + timedelta(hours=3),
updated_at=current_month_event + timedelta(hours=3),
),
]
)
db.add_all(
[
AuditLog(
actor_admin_id=None,
entity="requests",
entity_id=str(req_a_active.id),
action="MANUAL_CLAIM",
diff={"assigned_lawyer_id": str(lawyer_a.id)},
created_at=current_month_event,
updated_at=current_month_event,
),
AuditLog(
actor_admin_id=None,
entity="requests",
entity_id=str(req_a_closed.id),
action="MANUAL_REASSIGN",
diff={"from_lawyer_id": str(lawyer_b.id), "to_lawyer_id": str(lawyer_a.id)},
created_at=current_month_event + timedelta(minutes=10),
updated_at=current_month_event + timedelta(minutes=10),
),
AuditLog(
actor_admin_id=None,
entity="requests",
entity_id=str(req_b_active.id),
action="MANUAL_CLAIM",
diff={"assigned_lawyer_id": str(lawyer_b.id)},
created_at=current_month_event + timedelta(minutes=20),
updated_at=current_month_event + timedelta(minutes=20),
),
] ]
) )
db.commit() db.commit()
@ -194,21 +237,100 @@ class DashboardFinanceTests(unittest.TestCase):
body = response.json() body = response.json()
self.assertEqual(body.get("scope"), "ADMIN") self.assertEqual(body.get("scope"), "ADMIN")
self.assertIn("lawyer_loads", body) self.assertIn("lawyer_loads", body)
self.assertAlmostEqual(float(body.get("month_revenue") or 0.0), 2500.0, places=2)
self.assertAlmostEqual(float(body.get("month_expenses") or 0.0), 750.0, places=2)
by_email = {row["email"]: row for row in body["lawyer_loads"]} by_email = {row["email"]: row for row in body["lawyer_loads"]}
self.assertEqual(by_email["lawyer.a@example.com"]["active_load"], 1) self.assertEqual(by_email["lawyer.a@example.com"]["active_load"], 1)
self.assertEqual(by_email["lawyer.a@example.com"]["total_assigned"], 2) self.assertEqual(by_email["lawyer.a@example.com"]["total_assigned"], 2)
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["active_amount"]), 1000.0, places=2) self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["active_amount"]), 1000.0, places=2)
self.assertEqual(by_email["lawyer.a@example.com"]["monthly_paid_events"], 3) self.assertEqual(by_email["lawyer.a@example.com"]["monthly_paid_events"], 3)
self.assertEqual(by_email["lawyer.a@example.com"]["monthly_assigned_count"], 2)
self.assertEqual(by_email["lawyer.a@example.com"]["monthly_completed_count"], 1)
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_paid_gross"]), 2500.0, places=2) self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_paid_gross"]), 2500.0, places=2)
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_salary"]), 750.0, places=2) self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_salary"]), 750.0, places=2)
self.assertEqual(by_email["lawyer.b@example.com"]["active_load"], 1) self.assertEqual(by_email["lawyer.b@example.com"]["active_load"], 1)
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["active_amount"]), 2000.0, places=2) self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["active_amount"]), 2000.0, places=2)
self.assertEqual(by_email["lawyer.b@example.com"]["monthly_assigned_count"], 1)
self.assertEqual(by_email["lawyer.b@example.com"]["monthly_completed_count"], 0)
self.assertEqual(by_email["lawyer.b@example.com"]["monthly_paid_events"], 0) self.assertEqual(by_email["lawyer.b@example.com"]["monthly_paid_events"], 0)
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_paid_gross"]), 0.0, places=2) self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_paid_gross"]), 0.0, places=2)
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_salary"]), 0.0, places=2) self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_salary"]), 0.0, places=2)
def test_admin_can_get_lawyer_active_requests_dashboard_detail(self):
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),
Status(code="PAID", name="Оплачено", enabled=True, sort_order=2, is_terminal=False),
]
)
lawyer = AdminUser(
role="LAWYER",
name="Юрист Деталь",
email="lawyer.detail@example.com",
password_hash="hash",
salary_percent=25,
default_rate=4000,
is_active=True,
)
db.add(lawyer)
db.flush()
active_req = Request(
track_number="TRK-DETAIL-ACTIVE",
client_name="Клиент Деталь",
client_phone="+79990002001",
topic_code="civil",
status_code="NEW",
assigned_lawyer_id=str(lawyer.id),
invoice_amount=1200,
extra_fields={},
)
closed_req = Request(
track_number="TRK-DETAIL-CLOSED",
client_name="Клиент Закрыт",
client_phone="+79990002002",
topic_code="civil",
status_code="CLOSED",
assigned_lawyer_id=str(lawyer.id),
invoice_amount=700,
extra_fields={},
)
db.add_all([active_req, closed_req])
db.flush()
db.add(
StatusHistory(
request_id=active_req.id,
from_status="INVOICE",
to_status="PAID",
changed_by_admin_id=None,
created_at=now - timedelta(days=1),
updated_at=now - timedelta(days=1),
)
)
db.commit()
lawyer_id = str(lawyer.id)
response = self.client.get(
f"/api/admin/metrics/lawyers/{lawyer_id}/active-requests",
headers=self._headers("ADMIN"),
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(int(body.get("total") or 0), 1)
self.assertEqual(len(body.get("rows") or []), 1)
row = (body.get("rows") or [])[0]
self.assertEqual(row.get("track_number"), "TRK-DETAIL-ACTIVE")
self.assertEqual(int(row.get("month_paid_events") or 0), 1)
self.assertAlmostEqual(float(row.get("month_paid_amount") or 0.0), 1200.0, places=2)
self.assertAlmostEqual(float(row.get("month_salary_amount") or 0.0), 300.0, places=2)
self.assertAlmostEqual(float((body.get("totals") or {}).get("amount") or 0.0), 1200.0, places=2)
self.assertAlmostEqual(float((body.get("totals") or {}).get("salary") or 0.0), 300.0, places=2)
def test_lawyer_dashboard_is_scoped_to_current_lawyer(self): def test_lawyer_dashboard_is_scoped_to_current_lawyer(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
db.add_all( db.add_all(

View file

@ -11,6 +11,8 @@ os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test") os.environ.setdefault("S3_BUCKET", "test")
from app.main import app from app.main import app
from app.core.http_hardening import _response_security_headers
from starlette.requests import Request
class HttpHardeningTests(unittest.TestCase): class HttpHardeningTests(unittest.TestCase):
@ -57,3 +59,37 @@ class HttpHardeningTests(unittest.TestCase):
self.assertEqual(response.headers.get("x-content-type-options"), "nosniff") self.assertEqual(response.headers.get("x-content-type-options"), "nosniff")
self.assertEqual(response.headers.get("x-frame-options"), "DENY") self.assertEqual(response.headers.get("x-frame-options"), "DENY")
self.assertTrue(bool(response.headers.get("x-request-id"))) self.assertTrue(bool(response.headers.get("x-request-id")))
def test_file_preview_paths_allow_same_origin_framing_only(self):
scope = {
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/api/public/uploads/object/123",
"raw_path": b"/api/public/uploads/object/123",
"query_string": b"",
"headers": [],
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
}
headers = _response_security_headers(Request(scope))
self.assertEqual(headers.get("X-Frame-Options"), "SAMEORIGIN")
self.assertIn("frame-ancestors 'self'", str(headers.get("Content-Security-Policy")))
def test_non_file_paths_keep_deny_framing(self):
scope = {
"type": "http",
"http_version": "1.1",
"method": "GET",
"scheme": "http",
"path": "/api/public/requests/TRK-1",
"raw_path": b"/api/public/requests/TRK-1",
"query_string": b"",
"headers": [],
"client": ("127.0.0.1", 12345),
"server": ("testserver", 80),
}
headers = _response_security_headers(Request(scope))
self.assertEqual(headers.get("X-Frame-Options"), "DENY")
self.assertIn("frame-ancestors 'none'", str(headers.get("Content-Security-Policy")))

View file

@ -88,6 +88,8 @@ class MigrationTests(unittest.TestCase):
"form_fields", "form_fields",
"topic_required_fields", "topic_required_fields",
"topic_data_templates", "topic_data_templates",
"request_data_templates",
"request_data_template_items",
"request_data_requirements", "request_data_requirements",
"requests", "requests",
"messages", "messages",
@ -97,6 +99,7 @@ class MigrationTests(unittest.TestCase):
"otp_sessions", "otp_sessions",
"quotes", "quotes",
"admin_user_topics", "admin_user_topics",
"landing_featured_staff",
"topic_status_transitions", "topic_status_transitions",
"notifications", "notifications",
"invoices", "invoices",
@ -109,7 +112,7 @@ class MigrationTests(unittest.TestCase):
def test_alembic_version_is_set(self): def test_alembic_version_is_set(self):
with self.engine.connect() as conn: with self.engine.connect() as conn:
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
self.assertEqual(version, "0018_status_groups") self.assertEqual(version, "0024_featured_staff_carousel")
def test_responsible_column_exists_in_all_domain_tables(self): def test_responsible_column_exists_in_all_domain_tables(self):
tables = { tables = {
@ -122,6 +125,8 @@ class MigrationTests(unittest.TestCase):
"form_fields", "form_fields",
"topic_required_fields", "topic_required_fields",
"topic_data_templates", "topic_data_templates",
"request_data_templates",
"request_data_template_items",
"request_data_requirements", "request_data_requirements",
"requests", "requests",
"messages", "messages",
@ -131,6 +136,7 @@ class MigrationTests(unittest.TestCase):
"otp_sessions", "otp_sessions",
"quotes", "quotes",
"admin_user_topics", "admin_user_topics",
"landing_featured_staff",
"topic_status_transitions", "topic_status_transitions",
"notifications", "notifications",
"invoices", "invoices",
@ -176,15 +182,22 @@ class MigrationTests(unittest.TestCase):
columns = {column["name"] for column in self.inspector.get_columns("admin_users")} columns = {column["name"] for column in self.inspector.get_columns("admin_users")}
self.assertIn("default_rate", columns) self.assertIn("default_rate", columns)
self.assertIn("salary_percent", columns) self.assertIn("salary_percent", columns)
self.assertIn("phone", columns)
def test_requests_contains_financial_columns(self): def test_requests_contains_financial_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("requests")} columns = {column["name"] for column in self.inspector.get_columns("requests")}
self.assertIn("client_id", columns) self.assertIn("client_id", columns)
self.assertIn("important_date_at", columns)
self.assertIn("effective_rate", columns) self.assertIn("effective_rate", columns)
self.assertIn("request_cost", columns)
self.assertIn("invoice_amount", columns) self.assertIn("invoice_amount", columns)
self.assertIn("paid_at", columns) self.assertIn("paid_at", columns)
self.assertIn("paid_by_admin_id", columns) self.assertIn("paid_by_admin_id", columns)
def test_status_history_contains_important_date_column(self):
columns = {column["name"] for column in self.inspector.get_columns("status_history")}
self.assertIn("important_date_at", columns)
def test_invoices_contains_core_columns(self): def test_invoices_contains_core_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("invoices")} columns = {column["name"] for column in self.inspector.get_columns("invoices")}
self.assertIn("client_id", columns) self.assertIn("client_id", columns)
@ -221,3 +234,39 @@ class MigrationTests(unittest.TestCase):
self.assertIn("phone", columns) self.assertIn("phone", columns)
self.assertIn("created_at", columns) self.assertIn("created_at", columns)
self.assertIn("responsible", columns) self.assertIn("responsible", columns)
def test_topic_data_templates_contains_request_data_catalog_fields(self):
columns = {column["name"] for column in self.inspector.get_columns("topic_data_templates")}
self.assertIn("value_type", columns)
self.assertIn("document_name", columns)
def test_request_data_requirements_contains_chat_request_fields(self):
columns = {column["name"] for column in self.inspector.get_columns("request_data_requirements")}
self.assertIn("request_message_id", columns)
self.assertIn("field_type", columns)
self.assertIn("document_name", columns)
self.assertIn("value_text", columns)
self.assertIn("sort_order", columns)
def test_request_data_template_tables_contain_core_columns(self):
templates = {column["name"] for column in self.inspector.get_columns("request_data_templates")}
self.assertIn("topic_code", templates)
self.assertIn("name", templates)
self.assertIn("created_by_admin_id", templates)
self.assertIn("sort_order", templates)
items = {column["name"] for column in self.inspector.get_columns("request_data_template_items")}
self.assertIn("request_data_template_id", items)
self.assertIn("topic_data_template_id", items)
self.assertIn("key", items)
self.assertIn("label", items)
self.assertIn("value_type", items)
self.assertIn("sort_order", items)
def test_landing_featured_staff_contains_core_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("landing_featured_staff")}
self.assertIn("admin_user_id", columns)
self.assertIn("caption", columns)
self.assertIn("sort_order", columns)
self.assertIn("pinned", columns)
self.assertIn("enabled", columns)

View file

@ -487,6 +487,7 @@ class RequestRatesTests(unittest.TestCase):
description="public", description="public",
extra_fields={}, extra_fields={},
effective_rate=8800, effective_rate=8800,
request_cost=9900,
invoice_amount=12500, invoice_amount=12500,
) )
db.add(req) db.add(req)
@ -503,10 +504,72 @@ class RequestRatesTests(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = response.json() body = response.json()
self.assertNotIn("effective_rate", body) self.assertNotIn("effective_rate", body)
self.assertNotIn("request_cost", body)
self.assertNotIn("invoice_amount", body) self.assertNotIn("invoice_amount", body)
self.assertNotIn("paid_at", body) self.assertNotIn("paid_at", body)
self.assertNotIn("paid_by_admin_id", body) self.assertNotIn("paid_by_admin_id", body)
def test_admin_request_can_bind_existing_client_or_create_new_and_set_request_cost(self):
with self.SessionLocal() as db:
existing_client = Client(
full_name="Существующий клиент",
phone="+79990000100",
responsible="Администратор системы",
)
db.add(existing_client)
db.commit()
existing_client_id = str(existing_client.id)
admin_headers = self._auth_headers("ADMIN", "root@example.com")
created = self.client.post(
"/api/admin/requests",
headers=admin_headers,
json={
"client_id": existing_client_id,
"client_name": "Игнорировать",
"client_phone": "+70000000000",
"status_code": "NEW",
"description": "link existing client",
"request_cost": 3450,
},
)
self.assertEqual(created.status_code, 201, created.text)
request_id = created.json()["id"]
with self.SessionLocal() as db:
row = db.get(Request, UUID(request_id))
self.assertIsNotNone(row)
self.assertEqual(str(row.client_id), existing_client_id)
self.assertEqual(row.client_name, "Существующий клиент")
self.assertEqual(row.client_phone, "+79990000100")
self.assertAlmostEqual(float(row.request_cost or 0), 3450.0, places=2)
updated = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={
"client_id": "",
"client_name": "Новый клиент из админки",
"client_phone": "+79990000101",
"request_cost": 4200,
},
)
self.assertEqual(updated.status_code, 200, updated.text)
with self.SessionLocal() as db:
row = db.get(Request, UUID(request_id))
self.assertIsNotNone(row)
self.assertEqual(row.client_name, "Новый клиент из админки")
self.assertEqual(row.client_phone, "+79990000101")
self.assertAlmostEqual(float(row.request_cost or 0), 4200.0, places=2)
self.assertIsNotNone(row.client_id)
self.assertNotEqual(str(row.client_id), existing_client_id)
client = db.get(Client, row.client_id)
self.assertIsNotNone(client)
self.assertEqual(client.full_name, "Новый клиент из админки")
self.assertEqual(client.phone, "+79990000101")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -0,0 +1,151 @@
import unittest
import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import Boolean, Date, DateTime, Float, Integer, Numeric, String, create_engine
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from app.schemas.universal import FilterClause, Page, UniversalQuery
from app.services.universal_query import _coerce_filter_value, apply_universal_query
class _Base(DeclarativeBase):
pass
class _QueryTestModel(_Base):
__tablename__ = "_uq_test_model"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
bool_col: Mapped[bool] = mapped_column(Boolean)
int_col: Mapped[int] = mapped_column(Integer)
float_col: Mapped[float] = mapped_column(Float)
numeric_col: Mapped[Decimal] = mapped_column(Numeric(12, 2))
date_col: Mapped[date] = mapped_column(Date)
dt_col: Mapped[datetime] = mapped_column(DateTime(timezone=True))
uuid_col: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True))
text_col: Mapped[str] = mapped_column(String(50))
class _ApplyBase(DeclarativeBase):
pass
class _ApplyQueryModel(_ApplyBase):
__tablename__ = "_uq_apply_test_model"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(50))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))
class UniversalQueryCoercionTests(unittest.TestCase):
def test_boolean_accepts_string_values(self):
self.assertTrue(_coerce_filter_value(_QueryTestModel.bool_col, "true"))
self.assertTrue(_coerce_filter_value(_QueryTestModel.bool_col, "Да"))
self.assertFalse(_coerce_filter_value(_QueryTestModel.bool_col, "0"))
self.assertFalse(_coerce_filter_value(_QueryTestModel.bool_col, "нет"))
def test_boolean_invalid_value_raises_400(self):
with self.assertRaises(HTTPException) as ctx:
_coerce_filter_value(_QueryTestModel.bool_col, "maybe")
self.assertEqual(ctx.exception.status_code, 400)
def test_numbers_accept_string_values(self):
self.assertEqual(_coerce_filter_value(_QueryTestModel.int_col, "42"), 42)
self.assertAlmostEqual(_coerce_filter_value(_QueryTestModel.float_col, "3.14"), 3.14)
self.assertAlmostEqual(_coerce_filter_value(_QueryTestModel.float_col, "3,14"), 3.14)
self.assertEqual(_coerce_filter_value(_QueryTestModel.numeric_col, "99.50"), Decimal("99.50"))
def test_dates_accept_iso_date_and_datetime(self):
self.assertEqual(_coerce_filter_value(_QueryTestModel.date_col, "2026-02-26"), date(2026, 2, 26))
self.assertEqual(
_coerce_filter_value(_QueryTestModel.date_col, "2026-02-26T13:45:00+03:00"),
date(2026, 2, 26),
)
def test_datetime_accepts_date_only_and_makes_it_timezone_aware(self):
value = _coerce_filter_value(_QueryTestModel.dt_col, "2026-02-26")
self.assertIsInstance(value, datetime)
self.assertEqual(value.date(), date(2026, 2, 26))
self.assertIsNotNone(value.tzinfo)
self.assertEqual(value.tzinfo, timezone.utc)
def test_datetime_accepts_iso_datetime(self):
value = _coerce_filter_value(_QueryTestModel.dt_col, "2026-02-26T10:15:00+03:00")
self.assertIsInstance(value, datetime)
self.assertEqual(value.year, 2026)
self.assertIsNotNone(value.tzinfo)
def test_uuid_accepts_string(self):
uid = uuid.uuid4()
self.assertEqual(_coerce_filter_value(_QueryTestModel.uuid_col, str(uid)), uid)
def test_uuid_invalid_raises_400(self):
with self.assertRaises(HTTPException) as ctx:
_coerce_filter_value(_QueryTestModel.uuid_col, "not-a-uuid")
self.assertEqual(ctx.exception.status_code, 400)
def test_text_is_left_as_is(self):
self.assertEqual(_coerce_filter_value(_QueryTestModel.text_col, "abc"), "abc")
class UniversalQueryApplyTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine("sqlite+pysqlite:///:memory:")
_ApplyBase.metadata.create_all(cls.engine)
with Session(cls.engine) as session:
session.add_all(
[
_ApplyQueryModel(id=1, title="prev-day", created_at=datetime(2026, 2, 25, 23, 59, 59)),
_ApplyQueryModel(id=2, title="same-day-morning", created_at=datetime(2026, 2, 26, 9, 30, 0)),
_ApplyQueryModel(id=3, title="same-day-evening", created_at=datetime(2026, 2, 26, 23, 59, 59)),
_ApplyQueryModel(id=4, title="next-day", created_at=datetime(2026, 2, 27, 0, 0, 0)),
]
)
session.commit()
@classmethod
def tearDownClass(cls):
cls.engine.dispose()
def test_datetime_equal_date_uses_day_range(self):
with Session(self.engine) as session:
uq = UniversalQuery(
filters=[FilterClause(field="created_at", op="=", value="2026-02-26")],
sort=[],
page=Page(limit=50, offset=0),
)
q = apply_universal_query(session.query(_ApplyQueryModel), _ApplyQueryModel, uq)
rows = q.order_by(_ApplyQueryModel.id.asc()).all()
self.assertEqual([row.id for row in rows], [2, 3])
def test_datetime_not_equal_date_excludes_whole_day(self):
with Session(self.engine) as session:
uq = UniversalQuery(
filters=[FilterClause(field="created_at", op="!=", value="2026-02-26")],
sort=[],
page=Page(limit=50, offset=0),
)
q = apply_universal_query(session.query(_ApplyQueryModel), _ApplyQueryModel, uq)
rows = q.order_by(_ApplyQueryModel.id.asc()).all()
self.assertEqual([row.id for row in rows], [1, 4])
def test_datetime_equal_full_timestamp_stays_exact(self):
with Session(self.engine) as session:
uq = UniversalQuery(
filters=[FilterClause(field="created_at", op="=", value="2026-02-26T09:30:00")],
sort=[],
page=Page(limit=50, offset=0),
)
q = apply_universal_query(session.query(_ApplyQueryModel), _ApplyQueryModel, uq)
rows = q.order_by(_ApplyQueryModel.id.asc()).all()
self.assertEqual([row.id for row in rows], [2])
if __name__ == "__main__":
unittest.main()