mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 18:13:46 +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.core.deps import require_role
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.services.chat_service import create_admin_or_lawyer_message, list_messages_for_request, serialize_message
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
|
from app.models.request_data_template import RequestDataTemplate
|
||||||
|
from app.models.request_data_template_item import RequestDataTemplateItem
|
||||||
|
from app.models.topic_data_template import TopicDataTemplate
|
||||||
|
from app.services.chat_service import (
|
||||||
|
create_admin_or_lawyer_message,
|
||||||
|
list_messages_for_request,
|
||||||
|
serialize_message,
|
||||||
|
serialize_messages_for_request,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"}
|
||||||
|
|
||||||
|
|
||||||
def _request_uuid_or_400(request_id: str) -> UUID:
|
def _request_uuid_or_400(request_id: str) -> UUID:
|
||||||
|
|
@ -52,6 +64,121 @@ def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
|
||||||
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
|
raise HTTPException(status_code=403, detail="Юрист может работать только со своими назначенными заявками")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_uuid_or_400(raw: str, field_name: str) -> UUID:
|
||||||
|
try:
|
||||||
|
return UUID(str(raw))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Некорректное поле "{field_name}"')
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify_key(raw: str) -> str:
|
||||||
|
text = str(raw or "").strip().lower()
|
||||||
|
out = []
|
||||||
|
dash = False
|
||||||
|
for ch in text:
|
||||||
|
if ch.isalnum():
|
||||||
|
out.append(ch)
|
||||||
|
dash = False
|
||||||
|
continue
|
||||||
|
if not dash:
|
||||||
|
out.append("-")
|
||||||
|
dash = True
|
||||||
|
slug = "".join(out).strip("-")
|
||||||
|
return (slug or "data-field")[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_value_type(raw: str | None) -> str:
|
||||||
|
value = str(raw or "text").strip().lower()
|
||||||
|
if value not in ALLOWED_VALUE_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail='Тип поля должен быть одним из: string, text, date, number, file')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_template(row: TopicDataTemplate) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"topic_code": row.topic_code,
|
||||||
|
"key": row.key,
|
||||||
|
"label": row.label,
|
||||||
|
"value_type": str(row.value_type or "text"),
|
||||||
|
"document_name": row.document_name,
|
||||||
|
"description": row.description,
|
||||||
|
"sort_order": int(row.sort_order or 0),
|
||||||
|
"enabled": bool(row.enabled),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_request_data_template(row: RequestDataTemplate) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"topic_code": row.topic_code,
|
||||||
|
"name": row.name,
|
||||||
|
"description": row.description,
|
||||||
|
"enabled": bool(row.enabled),
|
||||||
|
"sort_order": int(row.sort_order or 0),
|
||||||
|
"created_by_admin_id": str(row.created_by_admin_id) if row.created_by_admin_id else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_request_data_template_item(row: RequestDataTemplateItem) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"request_data_template_id": str(row.request_data_template_id),
|
||||||
|
"topic_data_template_id": str(row.topic_data_template_id) if row.topic_data_template_id else None,
|
||||||
|
"key": row.key,
|
||||||
|
"label": row.label,
|
||||||
|
"value_type": str(row.value_type or "string"),
|
||||||
|
"sort_order": int(row.sort_order or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_data_request_items(db: Session, rows: list[RequestDataRequirement]) -> list[dict]:
|
||||||
|
attachment_ids: list[UUID] = []
|
||||||
|
for row in rows:
|
||||||
|
if str(row.field_type or "").lower() != "file":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
attachment_ids.append(UUID(str(row.value_text or "").strip()))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
attachment_map = {}
|
||||||
|
if attachment_ids:
|
||||||
|
attachment_rows = db.query(Attachment).filter(Attachment.id.in_(attachment_ids)).all() # type: ignore[name-defined]
|
||||||
|
attachment_map = {str(item.id): item for item in attachment_rows}
|
||||||
|
out = []
|
||||||
|
for index, row in enumerate(rows, start=1):
|
||||||
|
value_text = str(row.value_text or "").strip()
|
||||||
|
value_file = None
|
||||||
|
if str(row.field_type or "").lower() == "file" and value_text:
|
||||||
|
attachment = attachment_map.get(value_text)
|
||||||
|
if attachment is not None:
|
||||||
|
value_file = {
|
||||||
|
"attachment_id": str(attachment.id),
|
||||||
|
"file_name": attachment.file_name,
|
||||||
|
"mime_type": attachment.mime_type,
|
||||||
|
"size_bytes": int(attachment.size_bytes or 0),
|
||||||
|
"download_url": f"/api/admin/uploads/object/{attachment.id}",
|
||||||
|
}
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"request_message_id": str(row.request_message_id) if row.request_message_id else None,
|
||||||
|
"topic_template_id": str(row.topic_template_id) if row.topic_template_id else None,
|
||||||
|
"key": row.key,
|
||||||
|
"label": row.label,
|
||||||
|
"field_type": str(row.field_type or "text"),
|
||||||
|
"document_name": row.document_name,
|
||||||
|
"description": row.description,
|
||||||
|
"value_text": row.value_text,
|
||||||
|
"value_file": value_file,
|
||||||
|
"is_filled": bool(value_text),
|
||||||
|
"sort_order": int(row.sort_order or 0),
|
||||||
|
"index": index,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests/{request_id}/messages")
|
@router.get("/requests/{request_id}/messages")
|
||||||
def list_request_messages(
|
def list_request_messages(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
|
@ -61,7 +188,7 @@ def list_request_messages(
|
||||||
req = _request_for_id_or_404(db, request_id)
|
req = _request_for_id_or_404(db, request_id)
|
||||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||||
rows = list_messages_for_request(db, req.id)
|
rows = list_messages_for_request(db, req.id)
|
||||||
return {"rows": [serialize_message(row) for row in rows], "total": len(rows)}
|
return {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/messages", status_code=201)
|
@router.post("/requests/{request_id}/messages", status_code=201)
|
||||||
|
|
@ -95,3 +222,464 @@ def create_request_message(
|
||||||
actor_admin_user_id=actor_admin_user_id,
|
actor_admin_user_id=actor_admin_user_id,
|
||||||
)
|
)
|
||||||
return serialize_message(row)
|
return serialize_message(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{request_id}/data-request-templates")
|
||||||
|
def list_data_request_templates(
|
||||||
|
request_id: str,
|
||||||
|
document: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
req = _request_for_id_or_404(db, request_id)
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
topic_code = str(req.topic_code or "").strip()
|
||||||
|
if not topic_code:
|
||||||
|
return {"rows": [], "documents": [], "templates": []}
|
||||||
|
query = db.query(TopicDataTemplate).filter(TopicDataTemplate.topic_code == topic_code)
|
||||||
|
document_name = str(document or "").strip()
|
||||||
|
if document_name:
|
||||||
|
query = query.filter(TopicDataTemplate.document_name == document_name)
|
||||||
|
rows = (
|
||||||
|
query.order_by(
|
||||||
|
TopicDataTemplate.document_name.asc().nullsfirst(),
|
||||||
|
TopicDataTemplate.sort_order.asc(),
|
||||||
|
TopicDataTemplate.label.asc(),
|
||||||
|
TopicDataTemplate.key.asc(),
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
all_docs_rows = (
|
||||||
|
db.query(TopicDataTemplate.document_name)
|
||||||
|
.filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.enabled.is_(True))
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
documents = sorted({str(row[0]).strip() for row in all_docs_rows if row[0]}, key=lambda x: x.lower())
|
||||||
|
template_rows = (
|
||||||
|
db.query(RequestDataTemplate)
|
||||||
|
.filter(RequestDataTemplate.topic_code == topic_code, RequestDataTemplate.enabled.is_(True))
|
||||||
|
.order_by(RequestDataTemplate.sort_order.asc(), RequestDataTemplate.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"rows": [_serialize_template(row) for row in rows if row.enabled], # legacy catalog payload
|
||||||
|
"documents": documents, # legacy
|
||||||
|
"templates": [_serialize_request_data_template(row) for row in template_rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{request_id}/data-requests/{message_id}")
|
||||||
|
def get_data_request_batch(
|
||||||
|
request_id: str,
|
||||||
|
message_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
req = _request_for_id_or_404(db, request_id)
|
||||||
|
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||||
|
msg_uuid = _parse_uuid_or_400(message_id, "message_id")
|
||||||
|
message = db.get(Message, msg_uuid)
|
||||||
|
if message is None or message.request_id != req.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
|
||||||
|
rows = (
|
||||||
|
db.query(RequestDataRequirement)
|
||||||
|
.filter(
|
||||||
|
RequestDataRequirement.request_id == req.id,
|
||||||
|
RequestDataRequirement.request_message_id == msg_uuid,
|
||||||
|
)
|
||||||
|
.order_by(RequestDataRequirement.sort_order.asc(), RequestDataRequirement.created_at.asc(), RequestDataRequirement.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Набор данных для сообщения не найден")
|
||||||
|
return {
|
||||||
|
"message_id": str(message.id),
|
||||||
|
"request_id": str(req.id),
|
||||||
|
"track_number": req.track_number,
|
||||||
|
"document_name": rows[0].document_name if rows else None,
|
||||||
|
"items": _serialize_data_request_items(db, rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{request_id}/data-request-templates/{template_id}")
|
||||||
|
def get_data_request_template(
|
||||||
|
request_id: str,
|
||||||
|
template_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
req = _request_for_id_or_404(db, request_id)
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
template_uuid = _parse_uuid_or_400(template_id, "template_id")
|
||||||
|
template = db.get(RequestDataTemplate, template_uuid)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||||
|
if str(template.topic_code or "").strip() != str(req.topic_code or "").strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Шаблон не соответствует теме заявки")
|
||||||
|
rows = (
|
||||||
|
db.query(RequestDataTemplateItem)
|
||||||
|
.filter(RequestDataTemplateItem.request_data_template_id == template.id)
|
||||||
|
.order_by(RequestDataTemplateItem.sort_order.asc(), RequestDataTemplateItem.created_at.asc(), RequestDataTemplateItem.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"template": _serialize_request_data_template(template),
|
||||||
|
"items": [_serialize_request_data_template_item(row) for row in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/requests/{request_id}/data-request-templates", status_code=201)
|
||||||
|
def save_data_request_template(
|
||||||
|
request_id: str,
|
||||||
|
payload: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
req = _request_for_id_or_404(db, request_id)
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
topic_code = str(req.topic_code or "").strip()
|
||||||
|
if not topic_code:
|
||||||
|
raise HTTPException(status_code=400, detail="У заявки не указана тема")
|
||||||
|
|
||||||
|
body = payload or {}
|
||||||
|
name = str(body.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="Укажите название шаблона")
|
||||||
|
raw_items = body.get("items")
|
||||||
|
if not isinstance(raw_items, list) or not raw_items:
|
||||||
|
raise HTTPException(status_code=400, detail="Шаблон должен содержать хотя бы одно поле")
|
||||||
|
|
||||||
|
actor_uuid = None
|
||||||
|
raw_actor = str(admin.get("sub") or "").strip()
|
||||||
|
if raw_actor:
|
||||||
|
try:
|
||||||
|
actor_uuid = UUID(raw_actor)
|
||||||
|
except ValueError:
|
||||||
|
actor_uuid = None
|
||||||
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
|
||||||
|
template = None
|
||||||
|
template_id_raw = str(body.get("template_id") or "").strip()
|
||||||
|
if template_id_raw:
|
||||||
|
template = db.get(RequestDataTemplate, _parse_uuid_or_400(template_id_raw, "template_id"))
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||||
|
if str(template.topic_code or "").strip() != topic_code:
|
||||||
|
raise HTTPException(status_code=400, detail="Шаблон не соответствует теме заявки")
|
||||||
|
else:
|
||||||
|
template = (
|
||||||
|
db.query(RequestDataTemplate)
|
||||||
|
.filter(RequestDataTemplate.topic_code == topic_code, RequestDataTemplate.name == name)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if template is None:
|
||||||
|
template = RequestDataTemplate(
|
||||||
|
topic_code=topic_code,
|
||||||
|
name=name,
|
||||||
|
enabled=True,
|
||||||
|
sort_order=0,
|
||||||
|
created_by_admin_id=actor_uuid,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
db.add(template)
|
||||||
|
db.flush()
|
||||||
|
else:
|
||||||
|
actor_role = str(admin.get("role") or "").upper()
|
||||||
|
if actor_role == "LAWYER":
|
||||||
|
owner_id = str(template.created_by_admin_id or "").strip()
|
||||||
|
actor_id = str(actor_uuid or "").strip()
|
||||||
|
if owner_id and actor_id and owner_id != actor_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист может изменять только свои шаблоны")
|
||||||
|
template.name = name
|
||||||
|
template.responsible = responsible
|
||||||
|
db.add(template)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
touched_keys: set[str] = set()
|
||||||
|
normalized_items: list[tuple[int, TopicDataTemplate | None, str, str, str]] = []
|
||||||
|
for index, item in enumerate(raw_items):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Элемент шаблона должен быть объектом")
|
||||||
|
catalog = None
|
||||||
|
catalog_id_raw = str(item.get("topic_data_template_id") or item.get("topic_template_id") or "").strip()
|
||||||
|
if catalog_id_raw:
|
||||||
|
catalog = db.get(TopicDataTemplate, _parse_uuid_or_400(catalog_id_raw, "topic_data_template_id"))
|
||||||
|
if catalog is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Поле справочника не найдено")
|
||||||
|
label = str(item.get("label") or (catalog.label if catalog else "")).strip()
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(status_code=400, detail="Укажите наименование поля")
|
||||||
|
value_type = _normalize_value_type(item.get("value_type") or item.get("field_type") or (catalog.value_type if catalog else None))
|
||||||
|
key = str(item.get("key") or (catalog.key if catalog else "")).strip()
|
||||||
|
if not key:
|
||||||
|
key = _slugify_key(label)
|
||||||
|
key = key[:80]
|
||||||
|
if key in touched_keys:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{label}" добавлено дважды')
|
||||||
|
touched_keys.add(key)
|
||||||
|
|
||||||
|
if catalog is None:
|
||||||
|
catalog = (
|
||||||
|
db.query(TopicDataTemplate)
|
||||||
|
.filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.key == key)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if catalog is None:
|
||||||
|
catalog = TopicDataTemplate(
|
||||||
|
topic_code=topic_code,
|
||||||
|
key=key,
|
||||||
|
label=label,
|
||||||
|
value_type=value_type,
|
||||||
|
enabled=True,
|
||||||
|
required=True,
|
||||||
|
sort_order=index,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
db.add(catalog)
|
||||||
|
db.flush()
|
||||||
|
else:
|
||||||
|
changed = False
|
||||||
|
if str(catalog.label or "") != label:
|
||||||
|
catalog.label = label
|
||||||
|
changed = True
|
||||||
|
if str(catalog.value_type or "string") != value_type:
|
||||||
|
catalog.value_type = value_type
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
catalog.responsible = responsible
|
||||||
|
db.add(catalog)
|
||||||
|
db.flush()
|
||||||
|
normalized_items.append((index, catalog, key, label, value_type))
|
||||||
|
|
||||||
|
existing_items = (
|
||||||
|
db.query(RequestDataTemplateItem)
|
||||||
|
.filter(RequestDataTemplateItem.request_data_template_id == template.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_key = {str(row.key): row for row in existing_items}
|
||||||
|
for index, catalog, key, label, value_type in normalized_items:
|
||||||
|
row = by_key.get(key)
|
||||||
|
if row is None:
|
||||||
|
row = RequestDataTemplateItem(
|
||||||
|
request_data_template_id=template.id,
|
||||||
|
topic_data_template_id=catalog.id if catalog else None,
|
||||||
|
key=key,
|
||||||
|
label=label,
|
||||||
|
value_type=value_type,
|
||||||
|
sort_order=index,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.topic_data_template_id = catalog.id if catalog else None
|
||||||
|
row.label = label
|
||||||
|
row.value_type = value_type
|
||||||
|
row.sort_order = index
|
||||||
|
row.responsible = responsible
|
||||||
|
db.add(row)
|
||||||
|
for row in existing_items:
|
||||||
|
if str(row.key) not in touched_keys:
|
||||||
|
db.delete(row)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(template)
|
||||||
|
items = (
|
||||||
|
db.query(RequestDataTemplateItem)
|
||||||
|
.filter(RequestDataTemplateItem.request_data_template_id == template.id)
|
||||||
|
.order_by(RequestDataTemplateItem.sort_order.asc(), RequestDataTemplateItem.created_at.asc(), RequestDataTemplateItem.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {"template": _serialize_request_data_template(template), "items": [_serialize_request_data_template_item(row) for row in items]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/requests/{request_id}/data-requests", status_code=201)
|
||||||
|
def upsert_data_request_batch(
|
||||||
|
request_id: str,
|
||||||
|
payload: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
req = _request_for_id_or_404(db, request_id)
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
actor_role = str(admin.get("role") or "").strip().upper()
|
||||||
|
|
||||||
|
body = payload or {}
|
||||||
|
raw_items = body.get("items")
|
||||||
|
if not isinstance(raw_items, list) or not raw_items:
|
||||||
|
raise HTTPException(status_code=400, detail="Нужно передать список полей запроса")
|
||||||
|
|
||||||
|
message_id_raw = str(body.get("message_id") or "").strip()
|
||||||
|
existing_message = None
|
||||||
|
existing_message_rows: list[RequestDataRequirement] = []
|
||||||
|
if message_id_raw:
|
||||||
|
msg_uuid = _parse_uuid_or_400(message_id_raw, "message_id")
|
||||||
|
existing_message = db.get(Message, msg_uuid)
|
||||||
|
if existing_message is None or existing_message.request_id != req.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
|
||||||
|
existing_message_rows = (
|
||||||
|
db.query(RequestDataRequirement)
|
||||||
|
.filter(
|
||||||
|
RequestDataRequirement.request_id == req.id,
|
||||||
|
RequestDataRequirement.request_message_id == existing_message.id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
role = actor_role
|
||||||
|
actor_name = str(admin.get("email") or "").strip() or ("Юрист" if role == "LAWYER" else "Администратор")
|
||||||
|
actor_admin_user_id = str(admin.get("sub") or "").strip() or None
|
||||||
|
if actor_admin_user_id:
|
||||||
|
try:
|
||||||
|
actor_uuid = UUID(actor_admin_user_id)
|
||||||
|
except ValueError:
|
||||||
|
actor_uuid = None
|
||||||
|
if actor_uuid is not None:
|
||||||
|
actor_user = db.get(AdminUser, actor_uuid)
|
||||||
|
if actor_user is not None:
|
||||||
|
actor_name = str(actor_user.name or actor_user.email or actor_name)
|
||||||
|
existing_message = create_admin_or_lawyer_message(
|
||||||
|
db,
|
||||||
|
request=req,
|
||||||
|
body="Запрос",
|
||||||
|
actor_role=role,
|
||||||
|
actor_name=actor_name,
|
||||||
|
actor_admin_user_id=actor_admin_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
message_uuid = existing_message.id
|
||||||
|
topic_code = str(req.topic_code or "").strip()
|
||||||
|
document_name_default = str(body.get("document_name") or "").strip() or None
|
||||||
|
actor_uuid = None
|
||||||
|
raw_actor = str(admin.get("sub") or "").strip()
|
||||||
|
if raw_actor:
|
||||||
|
try:
|
||||||
|
actor_uuid = UUID(raw_actor)
|
||||||
|
except ValueError:
|
||||||
|
actor_uuid = None
|
||||||
|
|
||||||
|
normalized_rows: list[RequestDataRequirement] = []
|
||||||
|
touched_keys: set[str] = set()
|
||||||
|
for index, item in enumerate(raw_items):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Элемент списка полей должен быть объектом")
|
||||||
|
template = None
|
||||||
|
template_id_raw = str(item.get("topic_template_id") or "").strip()
|
||||||
|
if template_id_raw:
|
||||||
|
template = db.get(TopicDataTemplate, _parse_uuid_or_400(template_id_raw, "topic_template_id"))
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Шаблон дополнительного поля не найден")
|
||||||
|
|
||||||
|
label = str(item.get("label") or (template.label if template else "")).strip()
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(status_code=400, detail="Укажите наименование поля")
|
||||||
|
field_type = _normalize_value_type(item.get("field_type") or (template.value_type if template else None))
|
||||||
|
doc_name = str(item.get("document_name") or (template.document_name if template else "") or (document_name_default or "")).strip() or None
|
||||||
|
key = str(item.get("key") or (template.key if template else "")).strip()
|
||||||
|
if not key:
|
||||||
|
key = _slugify_key(label)
|
||||||
|
key = key[:80]
|
||||||
|
if key in touched_keys:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{label}" добавлено дважды')
|
||||||
|
touched_keys.add(key)
|
||||||
|
|
||||||
|
topic_template_id = None
|
||||||
|
if template is not None:
|
||||||
|
topic_template_id = template.id
|
||||||
|
elif topic_code:
|
||||||
|
existing_template = (
|
||||||
|
db.query(TopicDataTemplate)
|
||||||
|
.filter(TopicDataTemplate.topic_code == topic_code, TopicDataTemplate.key == key)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_template is None:
|
||||||
|
existing_template = TopicDataTemplate(
|
||||||
|
topic_code=topic_code,
|
||||||
|
key=key,
|
||||||
|
label=label,
|
||||||
|
value_type=field_type,
|
||||||
|
document_name=doc_name,
|
||||||
|
enabled=True,
|
||||||
|
required=True,
|
||||||
|
sort_order=index,
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
)
|
||||||
|
db.add(existing_template)
|
||||||
|
db.flush()
|
||||||
|
else:
|
||||||
|
changed = False
|
||||||
|
if str(existing_template.label or "") != label:
|
||||||
|
existing_template.label = label
|
||||||
|
changed = True
|
||||||
|
if str(existing_template.value_type or "text") != field_type:
|
||||||
|
existing_template.value_type = field_type
|
||||||
|
changed = True
|
||||||
|
if str(existing_template.document_name or "") != str(doc_name or ""):
|
||||||
|
existing_template.document_name = doc_name
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.add(existing_template)
|
||||||
|
db.flush()
|
||||||
|
topic_template_id = existing_template.id
|
||||||
|
|
||||||
|
req_row = (
|
||||||
|
db.query(RequestDataRequirement)
|
||||||
|
.filter(RequestDataRequirement.request_id == req.id, RequestDataRequirement.key == key)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if req_row is None:
|
||||||
|
req_row = RequestDataRequirement(
|
||||||
|
request_id=req.id,
|
||||||
|
request_message_id=message_uuid,
|
||||||
|
topic_template_id=topic_template_id,
|
||||||
|
key=key,
|
||||||
|
label=label,
|
||||||
|
field_type=field_type,
|
||||||
|
document_name=doc_name,
|
||||||
|
required=True,
|
||||||
|
sort_order=index,
|
||||||
|
created_by_admin_id=actor_uuid,
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if actor_role == "LAWYER" and str(req_row.value_text or "").strip():
|
||||||
|
current_message_id = str(req_row.request_message_id or "")
|
||||||
|
incoming_message_id = str(message_uuid or "")
|
||||||
|
current_topic_template_id = str(req_row.topic_template_id or "")
|
||||||
|
incoming_topic_template_id = str(topic_template_id or "")
|
||||||
|
current_doc_name = str(req_row.document_name or "") if req_row.document_name is not None else ""
|
||||||
|
incoming_doc_name = str(doc_name or "")
|
||||||
|
if (
|
||||||
|
str(req_row.label or "") != label
|
||||||
|
or str(req_row.field_type or "text") != field_type
|
||||||
|
or current_doc_name != incoming_doc_name
|
||||||
|
or current_topic_template_id != incoming_topic_template_id
|
||||||
|
or current_message_id != incoming_message_id
|
||||||
|
or int(req_row.sort_order or 0) != int(index)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист не может изменять заполненные клиентом данные")
|
||||||
|
req_row.request_message_id = message_uuid
|
||||||
|
req_row.topic_template_id = topic_template_id
|
||||||
|
req_row.label = label
|
||||||
|
req_row.field_type = field_type
|
||||||
|
req_row.document_name = doc_name
|
||||||
|
req_row.sort_order = index
|
||||||
|
req_row.responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
db.add(req_row)
|
||||||
|
normalized_rows.append(req_row)
|
||||||
|
|
||||||
|
if message_id_raw:
|
||||||
|
if actor_role == "LAWYER":
|
||||||
|
for row in existing_message_rows:
|
||||||
|
if row.key not in touched_keys and str(row.value_text or "").strip():
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист не может удалять заполненные клиентом данные")
|
||||||
|
for row in existing_message_rows:
|
||||||
|
if row.key not in touched_keys:
|
||||||
|
db.delete(row)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
fresh_messages = list_messages_for_request(db, req.id)
|
||||||
|
serialized = serialize_messages_for_request(db, req.id, fresh_messages)
|
||||||
|
payload_row = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None)
|
||||||
|
if payload_row is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Не удалось сформировать сообщение запроса")
|
||||||
|
return payload_row
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import importlib
|
||||||
import json
|
import json
|
||||||
import pkgutil
|
import pkgutil
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -27,6 +27,8 @@ from app.models.form_field import FormField
|
||||||
from app.models.client import Client
|
from app.models.client import Client
|
||||||
from app.models.table_availability import TableAvailability
|
from app.models.table_availability import TableAvailability
|
||||||
from app.models.request_data_requirement import RequestDataRequirement
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
|
from app.models.request_data_template import RequestDataTemplate
|
||||||
|
from app.models.request_data_template_item import RequestDataTemplateItem
|
||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
|
@ -75,6 +77,7 @@ REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid
|
||||||
REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"}
|
REQUEST_CALCULATED_FIELDS = {"invoice_amount", "paid_at", "paid_by_admin_id", "total_attachments_bytes"}
|
||||||
INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"}
|
INVOICE_CALCULATED_FIELDS = {"issued_by_admin_user_id", "issued_by_role", "issued_at", "paid_at"}
|
||||||
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
|
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
|
||||||
|
ALLOWED_REQUEST_DATA_VALUE_TYPES = {"string", "text", "date", "number", "file"}
|
||||||
|
|
||||||
# Per-table RBAC: table -> role -> actions.
|
# Per-table RBAC: table -> role -> actions.
|
||||||
# If a table is missing here, fallback rules are used.
|
# If a table is missing here, fallback rules are used.
|
||||||
|
|
@ -103,9 +106,12 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
|
||||||
"otp_sessions": {"ADMIN": {"query", "read"}},
|
"otp_sessions": {"ADMIN": {"query", "read"}},
|
||||||
"admin_users": {"ADMIN": set(CRUD_ACTIONS)},
|
"admin_users": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
|
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"landing_featured_staff": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)},
|
"topic_status_transitions": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"topic_required_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
"topic_required_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"topic_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
|
"topic_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"request_data_templates": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"request_data_template_items": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"request_data_requirements": {"ADMIN": set(CRUD_ACTIONS)},
|
"request_data_requirements": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"notifications": {"ADMIN": {"query", "read", "update"}},
|
"notifications": {"ADMIN": {"query", "read", "update"}},
|
||||||
}
|
}
|
||||||
|
|
@ -264,10 +270,13 @@ def _table_label(table_name: str) -> str:
|
||||||
"clients": "Клиенты",
|
"clients": "Клиенты",
|
||||||
"table_availability": "Доступность таблиц",
|
"table_availability": "Доступность таблиц",
|
||||||
"topic_required_fields": "Обязательные поля темы",
|
"topic_required_fields": "Обязательные поля темы",
|
||||||
"topic_data_templates": "Шаблоны данных темы",
|
"topic_data_templates": "Дополнительные данные",
|
||||||
|
"request_data_templates": "Шаблоны доп. данных",
|
||||||
|
"request_data_template_items": "Набор данных шаблона",
|
||||||
"topic_status_transitions": "Переходы статусов темы",
|
"topic_status_transitions": "Переходы статусов темы",
|
||||||
"admin_users": "Пользователи",
|
"admin_users": "Пользователи",
|
||||||
"admin_user_topics": "Дополнительные темы юристов",
|
"admin_user_topics": "Дополнительные темы юристов",
|
||||||
|
"landing_featured_staff": "Карусель сотрудников лендинга",
|
||||||
"attachments": "Вложения",
|
"attachments": "Вложения",
|
||||||
"messages": "Сообщения",
|
"messages": "Сообщения",
|
||||||
"audit_log": "Журнал аудита",
|
"audit_log": "Журнал аудита",
|
||||||
|
|
@ -355,8 +364,16 @@ def _column_label(table_name: str, column_name: str) -> str:
|
||||||
"key": "Ключ",
|
"key": "Ключ",
|
||||||
"name": "Название",
|
"name": "Название",
|
||||||
"label": "Метка",
|
"label": "Метка",
|
||||||
|
"caption": "Подпись",
|
||||||
|
"value_type": "Тип значения",
|
||||||
|
"document_name": "Документ",
|
||||||
|
"request_data_template_id": "Шаблон",
|
||||||
|
"request_data_template_item_id": "Элемент шаблона",
|
||||||
"text": "Текст",
|
"text": "Текст",
|
||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
|
"request_message_id": "ID сообщения запроса",
|
||||||
|
"field_type": "Тип поля",
|
||||||
|
"value_text": "Данные",
|
||||||
"author": "Автор",
|
"author": "Автор",
|
||||||
"source": "Источник",
|
"source": "Источник",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
|
@ -384,6 +401,7 @@ def _column_label(table_name: str, column_name: str) -> str:
|
||||||
"updated_at": "Дата обновления",
|
"updated_at": "Дата обновления",
|
||||||
"responsible": "Ответственный",
|
"responsible": "Ответственный",
|
||||||
"sort_order": "Порядок",
|
"sort_order": "Порядок",
|
||||||
|
"pinned": "Закреплен",
|
||||||
"is_active": "Активен",
|
"is_active": "Активен",
|
||||||
"enabled": "Активен",
|
"enabled": "Активен",
|
||||||
"required": "Обязательное",
|
"required": "Обязательное",
|
||||||
|
|
@ -396,6 +414,7 @@ def _column_label(table_name: str, column_name: str) -> str:
|
||||||
"primary_topic_code": "Профильная тема",
|
"primary_topic_code": "Профильная тема",
|
||||||
"default_rate": "Ставка по умолчанию",
|
"default_rate": "Ставка по умолчанию",
|
||||||
"effective_rate": "Ставка (фикс.)",
|
"effective_rate": "Ставка (фикс.)",
|
||||||
|
"request_cost": "Стоимость заявки",
|
||||||
"salary_percent": "Процент зарплаты",
|
"salary_percent": "Процент зарплаты",
|
||||||
"invoice_amount": "Сумма счета",
|
"invoice_amount": "Сумма счета",
|
||||||
"paid_by_admin_id": "Оплату подтвердил",
|
"paid_by_admin_id": "Оплату подтвердил",
|
||||||
|
|
@ -468,12 +487,17 @@ def _reference_override(table_name: str, column_name: str) -> tuple[str, str] |
|
||||||
("topic_required_fields", "topic_code"): ("topics", "code"),
|
("topic_required_fields", "topic_code"): ("topics", "code"),
|
||||||
("topic_required_fields", "field_key"): ("form_fields", "key"),
|
("topic_required_fields", "field_key"): ("form_fields", "key"),
|
||||||
("topic_data_templates", "topic_code"): ("topics", "code"),
|
("topic_data_templates", "topic_code"): ("topics", "code"),
|
||||||
|
("request_data_templates", "topic_code"): ("topics", "code"),
|
||||||
|
("request_data_templates", "created_by_admin_id"): ("admin_users", "id"),
|
||||||
|
("request_data_template_items", "request_data_template_id"): ("request_data_templates", "id"),
|
||||||
|
("request_data_template_items", "topic_data_template_id"): ("topic_data_templates", "id"),
|
||||||
("topic_status_transitions", "topic_code"): ("topics", "code"),
|
("topic_status_transitions", "topic_code"): ("topics", "code"),
|
||||||
("topic_status_transitions", "from_status"): ("statuses", "code"),
|
("topic_status_transitions", "from_status"): ("statuses", "code"),
|
||||||
("topic_status_transitions", "to_status"): ("statuses", "code"),
|
("topic_status_transitions", "to_status"): ("statuses", "code"),
|
||||||
("admin_users", "primary_topic_code"): ("topics", "code"),
|
("admin_users", "primary_topic_code"): ("topics", "code"),
|
||||||
("admin_user_topics", "admin_user_id"): ("admin_users", "id"),
|
("admin_user_topics", "admin_user_id"): ("admin_users", "id"),
|
||||||
("admin_user_topics", "topic_code"): ("topics", "code"),
|
("admin_user_topics", "topic_code"): ("topics", "code"),
|
||||||
|
("landing_featured_staff", "admin_user_id"): ("admin_users", "id"),
|
||||||
("request_data_requirements", "request_id"): ("requests", "id"),
|
("request_data_requirements", "request_id"): ("requests", "id"),
|
||||||
("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"),
|
("request_data_requirements", "topic_template_id"): ("topic_data_templates", "id"),
|
||||||
("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"),
|
("request_data_requirements", "created_by_admin_id"): ("admin_users", "id"),
|
||||||
|
|
@ -530,6 +554,8 @@ def _reference_label_field(table_name: str, value_field: str) -> str:
|
||||||
"status_groups": "name",
|
"status_groups": "name",
|
||||||
"form_fields": "label",
|
"form_fields": "label",
|
||||||
"topic_data_templates": "label",
|
"topic_data_templates": "label",
|
||||||
|
"request_data_templates": "name",
|
||||||
|
"request_data_template_items": "label",
|
||||||
"invoices": "invoice_number",
|
"invoices": "invoice_number",
|
||||||
"messages": "body",
|
"messages": "body",
|
||||||
"attachments": "file_name",
|
"attachments": "file_name",
|
||||||
|
|
@ -784,6 +810,8 @@ def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, An
|
||||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||||
data["email"] = email
|
data["email"] = email
|
||||||
data["role"] = role
|
data["role"] = role
|
||||||
|
if "phone" in data:
|
||||||
|
data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone")))
|
||||||
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
|
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
|
||||||
data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
|
data["primary_topic_code"] = _normalize_optional_string(data.get("primary_topic_code"))
|
||||||
data["password_hash"] = hash_password(raw_password)
|
data["password_hash"] = hash_password(raw_password)
|
||||||
|
|
@ -809,6 +837,8 @@ def _apply_admin_user_fields_for_update(payload: dict[str, Any]) -> dict[str, An
|
||||||
if not email:
|
if not email:
|
||||||
raise HTTPException(status_code=400, detail="Email не может быть пустым")
|
raise HTTPException(status_code=400, detail="Email не может быть пустым")
|
||||||
data["email"] = email
|
data["email"] = email
|
||||||
|
if "phone" in data:
|
||||||
|
data["phone"] = _normalize_optional_string(_normalize_client_phone(data.get("phone")))
|
||||||
if "avatar_url" in data:
|
if "avatar_url" in data:
|
||||||
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
|
data["avatar_url"] = _normalize_optional_string(data.get("avatar_url"))
|
||||||
if "primary_topic_code" in data:
|
if "primary_topic_code" in data:
|
||||||
|
|
@ -936,6 +966,88 @@ def _apply_topic_data_templates_fields(db: Session, payload: dict[str, Any]) ->
|
||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
||||||
data["key"] = key
|
data["key"] = key
|
||||||
|
if "value_type" in data:
|
||||||
|
value_type = str(data.get("value_type") or "").strip().lower()
|
||||||
|
if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file')
|
||||||
|
data["value_type"] = value_type
|
||||||
|
if "document_name" in data:
|
||||||
|
data["document_name"] = _normalize_optional_string(data.get("document_name"))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_request_data_templates_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
if "topic_code" in data:
|
||||||
|
topic_code = str(data.get("topic_code") or "").strip()
|
||||||
|
if not topic_code:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "topic_code" не может быть пустым')
|
||||||
|
_ensure_topic_exists_or_400(db, topic_code)
|
||||||
|
data["topic_code"] = topic_code
|
||||||
|
if "name" in data:
|
||||||
|
name = str(data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "name" не может быть пустым')
|
||||||
|
data["name"] = name
|
||||||
|
if "description" in data:
|
||||||
|
data["description"] = _normalize_optional_string(data.get("description"))
|
||||||
|
if "created_by_admin_id" in data and data.get("created_by_admin_id") is not None:
|
||||||
|
admin_id = _parse_uuid_or_400(data.get("created_by_admin_id"), "created_by_admin_id")
|
||||||
|
admin_user = db.get(AdminUser, admin_id)
|
||||||
|
if admin_user is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||||
|
data["created_by_admin_id"] = admin_id
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_request_data_template_items_fields(db: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
template = None
|
||||||
|
if "request_data_template_id" in data:
|
||||||
|
template_id = _parse_uuid_or_400(data.get("request_data_template_id"), "request_data_template_id")
|
||||||
|
template = db.get(RequestDataTemplate, template_id)
|
||||||
|
if template is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Шаблон не найден")
|
||||||
|
data["request_data_template_id"] = template_id
|
||||||
|
if "topic_data_template_id" in data and data.get("topic_data_template_id") is not None:
|
||||||
|
catalog_id = _parse_uuid_or_400(data.get("topic_data_template_id"), "topic_data_template_id")
|
||||||
|
catalog = db.get(TopicDataTemplate, catalog_id)
|
||||||
|
if catalog is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Поле доп. данных не найдено")
|
||||||
|
data["topic_data_template_id"] = catalog_id
|
||||||
|
if "key" not in data or not str(data.get("key") or "").strip():
|
||||||
|
data["key"] = str(catalog.key or "").strip()
|
||||||
|
if "label" not in data or not str(data.get("label") or "").strip():
|
||||||
|
data["label"] = str(catalog.label or catalog.key or "").strip()
|
||||||
|
if "value_type" not in data or not str(data.get("value_type") or "").strip():
|
||||||
|
data["value_type"] = str(catalog.value_type or "string")
|
||||||
|
if template is not None and str(template.topic_code or "").strip() and str(catalog.topic_code or "").strip():
|
||||||
|
if str(template.topic_code) != str(catalog.topic_code):
|
||||||
|
raise HTTPException(status_code=400, detail="Поле не соответствует теме шаблона")
|
||||||
|
if "key" in data:
|
||||||
|
key = str(data.get("key") or "").strip()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
||||||
|
data["key"] = key[:80]
|
||||||
|
if "label" in data:
|
||||||
|
label = str(data.get("label") or "").strip()
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "label" не может быть пустым')
|
||||||
|
data["label"] = label
|
||||||
|
if "value_type" in data:
|
||||||
|
value_type = str(data.get("value_type") or "").strip().lower()
|
||||||
|
if value_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "value_type" должно быть одним из: string, text, date, number, file')
|
||||||
|
data["value_type"] = value_type
|
||||||
|
if "sort_order" in data:
|
||||||
|
raw = data.get("sort_order")
|
||||||
|
if raw is None or str(raw).strip() == "":
|
||||||
|
data["sort_order"] = 0
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data["sort_order"] = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом')
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -953,11 +1065,31 @@ def _apply_request_data_requirements_fields(db: Session, payload: dict[str, Any]
|
||||||
if template is None:
|
if template is None:
|
||||||
raise HTTPException(status_code=400, detail="Шаблон темы не найден")
|
raise HTTPException(status_code=400, detail="Шаблон темы не найден")
|
||||||
data["topic_template_id"] = template_id
|
data["topic_template_id"] = template_id
|
||||||
|
if "request_message_id" in data and data.get("request_message_id") is not None:
|
||||||
|
data["request_message_id"] = _parse_uuid_or_400(data.get("request_message_id"), "request_message_id")
|
||||||
if "key" in data:
|
if "key" in data:
|
||||||
key = str(data.get("key") or "").strip()
|
key = str(data.get("key") or "").strip()
|
||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
raise HTTPException(status_code=400, detail='Поле "key" не может быть пустым')
|
||||||
data["key"] = key
|
data["key"] = key
|
||||||
|
if "field_type" in data:
|
||||||
|
field_type = str(data.get("field_type") or "").strip().lower()
|
||||||
|
if field_type not in ALLOWED_REQUEST_DATA_VALUE_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "field_type" должно быть одним из: string, text, date, number, file')
|
||||||
|
data["field_type"] = field_type
|
||||||
|
if "document_name" in data:
|
||||||
|
data["document_name"] = _normalize_optional_string(data.get("document_name"))
|
||||||
|
if "value_text" in data:
|
||||||
|
data["value_text"] = _normalize_optional_string(data.get("value_text"))
|
||||||
|
if "sort_order" in data:
|
||||||
|
raw_sort = data.get("sort_order")
|
||||||
|
if raw_sort is None or str(raw_sort).strip() == "":
|
||||||
|
data["sort_order"] = 0
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data["sort_order"] = int(raw_sort)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "sort_order" должно быть целым числом')
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1416,7 +1548,20 @@ def get_row(
|
||||||
if normalized == "attachments" and isinstance(row, Attachment):
|
if normalized == "attachments" and isinstance(row, Attachment):
|
||||||
req = _request_for_related_row_or_404(db, row)
|
req = _request_for_related_row_or_404(db, row)
|
||||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||||
return _strip_hidden_fields(normalized, _row_to_dict(row))
|
payload = _strip_hidden_fields(normalized, _row_to_dict(row))
|
||||||
|
if normalized == "requests" and isinstance(row, Request):
|
||||||
|
assigned_lawyer_id = str(row.assigned_lawyer_id or "").strip()
|
||||||
|
if assigned_lawyer_id:
|
||||||
|
try:
|
||||||
|
lawyer_uuid = uuid.UUID(assigned_lawyer_id)
|
||||||
|
except ValueError:
|
||||||
|
lawyer_uuid = None
|
||||||
|
if lawyer_uuid is not None:
|
||||||
|
lawyer = db.get(AdminUser, lawyer_uuid)
|
||||||
|
if lawyer is not None:
|
||||||
|
payload["assigned_lawyer_name"] = lawyer.name or lawyer.email or assigned_lawyer_id
|
||||||
|
payload["assigned_lawyer_phone"] = _serialize_value(getattr(lawyer, "phone", None))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{table_name}", status_code=201)
|
@router.post("/{table_name}", status_code=201)
|
||||||
|
|
@ -1490,6 +1635,10 @@ def create_row(
|
||||||
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
|
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
|
||||||
if normalized == "topic_data_templates":
|
if normalized == "topic_data_templates":
|
||||||
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
|
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
|
||||||
|
if normalized == "request_data_templates":
|
||||||
|
clean_payload = _apply_request_data_templates_fields(db, clean_payload)
|
||||||
|
if normalized == "request_data_template_items":
|
||||||
|
clean_payload = _apply_request_data_template_items_fields(db, clean_payload)
|
||||||
if normalized == "request_data_requirements":
|
if normalized == "request_data_requirements":
|
||||||
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
||||||
if normalized == "topic_status_transitions":
|
if normalized == "topic_status_transitions":
|
||||||
|
|
@ -1557,6 +1706,10 @@ def update_row(
|
||||||
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
|
clean_payload = _apply_topic_required_fields_fields(db, clean_payload)
|
||||||
if normalized == "topic_data_templates":
|
if normalized == "topic_data_templates":
|
||||||
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
|
clean_payload = _apply_topic_data_templates_fields(db, clean_payload)
|
||||||
|
if normalized == "request_data_templates":
|
||||||
|
clean_payload = _apply_request_data_templates_fields(db, clean_payload)
|
||||||
|
if normalized == "request_data_template_items":
|
||||||
|
clean_payload = _apply_request_data_template_items_fields(db, clean_payload)
|
||||||
if normalized == "request_data_requirements":
|
if normalized == "request_data_requirements":
|
||||||
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
||||||
if normalized == "topic_status_transitions":
|
if normalized == "topic_status_transitions":
|
||||||
|
|
@ -1603,23 +1756,9 @@ def update_row(
|
||||||
if normalized == "requests" and "status_code" in clean_payload:
|
if normalized == "requests" and "status_code" in clean_payload:
|
||||||
before_status = str(before.get("status_code") or "")
|
before_status = str(before.get("status_code") or "")
|
||||||
after_status = str(clean_payload.get("status_code") or "")
|
after_status = str(clean_payload.get("status_code") or "")
|
||||||
topic_code = str(before.get("topic_code") or "").strip() or None
|
|
||||||
if not transition_allowed_for_topic(db, topic_code, before_status, after_status):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Переход статуса не разрешен для выбранной темы",
|
|
||||||
)
|
|
||||||
if before_status != after_status and isinstance(row, Request):
|
if before_status != after_status and isinstance(row, Request):
|
||||||
extra_fields_override = clean_payload.get("extra_fields")
|
if "important_date_at" not in clean_payload or clean_payload.get("important_date_at") is None:
|
||||||
if not isinstance(extra_fields_override, dict):
|
clean_payload["important_date_at"] = datetime.now(timezone.utc) + timedelta(days=3)
|
||||||
extra_fields_override = row.extra_fields if isinstance(row.extra_fields, dict) else None
|
|
||||||
validate_transition_requirements_or_400(
|
|
||||||
db,
|
|
||||||
row,
|
|
||||||
from_status=before_status,
|
|
||||||
to_status=after_status,
|
|
||||||
extra_fields_override=extra_fields_override,
|
|
||||||
)
|
|
||||||
billing_note = apply_billing_transition_effects(
|
billing_note = apply_billing_transition_effects(
|
||||||
db,
|
db,
|
||||||
req=row,
|
req=row,
|
||||||
|
|
@ -1635,6 +1774,7 @@ def update_row(
|
||||||
from_status=before_status,
|
from_status=before_status,
|
||||||
to_status=after_status,
|
to_status=after_status,
|
||||||
admin=admin,
|
admin=admin,
|
||||||
|
important_date_at=clean_payload.get("important_date_at"),
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
notify_request_event(
|
notify_request_event(
|
||||||
|
|
@ -1643,7 +1783,15 @@ def update_row(
|
||||||
event_type=NOTIFICATION_EVENT_STATUS,
|
event_type=NOTIFICATION_EVENT_STATUS,
|
||||||
actor_role=_actor_role(admin),
|
actor_role=_actor_role(admin),
|
||||||
actor_admin_user_id=admin.get("sub"),
|
actor_admin_user_id=admin.get("sub"),
|
||||||
body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")),
|
body=(
|
||||||
|
f"{before_status} -> {after_status}"
|
||||||
|
+ (
|
||||||
|
f"\nВажная дата: {clean_payload.get('important_date_at').isoformat()}"
|
||||||
|
if isinstance(clean_payload.get("important_date_at"), datetime)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
+ (f"\n{billing_note}" if billing_note else "")
|
||||||
|
),
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
for key, value in clean_payload.items():
|
for key, value in clean_payload.items():
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
|
|
@ -59,6 +60,33 @@ def _uuid_or_none(value: str | None) -> UUID | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_assigned_lawyer_from_audit(diff: dict | None, action: str | None) -> str | None:
|
||||||
|
if not isinstance(diff, dict):
|
||||||
|
return None
|
||||||
|
action_code = str(action or "").upper()
|
||||||
|
if action_code == "MANUAL_CLAIM":
|
||||||
|
value = diff.get("assigned_lawyer_id")
|
||||||
|
return str(value).strip() if value else None
|
||||||
|
if action_code == "MANUAL_REASSIGN":
|
||||||
|
value = diff.get("to_lawyer_id")
|
||||||
|
return str(value).strip() if value else None
|
||||||
|
if action_code in {"CREATE", "UPDATE"}:
|
||||||
|
after = diff.get("after")
|
||||||
|
before = diff.get("before")
|
||||||
|
if action_code == "UPDATE":
|
||||||
|
if not isinstance(after, dict) or not isinstance(before, dict):
|
||||||
|
return None
|
||||||
|
prev_value = str(before.get("assigned_lawyer_id") or "").strip()
|
||||||
|
next_value = str(after.get("assigned_lawyer_id") or "").strip()
|
||||||
|
if not next_value or next_value == prev_value:
|
||||||
|
return None
|
||||||
|
return next_value
|
||||||
|
if isinstance(after, dict):
|
||||||
|
value = str(after.get("assigned_lawyer_id") or "").strip()
|
||||||
|
return value or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview")
|
@router.get("/overview")
|
||||||
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
||||||
role = str(admin.get("role") or "").upper()
|
role = str(admin.get("role") or "").upper()
|
||||||
|
|
@ -123,6 +151,32 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
paid_events_map = {str(lawyer_id): int(events) for lawyer_id, events, _ in paid_rows if lawyer_id}
|
paid_events_map = {str(lawyer_id): int(events) for lawyer_id, events, _ in paid_rows if lawyer_id}
|
||||||
monthly_gross_map = {str(lawyer_id): _to_float(gross) for lawyer_id, _, gross in paid_rows if lawyer_id}
|
monthly_gross_map = {str(lawyer_id): _to_float(gross) for lawyer_id, _, gross in paid_rows if lawyer_id}
|
||||||
|
|
||||||
|
monthly_completed_rows = (
|
||||||
|
db.query(Request.assigned_lawyer_id, func.count(func.distinct(StatusHistory.request_id)))
|
||||||
|
.join(StatusHistory, StatusHistory.request_id == Request.id)
|
||||||
|
.filter(Request.assigned_lawyer_id.is_not(None))
|
||||||
|
.filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start)
|
||||||
|
.filter(func.upper(StatusHistory.to_status).in_({str(code).upper() for code in terminal_codes}))
|
||||||
|
.group_by(Request.assigned_lawyer_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
monthly_completed_map = {str(lawyer_id): int(count) for lawyer_id, count in monthly_completed_rows if lawyer_id}
|
||||||
|
|
||||||
|
monthly_assigned_map: dict[str, int] = {}
|
||||||
|
audit_rows = (
|
||||||
|
db.query(AuditLog.action, AuditLog.diff)
|
||||||
|
.filter(AuditLog.entity == "requests")
|
||||||
|
.filter(AuditLog.created_at >= month_start, AuditLog.created_at < next_month_start)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for action, diff in audit_rows:
|
||||||
|
assigned_to = _extract_assigned_lawyer_from_audit(diff, action)
|
||||||
|
if not assigned_to:
|
||||||
|
continue
|
||||||
|
monthly_assigned_map[assigned_to] = int(monthly_assigned_map.get(assigned_to, 0)) + 1
|
||||||
|
|
||||||
|
monthly_revenue = round(sum(monthly_gross_map.values()), 2)
|
||||||
|
|
||||||
lawyers = (
|
lawyers = (
|
||||||
db.query(AdminUser)
|
db.query(AdminUser)
|
||||||
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
|
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
|
||||||
|
|
@ -146,6 +200,8 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
"active_load": active_load_map.get(lawyer_id, 0),
|
"active_load": active_load_map.get(lawyer_id, 0),
|
||||||
"total_assigned": total_load_map.get(lawyer_id, 0),
|
"total_assigned": total_load_map.get(lawyer_id, 0),
|
||||||
"active_amount": round(active_amount_map.get(lawyer_id, 0.0), 2),
|
"active_amount": round(active_amount_map.get(lawyer_id, 0.0), 2),
|
||||||
|
"monthly_assigned_count": monthly_assigned_map.get(lawyer_id, 0),
|
||||||
|
"monthly_completed_count": monthly_completed_map.get(lawyer_id, 0),
|
||||||
"monthly_paid_events": paid_events_map.get(lawyer_id, 0),
|
"monthly_paid_events": paid_events_map.get(lawyer_id, 0),
|
||||||
"monthly_paid_gross": round(monthly_paid_gross, 2),
|
"monthly_paid_gross": round(monthly_paid_gross, 2),
|
||||||
"monthly_salary": round(monthly_salary, 2),
|
"monthly_salary": round(monthly_salary, 2),
|
||||||
|
|
@ -221,6 +277,18 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
scoped_lawyer_loads = lawyer_loads
|
scoped_lawyer_loads = lawyer_loads
|
||||||
|
|
||||||
sla_snapshot = compute_sla_snapshot(db)
|
sla_snapshot = compute_sla_snapshot(db)
|
||||||
|
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
|
deadline_alert_query = (
|
||||||
|
db.query(func.count(Request.id))
|
||||||
|
.filter(Request.important_date_at.is_not(None))
|
||||||
|
.filter(Request.important_date_at < next_day_start)
|
||||||
|
.filter(Request.status_code.notin_(terminal_codes))
|
||||||
|
)
|
||||||
|
if role == "LAWYER" and actor_uuid is not None:
|
||||||
|
deadline_alert_query = deadline_alert_query.filter(Request.assigned_lawyer_id == str(actor_uuid))
|
||||||
|
elif role == "LAWYER":
|
||||||
|
deadline_alert_query = deadline_alert_query.filter(Request.id.is_(None))
|
||||||
|
deadline_alert_total = int(deadline_alert_query.scalar() or 0)
|
||||||
return {
|
return {
|
||||||
"scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN",
|
"scope": role if role in {"ADMIN", "LAWYER"} else "ADMIN",
|
||||||
"new": int(by_status.get("NEW", 0)),
|
"new": int(by_status.get("NEW", 0)),
|
||||||
|
|
@ -230,6 +298,11 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
"unassigned_total": unassigned_total,
|
"unassigned_total": unassigned_total,
|
||||||
"my_unread_updates": my_unread_updates,
|
"my_unread_updates": my_unread_updates,
|
||||||
"my_unread_by_event": my_unread_by_event,
|
"my_unread_by_event": my_unread_by_event,
|
||||||
|
"deadline_alert_total": deadline_alert_total,
|
||||||
|
"month_revenue": monthly_revenue,
|
||||||
|
"month_expenses": round(sum(_to_float(row.get("monthly_salary")) for row in scoped_lawyer_loads), 2)
|
||||||
|
if role == "LAWYER"
|
||||||
|
else round(sum(_to_float(row.get("monthly_salary")) for row in lawyer_loads), 2),
|
||||||
"frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"),
|
"frt_avg_minutes": sla_snapshot.get("frt_avg_minutes"),
|
||||||
"sla_overdue": sla_snapshot.get("overdue_total", 0),
|
"sla_overdue": sla_snapshot.get("overdue_total", 0),
|
||||||
"overdue_by_status": sla_snapshot.get("overdue_by_status", {}),
|
"overdue_by_status": sla_snapshot.get("overdue_by_status", {}),
|
||||||
|
|
@ -239,3 +312,79 @@ def overview(db: Session = Depends(get_db), admin=Depends(require_role("ADMIN",
|
||||||
"unread_for_lawyers": int(unread_for_lawyers),
|
"unread_for_lawyers": int(unread_for_lawyers),
|
||||||
"lawyer_loads": scoped_lawyer_loads,
|
"lawyer_loads": scoped_lawyer_loads,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lawyers/{lawyer_id}/active-requests")
|
||||||
|
def lawyer_active_requests(
|
||||||
|
lawyer_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
actor_role = str(admin.get("role") or "").upper()
|
||||||
|
actor_id = str(admin.get("sub") or "").strip()
|
||||||
|
if actor_role == "LAWYER" and str(lawyer_id) != actor_id:
|
||||||
|
return {"rows": [], "total": 0, "totals": {"amount": 0.0, "salary": 0.0}}
|
||||||
|
|
||||||
|
lawyer = db.query(AdminUser).filter(AdminUser.id == _uuid_or_none(lawyer_id), AdminUser.role == "LAWYER").first()
|
||||||
|
if lawyer is None:
|
||||||
|
return {"rows": [], "total": 0, "totals": {"amount": 0.0, "salary": 0.0}}
|
||||||
|
|
||||||
|
terminal_codes = _terminal_status_codes(db)
|
||||||
|
paid_codes = _paid_status_codes()
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
month_start, next_month_start = _month_bounds(now_utc)
|
||||||
|
|
||||||
|
salary_percent = _to_float(lawyer.salary_percent)
|
||||||
|
paid_by_request_rows = (
|
||||||
|
db.query(StatusHistory.request_id, func.count(StatusHistory.id))
|
||||||
|
.join(Request, Request.id == StatusHistory.request_id)
|
||||||
|
.filter(Request.assigned_lawyer_id == str(lawyer.id))
|
||||||
|
.filter(StatusHistory.created_at >= month_start, StatusHistory.created_at < next_month_start)
|
||||||
|
.filter(func.upper(StatusHistory.to_status).in_(paid_codes))
|
||||||
|
.group_by(StatusHistory.request_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
paid_events_per_request = {str(req_id): int(count) for req_id, count in paid_by_request_rows if req_id}
|
||||||
|
|
||||||
|
request_rows = (
|
||||||
|
db.query(Request)
|
||||||
|
.filter(Request.assigned_lawyer_id == str(lawyer.id))
|
||||||
|
.filter(Request.status_code.notin_(terminal_codes))
|
||||||
|
.order_by(Request.created_at.desc(), Request.track_number.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
total_amount = 0.0
|
||||||
|
total_salary = 0.0
|
||||||
|
for req in request_rows:
|
||||||
|
req_id = str(req.id)
|
||||||
|
invoice_amount = _to_float(req.invoice_amount)
|
||||||
|
paid_events = int(paid_events_per_request.get(req_id, 0))
|
||||||
|
month_paid_amount = round(invoice_amount * paid_events, 2)
|
||||||
|
month_salary_amount = round(month_paid_amount * salary_percent / 100.0, 2)
|
||||||
|
total_amount += month_paid_amount
|
||||||
|
total_salary += month_salary_amount
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": req_id,
|
||||||
|
"track_number": req.track_number,
|
||||||
|
"status_code": req.status_code,
|
||||||
|
"client_name": req.client_name,
|
||||||
|
"invoice_amount": round(invoice_amount, 2),
|
||||||
|
"month_paid_events": paid_events,
|
||||||
|
"month_paid_amount": month_paid_amount,
|
||||||
|
"month_salary_amount": month_salary_amount,
|
||||||
|
"created_at": req.created_at.isoformat() if req.created_at else None,
|
||||||
|
"paid_at": req.paid_at.isoformat() if req.paid_at else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rows": rows,
|
||||||
|
"total": len(rows),
|
||||||
|
"totals": {
|
||||||
|
"amount": round(total_amount, 2),
|
||||||
|
"salary": round(total_salary, 2),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,17 @@ from app.schemas.admin import (
|
||||||
RequestDataRequirementCreate,
|
RequestDataRequirementCreate,
|
||||||
RequestDataRequirementPatch,
|
RequestDataRequirementPatch,
|
||||||
RequestReassign,
|
RequestReassign,
|
||||||
|
RequestStatusChange,
|
||||||
)
|
)
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.client import Client
|
||||||
from app.models.request_data_requirement import RequestDataRequirement
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
from app.models.status_group import StatusGroup
|
from app.models.status_group import StatusGroup
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
from app.models.topic_data_template import TopicDataTemplate
|
from app.models.topic_data_template import TopicDataTemplate
|
||||||
from app.models.topic_status_transition import TopicStatusTransition
|
|
||||||
from app.services.notifications import (
|
from app.services.notifications import (
|
||||||
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
EVENT_STATUS as NOTIFICATION_EVENT_STATUS,
|
||||||
mark_admin_notifications_read,
|
mark_admin_notifications_read,
|
||||||
|
|
@ -33,9 +34,8 @@ from app.services.notifications import (
|
||||||
)
|
)
|
||||||
from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client
|
from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_lawyer, mark_unread_for_client
|
||||||
from app.services.request_status import actor_admin_uuid, apply_status_change_effects
|
from app.services.request_status import actor_admin_uuid, apply_status_change_effects
|
||||||
from app.services.status_flow import transition_allowed_for_topic
|
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
from app.services.status_transition_requirements import normalize_string_list, validate_transition_requirements_or_400
|
from app.services.status_transition_requirements import normalize_string_list
|
||||||
from app.services.billing_flow import apply_billing_transition_effects
|
from app.services.billing_flow import apply_billing_transition_effects
|
||||||
from app.services.universal_query import apply_universal_query
|
from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
|
|
@ -106,6 +106,149 @@ def _parse_datetime_safe(value: object) -> datetime | None:
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_important_date_or_default(raw: object, *, default_days: int = 3) -> datetime:
|
||||||
|
parsed = _parse_datetime_safe(raw)
|
||||||
|
if parsed:
|
||||||
|
return parsed
|
||||||
|
return datetime.now(timezone.utc) + timedelta(days=default_days)
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_status_codes(db: Session) -> set[str]:
|
||||||
|
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
|
||||||
|
codes = {str(code or "").strip() for (code,) in rows if str(code or "").strip()}
|
||||||
|
return codes or {"RESOLVED", "CLOSED", "REJECTED"}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_request_bool_filter_or_400(value: object) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
if text in {"1", "true", "yes", "y", "да"}:
|
||||||
|
return True
|
||||||
|
if text in {"0", "false", "no", "n", "нет"}:
|
||||||
|
return False
|
||||||
|
raise HTTPException(status_code=400, detail="Значение фильтра должно быть boolean")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_request_special_filters(uq: UniversalQuery) -> tuple[UniversalQuery, list[FilterClause]]:
|
||||||
|
filters = list(uq.filters or [])
|
||||||
|
special: list[FilterClause] = []
|
||||||
|
regular: list[FilterClause] = []
|
||||||
|
for clause in filters:
|
||||||
|
field = str(getattr(clause, "field", "") or "").strip()
|
||||||
|
if field in {"has_unread_updates", "deadline_alert"}:
|
||||||
|
special.append(clause)
|
||||||
|
else:
|
||||||
|
regular.append(clause)
|
||||||
|
return UniversalQuery(filters=regular, sort=list(uq.sort or []), page=uq.page), special
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_request_special_filters(
|
||||||
|
base_query,
|
||||||
|
*,
|
||||||
|
db: Session,
|
||||||
|
role: str,
|
||||||
|
actor_id: str,
|
||||||
|
special_filters: list[FilterClause],
|
||||||
|
):
|
||||||
|
if not special_filters:
|
||||||
|
return base_query
|
||||||
|
terminal_codes_cache: set[str] | None = None
|
||||||
|
for clause in special_filters:
|
||||||
|
field = str(clause.field or "").strip()
|
||||||
|
op = str(clause.op or "").strip()
|
||||||
|
if op not in {"=", "!="}:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Оператор "{op}" не поддерживается для фильтра "{field}"')
|
||||||
|
expected = _coerce_request_bool_filter_or_400(clause.value)
|
||||||
|
if field == "has_unread_updates":
|
||||||
|
if role == "LAWYER":
|
||||||
|
expr = Request.lawyer_has_unread_updates.is_(True)
|
||||||
|
else:
|
||||||
|
expr = or_(
|
||||||
|
Request.lawyer_has_unread_updates.is_(True),
|
||||||
|
Request.client_has_unread_updates.is_(True),
|
||||||
|
)
|
||||||
|
elif field == "deadline_alert":
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
next_day_start = datetime(now_utc.year, now_utc.month, now_utc.day, tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
|
if terminal_codes_cache is None:
|
||||||
|
terminal_codes_cache = _terminal_status_codes(db)
|
||||||
|
expr = (
|
||||||
|
Request.important_date_at.is_not(None)
|
||||||
|
& (Request.important_date_at < next_day_start)
|
||||||
|
& (Request.status_code.notin_(terminal_codes_cache))
|
||||||
|
)
|
||||||
|
if role == "LAWYER":
|
||||||
|
expr = expr & (Request.assigned_lawyer_id == actor_id)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
base_query = base_query.filter(expr if expected else ~expr)
|
||||||
|
return base_query
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_client_phone(value: object) -> str:
|
||||||
|
text = "".join(ch for ch in str(value or "") if ch.isdigit() or ch == "+")
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if text.startswith("8") and len(text) == 11:
|
||||||
|
text = "+7" + text[1:]
|
||||||
|
if not text.startswith("+") and text.isdigit():
|
||||||
|
text = "+" + text
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _client_uuid_or_none(value: object) -> UUID | None:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return UUID(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail='Некорректный "client_id"')
|
||||||
|
|
||||||
|
|
||||||
|
def _client_for_request_payload_or_400(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
client_id: object,
|
||||||
|
client_name: object,
|
||||||
|
client_phone: object,
|
||||||
|
responsible: str,
|
||||||
|
) -> Client:
|
||||||
|
client_uuid = _client_uuid_or_none(client_id)
|
||||||
|
if client_uuid is not None:
|
||||||
|
row = db.get(Client, client_uuid)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Клиент не найден")
|
||||||
|
return row
|
||||||
|
|
||||||
|
normalized_phone = _normalize_client_phone(client_phone)
|
||||||
|
if not normalized_phone:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно')
|
||||||
|
normalized_name = str(client_name or "").strip() or "Клиент"
|
||||||
|
|
||||||
|
row = db.query(Client).filter(Client.phone == normalized_phone).first()
|
||||||
|
if row is None:
|
||||||
|
row = Client(
|
||||||
|
full_name=normalized_name,
|
||||||
|
phone=normalized_phone,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
if normalized_name and row.full_name != normalized_name:
|
||||||
|
row.full_name = normalized_name
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
row.responsible = responsible
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
def _extract_case_deadline(extra_fields: object) -> datetime | None:
|
def _extract_case_deadline(extra_fields: object) -> datetime | None:
|
||||||
if not isinstance(extra_fields, dict):
|
if not isinstance(extra_fields, dict):
|
||||||
return None
|
return None
|
||||||
|
|
@ -281,8 +424,8 @@ def _request_data_requirement_row(row: RequestDataRequirement) -> dict:
|
||||||
def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
|
def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN","LAWYER"))):
|
||||||
base_query = db.query(Request)
|
base_query = db.query(Request)
|
||||||
role = str(admin.get("role") or "").upper()
|
role = str(admin.get("role") or "").upper()
|
||||||
|
actor = str(admin.get("sub") or "").strip()
|
||||||
if role == "LAWYER":
|
if role == "LAWYER":
|
||||||
actor = str(admin.get("sub") or "").strip()
|
|
||||||
if not actor:
|
if not actor:
|
||||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
base_query = base_query.filter(
|
base_query = base_query.filter(
|
||||||
|
|
@ -292,7 +435,15 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
q = apply_universal_query(base_query, Request, uq)
|
regular_uq, special_filters = _split_request_special_filters(uq)
|
||||||
|
base_query = _apply_request_special_filters(
|
||||||
|
base_query,
|
||||||
|
db=db,
|
||||||
|
role=role,
|
||||||
|
actor_id=actor,
|
||||||
|
special_filters=special_filters,
|
||||||
|
)
|
||||||
|
q = apply_universal_query(base_query, Request, regular_uq)
|
||||||
total = q.count()
|
total = q.count()
|
||||||
rows = q.offset(uq.page.offset).limit(uq.page.limit).all()
|
rows = q.offset(uq.page.offset).limit(uq.page.limit).all()
|
||||||
return {
|
return {
|
||||||
|
|
@ -300,11 +451,14 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
|
||||||
{
|
{
|
||||||
"id": str(r.id),
|
"id": str(r.id),
|
||||||
"track_number": r.track_number,
|
"track_number": r.track_number,
|
||||||
|
"client_id": str(r.client_id) if r.client_id else None,
|
||||||
"status_code": r.status_code,
|
"status_code": r.status_code,
|
||||||
"client_name": r.client_name,
|
"client_name": r.client_name,
|
||||||
"client_phone": r.client_phone,
|
"client_phone": r.client_phone,
|
||||||
"topic_code": r.topic_code,
|
"topic_code": r.topic_code,
|
||||||
|
"important_date_at": r.important_date_at.isoformat() if r.important_date_at else None,
|
||||||
"effective_rate": float(r.effective_rate) if r.effective_rate is not None else None,
|
"effective_rate": float(r.effective_rate) if r.effective_rate is not None else None,
|
||||||
|
"request_cost": float(r.request_cost) if r.request_cost is not None else None,
|
||||||
"invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None,
|
"invoice_amount": float(r.invoice_amount) if r.invoice_amount is not None else None,
|
||||||
"paid_at": r.paid_at.isoformat() if r.paid_at else None,
|
"paid_at": r.paid_at.isoformat() if r.paid_at else None,
|
||||||
"paid_by_admin_id": r.paid_by_admin_id,
|
"paid_by_admin_id": r.paid_by_admin_id,
|
||||||
|
|
@ -360,33 +514,8 @@ def get_requests_kanban(
|
||||||
|
|
||||||
request_id_to_row = {str(row.id): row for row in request_rows}
|
request_id_to_row = {str(row.id): row for row in request_rows}
|
||||||
request_ids = [row.id for row in request_rows]
|
request_ids = [row.id for row in request_rows]
|
||||||
topic_codes = {str(row.topic_code or "").strip() for row in request_rows if str(row.topic_code or "").strip()}
|
|
||||||
status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()}
|
status_codes = {str(row.status_code or "").strip() for row in request_rows if str(row.status_code or "").strip()}
|
||||||
|
|
||||||
transition_rows: list[TopicStatusTransition] = []
|
|
||||||
if topic_codes:
|
|
||||||
transition_rows = (
|
|
||||||
db.query(TopicStatusTransition)
|
|
||||||
.filter(
|
|
||||||
TopicStatusTransition.enabled.is_(True),
|
|
||||||
TopicStatusTransition.topic_code.in_(list(topic_codes)),
|
|
||||||
)
|
|
||||||
.order_by(
|
|
||||||
TopicStatusTransition.topic_code.asc(),
|
|
||||||
TopicStatusTransition.from_status.asc(),
|
|
||||||
TopicStatusTransition.sort_order.asc(),
|
|
||||||
TopicStatusTransition.to_status.asc(),
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
for row in transition_rows:
|
|
||||||
from_code = str(row.from_status or "").strip()
|
|
||||||
to_code = str(row.to_status or "").strip()
|
|
||||||
if from_code:
|
|
||||||
status_codes.add(from_code)
|
|
||||||
if to_code:
|
|
||||||
status_codes.add(to_code)
|
|
||||||
|
|
||||||
status_meta_map: dict[str, dict[str, object]] = {}
|
status_meta_map: dict[str, dict[str, object]] = {}
|
||||||
if status_codes:
|
if status_codes:
|
||||||
status_rows = (
|
status_rows = (
|
||||||
|
|
@ -448,16 +577,30 @@ def get_requests_kanban(
|
||||||
current_status_changed_at[request_id] = row.created_at
|
current_status_changed_at[request_id] = row.created_at
|
||||||
previous_status_by_request[request_id] = str(row.from_status or "").strip()
|
previous_status_by_request[request_id] = str(row.from_status or "").strip()
|
||||||
|
|
||||||
transitions_by_key: dict[tuple[str, str], list[TopicStatusTransition]] = {}
|
all_enabled_status_rows = (
|
||||||
transitions_to_key: dict[tuple[str, str], list[TopicStatusTransition]] = {}
|
db.query(Status, StatusGroup)
|
||||||
for row in transition_rows:
|
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
|
||||||
topic_code = str(row.topic_code or "").strip()
|
.filter(Status.enabled.is_(True))
|
||||||
from_status = str(row.from_status or "").strip()
|
.order_by(Status.sort_order.asc(), Status.name.asc(), Status.code.asc())
|
||||||
to_status = str(row.to_status or "").strip()
|
.all()
|
||||||
if not topic_code or not from_status or not to_status:
|
)
|
||||||
|
all_enabled_statuses: list[dict[str, object]] = []
|
||||||
|
for status_row, group_row in all_enabled_status_rows:
|
||||||
|
code = str(status_row.code or "").strip()
|
||||||
|
if not code:
|
||||||
continue
|
continue
|
||||||
transitions_by_key.setdefault((topic_code, from_status), []).append(row)
|
meta = {
|
||||||
transitions_to_key.setdefault((topic_code, to_status), []).append(row)
|
"code": code,
|
||||||
|
"name": str(status_row.name or code),
|
||||||
|
"kind": str(status_row.kind or "DEFAULT"),
|
||||||
|
"is_terminal": bool(status_row.is_terminal),
|
||||||
|
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
|
||||||
|
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
|
||||||
|
"status_group_order": (int(group_row.sort_order or 0) if group_row is not None else None),
|
||||||
|
"sort_order": int(status_row.sort_order or 0),
|
||||||
|
}
|
||||||
|
status_meta_map.setdefault(code, meta)
|
||||||
|
all_enabled_statuses.append(meta)
|
||||||
|
|
||||||
status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all()
|
status_groups_rows = db.query(StatusGroup).order_by(StatusGroup.sort_order.asc(), StatusGroup.name.asc()).all()
|
||||||
columns_catalog = [
|
columns_catalog = [
|
||||||
|
|
@ -474,7 +617,6 @@ def get_requests_kanban(
|
||||||
group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog}
|
group_totals: dict[str, int] = {row["key"]: 0 for row in columns_catalog}
|
||||||
for row in request_rows:
|
for row in request_rows:
|
||||||
request_id = str(row.id)
|
request_id = str(row.id)
|
||||||
topic_code = str(row.topic_code or "").strip()
|
|
||||||
status_code = str(row.status_code or "").strip()
|
status_code = str(row.status_code or "").strip()
|
||||||
status_meta = _status_meta_or_default(status_meta_map, status_code)
|
status_meta = _status_meta_or_default(status_meta_map, status_code)
|
||||||
status_group = str(status_meta.get("status_group_id") or "").strip()
|
status_group = str(status_meta.get("status_group_id") or "").strip()
|
||||||
|
|
@ -496,9 +638,9 @@ def get_requests_kanban(
|
||||||
}
|
}
|
||||||
columns_catalog.append(columns_by_key[status_group])
|
columns_catalog.append(columns_by_key[status_group])
|
||||||
available_transitions = []
|
available_transitions = []
|
||||||
for transition in transitions_by_key.get((topic_code, status_code), []):
|
for status_def in all_enabled_statuses:
|
||||||
to_status = str(transition.to_status or "").strip()
|
to_status = str(status_def.get("code") or "").strip()
|
||||||
if not to_status:
|
if not to_status or to_status == status_code:
|
||||||
continue
|
continue
|
||||||
to_meta = _status_meta_or_default(status_meta_map, to_status)
|
to_meta = _status_meta_or_default(status_meta_map, to_status)
|
||||||
target_group = str(to_meta.get("status_group_id") or "").strip()
|
target_group = str(to_meta.get("status_group_id") or "").strip()
|
||||||
|
|
@ -514,26 +656,12 @@ def get_requests_kanban(
|
||||||
"to_status": to_status,
|
"to_status": to_status,
|
||||||
"to_status_name": str(to_meta.get("name") or to_status),
|
"to_status_name": str(to_meta.get("name") or to_status),
|
||||||
"target_group": target_group,
|
"target_group": target_group,
|
||||||
"sla_hours": transition.sla_hours,
|
"is_terminal": bool(to_meta.get("is_terminal")),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
case_deadline = _extract_case_deadline(row.extra_fields)
|
case_deadline = row.important_date_at or _extract_case_deadline(row.extra_fields)
|
||||||
entered_at = current_status_changed_at.get(request_id) or row.created_at
|
|
||||||
previous_status = previous_status_by_request.get(request_id)
|
|
||||||
transition_candidates = transitions_to_key.get((topic_code, status_code), [])
|
|
||||||
matched_transition = None
|
|
||||||
if previous_status:
|
|
||||||
for transition in transition_candidates:
|
|
||||||
if str(transition.from_status or "").strip() == previous_status:
|
|
||||||
matched_transition = transition
|
|
||||||
break
|
|
||||||
if matched_transition is None and transition_candidates:
|
|
||||||
matched_transition = transition_candidates[0]
|
|
||||||
|
|
||||||
sla_deadline = None
|
sla_deadline = None
|
||||||
if entered_at and matched_transition and matched_transition.sla_hours is not None:
|
|
||||||
sla_deadline = entered_at + timedelta(hours=int(matched_transition.sla_hours))
|
|
||||||
|
|
||||||
assigned_id = str(row.assigned_lawyer_id or "").strip() or None
|
assigned_id = str(row.assigned_lawyer_id or "").strip() or None
|
||||||
items.append(
|
items.append(
|
||||||
|
|
@ -544,6 +672,7 @@ def get_requests_kanban(
|
||||||
"client_phone": row.client_phone,
|
"client_phone": row.client_phone,
|
||||||
"topic_code": row.topic_code,
|
"topic_code": row.topic_code,
|
||||||
"status_code": status_code,
|
"status_code": status_code,
|
||||||
|
"important_date_at": row.important_date_at.isoformat() if row.important_date_at else None,
|
||||||
"status_name": str(status_meta.get("name") or status_code),
|
"status_name": str(status_meta.get("name") or status_code),
|
||||||
"status_group": status_group,
|
"status_group": status_group,
|
||||||
"status_group_name": status_group_name or None,
|
"status_group_name": status_group_name or None,
|
||||||
|
|
@ -618,6 +747,13 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a
|
||||||
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
||||||
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
|
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
|
||||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
client = _client_for_request_payload_or_400(
|
||||||
|
db,
|
||||||
|
client_id=payload.client_id,
|
||||||
|
client_name=payload.client_name,
|
||||||
|
client_phone=payload.client_phone,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
|
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
|
||||||
effective_rate = payload.effective_rate
|
effective_rate = payload.effective_rate
|
||||||
if assigned_lawyer_id:
|
if assigned_lawyer_id:
|
||||||
|
|
@ -627,14 +763,17 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a
|
||||||
effective_rate = assigned_lawyer.default_rate
|
effective_rate = assigned_lawyer.default_rate
|
||||||
row = Request(
|
row = Request(
|
||||||
track_number=track,
|
track_number=track,
|
||||||
client_name=payload.client_name,
|
client_id=client.id,
|
||||||
client_phone=payload.client_phone,
|
client_name=client.full_name,
|
||||||
|
client_phone=client.phone,
|
||||||
topic_code=payload.topic_code,
|
topic_code=payload.topic_code,
|
||||||
status_code=payload.status_code,
|
status_code=payload.status_code,
|
||||||
|
important_date_at=payload.important_date_at,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
extra_fields=payload.extra_fields,
|
extra_fields=payload.extra_fields,
|
||||||
assigned_lawyer_id=assigned_lawyer_id,
|
assigned_lawyer_id=assigned_lawyer_id,
|
||||||
effective_rate=effective_rate,
|
effective_rate=effective_rate,
|
||||||
|
request_cost=payload.request_cost,
|
||||||
invoice_amount=payload.invoice_amount,
|
invoice_amount=payload.invoice_amount,
|
||||||
paid_at=payload.paid_at,
|
paid_at=payload.paid_at,
|
||||||
paid_by_admin_id=payload.paid_by_admin_id,
|
paid_by_admin_id=payload.paid_by_admin_id,
|
||||||
|
|
@ -682,30 +821,32 @@ def update_request(
|
||||||
changes["effective_rate"] = assigned_lawyer.default_rate
|
changes["effective_rate"] = assigned_lawyer.default_rate
|
||||||
old_status = str(row.status_code or "")
|
old_status = str(row.status_code or "")
|
||||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
if {"client_id", "client_name", "client_phone"}.intersection(set(changes.keys())):
|
||||||
|
client = _client_for_request_payload_or_400(
|
||||||
|
db,
|
||||||
|
client_id=changes.get("client_id", row.client_id),
|
||||||
|
client_name=changes.get("client_name", row.client_name),
|
||||||
|
client_phone=changes.get("client_phone", row.client_phone),
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
changes["client_id"] = client.id
|
||||||
|
changes["client_name"] = client.full_name
|
||||||
|
changes["client_phone"] = client.phone
|
||||||
|
status_changed = "status_code" in changes and str(changes.get("status_code") or "") != old_status
|
||||||
|
if status_changed and ("important_date_at" not in changes or changes.get("important_date_at") is None):
|
||||||
|
changes["important_date_at"] = _normalize_important_date_or_default(None)
|
||||||
for key, value in changes.items():
|
for key, value in changes.items():
|
||||||
setattr(row, key, value)
|
setattr(row, key, value)
|
||||||
if "status_code" in changes and str(changes.get("status_code") or "") != old_status:
|
if status_changed:
|
||||||
next_status = str(changes.get("status_code") or "")
|
next_status = str(changes.get("status_code") or "")
|
||||||
if not transition_allowed_for_topic(
|
important_date_at = row.important_date_at
|
||||||
db,
|
|
||||||
str(row.topic_code or "").strip() or None,
|
|
||||||
old_status,
|
|
||||||
next_status,
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
|
||||||
validate_transition_requirements_or_400(
|
|
||||||
db,
|
|
||||||
row,
|
|
||||||
from_status=old_status,
|
|
||||||
to_status=next_status,
|
|
||||||
extra_fields_override=row.extra_fields if isinstance(row.extra_fields, dict) else None,
|
|
||||||
)
|
|
||||||
billing_note = apply_billing_transition_effects(
|
billing_note = apply_billing_transition_effects(
|
||||||
db,
|
db,
|
||||||
req=row,
|
req=row,
|
||||||
from_status=old_status,
|
from_status=old_status,
|
||||||
to_status=next_status,
|
to_status=next_status,
|
||||||
admin=admin,
|
admin=admin,
|
||||||
|
important_date_at=important_date_at,
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
mark_unread_for_client(row, EVENT_STATUS)
|
mark_unread_for_client(row, EVENT_STATUS)
|
||||||
|
|
@ -723,7 +864,11 @@ def update_request(
|
||||||
event_type=NOTIFICATION_EVENT_STATUS,
|
event_type=NOTIFICATION_EVENT_STATUS,
|
||||||
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||||||
actor_admin_user_id=admin.get("sub"),
|
actor_admin_user_id=admin.get("sub"),
|
||||||
body=(f"{old_status} -> {next_status}" + (f"\n{billing_note}" if billing_note else "")),
|
body=(
|
||||||
|
f"{old_status} -> {next_status}"
|
||||||
|
+ (f"\nВажная дата: {important_date_at.isoformat()}" if important_date_at else "")
|
||||||
|
+ (f"\n{billing_note}" if billing_note else "")
|
||||||
|
),
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|
@ -772,14 +917,17 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
|
||||||
return {
|
return {
|
||||||
"id": str(req.id),
|
"id": str(req.id),
|
||||||
"track_number": req.track_number,
|
"track_number": req.track_number,
|
||||||
|
"client_id": str(req.client_id) if req.client_id else None,
|
||||||
"client_name": req.client_name,
|
"client_name": req.client_name,
|
||||||
"client_phone": req.client_phone,
|
"client_phone": req.client_phone,
|
||||||
"topic_code": req.topic_code,
|
"topic_code": req.topic_code,
|
||||||
"status_code": req.status_code,
|
"status_code": req.status_code,
|
||||||
|
"important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
|
||||||
"description": req.description,
|
"description": req.description,
|
||||||
"extra_fields": req.extra_fields,
|
"extra_fields": req.extra_fields,
|
||||||
"assigned_lawyer_id": req.assigned_lawyer_id,
|
"assigned_lawyer_id": req.assigned_lawyer_id,
|
||||||
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
|
"effective_rate": float(req.effective_rate) if req.effective_rate is not None else None,
|
||||||
|
"request_cost": float(req.request_cost) if req.request_cost is not None else None,
|
||||||
"invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None,
|
"invoice_amount": float(req.invoice_amount) if req.invoice_amount is not None else None,
|
||||||
"paid_at": req.paid_at.isoformat() if req.paid_at else None,
|
"paid_at": req.paid_at.isoformat() if req.paid_at else None,
|
||||||
"paid_by_admin_id": req.paid_by_admin_id,
|
"paid_by_admin_id": req.paid_by_admin_id,
|
||||||
|
|
@ -793,6 +941,86 @@ def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(re
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{request_id}/status-change")
|
||||||
|
def change_request_status(
|
||||||
|
request_id: str,
|
||||||
|
payload: RequestStatusChange,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
request_uuid = _request_uuid_or_400(request_id)
|
||||||
|
req = db.get(Request, request_uuid)
|
||||||
|
if not req:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_lawyer_can_manage_request_or_403(admin, req)
|
||||||
|
|
||||||
|
next_status = str(payload.status_code or "").strip()
|
||||||
|
if not next_status:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "status_code" обязательно')
|
||||||
|
|
||||||
|
status_row = db.query(Status).filter(Status.code == next_status, Status.enabled.is_(True)).first()
|
||||||
|
if status_row is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Указан несуществующий или неактивный статус")
|
||||||
|
|
||||||
|
old_status = str(req.status_code or "").strip()
|
||||||
|
if old_status == next_status:
|
||||||
|
raise HTTPException(status_code=400, detail="Выберите новый статус")
|
||||||
|
|
||||||
|
important_date_at = _normalize_important_date_or_default(payload.important_date_at)
|
||||||
|
comment = str(payload.comment or "").strip() or None
|
||||||
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
|
||||||
|
req.status_code = next_status
|
||||||
|
req.important_date_at = important_date_at
|
||||||
|
req.responsible = responsible
|
||||||
|
|
||||||
|
billing_note = apply_billing_transition_effects(
|
||||||
|
db,
|
||||||
|
req=req,
|
||||||
|
from_status=old_status,
|
||||||
|
to_status=next_status,
|
||||||
|
admin=admin,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
mark_unread_for_client(req, EVENT_STATUS)
|
||||||
|
apply_status_change_effects(
|
||||||
|
db,
|
||||||
|
req,
|
||||||
|
from_status=old_status,
|
||||||
|
to_status=next_status,
|
||||||
|
admin=admin,
|
||||||
|
comment=comment,
|
||||||
|
important_date_at=important_date_at,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=req,
|
||||||
|
event_type=NOTIFICATION_EVENT_STATUS,
|
||||||
|
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=(
|
||||||
|
f"{old_status} -> {next_status}"
|
||||||
|
+ f"\nВажная дата: {important_date_at.isoformat()}"
|
||||||
|
+ (f"\n{comment}" if comment else "")
|
||||||
|
+ (f"\n{billing_note}" if billing_note else "")
|
||||||
|
),
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(req)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"request_id": str(req.id),
|
||||||
|
"track_number": req.track_number,
|
||||||
|
"from_status": old_status or None,
|
||||||
|
"to_status": next_status,
|
||||||
|
"important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{request_id}/status-route")
|
@router.get("/{request_id}/status-route")
|
||||||
def get_request_status_route(
|
def get_request_status_route(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
|
@ -808,22 +1036,6 @@ def get_request_status_route(
|
||||||
topic_code = str(req.topic_code or "").strip()
|
topic_code = str(req.topic_code or "").strip()
|
||||||
current_status = str(req.status_code or "").strip()
|
current_status = str(req.status_code or "").strip()
|
||||||
|
|
||||||
transitions: list[TopicStatusTransition] = []
|
|
||||||
if topic_code:
|
|
||||||
transitions = (
|
|
||||||
db.query(TopicStatusTransition)
|
|
||||||
.filter(
|
|
||||||
TopicStatusTransition.topic_code == topic_code,
|
|
||||||
TopicStatusTransition.enabled.is_(True),
|
|
||||||
)
|
|
||||||
.order_by(
|
|
||||||
TopicStatusTransition.sort_order.asc(),
|
|
||||||
TopicStatusTransition.from_status.asc(),
|
|
||||||
TopicStatusTransition.to_status.asc(),
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
history_rows = (
|
history_rows = (
|
||||||
db.query(StatusHistory)
|
db.query(StatusHistory)
|
||||||
.filter(StatusHistory.request_id == req.id)
|
.filter(StatusHistory.request_id == req.id)
|
||||||
|
|
@ -841,23 +1053,28 @@ def get_request_status_route(
|
||||||
known_codes.add(from_code)
|
known_codes.add(from_code)
|
||||||
if to_code:
|
if to_code:
|
||||||
known_codes.add(to_code)
|
known_codes.add(to_code)
|
||||||
for row in transitions:
|
|
||||||
from_code = str(row.from_status or "").strip()
|
|
||||||
to_code = str(row.to_status or "").strip()
|
|
||||||
if from_code:
|
|
||||||
known_codes.add(from_code)
|
|
||||||
if to_code:
|
|
||||||
known_codes.add(to_code)
|
|
||||||
|
|
||||||
statuses_map: dict[str, dict[str, str]] = {}
|
statuses_map: dict[str, dict[str, str]] = {}
|
||||||
|
all_enabled_status_rows = db.query(Status, StatusGroup).outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id).filter(Status.enabled.is_(True)).all()
|
||||||
|
for status_row, _group_row in all_enabled_status_rows:
|
||||||
|
code = str(status_row.code or "").strip()
|
||||||
|
if code:
|
||||||
|
known_codes.add(code)
|
||||||
if known_codes:
|
if known_codes:
|
||||||
status_rows = db.query(Status).filter(Status.code.in_(list(known_codes))).all()
|
status_rows = (
|
||||||
|
db.query(Status, StatusGroup)
|
||||||
|
.outerjoin(StatusGroup, StatusGroup.id == Status.status_group_id)
|
||||||
|
.filter(Status.code.in_(list(known_codes)))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
statuses_map = {
|
statuses_map = {
|
||||||
str(row.code): {
|
str(status_row.code): {
|
||||||
"name": str(row.name or row.code),
|
"name": str(status_row.name or status_row.code),
|
||||||
"kind": str(row.kind or "DEFAULT"),
|
"kind": str(status_row.kind or "DEFAULT"),
|
||||||
|
"is_terminal": bool(status_row.is_terminal),
|
||||||
|
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
|
||||||
|
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
|
||||||
}
|
}
|
||||||
for row in status_rows
|
for status_row, group_row in status_rows
|
||||||
}
|
}
|
||||||
|
|
||||||
sequence_from_history: list[str] = []
|
sequence_from_history: list[str] = []
|
||||||
|
|
@ -885,27 +1102,8 @@ def get_request_status_route(
|
||||||
for code in sequence_from_history:
|
for code in sequence_from_history:
|
||||||
add_code(code)
|
add_code(code)
|
||||||
|
|
||||||
for row in transitions:
|
|
||||||
add_code(str(row.from_status or ""))
|
|
||||||
add_code(str(row.to_status or ""))
|
|
||||||
|
|
||||||
add_code(current_status)
|
add_code(current_status)
|
||||||
|
|
||||||
transition_by_to_status: dict[str, dict[str, object]] = {}
|
|
||||||
for row in transitions:
|
|
||||||
to_code = str(row.to_status or "").strip()
|
|
||||||
if not to_code:
|
|
||||||
continue
|
|
||||||
current = transition_by_to_status.get(to_code)
|
|
||||||
if current is None or int(row.sort_order or 0) < int(current.get("sort_order") or 0):
|
|
||||||
transition_by_to_status[to_code] = {
|
|
||||||
"from_status": str(row.from_status or "").strip() or None,
|
|
||||||
"sla_hours": row.sla_hours,
|
|
||||||
"required_data_keys": normalize_string_list(row.required_data_keys),
|
|
||||||
"required_mime_types": normalize_string_list(row.required_mime_types),
|
|
||||||
"sort_order": int(row.sort_order or 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
changed_at_by_status: dict[str, str] = {}
|
changed_at_by_status: dict[str, str] = {}
|
||||||
for row in history_rows:
|
for row in history_rows:
|
||||||
to_code = str(row.to_status or "").strip()
|
to_code = str(row.to_status or "").strip()
|
||||||
|
|
@ -922,7 +1120,6 @@ def get_request_status_route(
|
||||||
nodes: list[dict[str, str | int | None]] = []
|
nodes: list[dict[str, str | int | None]] = []
|
||||||
for index, code in enumerate(ordered_codes):
|
for index, code in enumerate(ordered_codes):
|
||||||
meta = statuses_map.get(code) or {}
|
meta = statuses_map.get(code) or {}
|
||||||
transition_meta = transition_by_to_status.get(code) or {}
|
|
||||||
state = "pending"
|
state = "pending"
|
||||||
if code == current_status:
|
if code == current_status:
|
||||||
state = "current"
|
state = "current"
|
||||||
|
|
@ -930,18 +1127,6 @@ def get_request_status_route(
|
||||||
state = "completed"
|
state = "completed"
|
||||||
|
|
||||||
note_parts: list[str] = []
|
note_parts: list[str] = []
|
||||||
from_status = transition_meta.get("from_status")
|
|
||||||
if from_status:
|
|
||||||
note_parts.append(f"Переход из статуса «{status_name(str(from_status))}»")
|
|
||||||
sla_hours = transition_meta.get("sla_hours")
|
|
||||||
if sla_hours is not None:
|
|
||||||
note_parts.append(f"SLA: {sla_hours} ч")
|
|
||||||
required_data_keys = transition_meta.get("required_data_keys") or []
|
|
||||||
if required_data_keys:
|
|
||||||
note_parts.append("Данные: " + ", ".join(str(item) for item in required_data_keys))
|
|
||||||
required_mime_types = transition_meta.get("required_mime_types") or []
|
|
||||||
if required_mime_types:
|
|
||||||
note_parts.append("Файлы: " + ", ".join(str(item) for item in required_mime_types))
|
|
||||||
kind = str(meta.get("kind") or "DEFAULT")
|
kind = str(meta.get("kind") or "DEFAULT")
|
||||||
if kind == "INVOICE":
|
if kind == "INVOICE":
|
||||||
note_parts.append("Этап выставления счета")
|
note_parts.append("Этап выставления счета")
|
||||||
|
|
@ -954,19 +1139,88 @@ def get_request_status_route(
|
||||||
"name": status_name(code),
|
"name": status_name(code),
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"state": state,
|
"state": state,
|
||||||
"sla_hours": sla_hours,
|
|
||||||
"required_data_keys": required_data_keys,
|
|
||||||
"required_mime_types": required_mime_types,
|
|
||||||
"changed_at": changed_at_by_status.get(code),
|
"changed_at": changed_at_by_status.get(code),
|
||||||
"note": " • ".join(note_parts),
|
"note": " • ".join(note_parts),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
history_entries: list[dict[str, object]] = []
|
||||||
|
timeline: list[dict[str, object]] = []
|
||||||
|
for row in history_rows:
|
||||||
|
timeline.append(
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"from_status": str(row.from_status or "").strip() or None,
|
||||||
|
"to_status": str(row.to_status or "").strip() or None,
|
||||||
|
"to_status_name": status_name(str(row.to_status or "").strip()) if str(row.to_status or "").strip() else None,
|
||||||
|
"created_at": row.created_at,
|
||||||
|
"important_date_at": row.important_date_at,
|
||||||
|
"comment": row.comment,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not timeline:
|
||||||
|
timeline.append(
|
||||||
|
{
|
||||||
|
"id": "current",
|
||||||
|
"from_status": None,
|
||||||
|
"to_status": current_status or None,
|
||||||
|
"to_status_name": status_name(current_status) if current_status else None,
|
||||||
|
"created_at": req.updated_at or req.created_at,
|
||||||
|
"important_date_at": req.important_date_at,
|
||||||
|
"comment": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for index, item in enumerate(timeline):
|
||||||
|
current_at = item.get("created_at")
|
||||||
|
next_at = timeline[index + 1].get("created_at") if index + 1 < len(timeline) else datetime.now(timezone.utc)
|
||||||
|
duration_seconds = None
|
||||||
|
if isinstance(current_at, datetime) and isinstance(next_at, datetime):
|
||||||
|
delta = next_at - current_at
|
||||||
|
duration_seconds = max(0, int(delta.total_seconds()))
|
||||||
|
history_entries.append(
|
||||||
|
{
|
||||||
|
"id": item.get("id"),
|
||||||
|
"from_status": item.get("from_status"),
|
||||||
|
"to_status": item.get("to_status"),
|
||||||
|
"to_status_name": item.get("to_status_name"),
|
||||||
|
"changed_at": current_at.isoformat() if isinstance(current_at, datetime) else None,
|
||||||
|
"important_date_at": item.get("important_date_at").isoformat() if isinstance(item.get("important_date_at"), datetime) else None,
|
||||||
|
"comment": item.get("comment"),
|
||||||
|
"duration_seconds": duration_seconds,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
available_statuses: list[dict[str, object]] = []
|
||||||
|
for status_row, group_row in sorted(
|
||||||
|
all_enabled_status_rows,
|
||||||
|
key=lambda pair: (
|
||||||
|
int(pair[1].sort_order or 0) if pair[1] is not None else 999,
|
||||||
|
int(pair[0].sort_order or 0),
|
||||||
|
str(pair[0].name or pair[0].code).lower(),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
code = str(status_row.code or "").strip()
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
available_statuses.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"name": str(status_row.name or code),
|
||||||
|
"kind": str(status_row.kind or "DEFAULT"),
|
||||||
|
"is_terminal": bool(status_row.is_terminal),
|
||||||
|
"status_group_id": str(status_row.status_group_id) if status_row.status_group_id else None,
|
||||||
|
"status_group_name": (str(group_row.name) if group_row is not None and group_row.name else None),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"request_id": str(req.id),
|
"request_id": str(req.id),
|
||||||
"track_number": req.track_number,
|
"track_number": req.track_number,
|
||||||
"topic_code": req.topic_code,
|
"topic_code": req.topic_code,
|
||||||
"current_status": current_status or None,
|
"current_status": current_status or None,
|
||||||
|
"current_important_date_at": req.important_date_at.isoformat() if req.important_date_at else None,
|
||||||
|
"available_statuses": available_statuses,
|
||||||
|
"history": list(reversed(history_entries)),
|
||||||
"nodes": nodes,
|
"nodes": nodes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat
|
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices, chat, test_utils
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
||||||
|
|
@ -13,3 +13,4 @@ router.include_router(notifications.router, prefix="/notifications", tags=["Admi
|
||||||
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
|
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
|
||||||
router.include_router(chat.router, prefix="/chat", tags=["AdminChat"])
|
router.include_router(chat.router, prefix="/chat", tags=["AdminChat"])
|
||||||
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
|
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
|
||||||
|
router.include_router(test_utils.router, prefix="/test-utils", tags=["AdminTestUtils"])
|
||||||
|
|
|
||||||
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 __future__ import annotations
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.deps import get_public_session
|
from app.core.deps import get_public_session
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
from app.schemas.public import PublicMessageCreate
|
from app.schemas.public import PublicMessageCreate
|
||||||
from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message
|
from app.services.chat_service import create_client_message, list_messages_for_request, serialize_message, serialize_messages_for_request
|
||||||
|
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_lawyer
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_meta_for_public(req: Request, value_text: str | None, db: Session) -> dict | None:
|
||||||
|
raw = str(value_text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
attachment_uuid = UUID(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
attachment = db.get(Attachment, attachment_uuid)
|
||||||
|
if attachment is None or attachment.request_id != req.id:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"attachment_id": str(attachment.id),
|
||||||
|
"file_name": attachment.file_name,
|
||||||
|
"mime_type": attachment.mime_type,
|
||||||
|
"size_bytes": int(attachment.size_bytes or 0),
|
||||||
|
"download_url": f"/api/public/uploads/object/{attachment.id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_phone(raw: str | None) -> str:
|
def _normalize_phone(raw: str | None) -> str:
|
||||||
value = str(raw or "").strip()
|
value = str(raw or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
|
|
@ -60,7 +85,7 @@ def list_messages_by_track(
|
||||||
req = _request_for_track_or_404(db, track_number)
|
req = _request_for_track_or_404(db, track_number)
|
||||||
_ensure_view_access_or_403(session, req)
|
_ensure_view_access_or_403(session, req)
|
||||||
rows = list_messages_for_request(db, req.id)
|
rows = list_messages_for_request(db, req.id)
|
||||||
return [serialize_message(row) for row in rows]
|
return serialize_messages_for_request(db, req.id, rows)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{track_number}/messages", status_code=201)
|
@router.post("/requests/{track_number}/messages", status_code=201)
|
||||||
|
|
@ -74,3 +99,131 @@ def create_message_by_track(
|
||||||
_ensure_view_access_or_403(session, req)
|
_ensure_view_access_or_403(session, req)
|
||||||
row = create_client_message(db, request=req, body=payload.body)
|
row = create_client_message(db, request=req, body=payload.body)
|
||||||
return serialize_message(row)
|
return serialize_message(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{track_number}/data-requests/{message_id}")
|
||||||
|
def get_data_request_by_message(
|
||||||
|
track_number: str,
|
||||||
|
message_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, track_number)
|
||||||
|
_ensure_view_access_or_403(session, req)
|
||||||
|
try:
|
||||||
|
message_uuid = UUID(str(message_id))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный идентификатор сообщения")
|
||||||
|
message = db.get(Message, message_uuid)
|
||||||
|
if message is None or message.request_id != req.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
|
||||||
|
rows = (
|
||||||
|
db.query(RequestDataRequirement)
|
||||||
|
.filter(
|
||||||
|
RequestDataRequirement.request_id == req.id,
|
||||||
|
RequestDataRequirement.request_message_id == message_uuid,
|
||||||
|
)
|
||||||
|
.order_by(RequestDataRequirement.sort_order.asc(), RequestDataRequirement.created_at.asc(), RequestDataRequirement.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Запрос данных не найден")
|
||||||
|
return {
|
||||||
|
"message_id": str(message.id),
|
||||||
|
"request_id": str(req.id),
|
||||||
|
"track_number": req.track_number,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"key": row.key,
|
||||||
|
"label": row.label,
|
||||||
|
"field_type": str(row.field_type or "text"),
|
||||||
|
"value_text": row.value_text,
|
||||||
|
"value_file": _attachment_meta_for_public(req, row.value_text, db) if str(row.field_type or "").lower() == "file" else None,
|
||||||
|
"is_filled": bool(str(row.value_text or "").strip()),
|
||||||
|
"sort_order": int(row.sort_order or 0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/requests/{track_number}/data-requests/{message_id}")
|
||||||
|
def save_data_request_values(
|
||||||
|
track_number: str,
|
||||||
|
message_id: str,
|
||||||
|
payload: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, track_number)
|
||||||
|
_ensure_view_access_or_403(session, req)
|
||||||
|
try:
|
||||||
|
message_uuid = UUID(str(message_id))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный идентификатор сообщения")
|
||||||
|
message = db.get(Message, message_uuid)
|
||||||
|
if message is None or message.request_id != req.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Сообщение запроса не найдено")
|
||||||
|
|
||||||
|
raw_items = (payload or {}).get("items")
|
||||||
|
if not isinstance(raw_items, list):
|
||||||
|
raise HTTPException(status_code=400, detail="Ожидается список items")
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(RequestDataRequirement)
|
||||||
|
.filter(
|
||||||
|
RequestDataRequirement.request_id == req.id,
|
||||||
|
RequestDataRequirement.request_message_id == message_uuid,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_id = {str(row.id): row for row in rows}
|
||||||
|
by_key = {str(row.key): row for row in rows}
|
||||||
|
updated = 0
|
||||||
|
for item in raw_items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
target = None
|
||||||
|
raw_id = str(item.get("id") or "").strip()
|
||||||
|
if raw_id:
|
||||||
|
target = by_id.get(raw_id)
|
||||||
|
if target is None:
|
||||||
|
raw_key = str(item.get("key") or "").strip()
|
||||||
|
if raw_key:
|
||||||
|
target = by_key.get(raw_key)
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
if str(target.field_type or "").lower() == "file":
|
||||||
|
attachment_id_raw = str(item.get("attachment_id") or item.get("value_text") or "").strip()
|
||||||
|
if attachment_id_raw:
|
||||||
|
try:
|
||||||
|
attachment_uuid = UUID(attachment_id_raw)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный attachment_id для файла")
|
||||||
|
attachment = db.get(Attachment, attachment_uuid)
|
||||||
|
if attachment is None or attachment.request_id != req.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Файл для поля не найден или недоступен")
|
||||||
|
normalized = str(attachment.id)
|
||||||
|
else:
|
||||||
|
normalized = ""
|
||||||
|
else:
|
||||||
|
value_text = item.get("value_text")
|
||||||
|
normalized = str(value_text).strip() if value_text is not None else ""
|
||||||
|
target.value_text = normalized or None
|
||||||
|
target.responsible = "Клиент"
|
||||||
|
db.add(target)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
mark_unread_for_lawyer(req, EVENT_MESSAGE)
|
||||||
|
req.responsible = "Клиент"
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
messages = list_messages_for_request(db, req.id)
|
||||||
|
serialized = serialize_messages_for_request(db, req.id, messages)
|
||||||
|
current = next((item for item in serialized if str(item.get("id")) == str(message_uuid)), None)
|
||||||
|
return {"updated": updated, "message": current}
|
||||||
|
|
|
||||||
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 fastapi import APIRouter
|
||||||
from app.api.public import requests, otp, quotes, uploads, chat
|
from app.api.public import requests, otp, quotes, uploads, chat, featured_staff
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(requests.router, prefix="/requests", tags=["Public"])
|
router.include_router(requests.router, prefix="/requests", tags=["Public"])
|
||||||
router.include_router(otp.router, prefix="/otp", tags=["Public"])
|
router.include_router(otp.router, prefix="/otp", tags=["Public"])
|
||||||
router.include_router(quotes.router, prefix="/quotes", tags=["Public"])
|
router.include_router(quotes.router, prefix="/quotes", tags=["Public"])
|
||||||
|
router.include_router(featured_staff.router, prefix="/featured-staff", tags=["Public"])
|
||||||
router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"])
|
router.include_router(uploads.router, prefix="/uploads", tags=["PublicFiles"])
|
||||||
router.include_router(chat.router, prefix="/chat", tags=["PublicChat"])
|
router.include_router(chat.router, prefix="/chat", tags=["PublicChat"])
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,19 @@ SECURITY_HEADERS = {
|
||||||
"Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
"Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FRAMEABLE_FILE_SECURITY_HEADERS = {
|
||||||
|
**SECURITY_HEADERS,
|
||||||
|
"X-Frame-Options": "SAMEORIGIN",
|
||||||
|
"Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'",
|
||||||
|
}
|
||||||
|
|
||||||
|
_FRAMEABLE_PATH_PATTERNS = (
|
||||||
|
re.compile(r"^/api/public/uploads/object/"),
|
||||||
|
re.compile(r"^/api/admin/uploads/object/"),
|
||||||
|
re.compile(r"^/api/public/requests/[^/]+/invoices/[^/]+/pdf$"),
|
||||||
|
re.compile(r"^/api/admin/invoices/[^/]+/pdf$"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _request_id_from_header(raw: str | None) -> str:
|
def _request_id_from_header(raw: str | None) -> str:
|
||||||
value = str(raw or "").strip()
|
value = str(raw or "").strip()
|
||||||
|
|
@ -33,6 +46,14 @@ def _request_id_from_header(raw: str | None) -> str:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _response_security_headers(request: Request) -> dict[str, str]:
|
||||||
|
method = str(request.method or "").upper()
|
||||||
|
path = str(request.url.path or "")
|
||||||
|
if method in {"GET", "HEAD"} and any(pattern.search(path) for pattern in _FRAMEABLE_PATH_PATTERNS):
|
||||||
|
return FRAMEABLE_FILE_SECURITY_HEADERS
|
||||||
|
return SECURITY_HEADERS
|
||||||
|
|
||||||
|
|
||||||
def install_http_hardening(app: FastAPI) -> None:
|
def install_http_hardening(app: FastAPI) -> None:
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def _http_hardening_middleware(request: Request, call_next):
|
async def _http_hardening_middleware(request: Request, call_next):
|
||||||
|
|
@ -42,7 +63,7 @@ def install_http_hardening(app: FastAPI) -> None:
|
||||||
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
||||||
for key, value in SECURITY_HEADERS.items():
|
for key, value in _response_security_headers(request).items():
|
||||||
response.headers[key] = value
|
response.headers[key] = value
|
||||||
# Backend serves application data and operational endpoints only.
|
# Backend serves application data and operational endpoints only.
|
||||||
# Keep responses non-cacheable to avoid stale or sensitive data reuse.
|
# Keep responses non-cacheable to avoid stale or sensitive data reuse.
|
||||||
|
|
|
||||||
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
|
role: Mapped[str] = mapped_column(String(20), nullable=False) # ADMIN|LAWYER
|
||||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
|
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||||
|
|
|
||||||
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)
|
client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||||
status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW")
|
status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW")
|
||||||
|
important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
effective_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
effective_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
|
request_cost: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||||
invoice_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
invoice_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
paid_by_admin_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
paid_by_admin_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import String, Boolean, Text, UniqueConstraint
|
from sqlalchemy import String, Boolean, Text, UniqueConstraint, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
|
@ -19,9 +19,14 @@ class RequestDataRequirement(Base, UUIDMixin, TimestampMixin):
|
||||||
)
|
)
|
||||||
|
|
||||||
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
|
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
request_message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
topic_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
|
topic_template_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
|
||||||
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
field_type: Mapped[str] = mapped_column(String(20), nullable=False, default="text")
|
||||||
|
document_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
value_text: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
created_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||||
|
|
|
||||||
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
|
import uuid
|
||||||
from sqlalchemy import String
|
from datetime import datetime
|
||||||
|
from sqlalchemy import DateTime, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
@ -12,3 +13,4 @@ class StatusHistory(Base, UUIDMixin, TimestampMixin):
|
||||||
to_status: Mapped[str] = mapped_column(String(50), nullable=False)
|
to_status: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
changed_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
changed_by_admin_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||||
comment: Mapped[str | None] = mapped_column(String(400), nullable=True)
|
comment: Mapped[str | None] = mapped_column(String(400), nullable=True)
|
||||||
|
important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ class TopicDataTemplate(Base, UUIDMixin, TimestampMixin):
|
||||||
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
topic_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
value_type: Mapped[str] = mapped_column(String(20), nullable=False, default="text")
|
||||||
|
document_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -63,14 +63,17 @@ class FormFieldUpsert(BaseModel):
|
||||||
|
|
||||||
class RequestAdminCreate(BaseModel):
|
class RequestAdminCreate(BaseModel):
|
||||||
track_number: Optional[str] = None
|
track_number: Optional[str] = None
|
||||||
|
client_id: Optional[str] = None
|
||||||
client_name: str
|
client_name: str
|
||||||
client_phone: str
|
client_phone: str
|
||||||
topic_code: Optional[str] = None
|
topic_code: Optional[str] = None
|
||||||
status_code: str = "NEW"
|
status_code: str = "NEW"
|
||||||
|
important_date_at: Optional[datetime] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
extra_fields: dict = Field(default_factory=dict)
|
extra_fields: dict = Field(default_factory=dict)
|
||||||
assigned_lawyer_id: Optional[str] = None
|
assigned_lawyer_id: Optional[str] = None
|
||||||
effective_rate: Optional[float] = None
|
effective_rate: Optional[float] = None
|
||||||
|
request_cost: Optional[float] = None
|
||||||
invoice_amount: Optional[float] = None
|
invoice_amount: Optional[float] = None
|
||||||
paid_at: Optional[datetime] = None
|
paid_at: Optional[datetime] = None
|
||||||
paid_by_admin_id: Optional[str] = None
|
paid_by_admin_id: Optional[str] = None
|
||||||
|
|
@ -79,14 +82,17 @@ class RequestAdminCreate(BaseModel):
|
||||||
|
|
||||||
class RequestAdminPatch(BaseModel):
|
class RequestAdminPatch(BaseModel):
|
||||||
track_number: Optional[str] = None
|
track_number: Optional[str] = None
|
||||||
|
client_id: Optional[str] = None
|
||||||
client_name: Optional[str] = None
|
client_name: Optional[str] = None
|
||||||
client_phone: Optional[str] = None
|
client_phone: Optional[str] = None
|
||||||
topic_code: Optional[str] = None
|
topic_code: Optional[str] = None
|
||||||
status_code: Optional[str] = None
|
status_code: Optional[str] = None
|
||||||
|
important_date_at: Optional[datetime] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
extra_fields: Optional[dict] = None
|
extra_fields: Optional[dict] = None
|
||||||
assigned_lawyer_id: Optional[str] = None
|
assigned_lawyer_id: Optional[str] = None
|
||||||
effective_rate: Optional[float] = None
|
effective_rate: Optional[float] = None
|
||||||
|
request_cost: Optional[float] = None
|
||||||
invoice_amount: Optional[float] = None
|
invoice_amount: Optional[float] = None
|
||||||
paid_at: Optional[datetime] = None
|
paid_at: Optional[datetime] = None
|
||||||
paid_by_admin_id: Optional[str] = None
|
paid_by_admin_id: Optional[str] = None
|
||||||
|
|
@ -97,6 +103,12 @@ class RequestReassign(BaseModel):
|
||||||
lawyer_id: str
|
lawyer_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class RequestStatusChange(BaseModel):
|
||||||
|
status_code: str
|
||||||
|
important_date_at: Optional[datetime] = None
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class RequestDataRequirementCreate(BaseModel):
|
class RequestDataRequirementCreate(BaseModel):
|
||||||
key: str
|
key: str
|
||||||
label: str
|
label: str
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ from fastapi import HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
|
from app.models.attachment import Attachment
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event
|
from app.services.notifications import EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, notify_request_event
|
||||||
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
|
from app.services.request_read_markers import EVENT_MESSAGE, mark_unread_for_client, mark_unread_for_lawyer
|
||||||
|
|
||||||
|
|
@ -32,6 +34,113 @@ def serialize_message(row: Message) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_request_data_label(label: str, limit: int = 18) -> str:
|
||||||
|
text = str(label or "").strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[: max(3, limit - 3)].rstrip() + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_messages_for_request(db: Session, request_id: Any, rows: list[Message]) -> list[dict[str, Any]]:
|
||||||
|
message_ids = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
message_ids.append(row.id)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
requirements = (
|
||||||
|
db.query(RequestDataRequirement)
|
||||||
|
.filter(
|
||||||
|
RequestDataRequirement.request_id == request_id,
|
||||||
|
RequestDataRequirement.request_message_id.in_(message_ids) if message_ids else False,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
RequestDataRequirement.request_message_id.asc(),
|
||||||
|
RequestDataRequirement.sort_order.asc(),
|
||||||
|
RequestDataRequirement.created_at.asc(),
|
||||||
|
RequestDataRequirement.id.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
if message_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
by_message_id: dict[str, list[RequestDataRequirement]] = {}
|
||||||
|
for item in requirements:
|
||||||
|
mid = str(item.request_message_id or "").strip()
|
||||||
|
if not mid:
|
||||||
|
continue
|
||||||
|
by_message_id.setdefault(mid, []).append(item)
|
||||||
|
file_attachment_ids = []
|
||||||
|
for item in requirements:
|
||||||
|
if str(item.field_type or "").lower() != "file":
|
||||||
|
continue
|
||||||
|
raw = str(item.value_text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
file_attachment_ids.append(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
attachment_map: dict[str, Attachment] = {}
|
||||||
|
if file_attachment_ids:
|
||||||
|
attachment_rows = db.query(Attachment).filter(Attachment.id.in_(file_attachment_ids)).all()
|
||||||
|
attachment_map = {str(row.id): row for row in attachment_rows}
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
payload = serialize_message(row)
|
||||||
|
linked = by_message_id.get(str(row.id), [])
|
||||||
|
if linked:
|
||||||
|
linked_sorted = sorted(
|
||||||
|
linked,
|
||||||
|
key=lambda req: (
|
||||||
|
1 if str(req.value_text or "").strip() else 0,
|
||||||
|
int(req.sort_order or 0),
|
||||||
|
req.created_at.timestamp() if getattr(req, "created_at", None) else 0,
|
||||||
|
str(req.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
items = []
|
||||||
|
all_filled = True
|
||||||
|
for idx, req in enumerate(linked_sorted, start=1):
|
||||||
|
value_text = str(req.value_text or "").strip()
|
||||||
|
is_filled = bool(value_text)
|
||||||
|
if not is_filled:
|
||||||
|
all_filled = False
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": str(req.id),
|
||||||
|
"index": idx,
|
||||||
|
"key": req.key,
|
||||||
|
"label": req.label,
|
||||||
|
"label_short": _truncate_request_data_label(str(req.label or "")),
|
||||||
|
"field_type": str(req.field_type or "text"),
|
||||||
|
"document_name": req.document_name,
|
||||||
|
"value_text": req.value_text,
|
||||||
|
"value_file": (
|
||||||
|
{
|
||||||
|
"attachment_id": str(attachment_map[value_text].id),
|
||||||
|
"file_name": attachment_map[value_text].file_name,
|
||||||
|
"mime_type": attachment_map[value_text].mime_type,
|
||||||
|
"size_bytes": int(attachment_map[value_text].size_bytes or 0),
|
||||||
|
"download_url": None,
|
||||||
|
}
|
||||||
|
if str(req.field_type or "").lower() == "file" and value_text in attachment_map
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"is_filled": is_filled,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload["message_kind"] = "REQUEST_DATA"
|
||||||
|
payload["request_data_items"] = items
|
||||||
|
payload["request_data_all_filled"] = all_filled and bool(items)
|
||||||
|
payload["body"] = "Запрос"
|
||||||
|
else:
|
||||||
|
payload["message_kind"] = "TEXT"
|
||||||
|
out.append(payload)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def create_client_message(db: Session, *, request: Request, body: str) -> Message:
|
def create_client_message(db: Session, *, request: Request, body: str) -> Message:
|
||||||
message_body = str(body or "").strip()
|
message_body = str(body or "").strip()
|
||||||
if not message_body:
|
if not message_body:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@ def register_status_history(
|
||||||
*,
|
*,
|
||||||
admin: dict[str, Any] | None = None,
|
admin: dict[str, Any] | None = None,
|
||||||
comment: str | None = None,
|
comment: str | None = None,
|
||||||
|
important_date_at: datetime | None = None,
|
||||||
responsible: str = "Администратор системы",
|
responsible: str = "Администратор системы",
|
||||||
) -> None:
|
) -> None:
|
||||||
db.add(
|
db.add(
|
||||||
|
|
@ -51,6 +53,7 @@ def register_status_history(
|
||||||
to_status=str(to_status or "").strip(),
|
to_status=str(to_status or "").strip(),
|
||||||
changed_by_admin_id=actor_admin_uuid(admin),
|
changed_by_admin_id=actor_admin_uuid(admin),
|
||||||
comment=comment,
|
comment=comment,
|
||||||
|
important_date_at=important_date_at,
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -64,6 +67,7 @@ def apply_status_change_effects(
|
||||||
to_status: str,
|
to_status: str,
|
||||||
admin: dict[str, Any] | None = None,
|
admin: dict[str, Any] | None = None,
|
||||||
comment: str | None = None,
|
comment: str | None = None,
|
||||||
|
important_date_at: datetime | None = None,
|
||||||
responsible: str = "Администратор системы",
|
responsible: str = "Администратор системы",
|
||||||
) -> None:
|
) -> None:
|
||||||
old_code = str(from_status or "").strip()
|
old_code = str(from_status or "").strip()
|
||||||
|
|
@ -78,5 +82,6 @@ def apply_status_change_effects(
|
||||||
new_code,
|
new_code,
|
||||||
admin=admin,
|
admin=admin,
|
||||||
comment=comment,
|
comment=comment,
|
||||||
|
important_date_at=important_date_at,
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.orm import Query
|
|
||||||
from sqlalchemy import asc, desc
|
from sqlalchemy import asc, desc
|
||||||
|
from sqlalchemy.orm import Query
|
||||||
|
|
||||||
from app.schemas.universal import UniversalQuery
|
from app.schemas.universal import UniversalQuery
|
||||||
|
|
||||||
|
|
||||||
|
def _bad_filter_value(column_key: str, kind: str) -> HTTPException:
|
||||||
|
return HTTPException(status_code=400, detail=f'Некорректное значение фильтра для поля "{column_key}" ({kind})')
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_bool_filter_value(column_key: str, value):
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
if text in {"1", "true", "yes", "y", "да"}:
|
||||||
|
return True
|
||||||
|
if text in {"0", "false", "no", "n", "нет"}:
|
||||||
|
return False
|
||||||
|
raise _bad_filter_value(column_key, "boolean")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_number_filter_value(column_key: str, value, python_type):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if python_type in {int, float} and isinstance(value, (int, float)):
|
||||||
|
return python_type(value)
|
||||||
|
if python_type is Decimal and isinstance(value, Decimal):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
raise _bad_filter_value(column_key, "number")
|
||||||
|
normalized = text.replace(",", ".")
|
||||||
|
try:
|
||||||
|
if python_type is int:
|
||||||
|
return int(normalized)
|
||||||
|
if python_type is float:
|
||||||
|
return float(normalized)
|
||||||
|
if python_type is Decimal:
|
||||||
|
return Decimal(normalized)
|
||||||
|
return python_type(normalized)
|
||||||
|
except (ValueError, TypeError, InvalidOperation):
|
||||||
|
raise _bad_filter_value(column_key, "number")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_date_filter_value(column_key: str, value):
|
||||||
|
if isinstance(value, date) and not isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise _bad_filter_value(column_key, "date")
|
||||||
|
try:
|
||||||
|
# Accept either YYYY-MM-DD or full ISO datetime and take its date part.
|
||||||
|
if "T" in text or " " in text:
|
||||||
|
return datetime.fromisoformat(text.replace("Z", "+00:00")).date()
|
||||||
|
return date.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
raise _bad_filter_value(column_key, "date")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_datetime_filter_value(column_key: str, value):
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
parsed = value
|
||||||
|
else:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise _bad_filter_value(column_key, "datetime")
|
||||||
|
try:
|
||||||
|
if "T" not in text and " " not in text and len(text) == 10:
|
||||||
|
# Date-only filter value for timestamp columns -> start of the day.
|
||||||
|
parsed = datetime.combine(date.fromisoformat(text), datetime.min.time())
|
||||||
|
else:
|
||||||
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
raise _bad_filter_value(column_key, "datetime")
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def _coerce_filter_value(column, value):
|
def _coerce_filter_value(column, value):
|
||||||
try:
|
try:
|
||||||
python_type = column.property.columns[0].type.python_type
|
python_type = column.property.columns[0].type.python_type
|
||||||
except Exception:
|
except Exception:
|
||||||
return value
|
return value
|
||||||
if python_type is uuid.UUID and isinstance(value, str):
|
if python_type is uuid.UUID:
|
||||||
|
if isinstance(value, uuid.UUID):
|
||||||
|
return value
|
||||||
try:
|
try:
|
||||||
return uuid.UUID(value)
|
return uuid.UUID(str(value or "").strip())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail=f'Некорректный UUID в фильтре поля "{column.key}"')
|
raise HTTPException(status_code=400, detail=f'Некорректный UUID в фильтре поля "{column.key}"')
|
||||||
|
if python_type is bool:
|
||||||
|
return _coerce_bool_filter_value(column.key, value)
|
||||||
|
if python_type in {int, float, Decimal}:
|
||||||
|
return _coerce_number_filter_value(column.key, value, python_type)
|
||||||
|
if python_type is date:
|
||||||
|
return _coerce_date_filter_value(column.key, value)
|
||||||
|
if python_type is datetime:
|
||||||
|
return _coerce_datetime_filter_value(column.key, value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _column_python_type(column):
|
||||||
|
try:
|
||||||
|
return column.property.columns[0].type.python_type
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_date_only_filter_literal(raw_value) -> bool:
|
||||||
|
if isinstance(raw_value, date) and not isinstance(raw_value, datetime):
|
||||||
|
return True
|
||||||
|
if not isinstance(raw_value, str):
|
||||||
|
return False
|
||||||
|
text = raw_value.strip()
|
||||||
|
if not text or "T" in text or " " in text:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
date.fromisoformat(text)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def apply_universal_query(q: Query, model, uq: UniversalQuery) -> Query:
|
def apply_universal_query(q: Query, model, uq: UniversalQuery) -> Query:
|
||||||
for f in uq.filters:
|
for f in uq.filters:
|
||||||
col = getattr(model, f.field, None)
|
col = getattr(model, f.field, None)
|
||||||
if col is None:
|
if col is None:
|
||||||
continue
|
continue
|
||||||
|
col_python_type = _column_python_type(col)
|
||||||
value = _coerce_filter_value(col, f.value)
|
value = _coerce_filter_value(col, f.value)
|
||||||
|
if col_python_type is datetime and f.op in {"=", "!="} and _is_date_only_filter_literal(f.value):
|
||||||
|
day_start = value
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
day_expr = (col >= day_start) & (col < day_end)
|
||||||
|
q = q.filter(day_expr if f.op == "=" else ~day_expr)
|
||||||
|
continue
|
||||||
if f.op == "=":
|
if f.op == "=":
|
||||||
q = q.filter(col == value)
|
q = q.filter(col == value)
|
||||||
elif f.op == "!=":
|
elif f.op == "!=":
|
||||||
|
|
|
||||||
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;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-data-item {
|
||||||
|
border-color: rgba(212, 168, 106, 0.35);
|
||||||
|
background: linear-gradient(160deg, rgba(76, 56, 20, 0.28), rgba(39, 29, 14, 0.34));
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-item.done {
|
||||||
|
border-color: rgba(73, 182, 142, 0.35);
|
||||||
|
background: linear-gradient(160deg, rgba(40, 86, 66, 0.26), rgba(26, 55, 43, 0.32));
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-item-author {
|
||||||
|
color: #a7b8cf;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-message-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: #eef3fb;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-message-btn:hover {
|
||||||
|
border-color: rgba(212, 168, 106, 0.42);
|
||||||
|
background: rgba(212, 168, 106, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-item.done .request-data-message-btn:hover {
|
||||||
|
border-color: rgba(73, 182, 142, 0.42);
|
||||||
|
background: rgba(73, 182, 142, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-message-title {
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffe0ac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-item.done .request-data-message-title {
|
||||||
|
color: #c8eed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-message-list {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.16rem;
|
||||||
|
max-height: 11.6rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-message-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: baseline;
|
||||||
|
color: #e0e9f7;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-message-row.filled .request-data-message-row-label {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #b8c4d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-message-row-index {
|
||||||
|
min-width: 1.9rem;
|
||||||
|
color: #ffd5a1;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-message-row-check {
|
||||||
|
color: #59d182;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-data-message-more {
|
||||||
|
color: #bac7da;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding-left: 1.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-inline {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.file-actions {
|
.file-actions {
|
||||||
margin-top: 0.45rem;
|
margin-top: 0.45rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -250,6 +343,63 @@ textarea {
|
||||||
margin: 0.7rem;
|
margin: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-request-modal {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-request-body {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 280px;
|
||||||
|
max-height: calc(92vh - 76px);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #0f1722;
|
||||||
|
padding: 0.8rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-request-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-request-form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(180px, 0.9fr) minmax(0, 1.4fr);
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.45rem 0;
|
||||||
|
border-bottom: 1px solid rgba(207, 217, 231, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-request-form-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-request-form-index {
|
||||||
|
color: #9fb0c6;
|
||||||
|
font-weight: 700;
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-request-form-label {
|
||||||
|
color: #e9f1fe;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding-top: 0.72rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-request-form textarea {
|
||||||
|
min-height: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-request-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-overlay {
|
.preview-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -338,6 +488,21 @@ textarea {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-text {
|
||||||
|
width: 100%;
|
||||||
|
min-height: min(60vh, 520px);
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(10, 16, 24, 0.88);
|
||||||
|
color: #dbe7f8;
|
||||||
|
padding: 0.7rem;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font: 500 0.86rem/1.45 "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-form {
|
.chat-form {
|
||||||
margin-top: 0.7rem;
|
margin-top: 0.7rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -375,6 +540,17 @@ textarea {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-request-form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-request-form-index,
|
||||||
|
.data-request-form-label {
|
||||||
|
padding-top: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-overlay" id="data-request-overlay" aria-hidden="true">
|
||||||
|
<div class="preview-modal data-request-modal" role="dialog" aria-modal="true" aria-labelledby="data-request-title">
|
||||||
|
<div class="preview-head">
|
||||||
|
<h3 id="data-request-title">Запрос данных</h3>
|
||||||
|
<button class="close-btn" id="data-request-close" type="button" aria-label="Закрыть">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="preview-body data-request-body">
|
||||||
|
<form id="data-request-form" class="data-request-form">
|
||||||
|
<div id="data-request-items"></div>
|
||||||
|
<div class="data-request-actions">
|
||||||
|
<button class="btn btn-ghost" id="data-request-save" type="submit">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="status" id="data-request-status"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/client.js"></script>
|
<script src="/client.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,29 @@
|
||||||
const previewTitle = document.getElementById("file-preview-title");
|
const previewTitle = document.getElementById("file-preview-title");
|
||||||
const previewClose = document.getElementById("file-preview-close");
|
const previewClose = document.getElementById("file-preview-close");
|
||||||
const previewBody = document.getElementById("file-preview-body");
|
const previewBody = document.getElementById("file-preview-body");
|
||||||
|
const dataRequestOverlay = document.getElementById("data-request-overlay");
|
||||||
|
const dataRequestClose = document.getElementById("data-request-close");
|
||||||
|
const dataRequestForm = document.getElementById("data-request-form");
|
||||||
|
const dataRequestItems = document.getElementById("data-request-items");
|
||||||
|
const dataRequestStatus = document.getElementById("data-request-status");
|
||||||
|
const dataRequestTitle = document.getElementById("data-request-title");
|
||||||
|
let previewObjectUrl = "";
|
||||||
|
|
||||||
let activeTrack = "";
|
let activeTrack = "";
|
||||||
let activeRequestId = "";
|
let activeRequestId = "";
|
||||||
|
let activeDataRequestMessageId = "";
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
try {
|
try {
|
||||||
const dt = new Date(value);
|
const dt = new Date(value);
|
||||||
if (Number.isNaN(dt.getTime())) return value;
|
if (Number.isNaN(dt.getTime())) return value;
|
||||||
return dt.toLocaleString("ru-RU");
|
const day = String(dt.getDate()).padStart(2, "0");
|
||||||
|
const month = String(dt.getMonth() + 1).padStart(2, "0");
|
||||||
|
const year = String(dt.getFullYear()).slice(-2);
|
||||||
|
const hours = String(dt.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(dt.getMinutes()).padStart(2, "0");
|
||||||
|
return `${day}.${month}.${year} ${hours}:${minutes}`;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +58,50 @@
|
||||||
el.textContent = message;
|
el.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDataRequestStatus(message, kind) {
|
||||||
|
if (!dataRequestStatus) return;
|
||||||
|
setStatus(dataRequestStatus, message || "", kind || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPublicRequestAttachment(file, requestId) {
|
||||||
|
const initResponse = await fetch("/api/public/uploads/init", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_name: file.name,
|
||||||
|
mime_type: file.type || "application/octet-stream",
|
||||||
|
size_bytes: file.size,
|
||||||
|
scope: "REQUEST_ATTACHMENT",
|
||||||
|
request_id: requestId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const initData = await parseJsonSafe(initResponse);
|
||||||
|
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку файла"));
|
||||||
|
|
||||||
|
const putResponse = await fetch(initData.presigned_url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
|
||||||
|
|
||||||
|
const completeResponse = await fetch("/api/public/uploads/complete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
key: initData.key,
|
||||||
|
file_name: file.name,
|
||||||
|
mime_type: file.type || "application/octet-stream",
|
||||||
|
size_bytes: file.size,
|
||||||
|
scope: "REQUEST_ATTACHMENT",
|
||||||
|
request_id: requestId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const completeData = await parseJsonSafe(completeResponse);
|
||||||
|
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку файла"));
|
||||||
|
return completeData;
|
||||||
|
}
|
||||||
|
|
||||||
async function parseJsonSafe(response) {
|
async function parseJsonSafe(response) {
|
||||||
try {
|
try {
|
||||||
return await response.json();
|
return await response.json();
|
||||||
|
|
@ -79,21 +136,171 @@
|
||||||
function detectPreviewKind(fileName, mimeType) {
|
function detectPreviewKind(fileName, mimeType) {
|
||||||
const name = String(fileName || "").toLowerCase();
|
const name = String(fileName || "").toLowerCase();
|
||||||
const mime = String(mimeType || "").toLowerCase();
|
const mime = String(mimeType || "").toLowerCase();
|
||||||
|
if (/\.(txt|md|csv|json|log|xml|ya?ml|ini|cfg)$/i.test(name)) return "text";
|
||||||
|
if (mime.startsWith("text/") || mime === "application/json" || mime === "application/xml" || mime === "text/xml") return "text";
|
||||||
if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image";
|
if (mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name)) return "image";
|
||||||
if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video";
|
if (mime.startsWith("video/") || /\.(mp4|webm|ogg|mov|m4v)$/.test(name)) return "video";
|
||||||
if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf";
|
if (mime === "application/pdf" || /\.pdf$/.test(name)) return "pdf";
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revokePreviewObjectUrl() {
|
||||||
|
if (!previewObjectUrl) return;
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(previewObjectUrl);
|
||||||
|
} catch (_) {}
|
||||||
|
previewObjectUrl = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTextPreview(arrayBuffer) {
|
||||||
|
const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0));
|
||||||
|
const sampleLength = Math.min(bytes.length, 4096);
|
||||||
|
let suspicious = 0;
|
||||||
|
for (let i = 0; i < sampleLength; i += 1) {
|
||||||
|
const byte = bytes[i];
|
||||||
|
if (byte === 0) suspicious += 4;
|
||||||
|
else if (byte < 9 || (byte > 13 && byte < 32)) suspicious += 1;
|
||||||
|
}
|
||||||
|
if (sampleLength && suspicious / sampleLength > 0.08) return null;
|
||||||
|
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes).replace(/\u0000/g, "");
|
||||||
|
return text.length > 200000 ? text.slice(0, 200000) + "\n\n[Текст обрезан для предпросмотра]" : text;
|
||||||
|
}
|
||||||
|
|
||||||
function closePreview() {
|
function closePreview() {
|
||||||
if (!previewOverlay || !previewBody) return;
|
if (!previewOverlay || !previewBody) return;
|
||||||
|
revokePreviewObjectUrl();
|
||||||
previewOverlay.classList.remove("open");
|
previewOverlay.classList.remove("open");
|
||||||
previewOverlay.setAttribute("aria-hidden", "true");
|
previewOverlay.setAttribute("aria-hidden", "true");
|
||||||
previewBody.innerHTML = "";
|
previewBody.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPreview(item) {
|
function closeDataRequestModal() {
|
||||||
|
if (!dataRequestOverlay || !dataRequestItems) return;
|
||||||
|
activeDataRequestMessageId = "";
|
||||||
|
dataRequestItems.innerHTML = "";
|
||||||
|
dataRequestOverlay.classList.remove("open");
|
||||||
|
dataRequestOverlay.setAttribute("aria-hidden", "true");
|
||||||
|
setDataRequestStatus("", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataRequestInputType(fieldType) {
|
||||||
|
const type = String(fieldType || "").toLowerCase();
|
||||||
|
if (type === "date") return "date";
|
||||||
|
if (type === "number") return "number";
|
||||||
|
if (type === "file") return "file";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDataRequestItemsForm(items) {
|
||||||
|
if (!dataRequestItems) return;
|
||||||
|
dataRequestItems.innerHTML = "";
|
||||||
|
if (!Array.isArray(items) || !items.length) {
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.className = "muted-inline";
|
||||||
|
p.textContent = "Нет полей для заполнения.";
|
||||||
|
dataRequestItems.appendChild(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0))
|
||||||
|
.forEach((item, index) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "data-request-form-row";
|
||||||
|
|
||||||
|
const indexNode = document.createElement("div");
|
||||||
|
indexNode.className = "data-request-form-index";
|
||||||
|
indexNode.textContent = String(index + 1) + ".";
|
||||||
|
row.appendChild(indexNode);
|
||||||
|
|
||||||
|
const labelNode = document.createElement("div");
|
||||||
|
labelNode.className = "data-request-form-label";
|
||||||
|
labelNode.textContent = String(item.label || item.key || "Поле");
|
||||||
|
row.appendChild(labelNode);
|
||||||
|
|
||||||
|
const inputWrap = document.createElement("div");
|
||||||
|
inputWrap.className = "field";
|
||||||
|
let input;
|
||||||
|
const normalizedFieldType = String(item.field_type || "").toLowerCase();
|
||||||
|
if (normalizedFieldType === "text") {
|
||||||
|
input = document.createElement("textarea");
|
||||||
|
input.rows = 3;
|
||||||
|
} else {
|
||||||
|
input = document.createElement("input");
|
||||||
|
input.type = dataRequestInputType(normalizedFieldType);
|
||||||
|
if (normalizedFieldType === "number") input.step = "any";
|
||||||
|
}
|
||||||
|
if (normalizedFieldType === "file") {
|
||||||
|
const currentFile = String(item.value_text || "").trim();
|
||||||
|
if (currentFile) {
|
||||||
|
const existing = document.createElement("div");
|
||||||
|
existing.className = "muted-inline";
|
||||||
|
existing.textContent =
|
||||||
|
"Текущее значение: " + String((item.value_file && item.value_file.file_name) || currentFile);
|
||||||
|
inputWrap.appendChild(existing);
|
||||||
|
}
|
||||||
|
if (item.value_file && item.value_file.download_url) {
|
||||||
|
const fileActions = document.createElement("div");
|
||||||
|
fileActions.className = "file-actions";
|
||||||
|
if (detectPreviewKind(item.value_file.file_name, item.value_file.mime_type) !== "none") {
|
||||||
|
const previewBtn = document.createElement("button");
|
||||||
|
previewBtn.type = "button";
|
||||||
|
previewBtn.className = "file-link-btn";
|
||||||
|
previewBtn.textContent = "Предпросмотр";
|
||||||
|
previewBtn.addEventListener("click", () => openPreview(item.value_file));
|
||||||
|
fileActions.appendChild(previewBtn);
|
||||||
|
}
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.className = "file-link-btn";
|
||||||
|
link.href = item.value_file.download_url;
|
||||||
|
link.textContent = "Открыть / скачать";
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
fileActions.appendChild(link);
|
||||||
|
inputWrap.appendChild(fileActions);
|
||||||
|
}
|
||||||
|
const hint = document.createElement("div");
|
||||||
|
hint.className = "muted-inline";
|
||||||
|
hint.textContent = "Выберите файл. Он будет загружен и привязан к полю запроса.";
|
||||||
|
inputWrap.appendChild(hint);
|
||||||
|
input.dataset.currentValue = currentFile;
|
||||||
|
} else {
|
||||||
|
input.value = item.value_text == null ? "" : String(item.value_text);
|
||||||
|
}
|
||||||
|
input.dataset.reqId = String(item.id || "");
|
||||||
|
input.dataset.reqKey = String(item.key || "");
|
||||||
|
input.dataset.reqFieldType = normalizedFieldType;
|
||||||
|
inputWrap.appendChild(input);
|
||||||
|
row.appendChild(inputWrap);
|
||||||
|
|
||||||
|
dataRequestItems.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDataRequestModal(message) {
|
||||||
|
if (!activeTrack || !message?.id || !dataRequestOverlay) return;
|
||||||
|
activeDataRequestMessageId = String(message.id);
|
||||||
|
dataRequestOverlay.classList.add("open");
|
||||||
|
dataRequestOverlay.setAttribute("aria-hidden", "false");
|
||||||
|
if (dataRequestTitle) dataRequestTitle.textContent = "Запрос данных";
|
||||||
|
setDataRequestStatus("Загрузка...", null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
"/api/public/chat/requests/" + encodeURIComponent(activeTrack) + "/data-requests/" + encodeURIComponent(activeDataRequestMessageId)
|
||||||
|
);
|
||||||
|
const data = await parseJsonSafe(response);
|
||||||
|
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось открыть запрос данных"));
|
||||||
|
renderDataRequestItemsForm(data?.items || []);
|
||||||
|
setDataRequestStatus("Заполните нужные поля и сохраните.", null);
|
||||||
|
} catch (error) {
|
||||||
|
setDataRequestStatus(error?.message || "Не удалось открыть запрос данных", "error");
|
||||||
|
renderDataRequestItemsForm([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPreview(item) {
|
||||||
if (!previewOverlay || !previewBody || !previewTitle || !item?.download_url) return;
|
if (!previewOverlay || !previewBody || !previewTitle || !item?.download_url) return;
|
||||||
|
revokePreviewObjectUrl();
|
||||||
previewBody.innerHTML = "";
|
previewBody.innerHTML = "";
|
||||||
previewTitle.textContent = item.file_name || "Предпросмотр файла";
|
previewTitle.textContent = item.file_name || "Предпросмотр файла";
|
||||||
const kind = detectPreviewKind(item.file_name, item.mime_type);
|
const kind = detectPreviewKind(item.file_name, item.mime_type);
|
||||||
|
|
@ -111,12 +318,62 @@
|
||||||
video.controls = true;
|
video.controls = true;
|
||||||
video.preload = "metadata";
|
video.preload = "metadata";
|
||||||
previewBody.appendChild(video);
|
previewBody.appendChild(video);
|
||||||
} else if (kind === "pdf") {
|
} else if (kind === "pdf" || kind === "text") {
|
||||||
const frame = document.createElement("iframe");
|
const loading = document.createElement("p");
|
||||||
frame.className = "preview-frame";
|
loading.className = "preview-note";
|
||||||
frame.src = item.download_url;
|
loading.textContent = "Загрузка предпросмотра...";
|
||||||
frame.title = item.file_name || "PDF";
|
previewBody.appendChild(loading);
|
||||||
previewBody.appendChild(frame);
|
try {
|
||||||
|
const response = await fetch(item.download_url, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) throw new Error("Не удалось загрузить файл для предпросмотра.");
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
previewBody.innerHTML = "";
|
||||||
|
|
||||||
|
if (kind === "pdf") {
|
||||||
|
const header = new Uint8Array(buffer.slice(0, 5));
|
||||||
|
const isPdf =
|
||||||
|
header.length >= 5 &&
|
||||||
|
header[0] === 0x25 &&
|
||||||
|
header[1] === 0x50 &&
|
||||||
|
header[2] === 0x44 &&
|
||||||
|
header[3] === 0x46 &&
|
||||||
|
header[4] === 0x2d;
|
||||||
|
if (isPdf) {
|
||||||
|
const frame = document.createElement("iframe");
|
||||||
|
frame.className = "preview-frame";
|
||||||
|
frame.src = item.download_url;
|
||||||
|
frame.title = item.file_name || "PDF";
|
||||||
|
previewBody.appendChild(frame);
|
||||||
|
} else {
|
||||||
|
const text = decodeTextPreview(buffer);
|
||||||
|
if (text != null) {
|
||||||
|
const note = document.createElement("p");
|
||||||
|
note.className = "preview-note";
|
||||||
|
note.textContent = "Файл помечен как PDF, но не является валидным PDF. Показан текстовый предпросмотр.";
|
||||||
|
previewBody.appendChild(note);
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.className = "preview-text";
|
||||||
|
pre.textContent = text || "Файл пуст.";
|
||||||
|
previewBody.appendChild(pre);
|
||||||
|
} else {
|
||||||
|
throw new Error("Файл помечен как PDF, но не является валидным PDF-документом.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const text = decodeTextPreview(buffer);
|
||||||
|
if (text == null) throw new Error("Не удалось распознать текстовый файл для предпросмотра.");
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.className = "preview-text";
|
||||||
|
pre.textContent = text || "Файл пуст.";
|
||||||
|
previewBody.appendChild(pre);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
previewBody.innerHTML = "";
|
||||||
|
const note = document.createElement("p");
|
||||||
|
note.className = "preview-note";
|
||||||
|
note.textContent = error instanceof Error ? error.message : "Не удалось открыть предпросмотр.";
|
||||||
|
previewBody.appendChild(note);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const note = document.createElement("p");
|
const note = document.createElement("p");
|
||||||
note.className = "preview-note";
|
note.className = "preview-note";
|
||||||
|
|
@ -150,10 +407,78 @@
|
||||||
time.textContent = formatDate(item.created_at);
|
time.textContent = formatDate(item.created_at);
|
||||||
li.appendChild(time);
|
li.appendChild(time);
|
||||||
|
|
||||||
const p = document.createElement("p");
|
if (String(item.message_kind || "") === "REQUEST_DATA") {
|
||||||
const author = item.author_name || item.author_type || "Участник";
|
li.classList.add("request-data-item");
|
||||||
p.textContent = author + ": " + (item.body || "");
|
if (item.request_data_all_filled) li.classList.add("done");
|
||||||
li.appendChild(p);
|
|
||||||
|
const author = document.createElement("div");
|
||||||
|
author.className = "request-data-item-author";
|
||||||
|
author.textContent = String(item.author_name || item.author_type || "Юрист");
|
||||||
|
li.appendChild(author);
|
||||||
|
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "request-data-message-btn";
|
||||||
|
button.addEventListener("click", () => openDataRequestModal(item));
|
||||||
|
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "request-data-message-title";
|
||||||
|
if (
|
||||||
|
item.request_data_all_filled &&
|
||||||
|
Array.isArray(item.request_data_items) &&
|
||||||
|
item.request_data_items.length === 1 &&
|
||||||
|
String(item.request_data_items[0]?.field_type || "").toLowerCase() === "file"
|
||||||
|
) {
|
||||||
|
title.textContent = "Файл";
|
||||||
|
} else {
|
||||||
|
title.textContent = "Запрос";
|
||||||
|
}
|
||||||
|
button.appendChild(title);
|
||||||
|
|
||||||
|
if (!item.request_data_all_filled && Array.isArray(item.request_data_items) && item.request_data_items.length) {
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "request-data-message-list";
|
||||||
|
const visibleItems = item.request_data_items.slice(0, 7);
|
||||||
|
visibleItems.forEach((req, idx) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "request-data-message-row";
|
||||||
|
if (req.is_filled) row.classList.add("filled");
|
||||||
|
|
||||||
|
const idxNode = document.createElement("span");
|
||||||
|
idxNode.className = "request-data-message-row-index";
|
||||||
|
idxNode.textContent = String(req.index || idx + 1) + ".";
|
||||||
|
row.appendChild(idxNode);
|
||||||
|
|
||||||
|
if (req.is_filled) {
|
||||||
|
const check = document.createElement("span");
|
||||||
|
check.className = "request-data-message-row-check";
|
||||||
|
check.textContent = "✓";
|
||||||
|
idxNode.prepend(check);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelNode = document.createElement("span");
|
||||||
|
labelNode.className = "request-data-message-row-label";
|
||||||
|
labelNode.textContent = String(req.label_short || req.label || "Поле");
|
||||||
|
row.appendChild(labelNode);
|
||||||
|
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
if (item.request_data_items.length > visibleItems.length) {
|
||||||
|
const more = document.createElement("div");
|
||||||
|
more.className = "request-data-message-more";
|
||||||
|
more.textContent = "... еще " + String(item.request_data_items.length - visibleItems.length);
|
||||||
|
list.appendChild(more);
|
||||||
|
}
|
||||||
|
button.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.appendChild(button);
|
||||||
|
} else {
|
||||||
|
const p = document.createElement("p");
|
||||||
|
const author = item.author_name || item.author_type || "Участник";
|
||||||
|
p.textContent = author + ": " + (item.body || "");
|
||||||
|
li.appendChild(p);
|
||||||
|
}
|
||||||
cabinetMessages.appendChild(li);
|
cabinetMessages.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -397,8 +722,75 @@
|
||||||
if (event.key === "Escape" && previewOverlay?.classList.contains("open")) {
|
if (event.key === "Escape" && previewOverlay?.classList.contains("open")) {
|
||||||
closePreview();
|
closePreview();
|
||||||
}
|
}
|
||||||
|
if (event.key === "Escape" && dataRequestOverlay?.classList.contains("open")) {
|
||||||
|
closeDataRequestModal();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (dataRequestClose) {
|
||||||
|
dataRequestClose.addEventListener("click", closeDataRequestModal);
|
||||||
|
}
|
||||||
|
if (dataRequestOverlay) {
|
||||||
|
dataRequestOverlay.addEventListener("click", (event) => {
|
||||||
|
if (event.target === dataRequestOverlay) closeDataRequestModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dataRequestForm) {
|
||||||
|
dataRequestForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!activeTrack || !activeDataRequestMessageId || !activeRequestId) return;
|
||||||
|
const inputs = Array.from(dataRequestForm.querySelectorAll("input[data-req-id], textarea[data-req-id]"));
|
||||||
|
try {
|
||||||
|
setDataRequestStatus("Сохраняем...", null);
|
||||||
|
const items = [];
|
||||||
|
for (const input of inputs) {
|
||||||
|
const fieldType = String(input.dataset.reqFieldType || "").toLowerCase();
|
||||||
|
if (fieldType === "file") {
|
||||||
|
let attachmentId = "";
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
setDataRequestStatus("Загружаем файл для поля...", null);
|
||||||
|
const completeData = await uploadPublicRequestAttachment(input.files[0], activeRequestId);
|
||||||
|
attachmentId = String((completeData && completeData.attachment_id) || "");
|
||||||
|
input.dataset.currentValue = attachmentId;
|
||||||
|
} else {
|
||||||
|
attachmentId = String(input.dataset.currentValue || "");
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
id: String(input.dataset.reqId || ""),
|
||||||
|
key: String(input.dataset.reqKey || ""),
|
||||||
|
attachment_id: attachmentId,
|
||||||
|
value_text: attachmentId,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
id: String(input.dataset.reqId || ""),
|
||||||
|
key: String(input.dataset.reqKey || ""),
|
||||||
|
value_text: String(input.value || ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDataRequestStatus("Сохраняем...", null);
|
||||||
|
const response = await fetch(
|
||||||
|
"/api/public/chat/requests/" +
|
||||||
|
encodeURIComponent(activeTrack) +
|
||||||
|
"/data-requests/" +
|
||||||
|
encodeURIComponent(activeDataRequestMessageId),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await parseJsonSafe(response);
|
||||||
|
if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось сохранить данные"));
|
||||||
|
setDataRequestStatus("Данные сохранены.", "ok");
|
||||||
|
await refreshCabinetData();
|
||||||
|
} catch (error) {
|
||||||
|
setDataRequestStatus(error?.message || "Не удалось сохранить данные", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
cabinetChatForm.addEventListener("submit", async (event) => {
|
cabinetChatForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!activeTrack) {
|
if (!activeTrack) {
|
||||||
|
|
@ -439,41 +831,7 @@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus(pageStatus, "Подготавливаем загрузку файла...", null);
|
setStatus(pageStatus, "Подготавливаем загрузку файла...", null);
|
||||||
const initResponse = await fetch("/api/public/uploads/init", {
|
await uploadPublicRequestAttachment(file, activeRequestId);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
file_name: file.name,
|
|
||||||
mime_type: file.type || "application/octet-stream",
|
|
||||||
size_bytes: file.size,
|
|
||||||
scope: "REQUEST_ATTACHMENT",
|
|
||||||
request_id: activeRequestId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const initData = await parseJsonSafe(initResponse);
|
|
||||||
if (!initResponse.ok) throw new Error(apiErrorDetail(initData, "Не удалось начать загрузку"));
|
|
||||||
|
|
||||||
const putResponse = await fetch(initData.presigned_url, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
||||||
body: file,
|
|
||||||
});
|
|
||||||
if (!putResponse.ok) throw new Error("Ошибка передачи файла в хранилище");
|
|
||||||
|
|
||||||
const completeResponse = await fetch("/api/public/uploads/complete", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
key: initData.key,
|
|
||||||
file_name: file.name,
|
|
||||||
mime_type: file.type || "application/octet-stream",
|
|
||||||
size_bytes: file.size,
|
|
||||||
scope: "REQUEST_ATTACHMENT",
|
|
||||||
request_id: activeRequestId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const completeData = await parseJsonSafe(completeResponse);
|
|
||||||
if (!completeResponse.ok) throw new Error(apiErrorDetail(completeData, "Не удалось завершить загрузку"));
|
|
||||||
|
|
||||||
cabinetFileInput.value = "";
|
cabinetFileInput.value = "";
|
||||||
await refreshCabinetData();
|
await refreshCabinetData();
|
||||||
|
|
|
||||||
|
|
@ -650,6 +650,151 @@
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.featured-team-section[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-team-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-team-track {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(280px, 34%);
|
||||||
|
gap: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
padding: 0.15rem 0.1rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-card {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(165deg, rgba(32, 43, 57, 0.95), rgba(17, 24, 32, 0.96));
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px 1fr;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
min-height: 146px;
|
||||||
|
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18);
|
||||||
|
animation: rise 0.45s ease forwards;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-avatar {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid rgba(212, 169, 104, 0.35);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-card-body {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-card-top h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: #eef4ff;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-chip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
border: 1px solid rgba(212, 169, 104, 0.35);
|
||||||
|
background: rgba(212, 169, 104, 0.12);
|
||||||
|
color: #f4d7a8;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-meta {
|
||||||
|
margin: 0;
|
||||||
|
color: #a6b5c8;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-caption {
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
color: #dde8f6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #e8eff8;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(212, 169, 104, 0.3);
|
||||||
|
background: rgba(212, 169, 104, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dots {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(207, 217, 231, 0.28);
|
||||||
|
transition: transform 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot.active {
|
||||||
|
background: rgba(212, 169, 104, 0.9);
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
.brand,
|
.brand,
|
||||||
.meta-row b {
|
.meta-row b {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
|
@ -668,6 +813,10 @@
|
||||||
.practices {
|
.practices {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.featured-team-track {
|
||||||
|
grid-auto-columns: minmax(300px, 52%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 740px) {
|
@media (max-width: 740px) {
|
||||||
|
|
@ -714,6 +863,18 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.featured-team-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-team-track {
|
||||||
|
grid-auto-columns: 86%;
|
||||||
|
}
|
||||||
|
|
||||||
.simple-list {
|
.simple-list {
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
}
|
}
|
||||||
|
|
@ -741,6 +902,15 @@
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.featured-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-avatar {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
padding-top: 1.4rem;
|
padding-top: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<a href="#practices">Компетенции</a>
|
<a href="#practices">Компетенции</a>
|
||||||
<a href="#approach">Подход</a>
|
<a href="#approach">Подход</a>
|
||||||
<a href="#expert">Эксперт</a>
|
<a href="#expert">Эксперт</a>
|
||||||
|
<a href="#team">Команда</a>
|
||||||
<button class="btn btn-ghost" type="button" data-open-access>Мои заявки</button>
|
<button class="btn btn-ghost" type="button" data-open-access>Мои заявки</button>
|
||||||
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
|
<button class="btn btn-ghost" type="button" data-open-modal>Оставить заявку</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -147,6 +148,21 @@
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="team" class="featured-team-section" hidden>
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Выдающиеся юристы</h2>
|
||||||
|
<p class="subtitle">Команда специалистов, которых администратор рекомендует для знакомства с нашим подходом и практикой.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="featured-team-shell">
|
||||||
|
<button class="carousel-nav prev" type="button" id="featured-team-prev" aria-label="Прокрутить влево">‹</button>
|
||||||
|
<div class="featured-team-track" id="featured-team-track" aria-live="polite"></div>
|
||||||
|
<button class="carousel-nav next" type="button" id="featured-team-next" aria-label="Прокрутить вправо">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="carousel-dots" id="featured-team-dots" aria-label="Навигация по карточкам"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="cta-band">
|
<section class="cta-band">
|
||||||
<p>Создайте заявку и получите номер обращения. По нему вы сможете отслеживать статус, чат и документы в отдельной странице клиента.</p>
|
<p>Создайте заявку и получите номер обращения. По нему вы сможете отслеживать статус, чат и документы в отдельной странице клиента.</p>
|
||||||
<button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button>
|
<button class="btn btn-primary" type="button" data-open-modal>Создать заявку</button>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@
|
||||||
|
|
||||||
const quoteText = document.getElementById("quote-text");
|
const quoteText = document.getElementById("quote-text");
|
||||||
const quoteMeta = document.getElementById("quote-meta");
|
const quoteMeta = document.getElementById("quote-meta");
|
||||||
|
const featuredTeamSection = document.getElementById("team");
|
||||||
|
const featuredTeamTrack = document.getElementById("featured-team-track");
|
||||||
|
const featuredTeamDots = document.getElementById("featured-team-dots");
|
||||||
|
const featuredTeamPrev = document.getElementById("featured-team-prev");
|
||||||
|
const featuredTeamNext = document.getElementById("featured-team-next");
|
||||||
|
|
||||||
function setStatus(el, message, kind) {
|
function setStatus(el, message, kind) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
@ -135,6 +140,120 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderFeaturedDots(count, activeIndex) {
|
||||||
|
if (!featuredTeamDots) return;
|
||||||
|
featuredTeamDots.innerHTML = "";
|
||||||
|
if (count <= 1) return;
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "carousel-dot" + (index === activeIndex ? " active" : "");
|
||||||
|
button.setAttribute("aria-label", "Карточка " + (index + 1));
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const card = featuredTeamTrack?.children?.[index];
|
||||||
|
if (card && typeof card.scrollIntoView === "function") {
|
||||||
|
card.scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
featuredTeamDots.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFeaturedCarouselControls() {
|
||||||
|
if (!featuredTeamTrack) return;
|
||||||
|
const scrollByCards = (dir) => {
|
||||||
|
const card = featuredTeamTrack.querySelector(".featured-card");
|
||||||
|
const step = card ? card.getBoundingClientRect().width + 14 : 320;
|
||||||
|
featuredTeamTrack.scrollBy({ left: dir * step, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (featuredTeamPrev) featuredTeamPrev.addEventListener("click", () => scrollByCards(-1));
|
||||||
|
if (featuredTeamNext) featuredTeamNext.addEventListener("click", () => scrollByCards(1));
|
||||||
|
|
||||||
|
let rafId = 0;
|
||||||
|
const syncDots = () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
const cards = Array.from(featuredTeamTrack.children || []);
|
||||||
|
if (!cards.length) return renderFeaturedDots(0, 0);
|
||||||
|
const trackLeft = featuredTeamTrack.getBoundingClientRect().left;
|
||||||
|
let bestIndex = 0;
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
const distance = Math.abs(card.getBoundingClientRect().left - trackLeft);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
bestIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderFeaturedDots(cards.length, bestIndex);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
featuredTeamTrack.addEventListener("scroll", syncDots, { passive: true });
|
||||||
|
window.addEventListener("resize", syncDots);
|
||||||
|
syncDots();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFeaturedStaff() {
|
||||||
|
if (!featuredTeamSection || !featuredTeamTrack) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/public/featured-staff?limit=24");
|
||||||
|
const data = await parseJsonSafe(response);
|
||||||
|
const items = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
if (!response.ok || items.length === 0) {
|
||||||
|
featuredTeamSection.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
featuredTeamTrack.innerHTML = "";
|
||||||
|
items.forEach((item) => {
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "featured-card";
|
||||||
|
|
||||||
|
const avatar = document.createElement("img");
|
||||||
|
avatar.className = "featured-avatar";
|
||||||
|
avatar.src = String(item.avatar_url || "");
|
||||||
|
avatar.alt = String(item.name || "Сотрудник");
|
||||||
|
avatar.loading = "lazy";
|
||||||
|
card.appendChild(avatar);
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "featured-card-body";
|
||||||
|
|
||||||
|
const top = document.createElement("div");
|
||||||
|
top.className = "featured-card-top";
|
||||||
|
const name = document.createElement("h3");
|
||||||
|
name.textContent = String(item.name || "Сотрудник");
|
||||||
|
top.appendChild(name);
|
||||||
|
if (item.pinned) {
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.className = "featured-chip";
|
||||||
|
chip.textContent = "Рекомендуем";
|
||||||
|
top.appendChild(chip);
|
||||||
|
}
|
||||||
|
body.appendChild(top);
|
||||||
|
|
||||||
|
const meta = document.createElement("p");
|
||||||
|
meta.className = "featured-meta";
|
||||||
|
meta.textContent = [item.role_label, item.primary_topic_name].filter(Boolean).join(" • ");
|
||||||
|
body.appendChild(meta);
|
||||||
|
|
||||||
|
const caption = document.createElement("p");
|
||||||
|
caption.className = "featured-caption";
|
||||||
|
caption.textContent = String(item.caption || "Практический опыт в сложных юридических делах и сопровождении споров.");
|
||||||
|
body.appendChild(caption);
|
||||||
|
|
||||||
|
card.appendChild(body);
|
||||||
|
featuredTeamTrack.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
featuredTeamSection.hidden = false;
|
||||||
|
initFeaturedCarouselControls();
|
||||||
|
} catch (_) {
|
||||||
|
featuredTeamSection.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
accessSendOtpButton.addEventListener("click", async () => {
|
accessSendOtpButton.addEventListener("click", async () => {
|
||||||
const phone = String(accessPhoneInput.value || "").trim();
|
const phone = String(accessPhoneInput.value || "").trim();
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
|
|
@ -254,4 +373,5 @@
|
||||||
|
|
||||||
loadTopics();
|
loadTopics();
|
||||||
loadQuotes();
|
loadQuotes();
|
||||||
|
loadFeaturedStaff();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -58,6 +58,20 @@
|
||||||
| P37 | сделано | Админ-авторизация и креды | Привести к единому правилу bootstrap-креды администратора (`admin@example.com` + согласованный пароль), обновить документацию/контекст и smoke-проверки логина | Реализован bootstrap-login с автосозданием администратора `admin@example.com` / `admin123`; добавлены автотесты `tests/test_admin_auth.py` |
|
| P37 | сделано | Админ-авторизация и креды | Привести к единому правилу bootstrap-креды администратора (`admin@example.com` + согласованный пароль), обновить документацию/контекст и smoke-проверки логина | Реализован bootstrap-login с автосозданием администратора `admin@example.com` / `admin123`; добавлены автотесты `tests/test_admin_auth.py` |
|
||||||
| P38 | сделано | Конструктор маршрутов статусов | Реализовать для администратора визуальный конструктор маршрутов статусов по каждой теме: вариативные переходы (в т.ч. возврат на предыдущий статус, переход в завершение и альтернативные ветки), SLA на переход, список обязательных документов/данных для закрытия шага | Добавлен UI-конструктор в справочнике переходов статусов (выбор темы, визуальные карточки статусов и исходящих переходов), расширены поля перехода (`required_data_keys`, `required_mime_types`), переходы валидируются API по требованиям шага (данные заявки + MIME вложений), добавлены backend/e2e тесты |
|
| P38 | сделано | Конструктор маршрутов статусов | Реализовать для администратора визуальный конструктор маршрутов статусов по каждой теме: вариативные переходы (в т.ч. возврат на предыдущий статус, переход в завершение и альтернативные ветки), SLA на переход, список обязательных документов/данных для закрытия шага | Добавлен UI-конструктор в справочнике переходов статусов (выбор темы, визуальные карточки статусов и исходящих переходов), расширены поля перехода (`required_data_keys`, `required_mime_types`), переходы валидируются API по требованиям шага (данные заявки + MIME вложений), добавлены backend/e2e тесты |
|
||||||
| P39 | сделано | Канбан по заявкам (LAWYER/ADMIN) | Реализовать канбан-доску заявок с унификацией разных статусных флоу через группы колонок (например: `Новые`, `В работе`, `Ожидание`, `Завершены`) + карточки заявок с ключевыми данными (дата создания, клиент, описание, новые сообщения/файлы, SLA deadline/дедлайн дела) | Добавлен backend endpoint `/api/admin/requests/kanban` (role-scope, группы статусов, SLA/case deadline, допустимые переходы); в `admin.jsx` добавлена секция `Канбан` с карточками, claim для юриста, drag&drop/быстрый перевод по допустимым переходам, open-in-place; покрыто unittest и e2e (`kanban_role_flow`) |
|
| P39 | сделано | Канбан по заявкам (LAWYER/ADMIN) | Реализовать канбан-доску заявок с унификацией разных статусных флоу через группы колонок (например: `Новые`, `В работе`, `Ожидание`, `Завершены`) + карточки заявок с ключевыми данными (дата создания, клиент, описание, новые сообщения/файлы, SLA deadline/дедлайн дела) | Добавлен backend endpoint `/api/admin/requests/kanban` (role-scope, группы статусов, SLA/case deadline, допустимые переходы); в `admin.jsx` добавлена секция `Канбан` с карточками, claim для юриста, drag&drop/быстрый перевод по допустимым переходам, open-in-place; покрыто unittest и e2e (`kanban_role_flow`) |
|
||||||
|
| P40 | сделано | Декомпозиция: подготовка сборки фронта | Подготовить модульную декомпозицию фронта: перевести entrypoint `admin.jsx` -> `admin/index.jsx`, включить `esbuild --bundle` в `frontend/Dockerfile`, зафиксировать совместимость `admin.html` и Docker Compose | Реализовано: добавлен `app/web/admin/index.jsx`, сборка переведена на `esbuild admin/index.jsx --bundle`, smoke e2e входа/навигации (`admin_entry_flow`) и сборка в контейнере проходят |
|
||||||
|
| P41 | сделано | Декомпозиция `admin.jsx`: shared-слой | Вынести из `admin.jsx` константы/маппинги/табличные конфиги и pure-utils (`format`, `filters`, `route`, `reference`) в отдельные модули | Реализовано: добавлены `app/web/admin/shared/constants.js`, `app/web/admin/shared/utils.js`, `app/web/admin/shared/state.js`; `admin.jsx` сокращен до ~4800 строк и использует shared-импорты; e2e smoke `admin_entry_flow`, `admin_role_flow`, `kanban_role_flow` зеленые |
|
||||||
|
| P42 | сделано | Декомпозиция `admin.jsx`: feature-слой | Разделить UI и логику на feature-модули (`kanban`, `request-workspace`, `config-dictionaries`, `invoices`, `dashboard`) + вынести кастомные hooks/services (`useAdminApi`, `useTablesState`, `useRequestWorkspace`, `useKanban`) | Корневой `App` выполняет orchestration/layout, feature-код изолирован по папкам, сценарии ADMIN/LAWYER/CLIENT не деградировали |
|
||||||
|
| P43 | к разработке | Декомпозиция backend CRUD | Разбить `app/api/admin/crud.py` на модули: `router`, `access`, `meta`, `payloads`, `service`, `audit` без изменения API-контракта и RBAC | Эндпоинты CRUD/meta работают как раньше, покрытие тестами сохранено/расширено, файл-монолит устранен |
|
||||||
|
| P44 | к разработке | Декомпозиция backend Requests | Разбить `app/api/admin/requests.py` на модули: `router`, `kanban`, `status_flow`, `data_templates`, `permissions`, `service` с сохранением текущего поведения | Эндпоинты заявок/канбана/маршрутов статусов проходят текущие тесты, ролевые ограничения и SLA-логика без регрессий |
|
||||||
|
| P45 | к разработке | Декомпозиция тестового слоя | Разделить `tests/test_admin_universal_crud.py` на тематические пакеты (`tests/admin/*`) + вынести общие фикстуры/фабрики | Тесты запускаются пакетно и по подмодулям, время/диагностика прогонов улучшаются, покрытие не снижается |
|
||||||
|
| P46 | к разработке | Финализация декомпозиции | Обновить runbook/контекст по новым путям модулей и тестов, выполнить полный регрессионный прогон (unittest + e2e) и закрыть технический долг по монолитам | `context/11_test_runbook.md` и связанные контексты актуальны, полный прогон тестов зеленый, декомпозиция завершена |
|
||||||
|
| P47 | к разработке | Запросы клиента по заявке (модель/миграции) | Добавить отдельную таблицу клиентских обращений по заявке (рабочее имя таблицы: `request_service_requests`, чтобы не конфликтовать с `requests`): тип `enum` (`CURATOR_CONTACT`, `LAWYER_CHANGE_REQUEST`), статус обработки, текст обращения, ссылки на заявку/клиента/назначенного юриста, read/unread флаги для ADMIN/LAWYER/CURATOR, аудит | Миграция применена, таблица доступна в БД, API/модели позволяют создать оба типа запросов, read/unread и аудит фиксируются |
|
||||||
|
| P48 | к разработке | RBAC и видимость запросов (куратор/смена юриста) | Реализовать правила видимости и доступа: запрос к куратору видят ADMIN (и будущий `CURATOR`) + назначенный юрист; запрос о смене юриста не видит назначенный юрист, видит ADMIN (и будущий `CURATOR` при включении роли); предусмотреть доступ к чату заявки для куратора и отправку сообщений от его имени | Правила видимости соблюдаются серверно, назначенный юрист не видит `LAWYER_CHANGE_REQUEST`, кураторский доступ к чату и чтение/запись работают по RBAC |
|
||||||
|
| P49 | к разработке | Клиентский UI: запрос к куратору / смена юриста | Добавить в клиентском контуре действия: (1) запрос консультации к администратору/куратору по делу; (2) запрос о смене юриста; показывать статус обработки и связанные уведомления по заявке, не раскрывая служебные поля | Клиент может создать оба типа запросов из UI заявки, видит подтверждение и статус, запросы связываются с конкретной заявкой |
|
||||||
|
| P50 | к разработке | Админ-панель: вкладка «Запросы» + индикатор в topbar | Добавить отдельную вкладку `Запросы` наравне с `Заявки` и `Счета`; таблица в общем стиле (фильтры/сортировка/пагинация), а также отдельную topbar-иконку (левее `!` и конверта), которая подсвечивается красным при непрочитанных запросах и открывает таблицу с фильтром по непрочитанным | Вкладка `Запросы` доступна ADMIN (и CURATOR при появлении роли), topbar-иконка показывает unread и открывает отфильтрованный список, визуально согласовано с текущими индикаторами |
|
||||||
|
| P51 | к разработке | Тесты: запросы к куратору / смена юриста | Добавить backend + e2e покрытия: создание запросов клиентом, RBAC-изоляция по типам, подсветка заявок/иконки в админке, видимость для юриста/админа/куратора, доступ к чату от куратора | Автотесты покрывают оба типа запросов и corner cases (невидимость запроса о смене юриста назначенному юристу, unread/reset, фильтрация в таблице `Запросы`) |
|
||||||
|
| P52 | сделано | Лендинг: карусель выдающихся юристов | Добавить на лендинг карусель сотрудников (выдающиеся юристы) с фотографиями; в выдачу попадают только пользователи ролей `LAWYER`/`ADMIN`, у которых заполнен `avatar_url` (фото в профиле) | На лендинге отображается карусель карточек сотрудников с фото, именем и подписью; без фото сотрудник в карусель не попадает |
|
||||||
|
| P53 | сделано | Справочник карусели сотрудников | Добавить отдельную таблицу/справочник для управления каруселью на лендинге: ссылка на сотрудника, порядок, активность, подпись, признак закрепления (`pinned`) и CRUD в админке | Администратор может добавлять/убирать сотрудников, менять порядок, задавать подпись и `pinned`; лендинг использует этот справочник для выдачи карусели |
|
||||||
|
|
||||||
## Критический маршрут (обязательный порядок)
|
## Критический маршрут (обязательный порядок)
|
||||||
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
|
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
|
||||||
|
|
@ -67,6 +81,9 @@
|
||||||
5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце).
|
5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце).
|
||||||
6. `P28 -> P29 -> P30 -> P31 -> P32 -> P33 -> P35 -> P34 -> P36 -> P37` (итерация UX/клиентского входа/чат-сервиса/навигации/доступов).
|
6. `P28 -> P29 -> P30 -> P31 -> P32 -> P33 -> P35 -> P34 -> P36 -> P37` (итерация UX/клиентского входа/чат-сервиса/навигации/доступов).
|
||||||
7. `P38 -> P39` (конструктор маршрутов и канбан-представление заявок для ролей).
|
7. `P38 -> P39` (конструктор маршрутов и канбан-представление заявок для ролей).
|
||||||
|
8. `P40 -> P41 -> P42 -> P43 -> P44 -> P45 -> P46` (декомпозиция фронта/бэка/тестов и финальная стабилизация).
|
||||||
|
9. `P47 -> P48 -> P49 -> P50 -> P51` (контур клиентских запросов к куратору/админу и запросов на смену юриста).
|
||||||
|
10. `P52 -> P53` (карусель сотрудников на лендинге и админское управление составом/порядком).
|
||||||
|
|
||||||
## Детализация P38-P39 (новый контур)
|
## Детализация P38-P39 (новый контур)
|
||||||
### P38. Конструктор маршрутов статусов
|
### P38. Конструктор маршрутов статусов
|
||||||
|
|
@ -93,6 +110,99 @@
|
||||||
5. Действия:
|
5. Действия:
|
||||||
перевод карточки между колонками только по допустимым серверным переходам.
|
перевод карточки между колонками только по допустимым серверным переходам.
|
||||||
|
|
||||||
|
## Детализация P40-P46 (декомпозиция монолитов)
|
||||||
|
### P40-P42. Фронтенд (admin)
|
||||||
|
1. Инфраструктурный шаг:
|
||||||
|
сначала подготовить сборку под модули (`index.jsx`, `--bundle`), затем приступать к разбиению.
|
||||||
|
2. Shared-слой:
|
||||||
|
вынести константы, конфиги таблиц/лейблов и pure-utils в `app/web/admin/*`.
|
||||||
|
3. Feature-слой:
|
||||||
|
выделить отдельные модули и hooks для Kanban, Workspace заявки, Справочников, Счетов, Dashboard.
|
||||||
|
4. Ограничение:
|
||||||
|
декомпозиция без изменения пользовательского поведения и API-контрактов.
|
||||||
|
5. Итог `P42`:
|
||||||
|
вынесены feature-секции (`kanban`, `request-workspace`, `dashboard`, `requests`, `invoices`, `config-dictionaries`, `quotes`, `availableTables`, `meta`) и hooks/services (`useAdminApi`, `useKanban`, `useRequestWorkspace`, `useTablesState`, `useTableActions`, `useTableFilterActions`, `useAdminCatalogLoaders`); `admin.jsx` сокращен до orchestration/layout уровня, role e2e-регресс подтвержден.
|
||||||
|
|
||||||
|
### P43-P44. Backend (admin API)
|
||||||
|
1. Разделение `crud.py`:
|
||||||
|
разнести права, метаданные, нормализацию payload, сервис CRUD и аудит по отдельным файлам.
|
||||||
|
2. Разделение `requests.py`:
|
||||||
|
выделить канбан, workflow статусов, шаблоны данных, проверки прав и сервисный слой.
|
||||||
|
3. Ограничение:
|
||||||
|
сохранить текущие URL/контракты эндпоинтов и существующую RBAC/SLA-логику.
|
||||||
|
|
||||||
|
### P45-P46. Тесты и контекст
|
||||||
|
1. Разделение тестов:
|
||||||
|
разнести большой `test_admin_universal_crud.py` по тематическим модулям и общим helper/fixture.
|
||||||
|
2. Финальная верификация:
|
||||||
|
обновить runbook, затем выполнить полный прогон backend + e2e перед переводом блока в `сделано`.
|
||||||
|
|
||||||
|
## Детализация P47-P51 (запросы к куратору / смена юриста)
|
||||||
|
### P47. Таблица клиентских запросов по заявке
|
||||||
|
1. Новая сущность:
|
||||||
|
добавить отдельную таблицу (рабочее имя `request_service_requests`, чтобы не конфликтовать с `requests`).
|
||||||
|
2. Поля:
|
||||||
|
`request_id`, `client_id`, `type(enum)`, `status(enum)`, `body`, `created_by_client`, `assigned_lawyer_id`, `resolved_by_admin_id`, `read/unread` маркеры по ролям, системные поля.
|
||||||
|
3. Типы запросов:
|
||||||
|
`CURATOR_CONTACT`, `LAWYER_CHANGE_REQUEST`.
|
||||||
|
4. Аудит:
|
||||||
|
создание/изменение статуса/прочтение логировать в `audit_log`.
|
||||||
|
|
||||||
|
### P48. Видимость и RBAC
|
||||||
|
1. `CURATOR_CONTACT`:
|
||||||
|
видят ADMIN (и будущий `CURATOR`) + назначенный юрист; подсветка заявки для администратора/куратора и ведущего юриста.
|
||||||
|
2. `LAWYER_CHANGE_REQUEST`:
|
||||||
|
не видит назначенный юрист; видит ADMIN (и будущий `CURATOR`, если роль будет добавлена).
|
||||||
|
3. Куратор:
|
||||||
|
предусмотреть серверные точки расширения под роль `CURATOR` (даже если пока используется ADMIN как куратор).
|
||||||
|
4. Чат:
|
||||||
|
куратор получает доступ к чтению/записи в чат заявки от своего имени по RBAC.
|
||||||
|
|
||||||
|
### P49. Клиентский UI
|
||||||
|
1. Кнопки в карточке заявки:
|
||||||
|
«Обратиться к куратору/администратору» и «Запросить смену юриста».
|
||||||
|
2. Формы:
|
||||||
|
модальные окна с текстом обращения, валидацией длины и подтверждением отправки.
|
||||||
|
3. Отображение:
|
||||||
|
клиент видит только свои запросы и их статус обработки.
|
||||||
|
|
||||||
|
### P50. Админский UI
|
||||||
|
1. Новая вкладка `Запросы`:
|
||||||
|
таблица в стиле `Заявки/Счета` (фильтр, сортировка, пагинация, server-side).
|
||||||
|
2. Topbar-иконка:
|
||||||
|
отдельный индикатор (левее `!` и конверта), красный `unread` + переход в `Запросы` с фильтром по непрочитанным.
|
||||||
|
3. Подсветка заявок:
|
||||||
|
заявка с открытыми клиентскими запросами должна визуально отмечаться в списке `Заявки` и/или карточке.
|
||||||
|
|
||||||
|
### P51. Тестирование
|
||||||
|
1. Backend:
|
||||||
|
позитивные/негативные тесты по типам запросов и RBAC.
|
||||||
|
2. E2E:
|
||||||
|
клиент создает оба типа запросов; админ видит их в новой вкладке и через topbar-иконку; назначенный юрист видит только `CURATOR_CONTACT`.
|
||||||
|
3. Corner cases:
|
||||||
|
unread/read reset, повторные запросы, пустой/слишком длинный текст, доступ к чужой заявке.
|
||||||
|
|
||||||
|
## Детализация P52-P53 (карусель сотрудников на лендинге)
|
||||||
|
### P52. Лендинг-карусель
|
||||||
|
1. Отображение:
|
||||||
|
карусель карточек сотрудников в блоке лендинга (UI/UX в стиле проекта, адаптивная для mobile/desktop).
|
||||||
|
2. Ограничение выборки:
|
||||||
|
в выдачу попадают только `LAWYER`/`ADMIN` с заполненным `avatar_url`.
|
||||||
|
3. Контент карточки:
|
||||||
|
фото, имя, подпись (из справочника карусели), при необходимости роль.
|
||||||
|
4. Сортировка:
|
||||||
|
сначала `pinned`, затем `sort_order`.
|
||||||
|
|
||||||
|
### P53. Справочник управления каруселью
|
||||||
|
1. Новая таблица:
|
||||||
|
отдельный справочник (например, `landing_featured_staff`) с полями: `admin_user_id`, `caption`, `sort_order`, `enabled`, `pinned` + системные поля.
|
||||||
|
2. Админский CRUD:
|
||||||
|
универсальная таблица/форма в справочниках для добавления, удаления, редактирования и сортировки.
|
||||||
|
3. Валидация:
|
||||||
|
серверно разрешать только сотрудников ролей `LAWYER`/`ADMIN`; при отсутствии фото элемент не попадет в публичную выдачу.
|
||||||
|
4. Публичный endpoint/выдача:
|
||||||
|
лендинг получает отдельную публичную выборку по активным элементам карусели.
|
||||||
|
|
||||||
## Правила выполнения для ИИ-агента
|
## Правила выполнения для ИИ-агента
|
||||||
1. Не менять бизнес-правила без обновления `context/*.md`.
|
1. Не менять бизнес-правила без обновления `context/*.md`.
|
||||||
2. Любую новую таблицу добавлять только через миграции + тест миграций.
|
2. Любую новую таблицу добавлять только через миграции + тест миграций.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
# Runbook Проверок (Тесты и Валидация по Плану)
|
# Runbook Проверок (Тесты и Валидация по Плану)
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P39` и как их запускать.
|
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P46` и как их запускать.
|
||||||
Использовать перед переводом пункта в статус `сделано`.
|
Использовать перед переводом пункта в статус `сделано`.
|
||||||
|
Детальная role-based матрица пользовательских сценариев (UI + corner cases): `/Users/tronosfera/Develop/Law/context/13_role_flows_test_matrix.md`.
|
||||||
|
Приоритизированный e2e backlog (P0/P1/P2 + покрытие): `/Users/tronosfera/Develop/Law/context/14_e2e_backlog_prioritized.md`.
|
||||||
|
|
||||||
## Базовые команды
|
## Базовые команды
|
||||||
1. Применить миграции:
|
1. Применить миграции:
|
||||||
|
|
@ -17,10 +19,10 @@ docker compose exec -T backend python -m unittest discover -s tests -p 'test_*.p
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T backend python -m compileall app tests alembic
|
docker compose exec -T backend python -m compileall app tests alembic
|
||||||
```
|
```
|
||||||
4. Проверка сборки `admin.jsx` через Docker Compose (на образе `frontend`):
|
4. Проверка сборки admin фронта через Docker Compose (на образе `frontend`, entrypoint `admin/index.jsx`):
|
||||||
```bash
|
```bash
|
||||||
docker compose build frontend
|
docker compose build frontend
|
||||||
docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"
|
docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin/index.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"
|
||||||
```
|
```
|
||||||
5. Браузерный E2E (Playwright) для ролевых UI-флоу (PUBLIC / LAWYER / ADMIN) через фиксированный образ `law-e2e-playwright:1.58.2`:
|
5. Браузерный E2E (Playwright) для ролевых UI-флоу (PUBLIC / LAWYER / ADMIN) через фиксированный образ `law-e2e-playwright:1.58.2`:
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -35,6 +37,15 @@ docker compose run --rm --no-deps \
|
||||||
e2e playwright test --config=playwright.config.js
|
e2e playwright test --config=playwright.config.js
|
||||||
```
|
```
|
||||||
Примечание: образ `e2e` собирается один раз и переиспользуется, браузеры/Playwright не скачиваются при каждом запуске.
|
Примечание: образ `e2e` собирается один раз и переиспользуется, браузеры/Playwright не скачиваются при каждом запуске.
|
||||||
|
6. Очистка e2e/тестовых артефактов в dev-БД (ручной запуск, если нужен вне e2e):
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python -m app.data.cleanup_test_artifacts
|
||||||
|
```
|
||||||
|
7. Сид ручных тестовых данных (10 клиентов, 4 юриста, заявки/чаты/счета):
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python -m app.data.manual_test_seed
|
||||||
|
```
|
||||||
|
Доступы и список тестовых заявок сохраняются в `/Users/tronosfera/Develop/Law/context/15_manual_test_access.md`.
|
||||||
|
|
||||||
## Матрица проверок по задачам
|
## Матрица проверок по задачам
|
||||||
| ID | Что проверяем | Где тесты | Как запускать |
|
| ID | Что проверяем | Где тесты | Как запускать |
|
||||||
|
|
@ -44,7 +55,7 @@ docker compose run --rm --no-deps \
|
||||||
| P03 | Universal CRUD + RBAC + audit | `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests -v` |
|
| P03 | Universal CRUD + RBAC + audit | `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests -v` |
|
||||||
| P04 | Пользователи, роли, пароли | `tests/test_admin_universal_crud.py` (тесты про `admin_users`) | команда как для `P03` |
|
| P04 | Пользователи, роли, пароли | `tests/test_admin_universal_crud.py` (тесты про `admin_users`) | команда как для `P03` |
|
||||||
| P05 | Базовый auto-assign | `tests/test_auto_assign.py` | `docker compose exec -T backend python -m unittest tests.test_auto_assign -v` |
|
| P05 | Базовый auto-assign | `tests/test_auto_assign.py` | `docker compose exec -T backend python -m unittest tests.test_auto_assign -v` |
|
||||||
| P06 | Админка `admin.jsx` + базовый UI контур | сборка `admin.jsx` + CRUD/API тесты | базовая команда 4 + тесты `P03` |
|
| P06 | Админка `admin.jsx` + базовый UI контур | сборка admin фронта + CRUD/API тесты | базовая команда 4 + тесты `P03` |
|
||||||
| P07 | Доп. темы юристов (`admin_user_topics`) | `tests/test_admin_universal_crud.py` | команда как для `P03` |
|
| P07 | Доп. темы юристов (`admin_user_topics`) | `tests/test_admin_universal_crud.py` | команда как для `P03` |
|
||||||
| P08 | Ручной claim (без гонок) | `tests/test_admin_universal_crud.py` (claim-тесты) | команда как для `P03` |
|
| P08 | Ручной claim (без гонок) | `tests/test_admin_universal_crud.py` (claim-тесты) | команда как для `P03` |
|
||||||
| P09 | ADMIN-only переназначение | `tests/test_admin_universal_crud.py` (reassign-тесты) | команда как для `P03` |
|
| P09 | ADMIN-only переназначение | `tests/test_admin_universal_crud.py` (reassign-тесты) | команда как для `P03` |
|
||||||
|
|
@ -61,7 +72,7 @@ docker compose run --rm --no-deps \
|
||||||
| P20 | Уведомления | `tests/test_notifications.py`, а также регрессии `tests/test_public_cabinet.py`, `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_notifications tests.test_public_cabinet tests.test_uploads_s3 tests.test_worker_maintenance -v`; затем полный прогон |
|
| P20 | Уведомления | `tests/test_notifications.py`, а также регрессии `tests/test_public_cabinet.py`, `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_notifications tests.test_public_cabinet tests.test_uploads_s3 tests.test_worker_maintenance -v`; затем полный прогон |
|
||||||
| P21 | Dashboard ADMIN/LAWYER | `tests/test_admin_universal_crud.py` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance tests.test_admin_universal_crud -v`; проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц |
|
| P21 | Dashboard ADMIN/LAWYER | `tests/test_admin_universal_crud.py` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance tests.test_admin_universal_crud -v`; проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц |
|
||||||
| P22 | Hardening/release | `tests/test_http_hardening.py` + весь regression + compile + миграции + UI build | `docker compose exec -T backend python -m unittest tests.test_http_hardening -v`; затем базовые команды 1-4 |
|
| P22 | Hardening/release | `tests/test_http_hardening.py` + весь regression + compile + миграции + UI build | `docker compose exec -T backend python -m unittest tests.test_http_hardening -v`; затем базовые команды 1-4 |
|
||||||
| P23 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать `admin.jsx` при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла |
|
| P23 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать admin фронт при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла |
|
||||||
| P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_rates tests.test_admin_universal_crud -v`; проверка что public API не отдает поля ставок/процентов |
|
| P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_rates tests.test_admin_universal_crud -v`; проверка что public API не отдает поля ставок/процентов |
|
||||||
| P25 | Billing-статус и шаблон счета | `tests/test_billing_flow.py`, `tests/test_invoices.py` + e2e статусных переходов | `docker compose exec -T backend python -m unittest tests.test_billing_flow tests.test_invoices tests.test_admin_universal_crud -v`; валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) |
|
| P25 | Billing-статус и шаблон счета | `tests/test_billing_flow.py`, `tests/test_invoices.py` + e2e статусных переходов | `docker compose exec -T backend python -m unittest tests.test_billing_flow tests.test_invoices tests.test_admin_universal_crud -v`; валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) |
|
||||||
| P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` |
|
| P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` |
|
||||||
|
|
@ -71,13 +82,20 @@ docker compose run --rm --no-deps \
|
||||||
| P30 | Отдельная страница работы с заявкой клиента | новые e2e для client workspace route + `tests/test_public_cabinet.py` | добавить e2e route-flow + прогон `test_public_cabinet` |
|
| P30 | Отдельная страница работы с заявкой клиента | новые e2e для client workspace route + `tests/test_public_cabinet.py` | добавить e2e route-flow + прогон `test_public_cabinet` |
|
||||||
| P31 | Вход клиента через phone+OTP модалку | новые e2e OTP modal flow + `tests/test_otp_rate_limit.py`, `tests/test_public_requests.py` | e2e + backend OTP тесты |
|
| P31 | Вход клиента через phone+OTP модалку | новые e2e OTP modal flow + `tests/test_otp_rate_limit.py`, `tests/test_public_requests.py` | e2e + backend OTP тесты |
|
||||||
| P32 | Переключение между заявками клиента | новые e2e multi-request flow + `tests/test_public_cabinet.py` | e2e multi-request + backend regression |
|
| P32 | Переключение между заявками клиента | новые e2e multi-request flow + `tests/test_public_cabinet.py` | e2e multi-request + backend regression |
|
||||||
| P33 | Чат в отдельном сервисе | `tests/test_public_cabinet.py`, `tests/test_admin_universal_crud.py` (chat service cases) + UI smoke (`client.js`, `admin.jsx`) | `docker compose run --rm backend python -m unittest tests.test_public_cabinet tests.test_admin_universal_crud -v` + фронт-сборка `admin.jsx` |
|
| P33 | Чат в отдельном сервисе | `tests/test_public_cabinet.py`, `tests/test_admin_universal_crud.py` (chat service cases) + UI smoke (`client.js`, `admin.jsx`) | `docker compose run --rm backend python -m unittest tests.test_public_cabinet tests.test_admin_universal_crud -v` + фронт-сборка admin entrypoint |
|
||||||
| P34 | Ненавязчивые цитаты в блоке «Первая консультация» | UI e2e/smoke лендинга | визуальная регрессия лендинга + Playwright public smoke |
|
| P34 | Ненавязчивые цитаты в блоке «Первая консультация» | UI e2e/smoke лендинга | визуальная регрессия лендинга + Playwright public smoke |
|
||||||
| P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` |
|
| P35 | Предпросмотр документов | `tests/test_uploads_s3.py` (`test_public_attachment_object_preview_returns_inline_response`) + Playwright (`e2e/tests/public_client_flow.spec.js`, `e2e/tests/lawyer_role_flow.spec.js`) | `docker compose run --rm backend python -m unittest tests.test_uploads_s3 -v` + Playwright UI-прогон preview в клиенте и во вкладке работы с заявкой юриста/админа через сервис `e2e` |
|
||||||
| P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` |
|
| P36 | Навигация в админку и редиректы | `e2e/tests/admin_entry_flow.spec.js` + redirect checks | Playwright `admin_entry_flow` + `curl -I -H 'Host: localhost:8081' http://localhost:8081/admin` (ожидается `302` и `Location: /admin.html`) + `curl -I http://localhost:8081/admin.html` |
|
||||||
| P37 | Единые bootstrap-креды админа | `tests/test_admin_auth.py` + auth smoke (`/api/admin/auth/login`) + docs consistency check | `docker compose run --rm backend python -m unittest tests.test_admin_auth -v` + UI/API login smoke с `admin@example.com` / `admin123` |
|
| P37 | Единые bootstrap-креды админа | `tests/test_admin_auth.py` + auth smoke (`/api/admin/auth/login`) + docs consistency check | `docker compose run --rm backend python -m unittest tests.test_admin_auth -v` + UI/API login smoke с `admin@example.com` / `admin123` |
|
||||||
| P38 | Конструктор маршрутов статусов (темы) | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py` + e2e `e2e/tests/admin_status_designer_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_worker_maintenance -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/admin_status_designer_flow.spec.js` |
|
| P38 | Конструктор маршрутов статусов (темы) | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py` + e2e `e2e/tests/admin_status_designer_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_worker_maintenance -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/admin_status_designer_flow.spec.js` |
|
||||||
| P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py` (`test_requests_kanban_returns_grouped_cards_and_role_scope`) + e2e `e2e/tests/kanban_role_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests.test_requests_kanban_returns_grouped_cards_and_role_scope -v` и `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/kanban_role_flow.spec.js`; дополнительно регресс `admin_role_flow`, `lawyer_role_flow` |
|
| P39 | Канбан заявок для LAWYER/ADMIN | `tests/test_admin_universal_crud.py` (`test_requests_kanban_returns_grouped_cards_and_role_scope`) + e2e `e2e/tests/kanban_role_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud.AdminUniversalCrudTests.test_requests_kanban_returns_grouped_cards_and_role_scope -v` и `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/kanban_role_flow.spec.js`; дополнительно регресс `admin_role_flow`, `lawyer_role_flow` |
|
||||||
|
| P40 | Подготовка модульной сборки admin фронта | `frontend/Dockerfile`, `app/web/admin/index.jsx`, smoke e2e | базовая команда 4 + `e2e/tests/admin_entry_flow.spec.js` |
|
||||||
|
| P41 | Декомпозиция shared-слоя admin | сборка admin фронта + role e2e smoke | базовая команда 4 + `e2e/tests/admin_role_flow.spec.js`, `e2e/tests/kanban_role_flow.spec.js` |
|
||||||
|
| P42 | Декомпозиция feature-слоя admin | сборка admin фронта + role e2e regression | базовая команда 4 + полный e2e через сервис `e2e` |
|
||||||
|
| P43 | Декомпозиция backend CRUD | `tests/test_admin_universal_crud.py`, `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_migrations -v` |
|
||||||
|
| P44 | Декомпозиция backend Requests | `tests/test_admin_universal_crud.py`, `tests/test_worker_maintenance.py`, e2e kanban | `docker compose exec -T backend python -m unittest tests.test_admin_universal_crud tests.test_worker_maintenance -v` + `e2e/tests/kanban_role_flow.spec.js` |
|
||||||
|
| P45 | Декомпозиция тестового слоя | пакетный запуск `tests/admin/*` + discovery | целевые команды по новым модулям + `python -m unittest discover -s tests -p 'test_*.py' -v` |
|
||||||
|
| P46 | Финализация декомпозиции | полный backend + frontend + e2e регресс | базовые команды 1-5 |
|
||||||
|
|
||||||
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
|
## Ролевое покрытие (PUBLIC / LAWYER / ADMIN)
|
||||||
### PUBLIC (клиент)
|
### PUBLIC (клиент)
|
||||||
|
|
@ -90,6 +108,7 @@ docker compose run --rm --no-deps \
|
||||||
|
|
||||||
### LAWYER (юрист)
|
### LAWYER (юрист)
|
||||||
- UI e2e: `e2e/tests/lawyer_role_flow.spec.js` (вход, claim неназначенной заявки, новая вкладка работы с заявкой, чтение обновлений, смена статуса).
|
- UI e2e: `e2e/tests/lawyer_role_flow.spec.js` (вход, claim неназначенной заявки, новая вкладка работы с заявкой, чтение обновлений, смена статуса).
|
||||||
|
- UI e2e: `e2e/tests/request_data_file_flow.spec.js` (юрист создает `Запрос` с `file`-полем, клиент загружает файл, юрист видит заполнение запроса).
|
||||||
- Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`.
|
- Дашборд юриста (свои, неназначенные, непрочитанные): `tests/test_dashboard_finance.py`.
|
||||||
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`.
|
- Видимость заявок: свои + неназначенные; запрет доступа к чужим: `tests/test_admin_universal_crud.py`.
|
||||||
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`.
|
- Claim неназначенной заявки, запрет takeover, запрет назначения через CRUD: `tests/test_admin_universal_crud.py`.
|
||||||
|
|
@ -114,12 +133,26 @@ docker compose run --rm --no-deps \
|
||||||
2. Выполнить целевые тесты пункта по матрице выше.
|
2. Выполнить целевые тесты пункта по матрице выше.
|
||||||
3. Выполнить полный прогон `unittest discover`.
|
3. Выполнить полный прогон `unittest discover`.
|
||||||
4. Выполнить `compileall`.
|
4. Выполнить `compileall`.
|
||||||
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose.
|
5. Для изменений админ-фронта выполнить сборку entrypoint `admin/index.jsx` через Docker Compose.
|
||||||
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
|
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
|
||||||
|
|
||||||
## Последние подтвержденные прогоны
|
## Последние подтвержденные прогоны
|
||||||
- `docker compose run --rm backend python -m unittest -v tests.test_admin_auth` — `3 passed`.
|
- `docker compose run --rm backend python -m unittest -v tests.test_admin_auth` — `3 passed`.
|
||||||
- `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v` — `105 passed`.
|
- `docker compose run --rm backend python -m unittest discover -s tests -p 'test_*.py' -v` — `105 passed`.
|
||||||
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно.
|
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно.
|
||||||
|
- `docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cache nodejs npm >/dev/null && npx --yes esbuild /usr/share/nginx/html/admin/index.jsx --loader:.jsx=jsx --bundle --outfile=/tmp/admin.bundle.js"` — успешно (`admin.bundle.js` собран).
|
||||||
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test tests/admin_entry_flow.spec.js --config=playwright.config.js` — `1 passed`.
|
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test tests/admin_entry_flow.spec.js --config=playwright.config.js` — `1 passed`.
|
||||||
|
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/admin_entry_flow.spec.js e2e/tests/admin_role_flow.spec.js` — `2 passed`.
|
||||||
|
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/kanban_role_flow.spec.js` — `1 passed`.
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line` — `2 passed` (после выноса `dashboard/requests/invoices` из `admin.jsx`).
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/admin_status_designer_flow.spec.js --reporter=line` — `2 passed` (после выноса `config`-секции в `ConfigSection`).
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js --reporter=line` — `1 passed` (после выноса `quotes/availableTables/meta` секций).
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/kanban_role_flow.spec.js --reporter=line` — `2 passed` (после выноса `useKanban`).
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/lawyer_role_flow.spec.js --reporter=line` — `1 passed` (после выноса `useRequestWorkspace` state/actions).
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/request_data_file_flow.spec.js --reporter=line` — `1 passed` (E2E: `REQUEST_DATA` с типом `file`, загрузка клиентом через S3, отметка выполнения у юриста).
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/kanban_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line` — `3 passed` (после внедрения `useTablesState` и переноса registry state таблиц).
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line` — `2 passed` (после переноса `loadRequestModalData`/`refreshRequestModal` в `useRequestWorkspace`).
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line` — `2 passed` (после переноса `openRequestDetails` и `submitRequestModalMessage` в `useRequestWorkspace`).
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/admin_status_designer_flow.spec.js e2e/tests/kanban_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line` — `4 passed` (после выноса `useTableActions`: `loadTable` + paging/sort).
|
||||||
|
- `docker compose run --rm -e E2E_BASE_URL=http://frontend e2e playwright test e2e/tests/admin_role_flow.spec.js e2e/tests/admin_status_designer_flow.spec.js e2e/tests/kanban_role_flow.spec.js e2e/tests/lawyer_role_flow.spec.js --reporter=line` — `4 passed` (после выноса `useTableFilterActions` и `useAdminCatalogLoaders`, закрытие `P42`).
|
||||||
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js` — `6 passed` (рольовые e2e + конструктор статусов + канбан: `admin_entry_flow`, `admin_role_flow`, `admin_status_designer_flow`, `kanban_role_flow`, `lawyer_role_flow`, `public_client_flow`).
|
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js` — `6 passed` (рольовые e2e + конструктор статусов + канбан: `admin_entry_flow`, `admin_role_flow`, `admin_status_designer_flow`, `kanban_role_flow`, `lawyer_role_flow`, `public_client_flow`).
|
||||||
|
|
|
||||||
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 { test, expect } = require("@playwright/test");
|
||||||
|
const { cleanupTrackedTestData } = require("./helpers");
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
await cleanupTrackedTestData(page, testInfo);
|
||||||
|
});
|
||||||
|
|
||||||
test("admin entry via route only: landing has no admin CTA and /admin opens panel", async ({ page }) => {
|
test("admin entry via route only: landing has no admin CTA and /admin opens panel", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,30 @@ const {
|
||||||
openDictionaryTree,
|
openDictionaryTree,
|
||||||
selectDictionaryNode,
|
selectDictionaryNode,
|
||||||
rowByTrack,
|
rowByTrack,
|
||||||
|
trackCleanupPhone,
|
||||||
|
trackCleanupTrack,
|
||||||
|
trackCleanupEmail,
|
||||||
|
cleanupTrackedTestData,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
|
|
||||||
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
||||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
|
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
|
||||||
|
|
||||||
test("admin flow via UI: dictionaries + users + topics + invoices", async ({ context, page }) => {
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
await cleanupTrackedTestData(page, testInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin flow via UI: dictionaries + users + topics + invoices", async ({ context, page }, testInfo) => {
|
||||||
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||||
const phone = randomPhone();
|
const phone = randomPhone();
|
||||||
|
trackCleanupPhone(testInfo, phone);
|
||||||
|
|
||||||
await preparePublicSession(context, page, appUrl, phone);
|
await preparePublicSession(context, page, appUrl, phone);
|
||||||
const { trackNumber } = await createRequestViaLanding(page, {
|
const { trackNumber } = await createRequestViaLanding(page, {
|
||||||
phone,
|
phone,
|
||||||
description: "Заявка для проверки админского UI-флоу",
|
description: "Заявка для проверки админского UI-флоу",
|
||||||
});
|
});
|
||||||
|
trackCleanupTrack(testInfo, trackNumber);
|
||||||
|
|
||||||
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
||||||
await expect(page.locator(".badge")).toContainText("роль: Администратор");
|
await expect(page.locator(".badge")).toContainText("роль: Администратор");
|
||||||
|
|
@ -30,12 +40,12 @@ test("admin flow via UI: dictionaries + users + topics + invoices", async ({ con
|
||||||
await openDictionaryTree(page);
|
await openDictionaryTree(page);
|
||||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Темы");
|
await expect(page.locator("aside .menu .menu-tree")).toContainText("Темы");
|
||||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Статусы");
|
await expect(page.locator("aside .menu .menu-tree")).toContainText("Статусы");
|
||||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Переходы статусов");
|
|
||||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Пользователи");
|
await expect(page.locator("aside .menu .menu-tree")).toContainText("Пользователи");
|
||||||
await expect(page.locator("aside .menu .menu-tree")).toContainText("Цитаты");
|
await expect(page.locator("aside .menu .menu-tree")).toContainText("Цитаты");
|
||||||
|
|
||||||
const unique = Date.now();
|
const unique = Date.now();
|
||||||
const lawyerEmail = `ui-lawyer-${unique}@example.com`;
|
const lawyerEmail = `ui-lawyer-${unique}@example.com`;
|
||||||
|
trackCleanupEmail(testInfo, lawyerEmail);
|
||||||
const topicName = `Тема UI ${unique}`;
|
const topicName = `Тема UI ${unique}`;
|
||||||
|
|
||||||
await selectDictionaryNode(page, "Пользователи");
|
await selectDictionaryNode(page, "Пользователи");
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
const { test, expect } = require("@playwright/test");
|
const { test, expect } = require("@playwright/test");
|
||||||
const { loginAdminPanel, openDictionaryTree } = require("./helpers");
|
const { loginAdminPanel, openDictionaryTree, cleanupTrackedTestData } = require("./helpers");
|
||||||
|
|
||||||
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
||||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
|
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
await cleanupTrackedTestData(page, testInfo);
|
||||||
|
});
|
||||||
|
|
||||||
test("admin status designer: open transitions dictionary and prefill topic in create modal", async ({ page }) => {
|
test("admin status designer: open transitions dictionary and prefill topic in create modal", async ({ page }) => {
|
||||||
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
await loginAdminPanel(page, { email: ADMIN_EMAIL, password: ADMIN_PASSWORD });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ dotenv.config({ path: path.resolve(__dirname, "../../.env") });
|
||||||
|
|
||||||
const PUBLIC_SECRET = process.env.PUBLIC_JWT_SECRET || "change_me_public";
|
const PUBLIC_SECRET = process.env.PUBLIC_JWT_SECRET || "change_me_public";
|
||||||
const PUBLIC_COOKIE_NAME = process.env.PUBLIC_COOKIE_NAME || "public_jwt";
|
const PUBLIC_COOKIE_NAME = process.env.PUBLIC_COOKIE_NAME || "public_jwt";
|
||||||
|
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com";
|
||||||
|
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD || "admin123";
|
||||||
|
|
||||||
function randomDigits(length) {
|
function randomDigits(length) {
|
||||||
let value = "";
|
let value = "";
|
||||||
|
|
@ -20,6 +22,42 @@ function randomPhone() {
|
||||||
return `+79${randomDigits(9)}`;
|
return `+79${randomDigits(9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTinyPdfBuffer(label = "E2E PDF") {
|
||||||
|
const safe = String(label || "E2E PDF").replace(/[()\\]/g, " ");
|
||||||
|
const stream = `BT /F1 16 Tf 24 96 Td (${safe}) Tj ET`;
|
||||||
|
const objects = [
|
||||||
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||||
|
"<< /Type /Pages /Count 1 /Kids [3 0 R] >>",
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
|
||||||
|
`<< /Length ${Buffer.byteLength(stream, "utf-8")} >>\nstream\n${stream}\nendstream`,
|
||||||
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||||
|
];
|
||||||
|
|
||||||
|
let pdf = "%PDF-1.4\n";
|
||||||
|
const offsets = [0];
|
||||||
|
for (let i = 0; i < objects.length; i += 1) {
|
||||||
|
offsets.push(Buffer.byteLength(pdf, "utf-8"));
|
||||||
|
pdf += `${i + 1} 0 obj\n${objects[i]}\nendobj\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xrefOffset = Buffer.byteLength(pdf, "utf-8");
|
||||||
|
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||||
|
pdf += "0000000000 65535 f \n";
|
||||||
|
for (let i = 1; i < offsets.length; i += 1) {
|
||||||
|
pdf += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`;
|
||||||
|
}
|
||||||
|
pdf += `trailer\n<< /Root 1 0 R /Size ${objects.length + 1} >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||||
|
return Buffer.from(pdf, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectMimeForFixture(fileName) {
|
||||||
|
const lower = String(fileName || "").toLowerCase();
|
||||||
|
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||||
|
if (lower.endsWith(".txt")) return "text/plain";
|
||||||
|
if (lower.endsWith(".json")) return "application/json";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
function createPublicCookieToken(phone) {
|
function createPublicCookieToken(phone) {
|
||||||
return jwt.sign({ sub: phone, purpose: "CREATE_REQUEST" }, PUBLIC_SECRET, {
|
return jwt.sign({ sub: phone, purpose: "CREATE_REQUEST" }, PUBLIC_SECRET, {
|
||||||
algorithm: "HS256",
|
algorithm: "HS256",
|
||||||
|
|
@ -27,6 +65,100 @@ function createPublicCookieToken(phone) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCleanupTracker() {
|
||||||
|
const state = {
|
||||||
|
track_numbers: new Set(),
|
||||||
|
phones: new Set(),
|
||||||
|
emails: new Set(),
|
||||||
|
hasArtifacts: false,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
addTrack(value) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) return;
|
||||||
|
state.track_numbers.add(text);
|
||||||
|
state.hasArtifacts = true;
|
||||||
|
},
|
||||||
|
addPhone(value) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) return;
|
||||||
|
state.phones.add(text);
|
||||||
|
state.hasArtifacts = true;
|
||||||
|
},
|
||||||
|
addEmail(value) {
|
||||||
|
const text = String(value || "").trim().toLowerCase();
|
||||||
|
if (!text) return;
|
||||||
|
state.emails.add(text);
|
||||||
|
state.hasArtifacts = true;
|
||||||
|
},
|
||||||
|
hasArtifacts() {
|
||||||
|
return state.hasArtifacts;
|
||||||
|
},
|
||||||
|
toPayload() {
|
||||||
|
return {
|
||||||
|
track_numbers: Array.from(state.track_numbers),
|
||||||
|
phones: Array.from(state.phones),
|
||||||
|
emails: Array.from(state.emails),
|
||||||
|
include_default_e2e_patterns: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getCleanupTracker(testInfo) {
|
||||||
|
if (!testInfo) return null;
|
||||||
|
if (!testInfo._cleanupTracker) {
|
||||||
|
testInfo._cleanupTracker = createCleanupTracker();
|
||||||
|
}
|
||||||
|
return testInfo._cleanupTracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackCleanupPhone(testInfo, phone) {
|
||||||
|
const tracker = _getCleanupTracker(testInfo);
|
||||||
|
if (tracker) tracker.addPhone(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackCleanupTrack(testInfo, trackNumber) {
|
||||||
|
const tracker = _getCleanupTracker(testInfo);
|
||||||
|
if (tracker) tracker.addTrack(trackNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackCleanupEmail(testInfo, email) {
|
||||||
|
const tracker = _getCleanupTracker(testInfo);
|
||||||
|
if (tracker) tracker.addEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTrackedTestData(page, testInfo) {
|
||||||
|
const tracker = testInfo && testInfo._cleanupTracker;
|
||||||
|
if (!tracker || !tracker.hasArtifacts()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||||
|
let token = "";
|
||||||
|
const loginResponse = await page.request.post(`${baseUrl}/api/admin/auth/login`, {
|
||||||
|
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
if (loginResponse.ok()) {
|
||||||
|
const body = await loginResponse.json().catch(() => ({}));
|
||||||
|
token = String(body?.access_token || "");
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(`E2E cleanup failed: admin login ${loginResponse.status()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupResponse = await page.request.post(`${baseUrl}/api/admin/test-utils/cleanup-test-data`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: tracker.toPayload(),
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
if (!cleanupResponse.ok()) {
|
||||||
|
const text = await cleanupResponse.text().catch(() => "");
|
||||||
|
throw new Error(`E2E cleanup failed: ${cleanupResponse.status()} ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function installPromptAutoAccept(page, code = "000000") {
|
async function installPromptAutoAccept(page, code = "000000") {
|
||||||
page.on("dialog", async (dialog) => {
|
page.on("dialog", async (dialog) => {
|
||||||
if (dialog.type() === "prompt") {
|
if (dialog.type() === "prompt") {
|
||||||
|
|
@ -130,11 +262,13 @@ async function sendCabinetMessage(page, text) {
|
||||||
|
|
||||||
async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E file") {
|
async function uploadCabinetFile(page, fileName = "e2e.txt", bodyText = "E2E file") {
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
|
const mimeType = detectMimeForFixture(fileName);
|
||||||
|
const buffer = mimeType === "application/pdf" ? buildTinyPdfBuffer(bodyText) : Buffer.from(bodyText, "utf-8");
|
||||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||||
await page.locator("#cabinet-file-input").setInputFiles({
|
await page.locator("#cabinet-file-input").setInputFiles({
|
||||||
name: fileName,
|
name: fileName,
|
||||||
mimeType: "application/pdf",
|
mimeType,
|
||||||
buffer: Buffer.from(bodyText, "utf-8"),
|
buffer,
|
||||||
});
|
});
|
||||||
await page.locator("#cabinet-file-upload").click();
|
await page.locator("#cabinet-file-upload").click();
|
||||||
|
|
||||||
|
|
@ -202,6 +336,11 @@ async function selectDictionaryNode(page, label) {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
randomPhone,
|
randomPhone,
|
||||||
|
createCleanupTracker,
|
||||||
|
trackCleanupPhone,
|
||||||
|
trackCleanupTrack,
|
||||||
|
trackCleanupEmail,
|
||||||
|
cleanupTrackedTestData,
|
||||||
preparePublicSession,
|
preparePublicSession,
|
||||||
createRequestViaLanding,
|
createRequestViaLanding,
|
||||||
openPublicCabinet,
|
openPublicCabinet,
|
||||||
|
|
@ -212,4 +351,5 @@ module.exports = {
|
||||||
rowByTrack,
|
rowByTrack,
|
||||||
openDictionaryTree,
|
openDictionaryTree,
|
||||||
selectDictionaryNode,
|
selectDictionaryNode,
|
||||||
|
buildTinyPdfBuffer,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,29 @@ const {
|
||||||
createRequestViaLanding,
|
createRequestViaLanding,
|
||||||
randomPhone,
|
randomPhone,
|
||||||
loginAdminPanel,
|
loginAdminPanel,
|
||||||
|
trackCleanupPhone,
|
||||||
|
trackCleanupTrack,
|
||||||
|
cleanupTrackedTestData,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
|
|
||||||
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
|
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
|
||||||
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
|
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
|
||||||
|
|
||||||
test("kanban flow via UI: lawyer sees unassigned card, claims and opens request in same tab", async ({ context, page }) => {
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
await cleanupTrackedTestData(page, testInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kanban flow via UI: lawyer sees unassigned card, claims and opens request in same tab", async ({ context, page }, testInfo) => {
|
||||||
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||||
const phone = randomPhone();
|
const phone = randomPhone();
|
||||||
|
trackCleanupPhone(testInfo, phone);
|
||||||
|
|
||||||
await preparePublicSession(context, page, appUrl, phone);
|
await preparePublicSession(context, page, appUrl, phone);
|
||||||
const { trackNumber } = await createRequestViaLanding(page, {
|
const { trackNumber } = await createRequestViaLanding(page, {
|
||||||
phone,
|
phone,
|
||||||
description: "Заявка для проверки канбана юриста",
|
description: "Заявка для проверки канбана юриста",
|
||||||
});
|
});
|
||||||
|
trackCleanupTrack(testInfo, trackNumber);
|
||||||
|
|
||||||
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
await loginAdminPanel(page, { email: LAWYER_EMAIL, password: LAWYER_PASSWORD });
|
||||||
await page.locator("aside .menu button[data-section='kanban']").click();
|
await page.locator("aside .menu button[data-section='kanban']").click();
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,23 @@ const {
|
||||||
loginAdminPanel,
|
loginAdminPanel,
|
||||||
openRequestsSection,
|
openRequestsSection,
|
||||||
rowByTrack,
|
rowByTrack,
|
||||||
|
buildTinyPdfBuffer,
|
||||||
|
trackCleanupPhone,
|
||||||
|
trackCleanupTrack,
|
||||||
|
cleanupTrackedTestData,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
|
|
||||||
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
|
const LAWYER_EMAIL = process.env.E2E_LAWYER_EMAIL || "ivan@mail.ru";
|
||||||
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
|
const LAWYER_PASSWORD = process.env.E2E_LAWYER_PASSWORD || "LawyerPass-123!";
|
||||||
|
|
||||||
test("lawyer flow via UI: claim request -> chat and files in request workspace tab -> change status", async ({ context, page }) => {
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
await cleanupTrackedTestData(page, testInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lawyer flow via UI: claim request -> chat and files in request workspace tab -> change status", async ({ context, page }, testInfo) => {
|
||||||
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||||
const phone = randomPhone();
|
const phone = randomPhone();
|
||||||
|
trackCleanupPhone(testInfo, phone);
|
||||||
|
|
||||||
await preparePublicSession(context, page, appUrl, phone);
|
await preparePublicSession(context, page, appUrl, phone);
|
||||||
|
|
||||||
|
|
@ -24,6 +33,7 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
|
||||||
phone,
|
phone,
|
||||||
description: "Заявка для проверки флоу юриста через UI",
|
description: "Заявка для проверки флоу юриста через UI",
|
||||||
});
|
});
|
||||||
|
trackCleanupTrack(testInfo, trackNumber);
|
||||||
|
|
||||||
await openPublicCabinet(page, trackNumber);
|
await openPublicCabinet(page, trackNumber);
|
||||||
await sendCabinetMessage(page, `Сообщение юристу ${Date.now()}`);
|
await sendCabinetMessage(page, `Сообщение юристу ${Date.now()}`);
|
||||||
|
|
@ -45,7 +55,7 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
|
||||||
await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
|
await expect(page.locator("#section-requests .status")).toContainText(/Заявка взята в работу|Список обновлен/);
|
||||||
|
|
||||||
const pagesBeforeOpen = context.pages().length;
|
const pagesBeforeOpen = context.pages().length;
|
||||||
await row.first().getByRole("button", { name: "Открыть заявку" }).click();
|
await row.first().locator(".request-track-link").click();
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
|
await expect.poll(() => context.pages().length).toBe(pagesBeforeOpen);
|
||||||
const requestPage = page;
|
const requestPage = page;
|
||||||
|
|
@ -57,7 +67,8 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
|
||||||
const clientFileRow = requestPage.locator("#request-modal-files li").filter({ hasText: clientFileName }).first();
|
const clientFileRow = requestPage.locator("#request-modal-files li").filter({ hasText: clientFileName }).first();
|
||||||
await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click();
|
await clientFileRow.getByRole("button", { name: /Предпросмотр/ }).click();
|
||||||
await expect(requestPage.locator("#request-file-preview-overlay")).toBeVisible();
|
await expect(requestPage.locator("#request-file-preview-overlay")).toBeVisible();
|
||||||
await expect(requestPage.locator("#request-file-preview-overlay .request-preview-frame")).toBeVisible();
|
await expect(requestPage.locator("#request-file-preview-overlay .request-preview-text")).toBeVisible();
|
||||||
|
await expect(requestPage.locator("#request-file-preview-overlay .request-preview-text")).toContainText("lawyer unread marker");
|
||||||
await requestPage.locator("#request-file-preview-overlay .close").click();
|
await requestPage.locator("#request-file-preview-overlay .close").click();
|
||||||
await requestPage.getByRole("tab", { name: "Чат" }).click();
|
await requestPage.getByRole("tab", { name: "Чат" }).click();
|
||||||
|
|
||||||
|
|
@ -73,7 +84,7 @@ test("lawyer flow via UI: claim request -> chat and files in request workspace t
|
||||||
{
|
{
|
||||||
name: lawyerFileName,
|
name: lawyerFileName,
|
||||||
mimeType: "application/pdf",
|
mimeType: "application/pdf",
|
||||||
buffer: Buffer.from("lawyer file from admin modal", "utf-8"),
|
buffer: buildTinyPdfBuffer("lawyer file from admin modal"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: droppedFileName,
|
name: droppedFileName,
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,19 @@ const {
|
||||||
sendCabinetMessage,
|
sendCabinetMessage,
|
||||||
uploadCabinetFile,
|
uploadCabinetFile,
|
||||||
randomPhone,
|
randomPhone,
|
||||||
|
trackCleanupPhone,
|
||||||
|
trackCleanupTrack,
|
||||||
|
cleanupTrackedTestData,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
|
|
||||||
test("public flow via UI: landing -> create request -> cabinet -> chat -> upload file", async ({ context, page }) => {
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
await cleanupTrackedTestData(page, testInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public flow via UI: landing -> create request -> cabinet -> chat -> upload file", async ({ context, page }, testInfo) => {
|
||||||
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
const appUrl = process.env.E2E_BASE_URL || "http://localhost:8081";
|
||||||
const phone = randomPhone();
|
const phone = randomPhone();
|
||||||
|
trackCleanupPhone(testInfo, phone);
|
||||||
|
|
||||||
await preparePublicSession(context, page, appUrl, phone);
|
await preparePublicSession(context, page, appUrl, phone);
|
||||||
|
|
||||||
|
|
@ -18,6 +26,7 @@ test("public flow via UI: landing -> create request -> cabinet -> chat -> upload
|
||||||
phone,
|
phone,
|
||||||
description: "Проверка публичного E2E флоу через UI.",
|
description: "Проверка публичного E2E флоу через UI.",
|
||||||
});
|
});
|
||||||
|
trackCleanupTrack(testInfo, trackNumber);
|
||||||
|
|
||||||
await openPublicCabinet(page, trackNumber);
|
await openPublicCabinet(page, trackNumber);
|
||||||
|
|
||||||
|
|
|
||||||
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
|
FROM node:22-alpine AS admin-build
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
COPY app/web/admin ./admin
|
||||||
COPY app/web/admin.jsx ./admin.jsx
|
COPY app/web/admin.jsx ./admin.jsx
|
||||||
RUN npm init -y >/dev/null 2>&1 \
|
RUN npm init -y >/dev/null 2>&1 \
|
||||||
&& npm install --silent esbuild@0.25.10 \
|
&& npm install --silent esbuild@0.25.10 \
|
||||||
&& npx esbuild admin.jsx --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js
|
&& npx esbuild admin/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
|
||||||
|
|
@ -7,27 +7,42 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
add_header X-Frame-Options "DENY" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
|
||||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
|
||||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
|
||||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
|
||||||
|
|
||||||
location = /admin {
|
location = /admin {
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
expires 10m;
|
expires 10m;
|
||||||
return 302 /admin.html;
|
return 302 /admin.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* \.jsx$ {
|
location ~* \.jsx$ {
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
expires 10m;
|
expires 10m;
|
||||||
default_type application/javascript;
|
default_type application/javascript;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
expires 10m;
|
expires 10m;
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from app.core.security import create_jwt
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.admin_user import AdminUser
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
|
|
@ -37,6 +38,7 @@ class DashboardFinanceTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
AdminUser.__table__.create(bind=cls.engine)
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
AuditLog.__table__.create(bind=cls.engine)
|
||||||
Request.__table__.create(bind=cls.engine)
|
Request.__table__.create(bind=cls.engine)
|
||||||
Status.__table__.create(bind=cls.engine)
|
Status.__table__.create(bind=cls.engine)
|
||||||
Message.__table__.create(bind=cls.engine)
|
Message.__table__.create(bind=cls.engine)
|
||||||
|
|
@ -50,6 +52,7 @@ class DashboardFinanceTests(unittest.TestCase):
|
||||||
Message.__table__.drop(bind=cls.engine)
|
Message.__table__.drop(bind=cls.engine)
|
||||||
Status.__table__.drop(bind=cls.engine)
|
Status.__table__.drop(bind=cls.engine)
|
||||||
Request.__table__.drop(bind=cls.engine)
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
AuditLog.__table__.drop(bind=cls.engine)
|
||||||
AdminUser.__table__.drop(bind=cls.engine)
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
cls.engine.dispose()
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
|
@ -60,6 +63,7 @@ class DashboardFinanceTests(unittest.TestCase):
|
||||||
db.execute(delete(Message))
|
db.execute(delete(Message))
|
||||||
db.execute(delete(Request))
|
db.execute(delete(Request))
|
||||||
db.execute(delete(Status))
|
db.execute(delete(Status))
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
db.execute(delete(AdminUser))
|
db.execute(delete(AdminUser))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
@ -185,6 +189,45 @@ class DashboardFinanceTests(unittest.TestCase):
|
||||||
created_at=now - timedelta(days=40),
|
created_at=now - timedelta(days=40),
|
||||||
updated_at=now - timedelta(days=40),
|
updated_at=now - timedelta(days=40),
|
||||||
),
|
),
|
||||||
|
StatusHistory(
|
||||||
|
request_id=req_a_closed.id,
|
||||||
|
from_status="IN_PROGRESS",
|
||||||
|
to_status="CLOSED",
|
||||||
|
changed_by_admin_id=None,
|
||||||
|
created_at=current_month_event + timedelta(hours=3),
|
||||||
|
updated_at=current_month_event + timedelta(hours=3),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
AuditLog(
|
||||||
|
actor_admin_id=None,
|
||||||
|
entity="requests",
|
||||||
|
entity_id=str(req_a_active.id),
|
||||||
|
action="MANUAL_CLAIM",
|
||||||
|
diff={"assigned_lawyer_id": str(lawyer_a.id)},
|
||||||
|
created_at=current_month_event,
|
||||||
|
updated_at=current_month_event,
|
||||||
|
),
|
||||||
|
AuditLog(
|
||||||
|
actor_admin_id=None,
|
||||||
|
entity="requests",
|
||||||
|
entity_id=str(req_a_closed.id),
|
||||||
|
action="MANUAL_REASSIGN",
|
||||||
|
diff={"from_lawyer_id": str(lawyer_b.id), "to_lawyer_id": str(lawyer_a.id)},
|
||||||
|
created_at=current_month_event + timedelta(minutes=10),
|
||||||
|
updated_at=current_month_event + timedelta(minutes=10),
|
||||||
|
),
|
||||||
|
AuditLog(
|
||||||
|
actor_admin_id=None,
|
||||||
|
entity="requests",
|
||||||
|
entity_id=str(req_b_active.id),
|
||||||
|
action="MANUAL_CLAIM",
|
||||||
|
diff={"assigned_lawyer_id": str(lawyer_b.id)},
|
||||||
|
created_at=current_month_event + timedelta(minutes=20),
|
||||||
|
updated_at=current_month_event + timedelta(minutes=20),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -194,21 +237,100 @@ class DashboardFinanceTests(unittest.TestCase):
|
||||||
body = response.json()
|
body = response.json()
|
||||||
self.assertEqual(body.get("scope"), "ADMIN")
|
self.assertEqual(body.get("scope"), "ADMIN")
|
||||||
self.assertIn("lawyer_loads", body)
|
self.assertIn("lawyer_loads", body)
|
||||||
|
self.assertAlmostEqual(float(body.get("month_revenue") or 0.0), 2500.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(body.get("month_expenses") or 0.0), 750.0, places=2)
|
||||||
|
|
||||||
by_email = {row["email"]: row for row in body["lawyer_loads"]}
|
by_email = {row["email"]: row for row in body["lawyer_loads"]}
|
||||||
self.assertEqual(by_email["lawyer.a@example.com"]["active_load"], 1)
|
self.assertEqual(by_email["lawyer.a@example.com"]["active_load"], 1)
|
||||||
self.assertEqual(by_email["lawyer.a@example.com"]["total_assigned"], 2)
|
self.assertEqual(by_email["lawyer.a@example.com"]["total_assigned"], 2)
|
||||||
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["active_amount"]), 1000.0, places=2)
|
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["active_amount"]), 1000.0, places=2)
|
||||||
self.assertEqual(by_email["lawyer.a@example.com"]["monthly_paid_events"], 3)
|
self.assertEqual(by_email["lawyer.a@example.com"]["monthly_paid_events"], 3)
|
||||||
|
self.assertEqual(by_email["lawyer.a@example.com"]["monthly_assigned_count"], 2)
|
||||||
|
self.assertEqual(by_email["lawyer.a@example.com"]["monthly_completed_count"], 1)
|
||||||
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_paid_gross"]), 2500.0, places=2)
|
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_paid_gross"]), 2500.0, places=2)
|
||||||
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_salary"]), 750.0, places=2)
|
self.assertAlmostEqual(float(by_email["lawyer.a@example.com"]["monthly_salary"]), 750.0, places=2)
|
||||||
|
|
||||||
self.assertEqual(by_email["lawyer.b@example.com"]["active_load"], 1)
|
self.assertEqual(by_email["lawyer.b@example.com"]["active_load"], 1)
|
||||||
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["active_amount"]), 2000.0, places=2)
|
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["active_amount"]), 2000.0, places=2)
|
||||||
|
self.assertEqual(by_email["lawyer.b@example.com"]["monthly_assigned_count"], 1)
|
||||||
|
self.assertEqual(by_email["lawyer.b@example.com"]["monthly_completed_count"], 0)
|
||||||
self.assertEqual(by_email["lawyer.b@example.com"]["monthly_paid_events"], 0)
|
self.assertEqual(by_email["lawyer.b@example.com"]["monthly_paid_events"], 0)
|
||||||
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_paid_gross"]), 0.0, places=2)
|
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_paid_gross"]), 0.0, places=2)
|
||||||
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_salary"]), 0.0, places=2)
|
self.assertAlmostEqual(float(by_email["lawyer.b@example.com"]["monthly_salary"]), 0.0, places=2)
|
||||||
|
|
||||||
|
def test_admin_can_get_lawyer_active_requests_dashboard_detail(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=1, is_terminal=True),
|
||||||
|
Status(code="PAID", name="Оплачено", enabled=True, sort_order=2, is_terminal=False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Деталь",
|
||||||
|
email="lawyer.detail@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
salary_percent=25,
|
||||||
|
default_rate=4000,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(lawyer)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
active_req = Request(
|
||||||
|
track_number="TRK-DETAIL-ACTIVE",
|
||||||
|
client_name="Клиент Деталь",
|
||||||
|
client_phone="+79990002001",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
assigned_lawyer_id=str(lawyer.id),
|
||||||
|
invoice_amount=1200,
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
closed_req = Request(
|
||||||
|
track_number="TRK-DETAIL-CLOSED",
|
||||||
|
client_name="Клиент Закрыт",
|
||||||
|
client_phone="+79990002002",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="CLOSED",
|
||||||
|
assigned_lawyer_id=str(lawyer.id),
|
||||||
|
invoice_amount=700,
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add_all([active_req, closed_req])
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
StatusHistory(
|
||||||
|
request_id=active_req.id,
|
||||||
|
from_status="INVOICE",
|
||||||
|
to_status="PAID",
|
||||||
|
changed_by_admin_id=None,
|
||||||
|
created_at=now - timedelta(days=1),
|
||||||
|
updated_at=now - timedelta(days=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/admin/metrics/lawyers/{lawyer_id}/active-requests",
|
||||||
|
headers=self._headers("ADMIN"),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(int(body.get("total") or 0), 1)
|
||||||
|
self.assertEqual(len(body.get("rows") or []), 1)
|
||||||
|
row = (body.get("rows") or [])[0]
|
||||||
|
self.assertEqual(row.get("track_number"), "TRK-DETAIL-ACTIVE")
|
||||||
|
self.assertEqual(int(row.get("month_paid_events") or 0), 1)
|
||||||
|
self.assertAlmostEqual(float(row.get("month_paid_amount") or 0.0), 1200.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(row.get("month_salary_amount") or 0.0), 300.0, places=2)
|
||||||
|
self.assertAlmostEqual(float((body.get("totals") or {}).get("amount") or 0.0), 1200.0, places=2)
|
||||||
|
self.assertAlmostEqual(float((body.get("totals") or {}).get("salary") or 0.0), 300.0, places=2)
|
||||||
|
|
||||||
def test_lawyer_dashboard_is_scoped_to_current_lawyer(self):
|
def test_lawyer_dashboard_is_scoped_to_current_lawyer(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
db.add_all(
|
db.add_all(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
os.environ.setdefault("S3_BUCKET", "test")
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app.core.http_hardening import _response_security_headers
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
||||||
class HttpHardeningTests(unittest.TestCase):
|
class HttpHardeningTests(unittest.TestCase):
|
||||||
|
|
@ -57,3 +59,37 @@ class HttpHardeningTests(unittest.TestCase):
|
||||||
self.assertEqual(response.headers.get("x-content-type-options"), "nosniff")
|
self.assertEqual(response.headers.get("x-content-type-options"), "nosniff")
|
||||||
self.assertEqual(response.headers.get("x-frame-options"), "DENY")
|
self.assertEqual(response.headers.get("x-frame-options"), "DENY")
|
||||||
self.assertTrue(bool(response.headers.get("x-request-id")))
|
self.assertTrue(bool(response.headers.get("x-request-id")))
|
||||||
|
|
||||||
|
def test_file_preview_paths_allow_same_origin_framing_only(self):
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/public/uploads/object/123",
|
||||||
|
"raw_path": b"/api/public/uploads/object/123",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
}
|
||||||
|
headers = _response_security_headers(Request(scope))
|
||||||
|
self.assertEqual(headers.get("X-Frame-Options"), "SAMEORIGIN")
|
||||||
|
self.assertIn("frame-ancestors 'self'", str(headers.get("Content-Security-Policy")))
|
||||||
|
|
||||||
|
def test_non_file_paths_keep_deny_framing(self):
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/public/requests/TRK-1",
|
||||||
|
"raw_path": b"/api/public/requests/TRK-1",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
}
|
||||||
|
headers = _response_security_headers(Request(scope))
|
||||||
|
self.assertEqual(headers.get("X-Frame-Options"), "DENY")
|
||||||
|
self.assertIn("frame-ancestors 'none'", str(headers.get("Content-Security-Policy")))
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ class MigrationTests(unittest.TestCase):
|
||||||
"form_fields",
|
"form_fields",
|
||||||
"topic_required_fields",
|
"topic_required_fields",
|
||||||
"topic_data_templates",
|
"topic_data_templates",
|
||||||
|
"request_data_templates",
|
||||||
|
"request_data_template_items",
|
||||||
"request_data_requirements",
|
"request_data_requirements",
|
||||||
"requests",
|
"requests",
|
||||||
"messages",
|
"messages",
|
||||||
|
|
@ -97,6 +99,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
"otp_sessions",
|
"otp_sessions",
|
||||||
"quotes",
|
"quotes",
|
||||||
"admin_user_topics",
|
"admin_user_topics",
|
||||||
|
"landing_featured_staff",
|
||||||
"topic_status_transitions",
|
"topic_status_transitions",
|
||||||
"notifications",
|
"notifications",
|
||||||
"invoices",
|
"invoices",
|
||||||
|
|
@ -109,7 +112,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_alembic_version_is_set(self):
|
def test_alembic_version_is_set(self):
|
||||||
with self.engine.connect() as conn:
|
with self.engine.connect() as conn:
|
||||||
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
||||||
self.assertEqual(version, "0018_status_groups")
|
self.assertEqual(version, "0024_featured_staff_carousel")
|
||||||
|
|
||||||
def test_responsible_column_exists_in_all_domain_tables(self):
|
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||||
tables = {
|
tables = {
|
||||||
|
|
@ -122,6 +125,8 @@ class MigrationTests(unittest.TestCase):
|
||||||
"form_fields",
|
"form_fields",
|
||||||
"topic_required_fields",
|
"topic_required_fields",
|
||||||
"topic_data_templates",
|
"topic_data_templates",
|
||||||
|
"request_data_templates",
|
||||||
|
"request_data_template_items",
|
||||||
"request_data_requirements",
|
"request_data_requirements",
|
||||||
"requests",
|
"requests",
|
||||||
"messages",
|
"messages",
|
||||||
|
|
@ -131,6 +136,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
"otp_sessions",
|
"otp_sessions",
|
||||||
"quotes",
|
"quotes",
|
||||||
"admin_user_topics",
|
"admin_user_topics",
|
||||||
|
"landing_featured_staff",
|
||||||
"topic_status_transitions",
|
"topic_status_transitions",
|
||||||
"notifications",
|
"notifications",
|
||||||
"invoices",
|
"invoices",
|
||||||
|
|
@ -176,15 +182,22 @@ class MigrationTests(unittest.TestCase):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("admin_users")}
|
columns = {column["name"] for column in self.inspector.get_columns("admin_users")}
|
||||||
self.assertIn("default_rate", columns)
|
self.assertIn("default_rate", columns)
|
||||||
self.assertIn("salary_percent", columns)
|
self.assertIn("salary_percent", columns)
|
||||||
|
self.assertIn("phone", columns)
|
||||||
|
|
||||||
def test_requests_contains_financial_columns(self):
|
def test_requests_contains_financial_columns(self):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("requests")}
|
columns = {column["name"] for column in self.inspector.get_columns("requests")}
|
||||||
self.assertIn("client_id", columns)
|
self.assertIn("client_id", columns)
|
||||||
|
self.assertIn("important_date_at", columns)
|
||||||
self.assertIn("effective_rate", columns)
|
self.assertIn("effective_rate", columns)
|
||||||
|
self.assertIn("request_cost", columns)
|
||||||
self.assertIn("invoice_amount", columns)
|
self.assertIn("invoice_amount", columns)
|
||||||
self.assertIn("paid_at", columns)
|
self.assertIn("paid_at", columns)
|
||||||
self.assertIn("paid_by_admin_id", columns)
|
self.assertIn("paid_by_admin_id", columns)
|
||||||
|
|
||||||
|
def test_status_history_contains_important_date_column(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("status_history")}
|
||||||
|
self.assertIn("important_date_at", columns)
|
||||||
|
|
||||||
def test_invoices_contains_core_columns(self):
|
def test_invoices_contains_core_columns(self):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("invoices")}
|
columns = {column["name"] for column in self.inspector.get_columns("invoices")}
|
||||||
self.assertIn("client_id", columns)
|
self.assertIn("client_id", columns)
|
||||||
|
|
@ -221,3 +234,39 @@ class MigrationTests(unittest.TestCase):
|
||||||
self.assertIn("phone", columns)
|
self.assertIn("phone", columns)
|
||||||
self.assertIn("created_at", columns)
|
self.assertIn("created_at", columns)
|
||||||
self.assertIn("responsible", columns)
|
self.assertIn("responsible", columns)
|
||||||
|
|
||||||
|
def test_topic_data_templates_contains_request_data_catalog_fields(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("topic_data_templates")}
|
||||||
|
self.assertIn("value_type", columns)
|
||||||
|
self.assertIn("document_name", columns)
|
||||||
|
|
||||||
|
def test_request_data_requirements_contains_chat_request_fields(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("request_data_requirements")}
|
||||||
|
self.assertIn("request_message_id", columns)
|
||||||
|
self.assertIn("field_type", columns)
|
||||||
|
self.assertIn("document_name", columns)
|
||||||
|
self.assertIn("value_text", columns)
|
||||||
|
self.assertIn("sort_order", columns)
|
||||||
|
|
||||||
|
def test_request_data_template_tables_contain_core_columns(self):
|
||||||
|
templates = {column["name"] for column in self.inspector.get_columns("request_data_templates")}
|
||||||
|
self.assertIn("topic_code", templates)
|
||||||
|
self.assertIn("name", templates)
|
||||||
|
self.assertIn("created_by_admin_id", templates)
|
||||||
|
self.assertIn("sort_order", templates)
|
||||||
|
|
||||||
|
items = {column["name"] for column in self.inspector.get_columns("request_data_template_items")}
|
||||||
|
self.assertIn("request_data_template_id", items)
|
||||||
|
self.assertIn("topic_data_template_id", items)
|
||||||
|
self.assertIn("key", items)
|
||||||
|
self.assertIn("label", items)
|
||||||
|
self.assertIn("value_type", items)
|
||||||
|
self.assertIn("sort_order", items)
|
||||||
|
|
||||||
|
def test_landing_featured_staff_contains_core_columns(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("landing_featured_staff")}
|
||||||
|
self.assertIn("admin_user_id", columns)
|
||||||
|
self.assertIn("caption", columns)
|
||||||
|
self.assertIn("sort_order", columns)
|
||||||
|
self.assertIn("pinned", columns)
|
||||||
|
self.assertIn("enabled", columns)
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,7 @@ class RequestRatesTests(unittest.TestCase):
|
||||||
description="public",
|
description="public",
|
||||||
extra_fields={},
|
extra_fields={},
|
||||||
effective_rate=8800,
|
effective_rate=8800,
|
||||||
|
request_cost=9900,
|
||||||
invoice_amount=12500,
|
invoice_amount=12500,
|
||||||
)
|
)
|
||||||
db.add(req)
|
db.add(req)
|
||||||
|
|
@ -503,10 +504,72 @@ class RequestRatesTests(unittest.TestCase):
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
body = response.json()
|
body = response.json()
|
||||||
self.assertNotIn("effective_rate", body)
|
self.assertNotIn("effective_rate", body)
|
||||||
|
self.assertNotIn("request_cost", body)
|
||||||
self.assertNotIn("invoice_amount", body)
|
self.assertNotIn("invoice_amount", body)
|
||||||
self.assertNotIn("paid_at", body)
|
self.assertNotIn("paid_at", body)
|
||||||
self.assertNotIn("paid_by_admin_id", body)
|
self.assertNotIn("paid_by_admin_id", body)
|
||||||
|
|
||||||
|
def test_admin_request_can_bind_existing_client_or_create_new_and_set_request_cost(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
existing_client = Client(
|
||||||
|
full_name="Существующий клиент",
|
||||||
|
phone="+79990000100",
|
||||||
|
responsible="Администратор системы",
|
||||||
|
)
|
||||||
|
db.add(existing_client)
|
||||||
|
db.commit()
|
||||||
|
existing_client_id = str(existing_client.id)
|
||||||
|
|
||||||
|
admin_headers = self._auth_headers("ADMIN", "root@example.com")
|
||||||
|
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/admin/requests",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"client_id": existing_client_id,
|
||||||
|
"client_name": "Игнорировать",
|
||||||
|
"client_phone": "+70000000000",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "link existing client",
|
||||||
|
"request_cost": 3450,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201, created.text)
|
||||||
|
request_id = created.json()["id"]
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
row = db.get(Request, UUID(request_id))
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(str(row.client_id), existing_client_id)
|
||||||
|
self.assertEqual(row.client_name, "Существующий клиент")
|
||||||
|
self.assertEqual(row.client_phone, "+79990000100")
|
||||||
|
self.assertAlmostEqual(float(row.request_cost or 0), 3450.0, places=2)
|
||||||
|
|
||||||
|
updated = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"client_id": "",
|
||||||
|
"client_name": "Новый клиент из админки",
|
||||||
|
"client_phone": "+79990000101",
|
||||||
|
"request_cost": 4200,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(updated.status_code, 200, updated.text)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
row = db.get(Request, UUID(request_id))
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row.client_name, "Новый клиент из админки")
|
||||||
|
self.assertEqual(row.client_phone, "+79990000101")
|
||||||
|
self.assertAlmostEqual(float(row.request_cost or 0), 4200.0, places=2)
|
||||||
|
self.assertIsNotNone(row.client_id)
|
||||||
|
self.assertNotEqual(str(row.client_id), existing_client_id)
|
||||||
|
client = db.get(Client, row.client_id)
|
||||||
|
self.assertIsNotNone(client)
|
||||||
|
self.assertEqual(client.full_name, "Новый клиент из админки")
|
||||||
|
self.assertEqual(client.phone, "+79990000101")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
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