mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
Task P052-P053
This commit is contained in:
parent
4d87cefcee
commit
4b9b2df2e3
80 changed files with 12433 additions and 3066 deletions
27
alembic/versions/0019_add_request_cost_to_requests.py
Normal file
27
alembic/versions/0019_add_request_cost_to_requests.py
Normal 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")
|
||||
|
||||
28
alembic/versions/0020_add_phone_to_admin_users.py
Normal file
28
alembic/versions/0020_add_phone_to_admin_users.py
Normal 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")
|
||||
50
alembic/versions/0021_request_data_chat_fields.py
Normal file
50
alembic/versions/0021_request_data_chat_fields.py
Normal 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")
|
||||
76
alembic/versions/0022_request_data_templates_tables.py
Normal file
76
alembic/versions/0022_request_data_templates_tables.py
Normal 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")
|
||||
35
alembic/versions/0023_status_important_date.py
Normal file
35
alembic/versions/0023_status_important_date.py
Normal 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")
|
||||
|
||||
72
alembic/versions/0024_featured_staff_carousel.py
Normal file
72
alembic/versions/0024_featured_staff_carousel.py
Normal 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")
|
||||
|
|
@ -8,10 +8,22 @@ from sqlalchemy.orm import Session
|
|||
from app.core.deps import require_role
|
||||
from app.db.session import get_db
|
||||
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.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()
|
||||
ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"}
|
||||
|
||||
|
||||
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="Юрист может работать только со своими назначенными заявками")
|
||||
|
||||
|
||||
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")
|
||||
def list_request_messages(
|
||||
request_id: str,
|
||||
|
|
@ -61,7 +188,7 @@ def list_request_messages(
|
|||
req = _request_for_id_or_404(db, request_id)
|
||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||
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)
|
||||
|
|
@ -95,3 +222,464 @@ def create_request_message(
|
|||
actor_admin_user_id=actor_admin_user_id,
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import importlib
|
|||
import json
|
||||
import pkgutil
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
|
@ -27,6 +27,8 @@ from app.models.form_field import FormField
|
|||
from app.models.client import Client
|
||||
from app.models.table_availability import TableAvailability
|
||||
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.message import Message
|
||||
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"}
|
||||
INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"}
|
||||
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
|
||||
ALLOWED_REQUEST_DATA_VALUE_TYPES = {"string", "text", "date", "number", "file"}
|
||||
|
||||
# Per-table RBAC: table -> role -> actions.
|
||||
# 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"}},
|
||||
"admin_users": {"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_required_fields": {"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)},
|
||||
"notifications": {"ADMIN": {"query", "read", "update"}},
|
||||
}
|
||||
|
|
@ -264,10 +270,13 @@ def _table_label(table_name: str) -> str:
|
|||
"clients": "Клиенты",
|
||||
"table_availability": "Доступность таблиц",
|
||||
"topic_required_fields": "Обязательные поля темы",
|
||||
"topic_data_templates": "Шаблоны данных темы",
|
||||
"topic_data_templates": "Дополнительные данные",
|
||||
"request_data_templates": "Шаблоны доп. данных",
|
||||
"request_data_template_items": "Набор данных шаблона",
|
||||
"topic_status_transitions": "Переходы статусов темы",
|
||||
"admin_users": "Пользователи",
|
||||
"admin_user_topics": "Дополнительные темы юристов",
|
||||
"landing_featured_staff": "Карусель сотрудников лендинга",
|
||||
"attachments": "Вложения",
|
||||
"messages": "Сообщения",
|
||||
"audit_log": "Журнал аудита",
|
||||
|
|
@ -355,8 +364,16 @@ def _column_label(table_name: str, column_name: str) -> str:
|
|||
"key": "Ключ",
|
||||
"name": "Название",
|
||||
"label": "Метка",
|
||||
"caption": "Подпись",
|
||||
"value_type": "Тип значения",
|
||||
"document_name": "Документ",
|
||||
"request_data_template_id": "Шаблон",
|
||||
"request_data_template_item_id": "Элемент шаблона",
|
||||
"text": "Текст",
|
||||
"description": "Описание",
|
||||
"request_message_id": "ID сообщения запроса",
|
||||
"field_type": "Тип поля",
|
||||
"value_text": "Данные",
|
||||
"author": "Автор",
|
||||
"source": "Источник",
|
||||
"email": "Email",
|
||||
|
|
@ -384,6 +401,7 @@ def _column_label(table_name: str, column_name: str) -> str:
|
|||
"updated_at": "Дата обновления",
|
||||
"responsible": "Ответственный",
|
||||
"sort_order": "Порядок",
|
||||
"pinned": "Закреплен",
|
||||
"is_active": "Активен",
|
||||
"enabled": "Активен",
|
||||
"required": "Обязательное",
|
||||
|
|
@ -396,6 +414,7 @@ def _column_label(table_name: str, column_name: str) -> str:
|
|||
"primary_topic_code": "Профильная тема",
|
||||
"default_rate": "Ставка по умолчанию",
|
||||
"effective_rate": "Ставка (фикс.)",
|
||||
"request_cost": "Стоимость заявки",
|
||||
"salary_percent": "Процент зарплаты",
|
||||
"invoice_amount": "Сумма счета",
|
||||
"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", "field_key"): ("form_fields", "key"),
|
||||
("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", "from_status"): ("statuses", "code"),
|
||||
("topic_status_transitions", "to_status"): ("statuses", "code"),
|
||||
("admin_users", "primary_topic_code"): ("topics", "code"),
|
||||
("admin_user_topics", "admin_user_id"): ("admin_users", "id"),
|
||||
("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", "topic_template_id"): ("topic_data_templates", "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",
|
||||
"form_fields": "label",
|
||||
"topic_data_templates": "label",
|
||||
"request_data_templates": "name",
|
||||
"request_data_template_items": "label",
|
||||
"invoices": "invoice_number",
|
||||
"messages": "body",
|
||||
"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 обязателен")
|
||||
data["email"] = email
|
||||
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["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="Email не может быть пустым")
|
||||
data["email"] = email
|
||||
if "phone" in data:
|
||||
data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone")))
|
||||
if "avatar_url" in data:
|
||||
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail='Поле "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
|
||||
|
||||
|
||||
|
|
@ -953,11 +1065,31 @@ def _apply_request_data_requirements_fields(db: Session, payload: dict[str, Any]
|
|||
if template is None:
|
||||
raise HTTPException(status_code=400, detail="Шаблон темы не найден")
|
||||
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:
|
||||
key = str(data.get("key") or "").strip()
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail='Поле "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
|
||||
|
||||
|
||||
|
|
@ -1416,7 +1548,20 @@ def get_row(
|
|||
if normalized == "attachments" and isinstance(row, Attachment):
|
||||
req = _request_for_related_row_or_404(db, row)
|
||||
_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)
|
||||
|
|
@ -1490,6 +1635,10 @@ def create_row(
|
|||
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
|
||||
if normalized == "topic_data_templates":
|
||||
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":
|
||||
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
||||
if normalized == "topic_status_transitions":
|
||||
|
|
@ -1557,6 +1706,10 @@ def update_row(
|
|||
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
|
||||
if normalized == "topic_data_templates":
|
||||
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":
|
||||
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
||||
if normalized == "topic_status_transitions":
|
||||
|
|
@ -1603,23 +1756,9 @@ def update_row(
|
|||
if normalized == "requests" and "status_code" in clean_payload:
|
||||
before_status = str(before.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):
|
||||
extra_fields_override = clean_payload.get("extra_fields")
|
||||
if not isinstance(extra_fields_override, dict):
|
||||
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,
|
||||
)
|
||||
if "important_date_at" not in clean_payload or clean_payload.get("important_date_at") is None:
|
||||
clean_payload["important_date_at"] = datetime.now(timezone.utc) + timedelta(days=3)
|
||||
billing_note = apply_billing_transition_effects(
|
||||
db,
|
||||
req=row,
|
||||
|
|
@ -1635,6 +1774,7 @@ def update_row(
|
|||
from_status=before_status,
|
||||
to_status=after_status,
|
||||
admin=admin,
|
||||
important_date_at=clean_payload.get("important_date_at"),
|
||||
responsible=responsible,
|
||||
)
|
||||
notify_request_event(
|
||||
|
|
@ -1643,7 +1783,15 @@ def update_row(
|
|||
event_type=NOTIFICATION_EVENT_STATUS,
|
||||
actor_role=_actor_role(admin),
|
||||
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,
|
||||
)
|
||||
for key, value in clean_payload.items():
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
|||
from app.core.deps import require_role
|
||||
from app.db.session import get_db
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.request import Request
|
||||
from app.models.status import Status
|
||||
from app.models.status_history import StatusHistory
|
||||
|
|
@ -59,6 +60,33 @@ def _uuid_or_none(value: str | None) -> UUID | 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")
|
||||
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
||||
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}
|
||||
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 = (
|
||||
db.query(AdminUser)
|
||||
.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),
|
||||
"total_assigned": total_load_map.get(lawyer_id, 0),
|
||||
"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_gross": round(monthly_paid_gross, 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
|
||||
|
||||
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 {
|
||||
"scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN",
|
||||
"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,
|
||||
"my_unread_updates": my_unread_updates,
|
||||
"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"),
|
||||
"sla_overdue": sla_snapshot.get("overdue_total", 0),
|
||||
"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),
|
||||
"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),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,16 +16,17 @@ from app.schemas.admin import (
|
|||
RequestDataRequirementCreate,
|
||||
RequestDataRequirementPatch,
|
||||
RequestReassign,
|
||||
RequestStatusChange,
|
||||
)
|
||||
from app.models.admin_user import AdminUser
|
||||
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 import Request
|
||||
from app.models.status import Status
|
||||
from app.models.status_group import StatusGroup
|
||||
from app.models.status_history import StatusHistory
|
||||
from app.models.topic_data_template import TopicDataTemplate
|
||||
from app.models.topic_status_transition import TopicStatusTransition
|
||||
from app.services.notifications import (
|
||||
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||
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_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.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.universal_query import apply_universal_query
|
||||
|
||||
|
|
@ -106,6 +106,149 @@ def _parse_datetime_safe(value: object) -> datetime | None:
|
|||
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:
|
||||
if not isinstance(extra_fields, dict):
|
||||
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"))):
|
||||
base_query = db.query(Request)
|
||||
role = str(admin.get("role") or "").upper()
|
||||
actor = str(admin.get("sub") or "").strip()
|
||||
if role == "LAWYER":
|
||||
actor = str(admin.get("sub") or "").strip()
|
||||
if not actor:
|
||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||
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()
|
||||
rows = q.offset(uq.page.offset).limit(uq.page.limit).all()
|
||||
return {
|
||||
|
|
@ -300,11 +451,14 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
|
|||
{
|
||||
"id": str(r.id),
|
||||
"track_number": r.track_number,
|
||||
"client_id": str(r.client_id) if r.client_id else None,
|
||||
"status_code": r.status_code,
|
||||
"client_name": r.client_name,
|
||||
"client_phone": r.client_phone,
|
||||
"topic_code": r.topic_code,
|
||||
"important_date_at": r.important_date_at.isoformat() if r.important_date_at else None,
|
||||
"effective_rate": float(r.effective_rate) if r.effective_rate is not None else None,
|
||||
"request_cost": float(r.request_cost) if r.request_cost is not None else None,
|
||||
"invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None,
|
||||
"paid_at": r.paid_at.isoformat() if r.paid_at else None,
|
||||
"paid_by_admin_id": r.paid_by_admin_id,
|
||||
|
|
@ -360,33 +514,8 @@ def get_requests_kanban(
|
|||
|
||||
request_id_to_row = {str(row.id): row 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()}
|
||||
|
||||
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]] = {}
|
||||
if status_codes:
|
||||
status_rows = (
|
||||
|
|
@ -448,16 +577,30 @@ def get_requests_kanban(
|
|||
current_status_changed_at[request_id] = row.created_at
|
||||
previous_status_by_request[request_id] = str(row.from_status or "").strip()
|
||||
|
||||
transitions_by_key: dict[tuple[str, str], list[TopicStatusTransition]] = {}
|
||||
transitions_to_key: dict[tuple[str, str], list[TopicStatusTransition]] = {}
|
||||
for row in transition_rows:
|
||||
topic_code = str(row.topic_code or "").strip()
|
||||
from_status = str(row.from_status or "").strip()
|
||||
to_status = str(row.to_status or "").strip()
|
||||
if not topic_code or not from_status or not to_status:
|
||||
all_enabled_status_rows = (
|
||||
db.query(Status, StatusGroup)
|
||||
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
|
||||
.filter(Status.enabled.is_(True))
|
||||
.order_by(Status.sort_order.asc(), Status.name.asc(), Status.code.asc())
|
||||
.all()
|
||||
)
|
||||
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
|
||||
transitions_by_key.setdefault((topic_code, from_status), []).append(row)
|
||||
transitions_to_key.setdefault((topic_code, to_status), []).append(row)
|
||||
meta = {
|
||||
"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()
|
||||
columns_catalog = [
|
||||
|
|
@ -474,7 +617,6 @@ def get_requests_kanban(
|
|||
group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog}
|
||||
for row in request_rows:
|
||||
request_id = str(row.id)
|
||||
topic_code = str(row.topic_code or "").strip()
|
||||
status_code = str(row.status_code or "").strip()
|
||||
status_meta = _status_meta_or_default(status_meta_map, status_code)
|
||||
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])
|
||||
available_transitions = []
|
||||
for transition in transitions_by_key.get((topic_code, status_code), []):
|
||||
to_status = str(transition.to_status or "").strip()
|
||||
if not to_status:
|
||||
for status_def in all_enabled_statuses:
|
||||
to_status = str(status_def.get("code") or "").strip()
|
||||
if not to_status or to_status == status_code:
|
||||
continue
|
||||
to_meta = _status_meta_or_default(status_meta_map, to_status)
|
||||
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_name": str(to_meta.get("name") or to_status),
|
||||
"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)
|
||||
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]
|
||||
|
||||
case_deadline = row.important_date_at or _extract_case_deadline(row.extra_fields)
|
||||
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
|
||||
items.append(
|
||||
|
|
@ -544,6 +672,7 @@ def get_requests_kanban(
|
|||
"client_phone": row.client_phone,
|
||||
"topic_code": row.topic_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_group": status_group,
|
||||
"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)
|
||||
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
|
||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||
client = _client_for_request_payload_or_400(
|
||||
db,
|
||||
client_id=payload.client_id,
|
||||
client_name=payload.client_name,
|
||||
client_phone=payload.client_phone,
|
||||
responsible=responsible,
|
||||
)
|
||||
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
|
||||
effective_rate = payload.effective_rate
|
||||
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
|
||||
row = Request(
|
||||
track_number=track,
|
||||
client_name=payload.client_name,
|
||||
client_phone=payload.client_phone,
|
||||
client_id=client.id,
|
||||
client_name=client.full_name,
|
||||
client_phone=client.phone,
|
||||
topic_code=payload.topic_code,
|
||||
status_code=payload.status_code,
|
||||
important_date_at=payload.important_date_at,
|
||||
description=payload.description,
|
||||
extra_fields=payload.extra_fields,
|
||||
assigned_lawyer_id=assigned_lawyer_id,
|
||||
effective_rate=effective_rate,
|
||||
request_cost=payload.request_cost,
|
||||
invoice_amount=payload.invoice_amount,
|
||||
paid_at=payload.paid_at,
|
||||
paid_by_admin_id=payload.paid_by_admin_id,
|
||||
|
|
@ -682,30 +821,32 @@ def update_request(
|
|||
changes["effective_rate"] = assigned_lawyer.default_rate
|
||||
old_status = str(row.status_code 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():
|
||||
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 "")
|
||||
if not transition_allowed_for_topic(
|
||||
db,
|
||||
str(row.topic_code or "").strip() or None,
|
||||
old_status,
|
||||
next_status,
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
||||
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,
|
||||
)
|
||||
important_date_at = row.important_date_at
|
||||
billing_note = apply_billing_transition_effects(
|
||||
db,
|
||||
req=row,
|
||||
from_status=old_status,
|
||||
to_status=next_status,
|
||||
admin=admin,
|
||||
important_date_at=important_date_at,
|
||||
responsible=responsible,
|
||||
)
|
||||
mark_unread_for_client(row, EVENT_STATUS)
|
||||
|
|
@ -723,7 +864,11 @@ def update_request(
|
|||
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{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,
|
||||
)
|
||||
try:
|
||||
|
|
@ -772,14 +917,17 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
|
|||
return {
|
||||
"id": str(req.id),
|
||||
"track_number": req.track_number,
|
||||
"client_id": str(req.client_id) if req.client_id else None,
|
||||
"client_name": req.client_name,
|
||||
"client_phone": req.client_phone,
|
||||
"topic_code": req.topic_code,
|
||||
"status_code": req.status_code,
|
||||
"important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
|
||||
"description": req.description,
|
||||
"extra_fields": req.extra_fields,
|
||||
"assigned_lawyer_id": req.assigned_lawyer_id,
|
||||
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
|
||||
"request_cost": float(req.request_cost) if req.request_cost is not None else None,
|
||||
"invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None,
|
||||
"paid_at": req.paid_at.isoformat() if req.paid_at else None,
|
||||
"paid_by_admin_id": req.paid_by_admin_id,
|
||||
|
|
@ -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")
|
||||
def get_request_status_route(
|
||||
request_id: str,
|
||||
|
|
@ -808,22 +1036,6 @@ def get_request_status_route(
|
|||
topic_code = str(req.topic_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 = (
|
||||
db.query(StatusHistory)
|
||||
.filter(StatusHistory.request_id == req.id)
|
||||
|
|
@ -841,23 +1053,28 @@ def get_request_status_route(
|
|||
known_codes.add(from_code)
|
||||
if 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]] = {}
|
||||
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:
|
||||
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 = {
|
||||
str(row.code): {
|
||||
"name": str(row.name or row.code),
|
||||
"kind": str(row.kind or "DEFAULT"),
|
||||
str(status_row.code): {
|
||||
"name": str(status_row.name or status_row.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),
|
||||
}
|
||||
for row in status_rows
|
||||
for status_row, group_row in status_rows
|
||||
}
|
||||
|
||||
sequence_from_history: list[str] = []
|
||||
|
|
@ -885,27 +1102,8 @@ def get_request_status_route(
|
|||
for code in sequence_from_history:
|
||||
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)
|
||||
|
||||
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] = {}
|
||||
for row in history_rows:
|
||||
to_code = str(row.to_status or "").strip()
|
||||
|
|
@ -922,7 +1120,6 @@ def get_request_status_route(
|
|||
nodes: list[dict[str, str | int | None]] = []
|
||||
for index, code in enumerate(ordered_codes):
|
||||
meta = statuses_map.get(code) or {}
|
||||
transition_meta = transition_by_to_status.get(code) or {}
|
||||
state = "pending"
|
||||
if code == current_status:
|
||||
state = "current"
|
||||
|
|
@ -930,18 +1127,6 @@ def get_request_status_route(
|
|||
state = "completed"
|
||||
|
||||
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")
|
||||
if kind == "INVOICE":
|
||||
note_parts.append("Этап выставления счета")
|
||||
|
|
@ -954,19 +1139,88 @@ def get_request_status_route(
|
|||
"name": status_name(code),
|
||||
"kind": kind,
|
||||
"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),
|
||||
"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 {
|
||||
"request_id": str(req.id),
|
||||
"track_number": req.track_number,
|
||||
"topic_code": req.topic_code,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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.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(chat.router, prefix="/chat", tags=["AdminChat"])
|
||||
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
|
||||
router.include_router(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"])
|
||||
|
|
|
|||
52
app/api/admin/test_utils.py
Normal file
52
app/api/admin/test_utils.py
Normal 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,
|
||||
}
|
||||
|
|
@ -1,17 +1,42 @@
|
|||
from __future__ import annotations
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_public_session
|
||||
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_data_requirement import RequestDataRequirement
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
value = str(raw or "").strip()
|
||||
if not value:
|
||||
|
|
@ -60,7 +85,7 @@ def list_messages_by_track(
|
|||
req = _request_for_track_or_404(db, track_number)
|
||||
_ensure_view_access_or_403(session, req)
|
||||
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)
|
||||
|
|
@ -74,3 +99,131 @@ def create_message_by_track(
|
|||
_ensure_view_access_or_403(session, req)
|
||||
row = create_client_message(db, request=req, body=payload.body)
|
||||
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}
|
||||
|
|
|
|||
64
app/api/public/featured_staff.py
Normal file
64
app/api/public/featured_staff.py
Normal 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)}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
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.include_router(requests.router, prefix="/requests", tags=["Public"])
|
||||
router.include_router(otp.router, prefix="/otp", 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(chat.router, prefix="/chat", tags=["PublicChat"])
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ SECURITY_HEADERS = {
|
|||
"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:
|
||||
value = str(raw or "").strip()
|
||||
|
|
@ -33,6 +46,14 @@ def _request_id_from_header(raw: str | None) -> str:
|
|||
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:
|
||||
@app.middleware("http")
|
||||
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)
|
||||
|
||||
for key, value in SECURITY_HEADERS.items():
|
||||
for key, value in _response_security_headers(request).items():
|
||||
response.headers[key] = value
|
||||
# Backend serves application data and operational endpoints only.
|
||||
# Keep responses non-cacheable to avoid stale or sensitive data reuse.
|
||||
|
|
|
|||
38
app/data/cleanup_test_artifacts.py
Normal file
38
app/data/cleanup_test_artifacts.py
Normal 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()
|
||||
694
app/data/manual_test_seed.py
Normal file
694
app/data/manual_test_seed.py
Normal 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()
|
||||
|
|
@ -8,6 +8,7 @@ class AdminUser(Base, UUIDMixin, TimestampMixin):
|
|||
role: Mapped[str] = mapped_column(String(20), nullable=False) # ADMIN|LAWYER
|
||||
name: Mapped[str] = mapped_column(String(200), 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)
|
||||
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)
|
||||
|
|
|
|||
18
app/models/landing_featured_staff.py
Normal file
18
app/models/landing_featured_staff.py
Normal 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)
|
||||
|
|
@ -15,10 +15,12 @@ class Request(Base, UUIDMixin, TimestampMixin):
|
|||
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)
|
||||
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)
|
||||
extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), 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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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.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_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)
|
||||
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
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)
|
||||
value_text: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
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)
|
||||
|
|
|
|||
24
app/models/request_data_template.py
Normal file
24
app/models/request_data_template.py
Normal 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)
|
||||
24
app/models/request_data_template_item.py
Normal file
24
app/models/request_data_template_item.py
Normal 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)
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import uuid
|
||||
from sqlalchemy import String
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
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)
|
||||
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)
|
||||
important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ class TopicDataTemplate(Base, UUIDMixin, TimestampMixin):
|
|||
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, 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="text")
|
||||
document_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
|
|
|||
|
|
@ -63,14 +63,17 @@ class FormFieldUpsert(BaseModel):
|
|||
|
||||
class RequestAdminCreate(BaseModel):
|
||||
track_number: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
client_name: str
|
||||
client_phone: str
|
||||
topic_code: Optional[str] = None
|
||||
status_code: str = "NEW"
|
||||
important_date_at: Optional[datetime] = None
|
||||
description: Optional[str] = None
|
||||
extra_fields: dict = Field(default_factory=dict)
|
||||
assigned_lawyer_id: Optional[str] = None
|
||||
effective_rate: Optional[float] = None
|
||||
request_cost: Optional[float] = None
|
||||
invoice_amount: Optional[float] = None
|
||||
paid_at: Optional[datetime] = None
|
||||
paid_by_admin_id: Optional[str] = None
|
||||
|
|
@ -79,14 +82,17 @@ class RequestAdminCreate(BaseModel):
|
|||
|
||||
class RequestAdminPatch(BaseModel):
|
||||
track_number: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
client_name: Optional[str] = None
|
||||
client_phone: Optional[str] = None
|
||||
topic_code: Optional[str] = None
|
||||
status_code: Optional[str] = None
|
||||
important_date_at: Optional[datetime] = None
|
||||
description: Optional[str] = None
|
||||
extra_fields: Optional[dict] = None
|
||||
assigned_lawyer_id: Optional[str] = None
|
||||
effective_rate: Optional[float] = None
|
||||
request_cost: Optional[float] = None
|
||||
invoice_amount: Optional[float] = None
|
||||
paid_at: Optional[datetime] = None
|
||||
paid_by_admin_id: Optional[str] = None
|
||||
|
|
@ -97,6 +103,12 @@ class RequestReassign(BaseModel):
|
|||
lawyer_id: str
|
||||
|
||||
|
||||
class RequestStatusChange(BaseModel):
|
||||
status_code: str
|
||||
important_date_at: Optional[datetime] = None
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
class RequestDataRequirementCreate(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.message import Message
|
||||
from app.models.attachment import Attachment
|
||||
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.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:
|
||||
message_body = str(body or "").strip()
|
||||
if not message_body:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ def register_status_history(
|
|||
*,
|
||||
admin: dict[str, Any] | None = None,
|
||||
comment: str | None = None,
|
||||
important_date_at: datetime | None = None,
|
||||
responsible: str = "Администратор системы",
|
||||
) -> None:
|
||||
db.add(
|
||||
|
|
@ -51,6 +53,7 @@ def register_status_history(
|
|||
to_status=str(to_status or "").strip(),
|
||||
changed_by_admin_id=actor_admin_uuid(admin),
|
||||
comment=comment,
|
||||
important_date_at=important_date_at,
|
||||
responsible=responsible,
|
||||
)
|
||||
)
|
||||
|
|
@ -64,6 +67,7 @@ def apply_status_change_effects(
|
|||
to_status: str,
|
||||
admin: dict[str, Any] | None = None,
|
||||
comment: str | None = None,
|
||||
important_date_at: datetime | None = None,
|
||||
responsible: str = "Администратор системы",
|
||||
) -> None:
|
||||
old_code = str(from_status or "").strip()
|
||||
|
|
@ -78,5 +82,6 @@ def apply_status_change_effects(
|
|||
new_code,
|
||||
admin=admin,
|
||||
comment=comment,
|
||||
important_date_at=important_date_at,
|
||||
responsible=responsible,
|
||||
)
|
||||
|
|
|
|||
249
app/services/test_data_cleanup.py
Normal file
249
app/services/test_data_cleanup.py
Normal 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
|
||||
|
|
@ -1,31 +1,146 @@
|
|||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Query
|
||||
from sqlalchemy import asc, desc
|
||||
from sqlalchemy.orm import Query
|
||||
|
||||
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):
|
||||
try:
|
||||
python_type = column.property.columns[0].type.python_type
|
||||
except Exception:
|
||||
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:
|
||||
return uuid.UUID(value)
|
||||
return uuid.UUID(str(value or "").strip())
|
||||
except ValueError:
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
for f in uq.filters:
|
||||
col = getattr(model, f.field, None)
|
||||
if col is None:
|
||||
continue
|
||||
col_python_type = _column_python_type(col)
|
||||
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 == "=":
|
||||
q = q.filter(col == value)
|
||||
elif f.op == "!=":
|
||||
|
|
|
|||
1093
app/web/admin.css
1093
app/web/admin.css
File diff suppressed because it is too large
Load diff
3496
app/web/admin.jsx
3496
app/web/admin.jsx
File diff suppressed because it is too large
Load diff
569
app/web/admin/features/config/ConfigSection.jsx
Normal file
569
app/web/admin/features/config/ConfigSection.jsx
Normal 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;
|
||||
253
app/web/admin/features/dashboard/DashboardSection.jsx
Normal file
253
app/web/admin/features/dashboard/DashboardSection.jsx
Normal 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;
|
||||
121
app/web/admin/features/invoices/InvoicesSection.jsx
Normal file
121
app/web/admin/features/invoices/InvoicesSection.jsx
Normal 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;
|
||||
246
app/web/admin/features/kanban/KanbanBoard.jsx
Normal file
246
app/web/admin/features/kanban/KanbanBoard.jsx
Normal 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;
|
||||
29
app/web/admin/features/meta/MetaSection.jsx
Normal file
29
app/web/admin/features/meta/MetaSection.jsx
Normal 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;
|
||||
96
app/web/admin/features/quotes/QuotesSection.jsx
Normal file
96
app/web/admin/features/quotes/QuotesSection.jsx
Normal 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;
|
||||
1907
app/web/admin/features/requests/RequestWorkspace.jsx
Normal file
1907
app/web/admin/features/requests/RequestWorkspace.jsx
Normal file
File diff suppressed because it is too large
Load diff
163
app/web/admin/features/requests/RequestsSection.jsx
Normal file
163
app/web/admin/features/requests/RequestsSection.jsx
Normal 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;
|
||||
67
app/web/admin/features/tables/AvailableTablesSection.jsx
Normal file
67
app/web/admin/features/tables/AvailableTablesSection.jsx
Normal 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;
|
||||
42
app/web/admin/hooks/useAdminApi.js
Normal file
42
app/web/admin/hooks/useAdminApi.js
Normal 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;
|
||||
82
app/web/admin/hooks/useAdminCatalogLoaders.js
Normal file
82
app/web/admin/hooks/useAdminCatalogLoaders.js
Normal 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;
|
||||
120
app/web/admin/hooks/useKanban.js
Normal file
120
app/web/admin/hooks/useKanban.js
Normal 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;
|
||||
436
app/web/admin/hooks/useRequestWorkspace.js
Normal file
436
app/web/admin/hooks/useRequestWorkspace.js
Normal 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;
|
||||
199
app/web/admin/hooks/useTableActions.js
Normal file
199
app/web/admin/hooks/useTableActions.js
Normal 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;
|
||||
120
app/web/admin/hooks/useTableFilterActions.js
Normal file
120
app/web/admin/hooks/useTableFilterActions.js
Normal 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;
|
||||
56
app/web/admin/hooks/useTablesState.js
Normal file
56
app/web/admin/hooks/useTablesState.js
Normal 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
1
app/web/admin/index.jsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
import "../admin.jsx";
|
||||
155
app/web/admin/shared/constants.js
Normal file
155
app/web/admin/shared/constants.js
Normal 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",
|
||||
]);
|
||||
30
app/web/admin/shared/state.js
Normal file
30
app/web/admin/shared/state.js
Normal 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,
|
||||
};
|
||||
}
|
||||
348
app/web/admin/shared/utils.js
Normal file
348
app/web/admin/shared/utils.js
Normal 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -219,6 +219,99 @@ textarea {
|
|||
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 {
|
||||
margin-top: 0.45rem;
|
||||
display: flex;
|
||||
|
|
@ -250,6 +343,63 @@ textarea {
|
|||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -338,6 +488,21 @@ textarea {
|
|||
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 {
|
||||
margin-top: 0.7rem;
|
||||
display: grid;
|
||||
|
|
@ -375,6 +540,17 @@ textarea {
|
|||
flex-direction: column;
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,24 @@
|
|||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -23,16 +23,29 @@
|
|||
const previewTitle = document.getElementById("file-preview-title");
|
||||
const previewClose = document.getElementById("file-preview-close");
|
||||
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 activeRequestId = "";
|
||||
let activeDataRequestMessageId = "";
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
try {
|
||||
const dt = new Date(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 (_) {
|
||||
return value;
|
||||
}
|
||||
|
|
@ -45,6 +58,50 @@
|
|||
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) {
|
||||
try {
|
||||
return await response.json();
|
||||
|
|
@ -79,21 +136,171 @@
|
|||
function detectPreviewKind(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";
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!previewOverlay || !previewBody) return;
|
||||
revokePreviewObjectUrl();
|
||||
previewOverlay.classList.remove("open");
|
||||
previewOverlay.setAttribute("aria-hidden", "true");
|
||||
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;
|
||||
revokePreviewObjectUrl();
|
||||
previewBody.innerHTML = "";
|
||||
previewTitle.textContent = item.file_name || "Предпросмотр файла";
|
||||
const kind = detectPreviewKind(item.file_name, item.mime_type);
|
||||
|
|
@ -111,12 +318,62 @@
|
|||
video.controls = true;
|
||||
video.preload = "metadata";
|
||||
previewBody.appendChild(video);
|
||||
} else if (kind === "pdf") {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "preview-frame";
|
||||
frame.src = item.download_url;
|
||||
frame.title = item.file_name || "PDF";
|
||||
previewBody.appendChild(frame);
|
||||
} else if (kind === "pdf" || kind === "text") {
|
||||
const loading = document.createElement("p");
|
||||
loading.className = "preview-note";
|
||||
loading.textContent = "Загрузка предпросмотра...";
|
||||
previewBody.appendChild(loading);
|
||||
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 {
|
||||
const note = document.createElement("p");
|
||||
note.className = "preview-note";
|
||||
|
|
@ -150,10 +407,78 @@
|
|||
time.textContent = formatDate(item.created_at);
|
||||
li.appendChild(time);
|
||||
|
||||
const p = document.createElement("p");
|
||||
const author = item.author_name || item.author_type || "Участник";
|
||||
p.textContent = author + ": " + (item.body || "");
|
||||
li.appendChild(p);
|
||||
if (String(item.message_kind || "") === "REQUEST_DATA") {
|
||||
li.classList.add("request-data-item");
|
||||
if (item.request_data_all_filled) li.classList.add("done");
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -397,8 +722,75 @@
|
|||
if (event.key === "Escape" && previewOverlay?.classList.contains("open")) {
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
if (!activeTrack) {
|
||||
|
|
@ -439,41 +831,7 @@
|
|||
|
||||
try {
|
||||
setStatus(pageStatus, "Подготавливаем загрузку файла...", null);
|
||||
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: 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, "Не удалось завершить загрузку"));
|
||||
await uploadPublicRequestAttachment(file, activeRequestId);
|
||||
|
||||
cabinetFileInput.value = "";
|
||||
await refreshCabinetData();
|
||||
|
|
|
|||
|
|
@ -650,6 +650,151 @@
|
|||
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,
|
||||
.meta-row b {
|
||||
overflow-wrap: anywhere;
|
||||
|
|
@ -668,6 +813,10 @@
|
|||
.practices {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.featured-team-track {
|
||||
grid-auto-columns: minmax(300px, 52%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 740px) {
|
||||
|
|
@ -714,6 +863,18 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.featured-team-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.carousel-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.featured-team-track {
|
||||
grid-auto-columns: 86%;
|
||||
}
|
||||
|
||||
.simple-list {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
|
@ -741,6 +902,15 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.featured-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.featured-avatar {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding-top: 1.4rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
<a href="#practices">Компетенции</a>
|
||||
<a href="#approach">Подход</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-modal>Оставить заявку</button>
|
||||
</nav>
|
||||
|
|
@ -147,6 +148,21 @@
|
|||
</article>
|
||||
</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">
|
||||
<p>Создайте заявку и получите номер обращения. По нему вы сможете отслеживать статус, чат и документы в отдельной странице клиента.</p>
|
||||
<button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@
|
|||
|
||||
const quoteText = document.getElementById("quote-text");
|
||||
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) {
|
||||
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 () => {
|
||||
const phone = String(accessPhoneInput.value || "").trim();
|
||||
if (!phone) {
|
||||
|
|
@ -254,4 +373,5 @@
|
|||
|
||||
loadTopics();
|
||||
loadQuotes();
|
||||
loadFeaturedStaff();
|
||||
})();
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -58,6 +58,20 @@
|
|||
| 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 тесты |
|
||||
| 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` (полный контур назначения).
|
||||
|
|
@ -67,6 +81,9 @@
|
|||
5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце).
|
||||
6. `P28 -> P29 -> P30 -> P31 -> P32 -> P33 -> P35 -> P34 -> P36 -> P37` (итерация UX/клиентского входа/чат-сервиса/навигации/доступов).
|
||||
7. `P38 -> P39` (конструктор маршрутов и канбан-представление заявок для ролей).
|
||||
8. `P40 -> P41 -> P42 -> P43 -> P44 -> P45 -> P46` (декомпозиция фронта/бэка/тестов и финальная стабилизация).
|
||||
9. `P47 -> P48 -> P49 -> P50 -> P51` (контур клиентских запросов к куратору/админу и запросов на смену юриста).
|
||||
10. `P52 -> P53` (карусель сотрудников на лендинге и админское управление составом/порядком).
|
||||
|
||||
## Детализация P38-P39 (новый контур)
|
||||
### P38. Конструктор маршрутов статусов
|
||||
|
|
@ -93,6 +110,99 @@
|
|||
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`.
|
||||
2. Любую новую таблицу добавлять только через миграции + тест миграций.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# 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. Применить миграции:
|
||||
|
|
@ -17,10 +19,10 @@ docker compose exec -T backend python -m unittest discover -s tests -p 'test_*.p
|
|||
```bash
|
||||
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
|
||||
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`:
|
||||
```bash
|
||||
|
|
@ -35,6 +37,15 @@ docker compose run --rm --no-deps \
|
|||
e2e playwright test --config=playwright.config.js
|
||||
```
|
||||
Примечание: образ `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 | Что проверяем | Где тесты | Как запускать |
|
||||
|
|
@ -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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| P08 | Ручной claim (без гонок) | `tests/test_admin_universal_crud.py` (claim-тесты) | команда как для `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`; затем полный прогон |
|
||||
| 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 |
|
||||
| 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 не отдает поля ставок/процентов |
|
||||
| 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` |
|
||||
|
|
@ -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` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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 (клиент)
|
||||
|
|
@ -90,6 +108,7 @@ docker compose run --rm --no-deps \
|
|||
|
||||
### LAWYER (юрист)
|
||||
- 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_admin_universal_crud.py`.
|
||||
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`.
|
||||
|
|
@ -114,12 +133,26 @@ docker compose run --rm --no-deps \
|
|||
2. Выполнить целевые тесты пункта по матрице выше.
|
||||
3. Выполнить полный прогон `unittest discover`.
|
||||
4. Выполнить `compileall`.
|
||||
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose.
|
||||
5. Для изменений админ-фронта выполнить сборку entrypoint `admin/index.jsx` через Docker Compose.
|
||||
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 discover -s tests -p 'test_*.py' -v` — `105 passed`.
|
||||
- `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 --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`).
|
||||
|
|
|
|||
470
context/13_role_flows_test_matrix.md
Normal file
470
context/13_role_flows_test_matrix.md
Normal 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)
|
||||
|
||||
92
context/14_e2e_backlog_prioritized.md
Normal file
92
context/14_e2e_backlog_prioritized.md
Normal 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-паттерны и должен сохраняться для приемки.
|
||||
54
context/15_manual_test_access.md
Normal file
54
context/15_manual_test_access.md
Normal 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/финансов.
|
||||
- В активных заявках есть переписка и непрочитанные уведомления.
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
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 }) => {
|
||||
await page.goto("/");
|
||||
|
|
|
|||
|
|
@ -7,20 +7,30 @@ const {
|
|||
openDictionaryTree,
|
||||
selectDictionaryNode,
|
||||
rowByTrack,
|
||||
trackCleanupPhone,
|
||||
trackCleanupTrack,
|
||||
trackCleanupEmail,
|
||||
cleanupTrackedTestData,
|
||||
} = require("./helpers");
|
||||
|
||||
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
||||
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 phone = randomPhone();
|
||||
trackCleanupPhone(testInfo, phone);
|
||||
|
||||
await preparePublicSession(context, page, appUrl, phone);
|
||||
const { trackNumber } = await createRequestViaLanding(page, {
|
||||
phone,
|
||||
description: "Заявка для проверки админского UI-флоу",
|
||||
});
|
||||
trackCleanupTrack(testInfo, trackNumber);
|
||||
|
||||
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
||||
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 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 lawyerEmail = `ui-lawyer-${unique}@example.com`;
|
||||
trackCleanupEmail(testInfo, lawyerEmail);
|
||||
const topicName = `Тема UI ${unique}`;
|
||||
|
||||
await selectDictionaryNode(page, "Пользователи");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
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_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 }) => {
|
||||
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ dotenv.config({ path: path.resolve(__dirname, "../../.env") });
|
|||
|
||||
const PUBLIC_SECRET = process.env.PUBLIC_JWT_SECRET || "change_me_public";
|
||||
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) {
|
||||
let value = "";
|
||||
|
|
@ -20,6 +22,42 @@ function randomPhone() {
|
|||
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) {
|
||||
return jwt.sign({ sub: phone, purpose: "CREATE_REQUEST" }, PUBLIC_SECRET, {
|
||||
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") {
|
||||
page.on("dialog", async (dialog) => {
|
||||
if (dialog.type() === "prompt") {
|
||||
|
|
@ -130,11 +262,13 @@ async function sendCabinetMessage(page, text) {
|
|||
|
||||
async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E file") {
|
||||
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) {
|
||||
await page.locator("#cabinet-file-input").setInputFiles({
|
||||
name: fileName,
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from(bodyText, "utf-8"),
|
||||
mimeType,
|
||||
buffer,
|
||||
});
|
||||
await page.locator("#cabinet-file-upload").click();
|
||||
|
||||
|
|
@ -202,6 +336,11 @@ async function selectDictionaryNode(page, label) {
|
|||
|
||||
module.exports = {
|
||||
randomPhone,
|
||||
createCleanupTracker,
|
||||
trackCleanupPhone,
|
||||
trackCleanupTrack,
|
||||
trackCleanupEmail,
|
||||
cleanupTrackedTestData,
|
||||
preparePublicSession,
|
||||
createRequestViaLanding,
|
||||
openPublicCabinet,
|
||||
|
|
@ -212,4 +351,5 @@ module.exports = {
|
|||
rowByTrack,
|
||||
openDictionaryTree,
|
||||
selectDictionaryNode,
|
||||
buildTinyPdfBuffer,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,20 +4,29 @@ const {
|
|||
createRequestViaLanding,
|
||||
randomPhone,
|
||||
loginAdminPanel,
|
||||
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("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 phone = randomPhone();
|
||||
trackCleanupPhone(testInfo, phone);
|
||||
|
||||
await preparePublicSession(context, page, appUrl, phone);
|
||||
const { trackNumber } = await createRequestViaLanding(page, {
|
||||
phone,
|
||||
description: "Заявка для проверки канбана юриста",
|
||||
});
|
||||
trackCleanupTrack(testInfo, trackNumber);
|
||||
|
||||
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
||||
await page.locator("aside .menu button[data-section='kanban']").click();
|
||||
|
|
|
|||
|
|
@ -9,14 +9,23 @@ const {
|
|||
loginAdminPanel,
|
||||
openRequestsSection,
|
||||
rowByTrack,
|
||||
buildTinyPdfBuffer,
|
||||
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("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 phone = randomPhone();
|
||||
trackCleanupPhone(testInfo, 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,
|
||||
description: "Заявка для проверки флоу юриста через UI",
|
||||
});
|
||||
trackCleanupTrack(testInfo, trackNumber);
|
||||
|
||||
await openPublicCabinet(page, trackNumber);
|
||||
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(/Заявка взята в работу|Список обновлен/);
|
||||
|
||||
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 expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
|
||||
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();
|
||||
await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click();
|
||||
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.getByRole("tab", { name: "Чат" }).click();
|
||||
|
||||
|
|
@ -73,7 +84,7 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
|
|||
{
|
||||
name: lawyerFileName,
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("lawyer file from admin modal", "utf-8"),
|
||||
buffer: buildTinyPdfBuffer("lawyer file from admin modal"),
|
||||
},
|
||||
{
|
||||
name: droppedFileName,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,19 @@ const {
|
|||
sendCabinetMessage,
|
||||
uploadCabinetFile,
|
||||
randomPhone,
|
||||
trackCleanupPhone,
|
||||
trackCleanupTrack,
|
||||
cleanupTrackedTestData,
|
||||
} = 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 phone = randomPhone();
|
||||
trackCleanupPhone(testInfo, phone);
|
||||
|
||||
await preparePublicSession(context, page, appUrl, phone);
|
||||
|
||||
|
|
@ -18,6 +26,7 @@ test("public flow via UI: landing -> create request -> cabinet -> chat -> upload
|
|||
phone,
|
||||
description: "Проверка публичного E2E флоу через UI.",
|
||||
});
|
||||
trackCleanupTrack(testInfo, trackNumber);
|
||||
|
||||
await openPublicCabinet(page, trackNumber);
|
||||
|
||||
|
|
|
|||
109
e2e/tests/request_data_file_flow.spec.js
Normal file
109
e2e/tests/request_data_file_flow.spec.js
Normal 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);
|
||||
});
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
FROM node:22-alpine AS admin-build
|
||||
WORKDIR /build
|
||||
COPY app/web/admin ./admin
|
||||
COPY app/web/admin.jsx ./admin.jsx
|
||||
RUN npm init -y >/dev/null 2>&1 \
|
||||
&& 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
|
||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
|
|
|||
|
|
@ -7,27 +7,42 @@ server {
|
|||
root /usr/share/nginx/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 {
|
||||
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;
|
||||
return 302 /admin.html;
|
||||
}
|
||||
|
||||
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;
|
||||
default_type application/javascript;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
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;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from app.core.security import create_jwt
|
|||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.message import Message
|
||||
from app.models.request import Request
|
||||
from app.models.status import Status
|
||||
|
|
@ -37,6 +38,7 @@ class DashboardFinanceTests(unittest.TestCase):
|
|||
)
|
||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||
AdminUser.__table__.create(bind=cls.engine)
|
||||
AuditLog.__table__.create(bind=cls.engine)
|
||||
Request.__table__.create(bind=cls.engine)
|
||||
Status.__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)
|
||||
Status.__table__.drop(bind=cls.engine)
|
||||
Request.__table__.drop(bind=cls.engine)
|
||||
AuditLog.__table__.drop(bind=cls.engine)
|
||||
AdminUser.__table__.drop(bind=cls.engine)
|
||||
cls.engine.dispose()
|
||||
|
||||
|
|
@ -60,6 +63,7 @@ class DashboardFinanceTests(unittest.TestCase):
|
|||
db.execute(delete(Message))
|
||||
db.execute(delete(Request))
|
||||
db.execute(delete(Status))
|
||||
db.execute(delete(AuditLog))
|
||||
db.execute(delete(AdminUser))
|
||||
db.commit()
|
||||
|
||||
|
|
@ -185,6 +189,45 @@ class DashboardFinanceTests(unittest.TestCase):
|
|||
created_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()
|
||||
|
|
@ -194,21 +237,100 @@ class DashboardFinanceTests(unittest.TestCase):
|
|||
body = response.json()
|
||||
self.assertEqual(body.get("scope"), "ADMIN")
|
||||
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"]}
|
||||
self.assertEqual(by_email["lawyer.a@example.com"]["active_load"], 1)
|
||||
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.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_salary"]), 750.0, places=2)
|
||||
|
||||
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.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.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)
|
||||
|
||||
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):
|
||||
with self.SessionLocal() as db:
|
||||
db.add_all(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ os.environ.setdefault("S3_SECRET_KEY", "test")
|
|||
os.environ.setdefault("S3_BUCKET", "test")
|
||||
|
||||
from app.main import app
|
||||
from app.core.http_hardening import _response_security_headers
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
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-frame-options"), "DENY")
|
||||
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")))
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ class MigrationTests(unittest.TestCase):
|
|||
"form_fields",
|
||||
"topic_required_fields",
|
||||
"topic_data_templates",
|
||||
"request_data_templates",
|
||||
"request_data_template_items",
|
||||
"request_data_requirements",
|
||||
"requests",
|
||||
"messages",
|
||||
|
|
@ -97,6 +99,7 @@ class MigrationTests(unittest.TestCase):
|
|||
"otp_sessions",
|
||||
"quotes",
|
||||
"admin_user_topics",
|
||||
"landing_featured_staff",
|
||||
"topic_status_transitions",
|
||||
"notifications",
|
||||
"invoices",
|
||||
|
|
@ -109,7 +112,7 @@ class MigrationTests(unittest.TestCase):
|
|||
def test_alembic_version_is_set(self):
|
||||
with self.engine.connect() as conn:
|
||||
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):
|
||||
tables = {
|
||||
|
|
@ -122,6 +125,8 @@ class MigrationTests(unittest.TestCase):
|
|||
"form_fields",
|
||||
"topic_required_fields",
|
||||
"topic_data_templates",
|
||||
"request_data_templates",
|
||||
"request_data_template_items",
|
||||
"request_data_requirements",
|
||||
"requests",
|
||||
"messages",
|
||||
|
|
@ -131,6 +136,7 @@ class MigrationTests(unittest.TestCase):
|
|||
"otp_sessions",
|
||||
"quotes",
|
||||
"admin_user_topics",
|
||||
"landing_featured_staff",
|
||||
"topic_status_transitions",
|
||||
"notifications",
|
||||
"invoices",
|
||||
|
|
@ -176,15 +182,22 @@ class MigrationTests(unittest.TestCase):
|
|||
columns = {column["name"] for column in self.inspector.get_columns("admin_users")}
|
||||
self.assertIn("default_rate", columns)
|
||||
self.assertIn("salary_percent", columns)
|
||||
self.assertIn("phone", columns)
|
||||
|
||||
def test_requests_contains_financial_columns(self):
|
||||
columns = {column["name"] for column in self.inspector.get_columns("requests")}
|
||||
self.assertIn("client_id", columns)
|
||||
self.assertIn("important_date_at", columns)
|
||||
self.assertIn("effective_rate", columns)
|
||||
self.assertIn("request_cost", columns)
|
||||
self.assertIn("invoice_amount", columns)
|
||||
self.assertIn("paid_at", 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):
|
||||
columns = {column["name"] for column in self.inspector.get_columns("invoices")}
|
||||
self.assertIn("client_id", columns)
|
||||
|
|
@ -221,3 +234,39 @@ class MigrationTests(unittest.TestCase):
|
|||
self.assertIn("phone", columns)
|
||||
self.assertIn("created_at", 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)
|
||||
|
|
|
|||
|
|
@ -487,6 +487,7 @@ class RequestRatesTests(unittest.TestCase):
|
|||
description="public",
|
||||
extra_fields={},
|
||||
effective_rate=8800,
|
||||
request_cost=9900,
|
||||
invoice_amount=12500,
|
||||
)
|
||||
db.add(req)
|
||||
|
|
@ -503,10 +504,72 @@ class RequestRatesTests(unittest.TestCase):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertNotIn("effective_rate", body)
|
||||
self.assertNotIn("request_cost", body)
|
||||
self.assertNotIn("invoice_amount", body)
|
||||
self.assertNotIn("paid_at", 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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
151
tests/test_universal_query.py
Normal file
151
tests/test_universal_query.py
Normal 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()
|
||||
Loading…
Reference in a new issue