Third commit

This commit is contained in:
TronoSfera 2026-02-23 17:54:19 +03:00
parent fb13d93ab3
commit 96649f8cc7
43 changed files with 5194 additions and 456 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/tmp/
*.idea
.env

View file

@ -18,6 +18,8 @@ from app.models.otp_session import OtpSession
from app.models.quote import Quote from app.models.quote import Quote
from app.models.admin_user_topic import AdminUserTopic from app.models.admin_user_topic import AdminUserTopic
from app.models.notification import Notification from app.models.notification import Notification
from app.models.invoice import Invoice
from app.models.security_audit_log import SecurityAuditLog
config = context.config config = context.config
fileConfig(config.config_file_name) fileConfig(config.config_file_name)

View file

@ -0,0 +1,56 @@
"""add invoices table
Revision ID: 0012_add_invoices_table
Revises: 0011_dashboard_financial_fields
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0012_add_invoices_table"
down_revision = "0011_dashboard_financial_fields"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"invoices",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("invoice_number", sa.String(length=40), nullable=False, unique=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="WAITING_PAYMENT"),
sa.Column("amount", sa.Numeric(14, 2), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=False, server_default="RUB"),
sa.Column("payer_display_name", sa.String(length=300), nullable=False),
sa.Column("payer_details_encrypted", sa.Text(), nullable=True),
sa.Column("issued_by_admin_user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("issued_by_role", sa.String(length=20), nullable=True),
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("paid_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_invoices_request_id", "invoices", ["request_id"])
op.create_index("ix_invoices_invoice_number", "invoices", ["invoice_number"], unique=True)
op.create_index("ix_invoices_status", "invoices", ["status"])
op.create_index("ix_invoices_issued_by_admin_user_id", "invoices", ["issued_by_admin_user_id"])
op.create_check_constraint("ck_invoices_amount_non_negative", "invoices", "amount >= 0")
op.create_check_constraint(
"ck_invoices_status_allowed",
"invoices",
"status IN ('WAITING_PAYMENT', 'PAID', 'CANCELED')",
)
def downgrade():
op.drop_constraint("ck_invoices_status_allowed", "invoices", type_="check")
op.drop_constraint("ck_invoices_amount_non_negative", "invoices", type_="check")
op.drop_index("ix_invoices_issued_by_admin_user_id", table_name="invoices")
op.drop_index("ix_invoices_status", table_name="invoices")
op.drop_index("ix_invoices_invoice_number", table_name="invoices")
op.drop_index("ix_invoices_request_id", table_name="invoices")
op.drop_table("invoices")

View file

@ -0,0 +1,33 @@
"""add status kind and invoice template for billing flow
Revision ID: 0013_status_kind_billing
Revises: 0012_add_invoices_table
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
revision = "0013_status_kind_billing"
down_revision = "0012_add_invoices_table"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("statuses", sa.Column("kind", sa.String(length=20), nullable=False, server_default="DEFAULT"))
op.add_column("statuses", sa.Column("invoice_template", sa.Text(), nullable=True))
op.create_check_constraint(
"ck_statuses_kind_allowed",
"statuses",
"kind IN ('DEFAULT', 'INVOICE', 'PAID')",
)
op.create_index("ix_statuses_kind", "statuses", ["kind"])
op.alter_column("statuses", "kind", server_default=None)
def downgrade():
op.drop_index("ix_statuses_kind", table_name="statuses")
op.drop_constraint("ck_statuses_kind_allowed", "statuses", type_="check")
op.drop_column("statuses", "invoice_template")
op.drop_column("statuses", "kind")

View file

@ -0,0 +1,51 @@
"""add security audit log table for file access events
Revision ID: 0014_security_audit_log
Revises: 0013_status_kind_billing
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0014_security_audit_log"
down_revision = "0013_status_kind_billing"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"security_audit_log",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
sa.Column("actor_role", sa.String(length=30), nullable=False),
sa.Column("actor_subject", sa.String(length=200), nullable=False, server_default=""),
sa.Column("actor_ip", sa.String(length=64), nullable=True),
sa.Column("action", sa.String(length=50), nullable=False),
sa.Column("scope", sa.String(length=50), nullable=False),
sa.Column("object_key", sa.String(length=500), nullable=True),
sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("attachment_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("allowed", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("reason", sa.String(length=400), nullable=True),
sa.Column("details", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
)
op.create_index("ix_security_audit_log_created_at", "security_audit_log", ["created_at"])
op.create_index("ix_security_audit_log_allowed", "security_audit_log", ["allowed"])
op.create_index("ix_security_audit_log_action", "security_audit_log", ["action"])
op.create_index("ix_security_audit_log_actor_subject", "security_audit_log", ["actor_subject"])
op.alter_column("security_audit_log", "details", server_default=None)
op.alter_column("security_audit_log", "allowed", server_default=None)
op.alter_column("security_audit_log", "actor_subject", server_default=None)
def downgrade():
op.drop_index("ix_security_audit_log_actor_subject", table_name="security_audit_log")
op.drop_index("ix_security_audit_log_action", table_name="security_audit_log")
op.drop_index("ix_security_audit_log_allowed", table_name="security_audit_log")
op.drop_index("ix_security_audit_log_created_at", table_name="security_audit_log")
op.drop_table("security_audit_log")

View file

@ -25,6 +25,8 @@ def _status_row(row: Status):
"enabled": row.enabled, "enabled": row.enabled,
"sort_order": row.sort_order, "sort_order": row.sort_order,
"is_terminal": row.is_terminal, "is_terminal": row.is_terminal,
"kind": row.kind,
"invoice_template": row.invoice_template,
} }

View file

@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.inspection import inspect as sa_inspect from sqlalchemy.inspection import inspect as sa_inspect
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.sql.sqltypes import Boolean, Date, DateTime, Float, Integer, JSON, Numeric
import app.models as models_pkg import app.models as models_pkg
from app.core.deps import get_current_admin from app.core.deps import get_current_admin
@ -47,6 +48,7 @@ from app.services.request_read_markers import (
from app.services.request_status import apply_status_change_effects from app.services.request_status import apply_status_change_effects
from app.services.status_flow import transition_allowed_for_topic 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.billing_flow import apply_billing_transition_effects, normalize_status_kind_or_400
from app.services.universal_query import apply_universal_query from app.services.universal_query import apply_universal_query
router = APIRouter() router = APIRouter()
@ -62,6 +64,7 @@ SYSTEM_FIELDS = {
"lawyer_has_unread_updates", "lawyer_has_unread_updates",
"lawyer_unread_event_type", "lawyer_unread_event_type",
} }
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"} ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
# Per-table RBAC: table -> role -> actions. # Per-table RBAC: table -> role -> actions.
@ -76,6 +79,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
"statuses": {"ADMIN": set(CRUD_ACTIONS)}, "statuses": {"ADMIN": set(CRUD_ACTIONS)},
"form_fields": {"ADMIN": set(CRUD_ACTIONS)}, "form_fields": {"ADMIN": set(CRUD_ACTIONS)},
"audit_log": {"ADMIN": {"query", "read"}}, "audit_log": {"ADMIN": {"query", "read"}},
"security_audit_log": {"ADMIN": {"query", "read"}},
"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)},
@ -168,6 +172,259 @@ def _columns_map(model: type) -> dict[str, Any]:
return {column.key: column for column in mapper.columns} return {column.key: column for column in mapper.columns}
def _column_kind(column: Any) -> str:
col_type = column.type
if isinstance(col_type, Boolean):
return "boolean"
if isinstance(col_type, (Integer, Numeric, Float)):
return "number"
if isinstance(col_type, DateTime):
return "datetime"
if isinstance(col_type, Date):
return "date"
if isinstance(col_type, JSON):
return "json"
try:
python_type = col_type.python_type
except Exception:
python_type = None
if python_type is uuid.UUID:
return "uuid"
return "text"
def _table_label(table_name: str) -> str:
normalized = _normalize_table_name(table_name)
if not normalized:
return "Таблица"
explicit_labels = {
"requests": "Заявки",
"invoices": "Счета",
"quotes": "Цитаты",
"topics": "Темы",
"statuses": "Статусы",
"form_fields": "Поля формы",
"topic_required_fields": "Обязательные поля темы",
"topic_data_templates": "Шаблоны данных темы",
"topic_status_transitions": "Переходы статусов темы",
"admin_users": "Пользователи",
"admin_user_topics": "Дополнительные темы юристов",
"attachments": "Вложения",
"messages": "Сообщения",
"audit_log": "Журнал аудита",
"security_audit_log": "Журнал безопасности файлов",
"status_history": "История статусов",
"request_data_requirements": "Требования данных заявки",
"otp_sessions": "OTP-сессии",
"notifications": "Уведомления",
}
if normalized in explicit_labels:
return explicit_labels[normalized]
return _humanize_identifier_ru(normalized)
def _humanize_identifier_ru(identifier: str) -> str:
normalized = _normalize_table_name(identifier)
if not normalized:
return "Таблица"
token_labels = {
"request": "заявка",
"requests": "заявки",
"invoice": "счет",
"invoices": "счета",
"topic": "тема",
"topics": "темы",
"status": "статус",
"statuses": "статусы",
"transition": "переход",
"transitions": "переходы",
"required": "обязательные",
"form": "формы",
"field": "поле",
"fields": "поля",
"template": "шаблон",
"templates": "шаблоны",
"data": "данных",
"requirement": "требование",
"requirements": "требования",
"admin": "админ",
"user": "пользователь",
"users": "пользователи",
"quote": "цитата",
"quotes": "цитаты",
"message": "сообщение",
"messages": "сообщения",
"attachment": "вложение",
"attachments": "вложения",
"notification": "уведомление",
"notifications": "уведомления",
"audit": "аудита",
"security": "безопасности",
"log": "журнал",
"history": "история",
"otp": "OTP",
"session": "сессия",
"sessions": "сессии",
"id": "ID",
}
words = [token_labels.get(token, token) for token in normalized.split("_") if token]
if not words:
return "Таблица"
phrase = " ".join(words).strip()
return phrase[:1].upper() + phrase[1:] if phrase else "Таблица"
def _column_label(table_name: str, column_name: str) -> str:
normalized_table = _normalize_table_name(table_name)
normalized_column = _normalize_table_name(column_name)
if not normalized_column:
return "Поле"
table_overrides = {
("invoices", "request_id"): "ID заявки",
("invoices", "issued_by_admin_user_id"): "ID сотрудника",
("request_data_requirements", "request_id"): "ID заявки",
}
if (normalized_table, normalized_column) in table_overrides:
return table_overrides[(normalized_table, normalized_column)]
explicit = {
"id": "ID",
"code": "Код",
"key": "Ключ",
"name": "Название",
"label": "Метка",
"text": "Текст",
"description": "Описание",
"author": "Автор",
"source": "Источник",
"email": "Email",
"role": "Роль",
"kind": "Тип",
"status": "Статус",
"status_code": "Статус",
"topic_code": "Тема",
"from_status": "Из статуса",
"to_status": "В статус",
"track_number": "Номер заявки",
"invoice_number": "Номер счета",
"invoice_template": "Шаблон счета",
"amount": "Сумма",
"currency": "Валюта",
"client_name": "Клиент",
"client_phone": "Телефон",
"payer_display_name": "Плательщик",
"payer_details_encrypted": "Реквизиты (шифр.)",
"issued_at": "Дата формирования",
"paid_at": "Дата оплаты",
"created_at": "Дата создания",
"updated_at": "Дата обновления",
"responsible": "Ответственный",
"sort_order": "Порядок",
"is_active": "Активен",
"enabled": "Активен",
"required": "Обязательное",
"nullable": "Может быть пустым",
"is_terminal": "Терминальный",
"request_id": "ID заявки",
"admin_user_id": "ID пользователя",
"assigned_lawyer_id": "Назначенный юрист",
"issued_by_admin_user_id": "ID сотрудника",
"primary_topic_code": "Профильная тема",
"default_rate": "Ставка по умолчанию",
"effective_rate": "Ставка (фикс.)",
"salary_percent": "Процент зарплаты",
"invoice_amount": "Сумма счета",
"paid_by_admin_id": "Оплату подтвердил",
"extra_fields": "Доп. поля",
"total_attachments_bytes": "Размер вложений (байт)",
"type": "Тип",
"options": "Опции",
"field_key": "Поле формы",
"sla_hours": "SLA (часы)",
"avatar_url": "Аватар",
"file_name": "Имя файла",
"mime_type": "MIME-тип",
"size_bytes": "Размер (байт)",
"s3_key": "Ключ S3",
"author_type": "Автор",
"is_fulfilled": "Выполнено",
"requested_by_admin_user_id": "Запросил сотрудник",
"fulfilled_at": "Дата выполнения",
"title": "Заголовок",
"body": "Текст",
"event_type": "Тип события",
"is_read": "Прочитано",
"read_at": "Дата прочтения",
"notified_at": "Дата уведомления",
"otp_code": "OTP-код",
"phone": "Телефон",
"verified_at": "Подтверждено",
"expires_at": "Истекает",
"jwt_token": "JWT-токен",
"action": "Действие",
"entity": "Сущность",
"entity_id": "ID сущности",
"actor_admin_id": "ID автора",
"actor_role": "Роль субъекта",
"actor_subject": "Субъект",
"actor_ip": "IP адрес",
"allowed": "Разрешено",
"reason": "Причина",
"diff": "Изменения",
"details": "Детали",
}
if normalized_column in explicit:
return explicit[normalized_column]
return _humanize_identifier_ru(normalized_column)
def _default_sort_for_table(model: type) -> list[dict[str, str]]:
columns = _columns_map(model)
if "sort_order" in columns:
return [{"field": "sort_order", "dir": "asc"}]
if "created_at" in columns:
return [{"field": "created_at", "dir": "desc"}]
pk = sa_inspect(model).primary_key
if pk:
return [{"field": pk[0].key, "dir": "asc"}]
return []
def _table_columns_meta(table_name: str, model: type) -> list[dict[str, Any]]:
mapper = sa_inspect(model)
hidden = _hidden_response_fields(table_name)
protected = _protected_input_fields(table_name)
primary_keys = {column.key for column in mapper.primary_key}
out: list[dict[str, Any]] = []
for column in mapper.columns:
name = column.key
if name in hidden:
continue
kind = _column_kind(column)
has_default = column.default is not None or column.server_default is not None or name in primary_keys
editable = name not in SYSTEM_FIELDS and name not in protected and name not in primary_keys
out.append(
{
"name": name,
"label": _column_label(table_name, name),
"kind": kind,
"nullable": bool(column.nullable),
"editable": bool(editable),
"sortable": True,
"filterable": kind != "json",
"required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable),
"has_default": bool(has_default),
"is_primary_key": name in primary_keys,
}
)
return out
def _hidden_response_fields(table_name: str) -> set[str]: def _hidden_response_fields(table_name: str) -> set[str]:
if table_name == "admin_users": if table_name == "admin_users":
return {"password_hash"} return {"password_hash"}
@ -270,6 +527,14 @@ def _normalize_optional_string(value: Any) -> str | None:
return text or None return text or None
def _active_lawyer_or_400(db: Session, lawyer_id: Any) -> AdminUser:
lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id")
lawyer = db.get(AdminUser, lawyer_uuid)
if lawyer is None or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active):
raise HTTPException(status_code=400, detail="Можно назначить только активного юриста")
return lawyer
def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, Any]: def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload) data = dict(payload)
if "password_hash" in data: if "password_hash" in data:
@ -466,6 +731,16 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any])
return data return data
def _apply_status_fields(payload: dict[str, Any]) -> dict[str, Any]:
data = dict(payload)
if "kind" in data:
data["kind"] = normalize_status_kind_or_400(data.get("kind"))
if "invoice_template" in data:
text = str(data.get("invoice_template") or "").strip()
data["invoice_template"] = text or None
return data
_RU_TO_LATIN = { _RU_TO_LATIN = {
"а": "a", "а": "a",
"б": "b", "б": "b",
@ -655,6 +930,36 @@ def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin:
) )
@router.get("/meta/tables")
def list_tables_meta(admin: dict = Depends(get_current_admin)):
role = str(admin.get("role") or "").upper()
if role != "ADMIN":
raise HTTPException(status_code=403, detail="Недостаточно прав")
table_models = _table_model_map()
rows: list[dict[str, Any]] = []
for table_name in sorted(table_models.keys()):
model = table_models[table_name]
actions = sorted(_allowed_actions(role, table_name))
rows.append(
{
"key": table_name,
"table": table_name,
"label": _table_label(table_name),
"section": "main" if table_name in {"requests", "invoices"} else "dictionary",
"actions": actions,
"query_endpoint": f"/api/admin/crud/{table_name}/query",
"create_endpoint": f"/api/admin/crud/{table_name}",
"update_endpoint_template": f"/api/admin/crud/{table_name}" + "/{id}",
"delete_endpoint_template": f"/api/admin/crud/{table_name}" + "/{id}",
"default_sort": _default_sort_for_table(model),
"columns": _table_columns_meta(table_name, model),
}
)
return {"tables": rows}
@router.post("/{table_name}/query") @router.post("/{table_name}/query")
def query_table( def query_table(
table_name: str, table_name: str,
@ -711,14 +1016,27 @@ def create_row(
): ):
normalized, model = _resolve_table_model(table_name) normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "create") _require_table_action(admin, normalized, "create")
if normalized == "requests" and _is_lawyer(admin): if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
assigned_lawyer_id = payload.get("assigned_lawyer_id") if isinstance(payload, dict) else None assigned_lawyer_id = payload.get("assigned_lawyer_id")
if str(assigned_lawyer_id or "").strip(): if str(assigned_lawyer_id or "").strip():
raise HTTPException(status_code=403, detail='Юрист не может назначать заявку при создании') raise HTTPException(status_code=403, detail='Юрист не может назначать заявку при создании')
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys())))
if forbidden_fields:
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
prepared = _prepare_create_payload(normalized, payload) prepared = _prepare_create_payload(normalized, payload)
if normalized == "requests": if normalized == "requests":
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields")) validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
if not _is_lawyer(admin):
assigned_raw = prepared.get("assigned_lawyer_id")
if assigned_raw is None or not str(assigned_raw).strip():
if "assigned_lawyer_id" in prepared:
prepared["assigned_lawyer_id"] = None
else:
assigned_lawyer = _active_lawyer_or_400(db, assigned_raw)
prepared["assigned_lawyer_id"] = str(assigned_lawyer.id)
if prepared.get("effective_rate") is None:
prepared["effective_rate"] = assigned_lawyer.default_rate
prepared = _apply_auto_fields_for_create(db, model, normalized, prepared) prepared = _apply_auto_fields_for_create(db, model, normalized, prepared)
clean_payload = _sanitize_payload( clean_payload = _sanitize_payload(
model, model,
@ -737,6 +1055,8 @@ def create_row(
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":
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload) clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if normalized == "statuses":
clean_payload = _apply_status_fields(clean_payload)
if "responsible" in _columns_map(model): if "responsible" in _columns_map(model):
clean_payload["responsible"] = _resolve_responsible(admin) clean_payload["responsible"] = _resolve_responsible(admin)
row = model(**clean_payload) row = model(**clean_payload)
@ -766,8 +1086,12 @@ def update_row(
): ):
normalized, model = _resolve_table_model(table_name) normalized, model = _resolve_table_model(table_name)
_require_table_action(admin, normalized, "update") _require_table_action(admin, normalized, "update")
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict) and "assigned_lawyer_id" in payload: if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') if "assigned_lawyer_id" in payload:
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys())))
if forbidden_fields:
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
row = _load_row_or_404(db, model, row_id) row = _load_row_or_404(db, model, row_id)
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)): if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования") raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования")
@ -791,6 +1115,17 @@ def update_row(
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":
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload) clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
if normalized == "statuses":
clean_payload = _apply_status_fields(clean_payload)
if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload:
assigned_raw = clean_payload.get("assigned_lawyer_id")
if assigned_raw is None or not str(assigned_raw).strip():
clean_payload["assigned_lawyer_id"] = None
else:
assigned_lawyer = _active_lawyer_or_400(db, assigned_raw)
clean_payload["assigned_lawyer_id"] = str(assigned_lawyer.id)
if isinstance(row, Request) and row.effective_rate is None and "effective_rate" not in clean_payload:
clean_payload["effective_rate"] = assigned_lawyer.default_rate
before = _row_to_dict(row) before = _row_to_dict(row)
if normalized == "topic_status_transitions": if normalized == "topic_status_transitions":
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip() next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
@ -807,6 +1142,14 @@ def update_row(
detail="Переход статуса не разрешен для выбранной темы", detail="Переход статуса не разрешен для выбранной темы",
) )
if before_status != after_status and isinstance(row, Request): if before_status != after_status and isinstance(row, Request):
billing_note = apply_billing_transition_effects(
db,
req=row,
from_status=before_status,
to_status=after_status,
admin=admin,
responsible=_resolve_responsible(admin),
)
mark_unread_for_client(row, EVENT_STATUS) mark_unread_for_client(row, EVENT_STATUS)
apply_status_change_effects( apply_status_change_effects(
db, db,
@ -822,7 +1165,7 @@ 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}", body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")),
responsible=_resolve_responsible(admin), responsible=_resolve_responsible(admin),
) )
for key, value in clean_payload.items(): for key, value in clean_payload.items():

439
app/api/admin/invoices.py Normal file
View file

@ -0,0 +1,439 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.deps import require_role
from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.invoice import Invoice
from app.models.request import Request
from app.schemas.universal import UniversalQuery
from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites
from app.services.invoice_pdf import build_invoice_pdf_bytes
from app.services.universal_query import apply_universal_query
router = APIRouter()
STATUS_WAITING = "WAITING_PAYMENT"
STATUS_PAID = "PAID"
STATUS_CANCELED = "CANCELED"
ALLOWED_STATUSES = {STATUS_WAITING, STATUS_PAID, STATUS_CANCELED}
STATUS_LABELS = {
STATUS_WAITING: "Ожидает оплату",
STATUS_PAID: "Оплачен",
STATUS_CANCELED: "Отменен",
}
def _to_float(value) -> float | None:
if value is None:
return None
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return None
def _to_iso(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _actor_uuid_or_401(admin: dict) -> UUID:
try:
return UUID(str(admin.get("sub") or ""))
except ValueError:
raise HTTPException(status_code=401, detail="Некорректный токен")
def _uuid_or_400(raw: str | None, field: str) -> UUID:
if not raw:
raise HTTPException(status_code=400, detail=f'Поле "{field}" обязательно')
try:
return UUID(str(raw))
except ValueError:
raise HTTPException(status_code=400, detail=f'Некорректное поле "{field}"')
def _normalize_status(raw: str | None) -> str:
value = str(raw or STATUS_WAITING).strip().upper()
if value not in ALLOWED_STATUSES:
raise HTTPException(status_code=400, detail="Некорректный статус счета")
return value
def _normalize_currency(raw: str | None) -> str:
value = str(raw or "RUB").strip().upper()[:3]
return value or "RUB"
def _amount_or_400(raw) -> float:
value = _to_float(raw)
if value is None:
raise HTTPException(status_code=400, detail='Поле "amount" обязательно и должно быть числом')
if value < 0:
raise HTTPException(status_code=400, detail='Поле "amount" не может быть отрицательным')
return round(value, 2)
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _invoice_number(db: Session) -> str:
prefix = _now_utc().strftime("%Y%m%d")
candidate = f"INV-{prefix}-{uuid4().hex[:8].upper()}"
exists = db.query(Invoice.id).filter(Invoice.invoice_number == candidate).first()
if exists is None:
return candidate
return f"INV-{prefix}-{uuid4().hex[:12].upper()}"
def _parse_requisites(raw) -> dict:
if raw is None:
return {}
if isinstance(raw, dict):
return dict(raw)
text = str(raw).strip()
if not text:
return {}
try:
data = json.loads(text)
except Exception:
raise HTTPException(status_code=400, detail='Поле "payer_details" должно быть JSON-объектом')
if not isinstance(data, dict):
raise HTTPException(status_code=400, detail='Поле "payer_details" должно быть JSON-объектом')
return data
def _ensure_lawyer_owns_request_or_403(role: str, actor_id: UUID, req: Request) -> None:
if role != "LAWYER":
return
assigned = str(req.assigned_lawyer_id or "").strip()
if not assigned or assigned != str(actor_id):
raise HTTPException(status_code=403, detail="Юрист видит и изменяет только свои счета")
def _serialize_invoice(
row: Invoice,
request_track: str | None,
issuer_name: str | None,
*,
include_payer_details: bool = False,
) -> dict:
payload = {
"id": str(row.id),
"invoice_number": row.invoice_number,
"request_id": str(row.request_id),
"request_track_number": request_track,
"status": row.status,
"status_label": STATUS_LABELS.get(str(row.status or "").upper(), row.status),
"amount": _to_float(row.amount),
"currency": row.currency,
"payer_display_name": row.payer_display_name,
"issued_by_admin_user_id": str(row.issued_by_admin_user_id) if row.issued_by_admin_user_id else None,
"issued_by_name": issuer_name,
"issued_by_role": row.issued_by_role,
"issued_at": _to_iso(row.issued_at),
"paid_at": _to_iso(row.paid_at),
"created_at": _to_iso(row.created_at),
"updated_at": _to_iso(row.updated_at),
"responsible": row.responsible,
"pdf_url": f"/api/admin/invoices/{row.id}/pdf",
}
if include_payer_details:
payload["payer_details"] = decrypt_requisites(row.payer_details_encrypted)
return payload
def _apply_paid_flags(req: Request, invoice: Invoice, *, admin_id: UUID | None) -> None:
req.invoice_amount = invoice.amount
req.paid_at = invoice.paid_at
req.paid_by_admin_id = str(admin_id) if admin_id else None
def _request_from_payload_or_404(db: Session, payload: dict) -> Request:
request_id_raw = payload.get("request_id")
track_number_raw = str(payload.get("request_track_number") or "").strip().upper()
if request_id_raw:
request_id = _uuid_or_400(request_id_raw, "request_id")
req = db.get(Request, request_id)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
return req
if track_number_raw:
req = db.query(Request).filter(Request.track_number == track_number_raw).first()
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
return req
raise HTTPException(status_code=400, detail='Поле "request_id" или "request_track_number" обязательно')
def _commit_or_400(db: Session, detail: str) -> None:
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail=detail)
@router.post("/query")
def query_invoices(
uq: UniversalQuery,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
role = str(admin.get("role") or "").upper()
actor_id = _actor_uuid_or_401(admin)
query = db.query(Invoice)
if role == "LAWYER":
query = query.join(Request, Request.id == Invoice.request_id).filter(Request.assigned_lawyer_id == str(actor_id))
query = apply_universal_query(query, Invoice, uq)
total = query.count()
rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
request_ids = {row.request_id for row in rows}
requests = db.query(Request.id, Request.track_number).filter(Request.id.in_(request_ids)).all() if request_ids else []
request_map = {str(row_id): track for row_id, track in requests}
issuer_ids = {row.issued_by_admin_user_id for row in rows if row.issued_by_admin_user_id}
users = db.query(AdminUser.id, AdminUser.name, AdminUser.email).filter(AdminUser.id.in_(issuer_ids)).all() if issuer_ids else []
issuer_map = {str(user_id): (name or email or str(user_id)) for user_id, name, email in users}
data = [
_serialize_invoice(
row,
request_track=request_map.get(str(row.request_id)),
issuer_name=issuer_map.get(str(row.issued_by_admin_user_id)) if row.issued_by_admin_user_id else None,
)
for row in rows
]
return {"rows": data, "total": int(total)}
@router.get("/{invoice_id}")
def get_invoice(
invoice_id: str,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
role = str(admin.get("role") or "").upper()
actor_id = _actor_uuid_or_401(admin)
invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id"))
if invoice is None:
raise HTTPException(status_code=404, detail="Счет не найден")
req = db.get(Request, invoice.request_id)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
return _serialize_invoice(
invoice,
request_track=req.track_number,
issuer_name=issuer.name if issuer else None,
include_payer_details=True,
)
@router.post("", status_code=201)
def create_invoice(
payload: dict,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
role = str(admin.get("role") or "").upper()
actor_id = _actor_uuid_or_401(admin)
actor_email = str(admin.get("email") or "").strip() or "Администратор системы"
req = _request_from_payload_or_404(db, payload)
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
status = _normalize_status(payload.get("status"))
if role == "LAWYER" and status == STATUS_PAID:
raise HTTPException(status_code=403, detail='Юрист не может ставить статус "Оплачен"')
payer_display_name = str(payload.get("payer_display_name") or "").strip()
if not payer_display_name:
raise HTTPException(status_code=400, detail='Поле "payer_display_name" обязательно')
invoice = Invoice(
request_id=req.id,
invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db),
status=status,
amount=_amount_or_400(payload.get("amount")),
currency=_normalize_currency(payload.get("currency")),
payer_display_name=payer_display_name,
payer_details_encrypted=encrypt_requisites(_parse_requisites(payload.get("payer_details"))),
issued_by_admin_user_id=actor_id,
issued_by_role=role,
issued_at=_now_utc(),
paid_at=None,
responsible=actor_email,
)
req.invoice_amount = invoice.amount
req.responsible = actor_email
if status == STATUS_PAID:
invoice.paid_at = _now_utc()
_apply_paid_flags(req, invoice, admin_id=actor_id if role == "ADMIN" else None)
db.add(invoice)
db.add(req)
_commit_or_400(db, "Счет с таким номером уже существует")
db.refresh(invoice)
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
return _serialize_invoice(invoice, request_track=req.track_number, issuer_name=issuer.name if issuer else None)
@router.patch("/{invoice_id}")
def update_invoice(
invoice_id: str,
payload: dict,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
role = str(admin.get("role") or "").upper()
actor_id = _actor_uuid_or_401(admin)
actor_email = str(admin.get("email") or "").strip() or "Администратор системы"
invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id"))
if invoice is None:
raise HTTPException(status_code=404, detail="Счет не найден")
req = db.get(Request, invoice.request_id)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
prev_status = str(invoice.status or "").upper()
prev_paid_at = invoice.paid_at
if "amount" in payload:
invoice.amount = _amount_or_400(payload.get("amount"))
req.invoice_amount = invoice.amount
if prev_status == STATUS_PAID:
req.paid_at = invoice.paid_at
if "currency" in payload:
invoice.currency = _normalize_currency(payload.get("currency"))
if "payer_display_name" in payload:
name = str(payload.get("payer_display_name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail='Поле "payer_display_name" не может быть пустым')
invoice.payer_display_name = name
if "payer_details" in payload:
invoice.payer_details_encrypted = encrypt_requisites(_parse_requisites(payload.get("payer_details")))
if "invoice_number" in payload and str(payload.get("invoice_number") or "").strip():
invoice.invoice_number = str(payload.get("invoice_number") or "").strip()
if "status" in payload:
next_status = _normalize_status(payload.get("status"))
if role == "LAWYER" and next_status == STATUS_PAID:
raise HTTPException(status_code=403, detail='Юрист не может ставить статус "Оплачен"')
if role == "LAWYER" and prev_status == STATUS_PAID and next_status != STATUS_PAID:
raise HTTPException(status_code=403, detail="Юрист не может менять статус уже оплаченного счета")
invoice.status = next_status
if next_status == STATUS_PAID:
if role != "ADMIN":
raise HTTPException(status_code=403, detail='Юрист не может ставить статус "Оплачен"')
invoice.paid_at = _now_utc()
_apply_paid_flags(req, invoice, admin_id=actor_id)
else:
invoice.paid_at = None
req.invoice_amount = invoice.amount
if prev_paid_at is not None and req.paid_at == prev_paid_at:
req.paid_at = None
req.paid_by_admin_id = None
invoice.responsible = actor_email
req.responsible = actor_email
db.add(invoice)
db.add(req)
_commit_or_400(db, "Счет с таким номером уже существует")
db.refresh(invoice)
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
return _serialize_invoice(invoice, request_track=req.track_number, issuer_name=issuer.name if issuer else None)
@router.delete("/{invoice_id}")
def delete_invoice(
invoice_id: str,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN")),
):
actor_email = str(admin.get("email") or "").strip() or "Администратор системы"
invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id"))
if invoice is None:
raise HTTPException(status_code=404, detail="Счет не найден")
req = db.get(Request, invoice.request_id)
if req is not None:
if invoice.paid_at is not None and req.paid_at == invoice.paid_at:
req.paid_at = None
req.paid_by_admin_id = None
req.responsible = actor_email
db.add(req)
db.delete(invoice)
db.commit()
return {"status": "удалено", "id": invoice_id, "responsible": actor_email}
@router.get("/{invoice_id}/pdf")
def download_invoice_pdf(
invoice_id: str,
db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
):
role = str(admin.get("role") or "").upper()
actor_id = _actor_uuid_or_401(admin)
invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id"))
if invoice is None:
raise HTTPException(status_code=404, detail="Счет не найден")
req = db.get(Request, invoice.request_id)
if req is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
requisites = decrypt_requisites(invoice.payer_details_encrypted)
pdf_bytes = build_invoice_pdf_bytes(
invoice_number=invoice.invoice_number,
amount=_to_float(invoice.amount) or 0.0,
currency=invoice.currency,
status=STATUS_LABELS.get(str(invoice.status or "").upper(), invoice.status or "-"),
issued_at=invoice.issued_at,
paid_at=invoice.paid_at,
payer_display_name=invoice.payer_display_name,
request_track_number=req.track_number,
issued_by_name=(issuer.name if issuer else None),
requisites=requisites,
)
file_name = f"{invoice.invoice_number}.pdf"
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers)

View file

@ -4,7 +4,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy import update from sqlalchemy import case, update
from app.db.session import get_db from app.db.session import get_db
from app.core.deps import require_role from app.core.deps import require_role
@ -30,9 +30,11 @@ from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_law
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.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.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
router = APIRouter() router = APIRouter()
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
def _request_uuid_or_400(request_id: str) -> UUID: def _request_uuid_or_400(request_id: str) -> UUID:
@ -42,6 +44,17 @@ def _request_uuid_or_400(request_id: str) -> UUID:
raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки") raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки")
def _active_lawyer_or_400(db: Session, lawyer_id: str) -> AdminUser:
try:
lawyer_uuid = UUID(str(lawyer_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный идентификатор юриста")
lawyer = db.get(AdminUser, lawyer_uuid)
if not lawyer or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active):
raise HTTPException(status_code=400, detail="Можно назначить только активного юриста")
return lawyer
def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None: def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
role = str(admin.get("role") or "").upper() role = str(admin.get("role") or "").upper()
if role != "LAWYER": if role != "LAWYER":
@ -99,11 +112,23 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
@router.post("", status_code=201) @router.post("", status_code=201)
def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))): def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
if str(admin.get("role") or "").upper() == "LAWYER" and str(payload.assigned_lawyer_id or "").strip(): actor_role = str(admin.get("role") or "").upper()
if actor_role == "LAWYER" and str(payload.assigned_lawyer_id or "").strip():
raise HTTPException(status_code=403, detail="Юрист не может назначать заявку при создании") raise HTTPException(status_code=403, detail="Юрист не может назначать заявку при создании")
if actor_role == "LAWYER":
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.model_fields_set)))
if forbidden_fields:
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
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 "Администратор системы"
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
effective_rate = payload.effective_rate
if assigned_lawyer_id:
assigned_lawyer = _active_lawyer_or_400(db, assigned_lawyer_id)
assigned_lawyer_id = str(assigned_lawyer.id)
if effective_rate is None:
effective_rate = assigned_lawyer.default_rate
row = Request( row = Request(
track_number=track, track_number=track,
client_name=payload.client_name, client_name=payload.client_name,
@ -112,8 +137,8 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a
status_code=payload.status_code, status_code=payload.status_code,
description=payload.description, description=payload.description,
extra_fields=payload.extra_fields, extra_fields=payload.extra_fields,
assigned_lawyer_id=payload.assigned_lawyer_id, assigned_lawyer_id=assigned_lawyer_id,
effective_rate=payload.effective_rate, effective_rate=effective_rate,
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,
@ -142,26 +167,49 @@ def update_request(
if not row: if not row:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if str(admin.get("role") or "").upper() == "LAWYER" and "assigned_lawyer_id" in changes: actor_role = str(admin.get("role") or "").upper()
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') if actor_role == "LAWYER":
if "assigned_lawyer_id" in changes:
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(changes.keys())))
if forbidden_fields:
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
if actor_role == "ADMIN" and "assigned_lawyer_id" in changes:
assigned_raw = changes.get("assigned_lawyer_id")
if assigned_raw is None or not str(assigned_raw).strip():
changes["assigned_lawyer_id"] = None
else:
assigned_lawyer = _active_lawyer_or_400(db, str(assigned_raw))
changes["assigned_lawyer_id"] = str(assigned_lawyer.id)
if row.effective_rate is None and "effective_rate" not in changes:
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 "Администратор системы"
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_code" in changes and str(changes.get("status_code") or "") != old_status:
next_status = str(changes.get("status_code") or "")
if not transition_allowed_for_topic( if not transition_allowed_for_topic(
db, db,
str(row.topic_code or "").strip() or None, str(row.topic_code or "").strip() or None,
old_status, old_status,
str(changes.get("status_code") or ""), next_status,
): ):
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы") raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
billing_note = apply_billing_transition_effects(
db,
req=row,
from_status=old_status,
to_status=next_status,
admin=admin,
responsible=responsible,
)
mark_unread_for_client(row, EVENT_STATUS) mark_unread_for_client(row, EVENT_STATUS)
apply_status_change_effects( apply_status_change_effects(
db, db,
row, row,
from_status=old_status, from_status=old_status,
to_status=str(changes.get("status_code") or ""), to_status=next_status,
admin=admin, admin=admin,
responsible=responsible, responsible=responsible,
) )
@ -171,7 +219,7 @@ 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} -> {str(changes.get('status_code') or '').strip()}", body=(f"{old_status} -> {next_status}" + (f"\n{billing_note}" if billing_note else "")),
responsible=responsible, responsible=responsible,
) )
try: try:
@ -263,6 +311,7 @@ def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(
.where(Request.id == request_uuid, Request.assigned_lawyer_id.is_(None)) .where(Request.id == request_uuid, Request.assigned_lawyer_id.is_(None))
.values( .values(
assigned_lawyer_id=str(lawyer_uuid), assigned_lawyer_id=str(lawyer_uuid),
effective_rate=case((Request.effective_rate.is_(None), lawyer.default_rate), else_=Request.effective_rate),
updated_at=now, updated_at=now,
responsible=responsible, responsible=responsible,
) )
@ -346,6 +395,7 @@ def reassign_request(
.where(Request.id == request_uuid, Request.assigned_lawyer_id == old_assigned) .where(Request.id == request_uuid, Request.assigned_lawyer_id == old_assigned)
.values( .values(
assigned_lawyer_id=str(lawyer_uuid), assigned_lawyer_id=str(lawyer_uuid),
effective_rate=case((Request.effective_rate.is_(None), target_lawyer.default_rate), else_=Request.effective_rate),
updated_at=now, updated_at=now,
responsible=responsible, responsible=responsible,
) )

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices
router = APIRouter() router = APIRouter()
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"]) router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
@ -10,4 +10,5 @@ router.include_router(config.router, prefix="/config", tags=["AdminConfig"])
router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"]) router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"])
router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"]) router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"]) router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"]) router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])

View file

@ -4,7 +4,7 @@ import uuid
from typing import Tuple from typing import Tuple
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request as FastapiRequest
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -19,6 +19,7 @@ from app.models.request import Request
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
from app.services.security_audit import record_file_security_event
from app.services.s3_storage import build_object_key, get_s3_storage from app.services.s3_storage import build_object_key, get_s3_storage
router = APIRouter() router = APIRouter()
@ -77,169 +78,327 @@ def _uuid_or_none(raw: str) -> uuid.UUID | None:
return None return None
def _client_ip(http_request: FastapiRequest) -> str | None:
if http_request is None:
return None
forwarded = str(http_request.headers.get("x-forwarded-for") or "").strip()
if forwarded:
first = forwarded.split(",")[0].strip()
if first:
return first
if http_request.client and http_request.client.host:
return str(http_request.client.host)
return None
@router.post("/init", response_model=UploadInitResponse) @router.post("/init", response_model=UploadInitResponse)
def upload_init( def upload_init(
payload: UploadInitPayload, payload: UploadInitPayload,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")), admin: dict = Depends(require_role("ADMIN", "LAWYER")),
): ):
_validate_size_or_400(payload.size_bytes) role = str(admin.get("role") or "").upper() or "UNKNOWN"
storage = get_s3_storage() actor_id = str(admin.get("sub") or "").strip()
role = str(admin.get("role") or "") actor_ip = _client_ip(http_request)
actor_id = str(admin.get("sub") or "") responsible = str(admin.get("email") or "").strip() or "Администратор системы"
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
if payload.scope == UploadScope.REQUEST_ATTACHMENT: try:
request_uuid = _uuid_or_400(payload.request_id, "request_id") _validate_size_or_400(payload.size_bytes)
request = db.get(Request, request_uuid) storage = get_s3_storage()
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_case_capacity_or_400(request, payload.size_bytes)
key = build_object_key(f"requests/{request.id}", payload.file_name)
return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
if payload.scope == UploadScope.USER_AVATAR: if payload.scope == UploadScope.REQUEST_ATTACHMENT:
target_user_id = str(payload.user_id or actor_id) request_uuid = _uuid_or_400(payload.request_id, "request_id")
target_uuid = _uuid_or_400(target_user_id, "user_id") request = db.get(Request, request_uuid)
if role != "ADMIN" and str(target_uuid) != actor_id: if request is None:
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара") raise HTTPException(status_code=404, detail="Заявка не найдена")
user = db.get(AdminUser, target_uuid) _ensure_case_capacity_or_400(request, payload.size_bytes)
if user is None: key = build_object_key(f"requests/{request.id}", payload.file_name)
raise HTTPException(status_code=404, detail="Пользователь не найден") response = UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
key = build_object_key(f"avatars/{user.id}", payload.file_name) record_file_security_event(
return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type)) db,
actor_role=role,
actor_subject=actor_id,
actor_ip=actor_ip,
action="UPLOAD_INIT",
scope=scope_name,
allowed=True,
object_key=key,
request_id=request.id,
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
responsible=responsible,
persist_now=True,
)
return response
raise HTTPException(status_code=400, detail="Неподдерживаемый scope") if payload.scope == UploadScope.USER_AVATAR:
target_user_id = str(payload.user_id or actor_id)
target_uuid = _uuid_or_400(target_user_id, "user_id")
if role != "ADMIN" and str(target_uuid) != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
user = db.get(AdminUser, target_uuid)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
key = build_object_key(f"avatars/{user.id}", payload.file_name)
response = UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
record_file_security_event(
db,
actor_role=role,
actor_subject=actor_id,
actor_ip=actor_ip,
action="UPLOAD_INIT",
scope=scope_name,
allowed=True,
object_key=key,
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
responsible=responsible,
persist_now=True,
)
return response
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
except HTTPException as exc:
record_file_security_event(
db,
actor_role=role,
actor_subject=actor_id,
actor_ip=actor_ip,
action="UPLOAD_INIT",
scope=scope_name,
allowed=False,
reason=str(exc.detail),
request_id=_uuid_or_none(payload.request_id),
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
responsible=responsible,
persist_now=True,
)
raise
@router.post("/complete", response_model=UploadCompleteResponse) @router.post("/complete", response_model=UploadCompleteResponse)
def upload_complete( def upload_complete(
payload: UploadCompletePayload, payload: UploadCompletePayload,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")), admin: dict = Depends(require_role("ADMIN", "LAWYER")),
): ):
_validate_size_or_400(payload.size_bytes) role = str(admin.get("role") or "").upper() or "UNKNOWN"
storage = get_s3_storage()
role = str(admin.get("role") or "")
actor_id = str(admin.get("sub") or "") actor_id = str(admin.get("sub") or "")
actor_ip = _client_ip(http_request)
responsible = str(admin.get("email") or "").strip() or "Администратор системы" responsible = str(admin.get("email") or "").strip() or "Администратор системы"
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
try: try:
head = storage.head_object(payload.key) _validate_size_or_400(payload.size_bytes)
except ClientError: storage = get_s3_storage()
raise HTTPException(status_code=400, detail="Файл не найден в хранилище") try:
head = storage.head_object(payload.key)
except ClientError:
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
actual_size = int(head.get("ContentLength") or payload.size_bytes) actual_size = int(head.get("ContentLength") or payload.size_bytes)
if actual_size <= 0: if actual_size <= 0:
raise HTTPException(status_code=400, detail="Некорректный размер файла") raise HTTPException(status_code=400, detail="Некорректный размер файла")
if actual_size > _max_file_bytes(): if actual_size > _max_file_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
if payload.scope == UploadScope.REQUEST_ATTACHMENT: if payload.scope == UploadScope.REQUEST_ATTACHMENT:
request_uuid = _uuid_or_400(payload.request_id, "request_id") request_uuid = _uuid_or_400(payload.request_id, "request_id")
request = db.get(Request, request_uuid) request = db.get(Request, request_uuid)
if request is None: if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") _ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
_ensure_case_capacity_or_400(request, actual_size) _ensure_case_capacity_or_400(request, actual_size)
message_uuid = None message_uuid = None
if payload.message_id: if payload.message_id:
message_uuid = _uuid_or_400(payload.message_id, "message_id") message_uuid = _uuid_or_400(payload.message_id, "message_id")
message = db.get(Message, message_uuid) message = db.get(Message, message_uuid)
if message is None or message.request_id != request.id: if message is None or message.request_id != request.id:
raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки") raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки")
if bool(message.immutable): if bool(message.immutable):
raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению") raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению")
row = Attachment( row = Attachment(
request_id=request.id, request_id=request.id,
message_id=message_uuid, message_id=message_uuid,
file_name=payload.file_name, file_name=payload.file_name,
mime_type=payload.mime_type, mime_type=payload.mime_type,
size_bytes=actual_size, size_bytes=actual_size,
s3_key=payload.key, s3_key=payload.key,
responsible=responsible, responsible=responsible,
) )
mark_unread_for_client(request, EVENT_ATTACHMENT) mark_unread_for_client(request, EVENT_ATTACHMENT)
notify_request_event( notify_request_event(
db,
request=request,
event_type=NOTIFICATION_EVENT_ATTACHMENT,
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
actor_admin_user_id=admin.get("sub"),
body=f'Файл: {payload.file_name}',
responsible=responsible,
)
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
request.responsible = responsible
db.add(row)
db.add(request)
record_file_security_event(
db,
actor_role=role,
actor_subject=actor_id,
actor_ip=actor_ip,
action="UPLOAD_COMPLETE",
scope=scope_name,
allowed=True,
object_key=payload.key,
request_id=request.id,
details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)},
responsible=responsible,
)
db.commit()
db.refresh(row)
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
if payload.scope == UploadScope.USER_AVATAR:
target_user_id = str(payload.user_id or actor_id)
target_uuid = _uuid_or_400(target_user_id, "user_id")
if role != "ADMIN" and str(target_uuid) != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
user = db.get(AdminUser, target_uuid)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
user.avatar_url = f"s3://{payload.key}"
user.responsible = responsible
db.add(user)
record_file_security_event(
db,
actor_role=role,
actor_subject=actor_id,
actor_ip=actor_ip,
action="UPLOAD_COMPLETE",
scope=scope_name,
allowed=True,
object_key=payload.key,
details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)},
responsible=responsible,
)
db.commit()
return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url)
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
except HTTPException as exc:
record_file_security_event(
db, db,
request=request, actor_role=role,
event_type=NOTIFICATION_EVENT_ATTACHMENT, actor_subject=actor_id,
actor_role=str(admin.get("role") or "").upper() or "ADMIN", actor_ip=actor_ip,
actor_admin_user_id=admin.get("sub"), action="UPLOAD_COMPLETE",
body=f'Файл: {payload.file_name}', scope=scope_name,
allowed=False,
reason=str(exc.detail),
object_key=payload.key,
request_id=_uuid_or_none(payload.request_id),
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
responsible=responsible, responsible=responsible,
persist_now=True,
) )
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size raise
request.responsible = responsible
db.add(row)
db.add(request)
db.commit()
db.refresh(row)
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
if payload.scope == UploadScope.USER_AVATAR:
target_user_id = str(payload.user_id or actor_id)
target_uuid = _uuid_or_400(target_user_id, "user_id")
if role != "ADMIN" and str(target_uuid) != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
user = db.get(AdminUser, target_uuid)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
user.avatar_url = f"s3://{payload.key}"
user.responsible = responsible
db.add(user)
db.commit()
return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url)
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
@router.get("/object/{object_key:path}") @router.get("/object/{object_key:path}")
def get_object_proxy(object_key: str, token: str = Query(...), db: Session = Depends(get_db)): def get_object_proxy(
try: object_key: str,
claims = decode_jwt(token, settings.ADMIN_JWT_SECRET) http_request: FastapiRequest,
except Exception: token: str = Query(...),
raise HTTPException(status_code=401, detail="Некорректный токен") db: Session = Depends(get_db),
role = str(claims.get("role") or "").upper() ):
if role not in {"ADMIN", "LAWYER"}:
raise HTTPException(status_code=403, detail="Недостаточно прав")
key = str(object_key or "").strip() key = str(object_key or "").strip()
if not key: scope = "UNKNOWN"
raise HTTPException(status_code=400, detail="Некорректный ключ объекта") scoped_uuid: uuid.UUID | None = None
actor_role = "UNKNOWN"
actor_subject = ""
actor_ip = _client_ip(http_request)
responsible = "Администратор системы"
scope, scoped_id_raw = _parse_scoped_object_key(key) try:
if role == "LAWYER": try:
actor_id = _uuid_or_none(claims.get("sub")) claims = decode_jwt(token, settings.ADMIN_JWT_SECRET)
if actor_id is None: except Exception:
raise HTTPException(status_code=401, detail="Некорректный токен") raise HTTPException(status_code=401, detail="Некорректный токен")
scoped_uuid = _uuid_or_none(scoped_id_raw) actor_role = str(claims.get("role") or "").upper()
if scope == "avatars": actor_subject = str(claims.get("sub") or "").strip()
if scoped_uuid is None or scoped_uuid != actor_id: responsible = str(claims.get("email") or "").strip() or "Администратор системы"
raise HTTPException(status_code=403, detail="Недостаточно прав") if actor_role not in {"ADMIN", "LAWYER"}:
elif scope == "requests":
if scoped_uuid is None:
raise HTTPException(status_code=403, detail="Недостаточно прав")
# LAWYER can download files from own or unassigned requests only.
request = db.get(Request, scoped_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
assigned = str(request.assigned_lawyer_id or "").strip()
if assigned and assigned != str(actor_id):
raise HTTPException(status_code=403, detail="Недостаточно прав")
else:
raise HTTPException(status_code=403, detail="Недостаточно прав") raise HTTPException(status_code=403, detail="Недостаточно прав")
try: if not key:
obj = get_s3_storage().get_object(key) raise HTTPException(status_code=400, detail="Некорректный ключ объекта")
except ClientError:
raise HTTPException(status_code=404, detail="Файл не найден")
body = obj["Body"] scope, scoped_id_raw = _parse_scoped_object_key(key)
content_length = obj.get("ContentLength") scoped_uuid = _uuid_or_none(scoped_id_raw)
media_type = obj.get("ContentType") or "application/octet-stream" if actor_role == "LAWYER":
headers = {} actor_id = _uuid_or_none(claims.get("sub"))
if content_length is not None: if actor_id is None:
headers["Content-Length"] = str(content_length) raise HTTPException(status_code=401, detail="Некорректный токен")
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers) if scope == "avatars":
if scoped_uuid is None or scoped_uuid != actor_id:
raise HTTPException(status_code=403, detail="Недостаточно прав")
elif scope == "requests":
if scoped_uuid is None:
raise HTTPException(status_code=403, detail="Недостаточно прав")
# LAWYER can download files from own or unassigned requests only.
request = db.get(Request, scoped_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
assigned = str(request.assigned_lawyer_id or "").strip()
if assigned and assigned != str(actor_id):
raise HTTPException(status_code=403, detail="Недостаточно прав")
else:
raise HTTPException(status_code=403, detail="Недостаточно прав")
try:
obj = get_s3_storage().get_object(key)
except ClientError:
raise HTTPException(status_code=404, detail="Файл не найден")
record_file_security_event(
db,
actor_role=actor_role,
actor_subject=actor_subject,
actor_ip=actor_ip,
action="DOWNLOAD_OBJECT",
scope=scope,
allowed=True,
object_key=key,
request_id=scoped_uuid if scope == "requests" else None,
details={},
responsible=responsible,
persist_now=True,
)
body = obj["Body"]
content_length = obj.get("ContentLength")
media_type = obj.get("ContentType") or "application/octet-stream"
headers = {}
if content_length is not None:
headers["Content-Length"] = str(content_length)
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)
except HTTPException as exc:
record_file_security_event(
db,
actor_role=actor_role,
actor_subject=actor_subject,
actor_ip=actor_ip,
action="DOWNLOAD_OBJECT",
scope=scope,
allowed=False,
reason=str(exc.detail),
object_key=key or None,
request_id=scoped_uuid if scope == "requests" else None,
details={},
responsible=responsible,
persist_now=True,
)
raise

View file

@ -1,17 +1,19 @@
from __future__ import annotations from __future__ import annotations
import secrets import secrets
import hashlib
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.security import create_jwt, hash_password, verify_password from app.core.security import create_jwt, hash_password, verify_password
from app.db.session import get_db from app.db.session import get_db
from app.models.otp_session import OtpSession from app.models.otp_session import OtpSession
from app.models.request import Request from app.models.request import Request as RequestModel
from app.schemas.public import OtpSend, OtpVerify from app.schemas.public import OtpSend, OtpVerify
from app.services.rate_limit import get_rate_limiter
router = APIRouter() router = APIRouter()
@ -55,6 +57,45 @@ def _generate_code() -> str:
return f"{secrets.randbelow(1_000_000):06d}" return f"{secrets.randbelow(1_000_000):06d}"
def _client_ip(request: Request) -> str:
xff = str(request.headers.get("x-forwarded-for") or "").strip()
if xff:
first = xff.split(",")[0].strip()
if first:
return first
client = request.client
return str(client.host if client else "unknown")
def _hash_key_part(value: str | None) -> str:
raw = str(value or "").strip()
if not raw:
return "-"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20]
def _rate_limit_or_429(action: str, *, purpose: str, client_ip: str, phone: str | None, track_number: str | None) -> None:
limiter = get_rate_limiter()
window = int(max(settings.OTP_RATE_LIMIT_WINDOW_SECONDS, 1))
limit = int(max(settings.OTP_SEND_RATE_LIMIT if action == "send" else settings.OTP_VERIFY_RATE_LIMIT, 1))
purpose_norm = str(purpose or "").strip().upper()
keys = [
f"otp:{action}:ip:{_hash_key_part(client_ip)}:purpose:{purpose_norm}",
]
if phone:
keys.append(f"otp:{action}:phone:{_hash_key_part(phone)}:purpose:{purpose_norm}")
if track_number:
keys.append(f"otp:{action}:track:{_hash_key_part(track_number)}:purpose:{purpose_norm}")
for key in keys:
result = limiter.hit(key, limit=limit, window_seconds=window)
if not result.allowed:
raise HTTPException(
status_code=429,
detail=f"Слишком много OTP-запросов. Повторите через {max(result.retry_after_seconds, 1)} сек.",
)
def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> None: def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> None:
token = create_jwt( token = create_jwt(
{"sub": subject, "purpose": purpose}, {"sub": subject, "purpose": purpose},
@ -82,7 +123,7 @@ def _mock_sms_send(phone: str, code: str, purpose: str, track_number: str | None
@router.post("/send") @router.post("/send")
def send_otp(payload: OtpSend, db: Session = Depends(get_db)): def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
purpose = _normalize_purpose(payload.purpose) purpose = _normalize_purpose(payload.purpose)
if purpose not in ALLOWED_PURPOSES: if purpose not in ALLOWED_PURPOSES:
raise HTTPException(status_code=400, detail="Некорректная цель OTP") raise HTTPException(status_code=400, detail="Некорректная цель OTP")
@ -97,13 +138,21 @@ def send_otp(payload: OtpSend, db: Session = Depends(get_db)):
track_number = _normalize_track(payload.track_number) track_number = _normalize_track(payload.track_number)
if not track_number: if not track_number:
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST') raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
request = db.query(Request).filter(Request.track_number == track_number).first() request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first()
if request is None: if request_row is None:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
phone = _normalize_phone(request.client_phone) phone = _normalize_phone(request_row.client_phone)
if not phone: if not phone:
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона") raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
_rate_limit_or_429(
"send",
purpose=purpose,
client_ip=_client_ip(request),
phone=phone or None,
track_number=track_number,
)
code = _generate_code() code = _generate_code()
now = _now_utc() now = _now_utc()
expires_at = now + timedelta(minutes=OTP_TTL_MINUTES) expires_at = now + timedelta(minutes=OTP_TTL_MINUTES)
@ -139,7 +188,7 @@ def send_otp(payload: OtpSend, db: Session = Depends(get_db)):
@router.post("/verify") @router.post("/verify")
def verify_otp(payload: OtpVerify, response: Response, db: Session = Depends(get_db)): def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Session = Depends(get_db)):
purpose = _normalize_purpose(payload.purpose) purpose = _normalize_purpose(payload.purpose)
if purpose not in ALLOWED_PURPOSES: if purpose not in ALLOWED_PURPOSES:
raise HTTPException(status_code=400, detail="Некорректная цель OTP") raise HTTPException(status_code=400, detail="Некорректная цель OTP")
@ -155,6 +204,14 @@ def verify_otp(payload: OtpVerify, response: Response, db: Session = Depends(get
if not track_number: if not track_number:
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST') raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
_rate_limit_or_429(
"verify",
purpose=purpose,
client_ip=_client_ip(request),
phone=phone,
track_number=track_number,
)
query = db.query(OtpSession).filter( query = db.query(OtpSession).filter(
OtpSession.purpose == purpose, OtpSession.purpose == purpose,
OtpSession.track_number == track_number, OtpSession.track_number == track_number,

View file

@ -5,16 +5,21 @@ from uuid import UUID
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.deps import get_public_session from app.core.deps import get_public_session
from app.core.security import create_jwt from app.core.security import create_jwt
from app.db.session import get_db from app.db.session import get_db
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.invoice import Invoice
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_history import StatusHistory from app.models.status_history import StatusHistory
from app.services.invoice_crypto import decrypt_requisites
from app.services.invoice_pdf import build_invoice_pdf_bytes
from app.services.notifications import ( from app.services.notifications import (
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
get_client_notification, get_client_notification,
@ -39,6 +44,11 @@ router = APIRouter()
OTP_CREATE_PURPOSE = "CREATE_REQUEST" OTP_CREATE_PURPOSE = "CREATE_REQUEST"
OTP_VIEW_PURPOSE = "VIEW_REQUEST" OTP_VIEW_PURPOSE = "VIEW_REQUEST"
INVOICE_STATUS_LABELS = {
"WAITING_PAYMENT": "Ожидает оплату",
"PAID": "Оплачен",
"CANCELED": "Отменен",
}
def _normalize_phone(raw: str | None) -> str: def _normalize_phone(raw: str | None) -> str:
@ -92,6 +102,22 @@ def _to_iso(value) -> str | None:
return value.isoformat() if value is not None else None return value.isoformat() if value is not None else None
def _public_invoice_payload(row: Invoice, track_number: str) -> dict:
status_code = str(row.status or "").upper()
return {
"id": str(row.id),
"invoice_number": row.invoice_number,
"status": row.status,
"status_label": INVOICE_STATUS_LABELS.get(status_code, row.status),
"amount": float(row.amount) if row.amount is not None else 0.0,
"currency": row.currency,
"payer_display_name": row.payer_display_name,
"issued_at": _to_iso(row.issued_at),
"paid_at": _to_iso(row.paid_at),
"download_url": f"/api/public/requests/{track_number}/invoices/{row.id}/pdf",
}
@router.post("", response_model=PublicRequestCreated, status_code=201) @router.post("", response_model=PublicRequestCreated, status_code=201)
def create_request( def create_request(
payload: PublicRequestCreate, payload: PublicRequestCreate,
@ -258,6 +284,58 @@ def list_attachments_by_track(
] ]
@router.get("/{track_number}/invoices")
def list_invoices_by_track(
track_number: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
rows = (
db.query(Invoice)
.filter(Invoice.request_id == req.id)
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
.all()
)
return [_public_invoice_payload(row, req.track_number) for row in rows]
@router.get("/{track_number}/invoices/{invoice_id}/pdf")
def download_invoice_pdf_by_track(
track_number: str,
invoice_id: str,
db: Session = Depends(get_db),
session: dict = Depends(get_public_session),
):
req = _request_for_track_or_404(db, session, track_number)
try:
invoice_uuid = UUID(str(invoice_id))
except ValueError:
raise HTTPException(status_code=400, detail="Некорректный invoice_id")
invoice = db.get(Invoice, invoice_uuid)
if invoice is None or str(invoice.request_id) != str(req.id):
raise HTTPException(status_code=404, detail="Счет не найден")
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
requisites = decrypt_requisites(invoice.payer_details_encrypted)
pdf_bytes = build_invoice_pdf_bytes(
invoice_number=invoice.invoice_number,
amount=float(invoice.amount) if invoice.amount is not None else 0.0,
currency=invoice.currency,
status=INVOICE_STATUS_LABELS.get(str(invoice.status or "").upper(), invoice.status or "-"),
issued_at=invoice.issued_at,
paid_at=invoice.paid_at,
payer_display_name=invoice.payer_display_name,
request_track_number=req.track_number,
issued_by_name=(issuer.name if issuer else invoice.issued_by_role),
requisites=requisites,
)
file_name = f"{invoice.invoice_number}.pdf"
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers)
@router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead]) @router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead])
def list_status_history_by_track( def list_status_history_by_track(
track_number: str, track_number: str,

View file

@ -4,7 +4,7 @@ import uuid
from urllib.parse import quote from urllib.parse import quote
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -16,6 +16,7 @@ from app.models.request import Request
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_lawyer from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_lawyer
from app.services.security_audit import record_file_security_event
from app.services.s3_storage import build_object_key, get_s3_storage from app.services.s3_storage import build_object_key, get_s3_storage
router = APIRouter() router = APIRouter()
@ -64,101 +65,236 @@ def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session
return attachment return attachment
def _client_ip(http_request: FastapiRequest) -> str | None:
if http_request is None:
return None
forwarded = str(http_request.headers.get("x-forwarded-for") or "").strip()
if forwarded:
first = forwarded.split(",")[0].strip()
if first:
return first
if http_request.client and http_request.client.host:
return str(http_request.client.host)
return None
@router.post("/init", response_model=UploadInitResponse) @router.post("/init", response_model=UploadInitResponse)
def upload_init(payload: UploadInitPayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)): def upload_init(
if payload.scope != UploadScope.REQUEST_ATTACHMENT: payload: UploadInitPayload,
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT") http_request: FastapiRequest,
if int(payload.size_bytes or 0) <= 0: db: Session = Depends(get_db),
raise HTTPException(status_code=400, detail="Некорректный размер файла") session: dict = Depends(get_public_session),
if int(payload.size_bytes) > _max_file_bytes(): ):
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") actor_subject = str(session.get("sub") or "").strip()
actor_ip = _client_ip(http_request)
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
try:
if payload.scope != UploadScope.REQUEST_ATTACHMENT:
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT")
if int(payload.size_bytes or 0) <= 0:
raise HTTPException(status_code=400, detail="Некорректный размер файла")
if int(payload.size_bytes) > _max_file_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
request_uuid = _uuid_or_400(payload.request_id, "request_id") request_uuid = _uuid_or_400(payload.request_id, "request_id")
request = db.get(Request, request_uuid) request = db.get(Request, request_uuid)
if request is None: if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена") raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_public_request_access_or_403(request, session) _ensure_public_request_access_or_403(request, session)
current = int(request.total_attachments_bytes or 0) current = int(request.total_attachments_bytes or 0)
if current + int(payload.size_bytes) > _max_case_bytes(): if current + int(payload.size_bytes) > _max_case_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)") raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
key = build_object_key(f"requests/{request.id}", payload.file_name) key = build_object_key(f"requests/{request.id}", payload.file_name)
presigned_url = get_s3_storage().create_presigned_put_url(key, payload.mime_type) presigned_url = get_s3_storage().create_presigned_put_url(key, payload.mime_type)
return UploadInitResponse(key=key, presigned_url=presigned_url) record_file_security_event(
db,
actor_role="CLIENT",
actor_subject=actor_subject,
actor_ip=actor_ip,
action="UPLOAD_INIT",
scope=scope_name,
allowed=True,
object_key=key,
request_id=request.id,
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
responsible="Клиент",
persist_now=True,
)
return UploadInitResponse(key=key, presigned_url=presigned_url)
except HTTPException as exc:
record_file_security_event(
db,
actor_role="CLIENT",
actor_subject=actor_subject,
actor_ip=actor_ip,
action="UPLOAD_INIT",
scope=scope_name,
allowed=False,
reason=str(exc.detail),
object_key=None,
request_id=payload.request_id,
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
responsible="Клиент",
persist_now=True,
)
raise
@router.post("/complete", response_model=UploadCompleteResponse) @router.post("/complete", response_model=UploadCompleteResponse)
def upload_complete(payload: UploadCompletePayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)): def upload_complete(
if payload.scope != UploadScope.REQUEST_ATTACHMENT: payload: UploadCompletePayload,
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT") http_request: FastapiRequest,
request_uuid = _uuid_or_400(payload.request_id, "request_id") db: Session = Depends(get_db),
request = db.get(Request, request_uuid) session: dict = Depends(get_public_session),
if request is None: ):
raise HTTPException(status_code=404, detail="Заявка не найдена") actor_subject = str(session.get("sub") or "").strip()
_ensure_public_request_access_or_403(request, session) actor_ip = _client_ip(http_request)
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
storage = get_s3_storage()
try: try:
head = storage.head_object(payload.key) if payload.scope != UploadScope.REQUEST_ATTACHMENT:
except ClientError: raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT")
raise HTTPException(status_code=400, detail="Файл не найден в хранилище") request_uuid = _uuid_or_400(payload.request_id, "request_id")
request = db.get(Request, request_uuid)
if request is None:
raise HTTPException(status_code=404, detail="Заявка не найдена")
_ensure_public_request_access_or_403(request, session)
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
actual_size = int(head.get("ContentLength") or payload.size_bytes or 0) storage = get_s3_storage()
if actual_size <= 0: try:
raise HTTPException(status_code=400, detail="Некорректный размер файла") head = storage.head_object(payload.key)
if actual_size > _max_file_bytes(): except ClientError:
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes():
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
row = Attachment( actual_size = int(head.get("ContentLength") or payload.size_bytes or 0)
request_id=request.id, if actual_size <= 0:
message_id=None, raise HTTPException(status_code=400, detail="Некорректный размер файла")
file_name=payload.file_name, if actual_size > _max_file_bytes():
mime_type=payload.mime_type, raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
size_bytes=actual_size, if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes():
s3_key=payload.key, raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
responsible="Клиент",
) row = Attachment(
mark_unread_for_lawyer(request, EVENT_ATTACHMENT) request_id=request.id,
notify_request_event( message_id=None,
db, file_name=payload.file_name,
request=request, mime_type=payload.mime_type,
event_type=NOTIFICATION_EVENT_ATTACHMENT, size_bytes=actual_size,
actor_role="CLIENT", s3_key=payload.key,
body=f'Файл: {payload.file_name}', responsible="Клиент",
responsible="Клиент", )
) mark_unread_for_lawyer(request, EVENT_ATTACHMENT)
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size notify_request_event(
request.responsible = "Клиент" db,
db.add(row) request=request,
db.add(request) event_type=NOTIFICATION_EVENT_ATTACHMENT,
db.commit() actor_role="CLIENT",
db.refresh(row) body=f'Файл: {payload.file_name}',
return UploadCompleteResponse(status="ok", attachment_id=str(row.id)) responsible="Клиент",
)
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
request.responsible = "Клиент"
db.add(row)
db.add(request)
record_file_security_event(
db,
actor_role="CLIENT",
actor_subject=actor_subject,
actor_ip=actor_ip,
action="UPLOAD_COMPLETE",
scope=scope_name,
allowed=True,
object_key=payload.key,
request_id=request.id,
details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)},
responsible="Клиент",
)
db.commit()
db.refresh(row)
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
except HTTPException as exc:
record_file_security_event(
db,
actor_role="CLIENT",
actor_subject=actor_subject,
actor_ip=actor_ip,
action="UPLOAD_COMPLETE",
scope=scope_name,
allowed=False,
reason=str(exc.detail),
object_key=payload.key,
request_id=payload.request_id,
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
responsible="Клиент",
persist_now=True,
)
raise
@router.get("/object/{attachment_id}") @router.get("/object/{attachment_id}")
def get_public_attachment_object( def get_public_attachment_object(
attachment_id: str, attachment_id: str,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
attachment = _load_attachment_with_access_or_4xx(attachment_id, db, session) actor_subject = str(session.get("sub") or "").strip()
actor_ip = _client_ip(http_request)
attachment_uuid = _uuid_or_400(attachment_id, "attachment_id")
request_id = None
key = None
try: try:
obj = get_s3_storage().get_object(attachment.s3_key) attachment = _load_attachment_with_access_or_4xx(attachment_id, db, session)
except ClientError: key = attachment.s3_key
raise HTTPException(status_code=404, detail="Файл не найден в хранилище") request_id = attachment.request_id
try:
obj = get_s3_storage().get_object(attachment.s3_key)
except ClientError:
raise HTTPException(status_code=404, detail="Файл не найден в хранилище")
body = obj["Body"] record_file_security_event(
content_length = obj.get("ContentLength") db,
media_type = obj.get("ContentType") or attachment.mime_type or "application/octet-stream" actor_role="CLIENT",
encoded_name = quote(str(attachment.file_name or "file"), safe="") actor_subject=actor_subject,
headers = { actor_ip=actor_ip,
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_name}", action="DOWNLOAD_OBJECT",
} scope="REQUEST_ATTACHMENT",
if content_length is not None: allowed=True,
headers["Content-Length"] = str(content_length) object_key=key,
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers) request_id=request_id,
attachment_id=attachment.id,
details={},
responsible="Клиент",
persist_now=True,
)
body = obj["Body"]
content_length = obj.get("ContentLength")
media_type = obj.get("ContentType") or attachment.mime_type or "application/octet-stream"
encoded_name = quote(str(attachment.file_name or "file"), safe="")
headers = {
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_name}",
}
if content_length is not None:
headers["Content-Length"] = str(content_length)
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)
except HTTPException as exc:
record_file_security_event(
db,
actor_role="CLIENT",
actor_subject=actor_subject,
actor_ip=actor_ip,
action="DOWNLOAD_OBJECT",
scope="REQUEST_ATTACHMENT",
allowed=False,
reason=str(exc.detail),
object_key=key,
request_id=request_id,
attachment_id=attachment_uuid,
details={},
responsible="Клиент",
persist_now=True,
)
raise

View file

@ -29,6 +29,9 @@ class Settings(BaseSettings):
TELEGRAM_CHAT_ID: str = "0" TELEGRAM_CHAT_ID: str = "0"
SMS_PROVIDER: str = "dummy" SMS_PROVIDER: str = "dummy"
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption" DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
OTP_SEND_RATE_LIMIT: int = 8
OTP_VERIFY_RATE_LIMIT: int = 20
@property @property
def cors_origins_list(self) -> List[str]: def cors_origins_list(self) -> List[str]:

View file

@ -0,0 +1,54 @@
from __future__ import annotations
import logging
import re
from time import perf_counter
from uuid import uuid4
from fastapi import FastAPI, Request
REQUEST_ID_HEADER = "X-Request-ID"
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
_LOG = logging.getLogger("app.http")
SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"X-Permitted-Cross-Domain-Policies": "none",
"Cross-Origin-Opener-Policy": "same-origin",
}
def _request_id_from_header(raw: str | None) -> str:
value = str(raw or "").strip()
if not value:
return uuid4().hex
if not _REQUEST_ID_RE.fullmatch(value):
return uuid4().hex
return value
def install_http_hardening(app: FastAPI) -> None:
@app.middleware("http")
async def _http_hardening_middleware(request: Request, call_next):
request_id = _request_id_from_header(request.headers.get(REQUEST_ID_HEADER))
request.state.request_id = request_id
started_at = perf_counter()
response = await call_next(request)
for key, value in SECURITY_HEADERS.items():
response.headers[key] = value
response.headers[REQUEST_ID_HEADER] = request_id
duration_ms = (perf_counter() - started_at) * 1000.0
_LOG.info(
"%s %s status=%s duration_ms=%.2f request_id=%s",
request.method,
request.url.path,
response.status_code,
duration_ms,
request_id,
)
return response

View file

@ -3,6 +3,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.core.config import settings from app.core.config import settings
from app.core.http_hardening import install_http_hardening
from app.api.public.router import router as public_router from app.api.public.router import router as public_router
from app.api.admin.router import router as admin_router from app.api.admin.router import router as admin_router
@ -17,6 +18,7 @@ app.add_middleware(
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
install_http_hardening(app)
app.include_router(public_router, prefix="/api/public") app.include_router(public_router, prefix="/api/public")
app.include_router(admin_router, prefix="/api/admin") app.include_router(admin_router, prefix="/api/admin")

25
app/models/invoice.py Normal file
View file

@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Numeric, String, Text
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 Invoice(Base, UUIDMixin, TimestampMixin):
__tablename__ = "invoices"
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
invoice_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True, default="WAITING_PAYMENT")
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="RUB")
payer_display_name: Mapped[str] = mapped_column(String(300), nullable=False)
payer_details_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
issued_by_admin_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
issued_by_role: Mapped[str | None] = mapped_column(String(20), nullable=True)
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View file

@ -0,0 +1,29 @@
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, JSON, 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 SecurityAuditLog(Base, UUIDMixin, TimestampMixin):
__tablename__ = "security_audit_log"
actor_role: Mapped[str] = mapped_column(String(30), nullable=False)
actor_subject: Mapped[str] = mapped_column(String(200), nullable=False, default="")
actor_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
action: Mapped[str] = mapped_column(String(50), nullable=False)
scope: Mapped[str] = mapped_column(String(50), nullable=False)
object_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
request_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
reason: Mapped[str | None] = mapped_column(String(400), nullable=True)
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)

View file

@ -1,4 +1,4 @@
from sqlalchemy import String, Boolean, Integer from sqlalchemy import String, Boolean, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base from app.db.session import Base
from app.models.common import UUIDMixin, TimestampMixin from app.models.common import UUIDMixin, TimestampMixin
@ -10,3 +10,5 @@ class Status(Base, UUIDMixin, TimestampMixin):
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
kind: Mapped[str] = mapped_column(String(20), default="DEFAULT", nullable=False)
invoice_template: Mapped[str | None] = mapped_column(Text, nullable=True)

View file

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from typing import Optional from typing import Optional
class AdminLogin(BaseModel): class AdminLogin(BaseModel):
@ -32,6 +32,22 @@ class StatusUpsert(BaseModel):
enabled: bool = True enabled: bool = True
sort_order: int = 0 sort_order: int = 0
is_terminal: bool = False is_terminal: bool = False
kind: str = "DEFAULT"
invoice_template: Optional[str] = None
@field_validator("kind")
@classmethod
def validate_kind(cls, value: str) -> str:
normalized = str(value or "DEFAULT").strip().upper()
if normalized not in {"DEFAULT", "INVOICE", "PAID"}:
raise ValueError('kind должен быть одним из: DEFAULT, INVOICE, PAID')
return normalized
@field_validator("invoice_template")
@classmethod
def normalize_template(cls, value: Optional[str]) -> Optional[str]:
text = str(value or "").strip()
return text or None
class FormFieldUpsert(BaseModel): class FormFieldUpsert(BaseModel):

View file

@ -0,0 +1,288 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from string import Formatter
from typing import Any
from uuid import UUID, uuid4
from fastapi import HTTPException
from sqlalchemy import inspect
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.models.invoice import Invoice
from app.models.request import Request
from app.models.status import Status
from app.services.invoice_crypto import encrypt_requisites
STATUS_KIND_DEFAULT = "DEFAULT"
STATUS_KIND_INVOICE = "INVOICE"
STATUS_KIND_PAID = "PAID"
ALLOWED_STATUS_KINDS = {STATUS_KIND_DEFAULT, STATUS_KIND_INVOICE, STATUS_KIND_PAID}
INVOICE_STATUS_WAITING = "WAITING_PAYMENT"
INVOICE_STATUS_PAID = "PAID"
FALLBACK_INVOICE_CODES = {"INVOICE", "BILLING", "WAITING_PAYMENT"}
FALLBACK_PAID_CODES = {"PAID", "ОПЛАЧЕНО"}
DEFAULT_INVOICE_TEMPLATE = (
"Счет по заявке {track_number}. "
"Клиент: {client_name}. "
"Тема: {topic_code}. "
"Сумма: {amount} RUB."
)
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _to_float(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return None
def _actor_uuid_or_none(admin: dict[str, Any] | None) -> UUID | None:
if not admin:
return None
try:
return UUID(str(admin.get("sub") or ""))
except ValueError:
return None
def _normalize_kind(raw: str | None) -> str:
value = str(raw or STATUS_KIND_DEFAULT).strip().upper()
if value not in ALLOWED_STATUS_KINDS:
return STATUS_KIND_DEFAULT
return value
def normalize_status_kind_or_400(raw: str | None) -> str:
value = str(raw or STATUS_KIND_DEFAULT).strip().upper()
if value not in ALLOWED_STATUS_KINDS:
raise HTTPException(status_code=400, detail='Поле "kind" должно быть одним из: DEFAULT, INVOICE, PAID')
return value
def _table_exists(db: Session, table_name: str) -> bool:
try:
bind = db.get_bind()
if bind is None:
return False
return table_name in set(inspect(bind).get_table_names())
except SQLAlchemyError:
return False
def _status_kind(db: Session, status_code: str) -> str:
code = str(status_code or "").strip()
if not code:
return STATUS_KIND_DEFAULT
row = db.query(Status.kind).filter(Status.code == code).first()
if row and row[0]:
return _normalize_kind(row[0])
upper = code.upper()
if upper in FALLBACK_INVOICE_CODES:
return STATUS_KIND_INVOICE
if upper in FALLBACK_PAID_CODES:
return STATUS_KIND_PAID
return STATUS_KIND_DEFAULT
def _status_template(db: Session, status_code: str) -> str | None:
code = str(status_code or "").strip()
if not code:
return None
row = db.query(Status.invoice_template).filter(Status.code == code).first()
if row is None:
return None
value = str(row[0] or "").strip()
return value or None
def _invoice_number(db: Session) -> str:
prefix = _now_utc().strftime("%Y%m%d")
candidate = f"INV-{prefix}-{uuid4().hex[:8].upper()}"
exists = db.query(Invoice.id).filter(Invoice.invoice_number == candidate).first()
if exists is None:
return candidate
return f"INV-{prefix}-{uuid4().hex[:12].upper()}"
def _safe_render_template(template: str, values: dict[str, Any]) -> str:
source = str(template or "").strip() or DEFAULT_INVOICE_TEMPLATE
allowed = {
"request_id",
"track_number",
"client_name",
"client_phone",
"topic_code",
"from_status",
"to_status",
"effective_rate",
"invoice_amount",
"amount",
}
formatter = Formatter()
out = source
for _, field_name, _, _ in formatter.parse(source):
if not field_name:
continue
if field_name not in allowed:
raise HTTPException(status_code=400, detail=f'Шаблон счета содержит недопустимый placeholder: "{field_name}"')
try:
out = source.format_map({key: values.get(key) for key in allowed})
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Ошибка рендера шаблона счета: {exc}")
return out
def _create_waiting_invoice(
db: Session,
*,
req: Request,
to_status: str,
from_status: str,
admin: dict[str, Any] | None,
responsible: str,
) -> str:
waiting = (
db.query(Invoice)
.filter(Invoice.request_id == req.id, Invoice.status == INVOICE_STATUS_WAITING)
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
.first()
)
if waiting is not None:
return waiting.invoice_number
base_amount = _to_float(req.invoice_amount)
if base_amount is None or base_amount <= 0:
base_amount = _to_float(req.effective_rate)
amount = round(float(base_amount or 0.0), 2)
template = _status_template(db, to_status) or DEFAULT_INVOICE_TEMPLATE
rendered_template = _safe_render_template(
template,
{
"request_id": str(req.id),
"track_number": req.track_number,
"client_name": req.client_name,
"client_phone": req.client_phone,
"topic_code": req.topic_code,
"from_status": from_status,
"to_status": to_status,
"effective_rate": _to_float(req.effective_rate),
"invoice_amount": _to_float(req.invoice_amount),
"amount": amount,
},
)
actor = _actor_uuid_or_none(admin)
role = str((admin or {}).get("role") or "").strip().upper() or None
invoice = Invoice(
request_id=req.id,
invoice_number=_invoice_number(db),
status=INVOICE_STATUS_WAITING,
amount=amount,
currency="RUB",
payer_display_name=str(req.client_name or "").strip() or "Клиент",
payer_details_encrypted=encrypt_requisites(
{
"template_rendered": rendered_template,
"request_track_number": req.track_number,
"topic_code": req.topic_code,
}
),
issued_by_admin_user_id=actor,
issued_by_role=role,
issued_at=_now_utc(),
paid_at=None,
responsible=responsible,
)
db.add(invoice)
if req.invoice_amount is None:
req.invoice_amount = amount
req.responsible = responsible
db.add(req)
return invoice.invoice_number
def _mark_waiting_invoice_paid_or_400(
db: Session,
*,
req: Request,
admin: dict[str, Any] | None,
responsible: str,
) -> tuple[str, float]:
actor = _actor_uuid_or_none(admin)
role = str((admin or {}).get("role") or "").strip().upper()
if role != "ADMIN":
raise HTTPException(status_code=403, detail='Статус "Оплачено" может поставить только администратор')
waiting = (
db.query(Invoice)
.filter(Invoice.request_id == req.id, Invoice.status == INVOICE_STATUS_WAITING)
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
.first()
)
if waiting is None:
raise HTTPException(status_code=400, detail='Для перехода в статус "Оплачено" нужен счет в статусе "Ожидает оплату"')
waiting.status = INVOICE_STATUS_PAID
waiting.paid_at = _now_utc()
waiting.responsible = responsible
db.add(waiting)
req.invoice_amount = waiting.amount
req.paid_at = waiting.paid_at
req.paid_by_admin_id = str(actor) if actor else None
req.responsible = responsible
db.add(req)
return waiting.invoice_number, round(float(_to_float(waiting.amount) or 0.0), 2)
def apply_billing_transition_effects(
db: Session,
*,
req: Request,
from_status: str,
to_status: str,
admin: dict[str, Any] | None,
responsible: str,
) -> str | None:
if not _table_exists(db, "invoices"):
return None
from_kind = _status_kind(db, from_status)
to_kind = _status_kind(db, to_status)
if to_kind == STATUS_KIND_INVOICE and from_kind != STATUS_KIND_INVOICE:
number = _create_waiting_invoice(
db,
req=req,
to_status=to_status,
from_status=from_status,
admin=admin,
responsible=responsible,
)
return f"Выставлен счет {number}"
if to_kind == STATUS_KIND_PAID:
number, amount = _mark_waiting_invoice_paid_or_400(
db,
req=req,
admin=admin,
responsible=responsible,
)
return f"Оплачен счет {number} на сумму {amount:.2f}"
return None

View file

@ -0,0 +1,56 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
from typing import Any
from app.core.config import settings
_VERSION = b"v1"
def _key() -> bytes:
secret = str(settings.DATA_ENCRYPTION_SECRET or "").strip()
if not secret or secret == "change_me_data_encryption":
secret = str(settings.ADMIN_JWT_SECRET or "change_me_admin")
return hashlib.sha256(secret.encode("utf-8")).digest()
def _xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
def encrypt_requisites(data: dict[str, Any] | None) -> str:
payload = dict(data or {})
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
nonce = secrets.token_bytes(16)
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(raw))
cipher = _xor_bytes(raw, stream)
tag = hmac.new(_key(), _VERSION + nonce + cipher, hashlib.sha256).digest()
token = _VERSION + nonce + tag + cipher
return base64.urlsafe_b64encode(token).decode("ascii")
def decrypt_requisites(token: str | None) -> dict[str, Any]:
encoded = str(token or "").strip()
if not encoded:
return {}
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
if len(blob) < 2 + 16 + 32:
raise ValueError("Некорректные зашифрованные реквизиты")
version = blob[:2]
nonce = blob[2:18]
tag = blob[18:50]
cipher = blob[50:]
if version != _VERSION:
raise ValueError("Неподдерживаемая версия шифрования")
expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest()
if not hmac.compare_digest(tag, expected):
raise ValueError("Поврежденные зашифрованные реквизиты")
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher))
raw = _xor_bytes(cipher, stream)
data = json.loads(raw.decode("utf-8"))
return data if isinstance(data, dict) else {}

View file

@ -0,0 +1,84 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import unicodedata
def _ascii_text(value: Any) -> str:
text = str(value or "")
normalized = unicodedata.normalize("NFKD", text)
return normalized.encode("ascii", "ignore").decode("ascii")
def _escape_pdf_text(value: str) -> str:
return value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
def _build_content_stream(lines: list[str]) -> bytes:
safe_lines = [_escape_pdf_text(_ascii_text(line)) for line in lines]
if not safe_lines:
safe_lines = ["Invoice"]
parts = ["BT", "/F1 11 Tf", "50 800 Td"]
for index, line in enumerate(safe_lines):
if index == 0:
parts.append(f"({line}) Tj")
else:
parts.append("T*")
parts.append(f"({line}) Tj")
parts.append("ET")
return "\n".join(parts).encode("latin-1", errors="ignore")
def build_invoice_pdf_bytes(
*,
invoice_number: str,
amount: float,
currency: str,
status: str,
issued_at: datetime | None,
paid_at: datetime | None,
payer_display_name: str,
request_track_number: str,
issued_by_name: str | None,
requisites: dict[str, Any] | None,
) -> bytes:
lines = [
f"Invoice: {invoice_number}",
f"Request: {request_track_number}",
f"Payer: {payer_display_name}",
f"Amount: {amount:.2f} {currency}",
f"Status: {status}",
f"Issued at: {issued_at.isoformat() if issued_at else '-'}",
f"Paid at: {paid_at.isoformat() if paid_at else '-'}",
f"Issued by: {issued_by_name or '-'}",
"Requisites:",
]
req = dict(requisites or {})
if req:
for key in sorted(req.keys()):
lines.append(f"{key}: {req.get(key)}")
else:
lines.append("-")
stream = _build_content_stream(lines)
objects = [
b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n",
b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n",
b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj\n",
b"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n",
f"5 0 obj << /Length {len(stream)} >> stream\n".encode("latin-1") + stream + b"\nendstream endobj\n",
]
body = b"%PDF-1.4\n"
offsets = [0]
for obj in objects:
offsets.append(len(body))
body += obj
xref_offset = len(body)
body += f"xref\n0 {len(objects)+1}\n".encode("latin-1")
body += b"0000000000 65535 f \n"
for offset in offsets[1:]:
body += f"{offset:010d} 00000 n \n".encode("latin-1")
body += f"trailer << /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("latin-1")
return body

View file

@ -0,0 +1,87 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from threading import Lock
from typing import Protocol
import redis
from app.core.config import settings
_LOG = logging.getLogger("app.rate_limit")
@dataclass
class RateLimitResult:
allowed: bool
retry_after_seconds: int
current_value: int
class RateLimiter(Protocol):
def hit(self, key: str, *, limit: int, window_seconds: int) -> RateLimitResult:
...
class InMemoryRateLimiter:
def __init__(self):
self._data: dict[str, tuple[int, datetime]] = {}
self._lock = Lock()
def hit(self, key: str, *, limit: int, window_seconds: int) -> RateLimitResult:
now = datetime.now(timezone.utc)
with self._lock:
count, expires_at = self._data.get(key, (0, now))
if expires_at <= now:
count = 0
expires_at = now + timedelta(seconds=max(int(window_seconds), 1))
count += 1
self._data[key] = (count, expires_at)
retry_after = max(0, int((expires_at - now).total_seconds()))
return RateLimitResult(allowed=count <= limit, retry_after_seconds=retry_after, current_value=count)
class RedisRateLimiter:
def __init__(self, client: redis.Redis):
self.client = client
def hit(self, key: str, *, limit: int, window_seconds: int) -> RateLimitResult:
count = int(self.client.incr(key))
if count == 1:
self.client.expire(key, int(max(window_seconds, 1)))
ttl = int(self.client.ttl(key))
if ttl < 0:
ttl = int(max(window_seconds, 1))
return RateLimitResult(allowed=count <= limit, retry_after_seconds=ttl, current_value=count)
_cached_limiter: RateLimiter | None = None
def _build_limiter() -> RateLimiter:
try:
client = redis.Redis.from_url(
settings.REDIS_URL,
decode_responses=True,
socket_timeout=0.4,
socket_connect_timeout=0.4,
)
client.ping()
return RedisRateLimiter(client)
except Exception:
_LOG.warning("Redis limiter unavailable; fallback to in-memory limiter")
return InMemoryRateLimiter()
def get_rate_limiter() -> RateLimiter:
global _cached_limiter
if _cached_limiter is None:
_cached_limiter = _build_limiter()
return _cached_limiter
def reset_rate_limiter_for_tests() -> None:
global _cached_limiter
_cached_limiter = None

View file

@ -0,0 +1,129 @@
from __future__ import annotations
import logging
import uuid
from datetime import timedelta
from typing import Any
from sqlalchemy import func, inspect
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.models.security_audit_log import SecurityAuditLog
from app.models.common import utcnow
logger = logging.getLogger(__name__)
SUSPICIOUS_DENY_WINDOW_MINUTES = 10
SUSPICIOUS_DENY_THRESHOLD = 5
def _uuid_or_none(raw: str | uuid.UUID | None) -> uuid.UUID | None:
if raw is None:
return None
if isinstance(raw, uuid.UUID):
return raw
try:
return uuid.UUID(str(raw))
except (TypeError, ValueError):
return None
def _safe_details(details: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(details, dict):
return {}
safe: dict[str, Any] = {}
for key, value in details.items():
if value is None or isinstance(value, (str, int, float, bool)):
safe[str(key)] = value
else:
safe[str(key)] = str(value)
return safe
def _emit_suspicious_denied_download_alert(
db: Session,
*,
actor_role: str,
actor_subject: str,
actor_ip: str | None,
) -> None:
if not actor_subject and not actor_ip:
return
since = utcnow() - timedelta(minutes=SUSPICIOUS_DENY_WINDOW_MINUTES)
query = db.query(func.count(SecurityAuditLog.id)).filter(
SecurityAuditLog.created_at >= since,
SecurityAuditLog.action == "DOWNLOAD_OBJECT",
SecurityAuditLog.allowed.is_(False),
)
if actor_subject:
query = query.filter(SecurityAuditLog.actor_subject == actor_subject)
elif actor_ip:
query = query.filter(SecurityAuditLog.actor_ip == actor_ip)
denied_count = int(query.scalar() or 0)
if denied_count >= SUSPICIOUS_DENY_THRESHOLD:
logger.warning(
"SECURITY_ALERT repeated denied download attempts role=%s subject=%s ip=%s count=%s window_min=%s",
actor_role,
actor_subject or "-",
actor_ip or "-",
denied_count,
SUSPICIOUS_DENY_WINDOW_MINUTES,
)
def record_file_security_event(
db: Session,
*,
actor_role: str,
actor_subject: str,
actor_ip: str | None,
action: str,
scope: str,
allowed: bool,
reason: str | None = None,
object_key: str | None = None,
request_id: str | uuid.UUID | None = None,
attachment_id: str | uuid.UUID | None = None,
details: dict[str, Any] | None = None,
responsible: str | None = None,
persist_now: bool = False,
) -> None:
# Security telemetry must not block business flow if DB log write fails.
try:
bind = db.get_bind()
if bind is None or not inspect(bind).has_table("security_audit_log"):
return
row = SecurityAuditLog(
actor_role=str(actor_role or "UNKNOWN").upper(),
actor_subject=str(actor_subject or "").strip(),
actor_ip=str(actor_ip or "").strip() or None,
action=str(action or "").strip().upper() or "UNKNOWN",
scope=str(scope or "").strip().upper() or "UNKNOWN",
object_key=str(object_key or "").strip() or None,
request_id=_uuid_or_none(request_id),
attachment_id=_uuid_or_none(attachment_id),
allowed=bool(allowed),
reason=(str(reason)[:400] if reason is not None else None),
details=_safe_details(details),
responsible=str(responsible or "Администратор системы").strip() or "Администратор системы",
)
db.add(row)
db.flush()
if not bool(allowed) and str(action or "").upper() == "DOWNLOAD_OBJECT":
_emit_suspicious_denied_download_alert(
db,
actor_role=row.actor_role,
actor_subject=row.actor_subject,
actor_ip=row.actor_ip,
)
if persist_now:
db.commit()
except SQLAlchemyError:
if persist_now:
try:
db.rollback()
except Exception:
pass

View file

@ -29,6 +29,16 @@
CLOSED: "Закрыта", CLOSED: "Закрыта",
REJECTED: "Отклонена", REJECTED: "Отклонена",
}; };
const INVOICE_STATUS_LABELS = {
WAITING_PAYMENT: "Ожидает оплату",
PAID: "Оплачен",
CANCELED: "Отменен",
};
const STATUS_KIND_LABELS = {
DEFAULT: "Обычный",
INVOICE: "Выставление счета",
PAID: "Оплачено",
};
const REQUEST_UPDATE_EVENT_LABELS = { const REQUEST_UPDATE_EVENT_LABELS = {
MESSAGE: "сообщение", MESSAGE: "сообщение",
@ -42,6 +52,11 @@
endpoint: "/api/admin/crud/requests/query", endpoint: "/api/admin/crud/requests/query",
sort: [{ field: "created_at", dir: "desc" }], sort: [{ field: "created_at", dir: "desc" }],
}, },
invoices: {
table: "invoices",
endpoint: "/api/admin/invoices/query",
sort: [{ field: "issued_at", dir: "desc" }],
},
quotes: { quotes: {
table: "quotes", table: "quotes",
endpoint: "/api/admin/crud/quotes/query", endpoint: "/api/admin/crud/quotes/query",
@ -99,6 +114,31 @@
}, },
]) ])
); );
TABLE_MUTATION_CONFIG.invoices = {
create: "/api/admin/invoices",
update: (id) => "/api/admin/invoices/" + id,
delete: (id) => "/api/admin/invoices/" + id,
};
const TABLE_KEY_ALIASES = {
form_fields: "formFields",
topic_required_fields: "topicRequiredFields",
topic_data_templates: "topicDataTemplates",
topic_status_transitions: "statusTransitions",
admin_users: "users",
admin_user_topics: "userTopics",
};
const TABLE_UNALIASES = Object.fromEntries(Object.entries(TABLE_KEY_ALIASES).map(([table, alias]) => [alias, table]));
const KNOWN_CONFIG_TABLE_KEYS = new Set([
"quotes",
"topics",
"statuses",
"formFields",
"topicRequiredFields",
"topicDataTemplates",
"statusTransitions",
"users",
"userTopics",
]);
function createTableState() { function createTableState() {
return { return {
@ -111,6 +151,29 @@
}; };
} }
function humanizeKey(value) {
const text = String(value || "")
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!text) return "-";
return text.charAt(0).toUpperCase() + text.slice(1);
}
function metaKindToFilterType(kind) {
if (kind === "boolean") return "boolean";
if (kind === "number") return "number";
if (kind === "date" || kind === "datetime") return "date";
return "text";
}
function metaKindToRecordType(kind) {
if (kind === "boolean") return "boolean";
if (kind === "number") return "number";
if (kind === "json") return "json";
return "text";
}
function decodeJwtPayload(token) { function decodeJwtPayload(token) {
try { try {
const payload = token.split(".")[1] || ""; const payload = token.split(".")[1] || "";
@ -139,6 +202,14 @@
return STATUS_LABELS[code] || code || "-"; return STATUS_LABELS[code] || code || "-";
} }
function invoiceStatusLabel(code) {
return INVOICE_STATUS_LABELS[code] || code || "-";
}
function statusKindLabel(code) {
return STATUS_KIND_LABELS[code] || code || "-";
}
function boolLabel(value) { function boolLabel(value) {
return value ? "Да" : "Нет"; return value ? "Да" : "Нет";
} }
@ -821,6 +892,7 @@
const [tables, setTables] = useState({ const [tables, setTables] = useState({
requests: createTableState(), requests: createTableState(),
invoices: createTableState(),
quotes: createTableState(), quotes: createTableState(),
topics: createTableState(), topics: createTableState(),
statuses: createTableState(), statuses: createTableState(),
@ -831,6 +903,7 @@
users: createTableState(), users: createTableState(),
userTopics: createTableState(), userTopics: createTableState(),
}); });
const [tableCatalog, setTableCatalog] = useState([]);
const [dictionaries, setDictionaries] = useState({ const [dictionaries, setDictionaries] = useState({
topics: [], topics: [],
@ -851,7 +924,7 @@
form: {}, form: {},
}); });
const [configActiveKey, setConfigActiveKey] = useState("quotes"); const [configActiveKey, setConfigActiveKey] = useState("");
const [referencesExpanded, setReferencesExpanded] = useState(true); const [referencesExpanded, setReferencesExpanded] = useState(true);
const [metaEntity, setMetaEntity] = useState("quotes"); const [metaEntity, setMetaEntity] = useState("quotes");
@ -924,6 +997,14 @@
.map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" })); .map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" }));
}, [dictionaries.statuses]); }, [dictionaries.statuses]);
const getInvoiceStatusOptions = useCallback(() => {
return Object.entries(INVOICE_STATUS_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
}, []);
const getStatusKindOptions = useCallback(() => {
return Object.entries(STATUS_KIND_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
}, []);
const getTopicOptions = useCallback(() => { const getTopicOptions = useCallback(() => {
return (dictionaries.topics || []) return (dictionaries.topics || [])
.filter((item) => item && item.code) .filter((item) => item && item.code)
@ -953,6 +1034,51 @@
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" })); return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" }));
}, []); }, []);
const tableCatalogMap = useMemo(() => {
const map = {};
(tableCatalog || []).forEach((item) => {
if (!item || !item.key) return;
map[item.key] = item;
});
return map;
}, [tableCatalog]);
const dictionaryTableItems = useMemo(() => {
return (tableCatalog || [])
.filter((item) => item && item.section === "dictionary" && Array.isArray(item.actions) && item.actions.includes("query"))
.sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru"));
}, [tableCatalog]);
const resolveTableConfig = useCallback(
(tableKey) => {
if (TABLE_SERVER_CONFIG[tableKey]) return TABLE_SERVER_CONFIG[tableKey];
const meta = tableCatalogMap[tableKey];
if (!meta || !meta.table) return null;
const tableName = String(meta.table || tableKey);
return {
table: tableName,
endpoint: String(meta.query_endpoint || ("/api/admin/crud/" + tableName + "/query")),
sort: Array.isArray(meta.default_sort) && meta.default_sort.length ? meta.default_sort : [{ field: "created_at", dir: "desc" }],
};
},
[tableCatalogMap]
);
const resolveMutationConfig = useCallback(
(tableKey) => {
if (TABLE_MUTATION_CONFIG[tableKey]) return TABLE_MUTATION_CONFIG[tableKey];
const meta = tableCatalogMap[tableKey];
if (!meta || !meta.table) return null;
const tableName = String(meta.table || tableKey);
return {
create: String(meta.create_endpoint || ("/api/admin/crud/" + tableName)),
update: (id) => String(meta.update_endpoint_template || ("/api/admin/crud/" + tableName + "/{id}")).replace("{id}", String(id)),
delete: (id) => String(meta.delete_endpoint_template || ("/api/admin/crud/" + tableName + "/{id}")).replace("{id}", String(id)),
};
},
[tableCatalogMap]
);
const getFilterFields = useCallback( const getFilterFields = useCallback(
(tableKey) => { (tableKey) => {
if (tableKey === "requests") { if (tableKey === "requests") {
@ -968,6 +1094,20 @@
{ field: "created_at", label: "Дата создания", type: "date" }, { field: "created_at", label: "Дата создания", type: "date" },
]; ];
} }
if (tableKey === "invoices") {
return [
{ field: "invoice_number", label: "Номер счета", type: "text" },
{ field: "status", label: "Статус", type: "enum", options: getInvoiceStatusOptions },
{ field: "amount", label: "Сумма", type: "number" },
{ field: "currency", label: "Валюта", type: "text" },
{ field: "payer_display_name", label: "Плательщик", type: "text" },
{ field: "request_id", label: "ID заявки", type: "text" },
{ field: "issued_by_admin_user_id", label: "ID сотрудника", type: "text" },
{ field: "issued_at", label: "Дата формирования", type: "date" },
{ field: "paid_at", label: "Дата оплаты", type: "date" },
{ field: "created_at", label: "Дата создания", type: "date" },
];
}
if (tableKey === "quotes") { if (tableKey === "quotes") {
return [ return [
{ field: "author", label: "Автор", type: "text" }, { field: "author", label: "Автор", type: "text" },
@ -990,6 +1130,7 @@
return [ return [
{ field: "code", label: "Код", type: "text" }, { field: "code", label: "Код", type: "text" },
{ field: "name", label: "Название", type: "text" }, { field: "name", label: "Название", type: "text" },
{ field: "kind", label: "Тип", type: "enum", options: getStatusKindOptions },
{ field: "enabled", label: "Активен", type: "boolean" }, { field: "enabled", label: "Активен", type: "boolean" },
{ field: "sort_order", label: "Порядок", type: "number" }, { field: "sort_order", label: "Порядок", type: "number" },
{ field: "is_terminal", label: "Терминальный", type: "boolean" }, { field: "is_terminal", label: "Терминальный", type: "boolean" },
@ -1056,13 +1197,37 @@
{ field: "created_at", label: "Дата создания", type: "date" }, { field: "created_at", label: "Дата создания", type: "date" },
]; ];
} }
return []; const meta = tableCatalogMap[tableKey];
if (!meta || !Array.isArray(meta.columns)) return [];
return (meta.columns || [])
.filter((column) => column && column.name && column.filterable !== false)
.map((column) => {
const name = String(column.name);
const label = String(column.label || humanizeKey(name));
if (name === "topic_code") return { field: name, label, type: "reference", options: getTopicOptions };
if (name === "status_code" || name === "from_status" || name === "to_status") {
return { field: name, label, type: "reference", options: getStatusOptions };
}
if (name === "field_key") return { field: name, label, type: "reference", options: getFormFieldKeyOptions };
return { field: name, label, type: metaKindToFilterType(column.kind) };
});
}, },
[getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions] [
tableCatalogMap,
getFormFieldKeyOptions,
getFormFieldTypeOptions,
getInvoiceStatusOptions,
getLawyerOptions,
getRoleOptions,
getStatusKindOptions,
getStatusOptions,
getTopicOptions,
]
); );
const getTableLabel = useCallback((tableKey) => { const getTableLabel = useCallback((tableKey) => {
if (tableKey === "requests") return "Заявки"; if (tableKey === "requests") return "Заявки";
if (tableKey === "invoices") return "Счета";
if (tableKey === "quotes") return "Цитаты"; if (tableKey === "quotes") return "Цитаты";
if (tableKey === "topics") return "Темы"; if (tableKey === "topics") return "Темы";
if (tableKey === "statuses") return "Статусы"; if (tableKey === "statuses") return "Статусы";
@ -1072,8 +1237,11 @@
if (tableKey === "statusTransitions") return "Переходы статусов"; if (tableKey === "statusTransitions") return "Переходы статусов";
if (tableKey === "users") return "Пользователи"; if (tableKey === "users") return "Пользователи";
if (tableKey === "userTopics") return "Дополнительные темы юристов"; if (tableKey === "userTopics") return "Дополнительные темы юристов";
return "Таблица"; const meta = tableCatalogMap[tableKey];
}, []); if (meta && meta.label) return String(meta.label);
const raw = TABLE_UNALIASES[tableKey] || tableKey;
return humanizeKey(raw);
}, [tableCatalogMap]);
const getRecordFields = useCallback( const getRecordFields = useCallback(
(tableKey) => { (tableKey) => {
@ -1094,6 +1262,17 @@
{ key: "total_attachments_bytes", label: "Размер вложений (байт)", type: "number", optional: true, defaultValue: "0" }, { key: "total_attachments_bytes", label: "Размер вложений (байт)", type: "number", optional: true, defaultValue: "0" },
]; ];
} }
if (tableKey === "invoices") {
return [
{ key: "request_track_number", label: "Номер заявки", type: "text", required: true, createOnly: true },
{ key: "invoice_number", label: "Номер счета", type: "text", optional: true, placeholder: "Оставьте пустым для автогенерации" },
{ key: "status", label: "Статус", type: "enum", required: true, options: getInvoiceStatusOptions, defaultValue: "WAITING_PAYMENT" },
{ key: "amount", label: "Сумма", type: "number", required: true },
{ key: "currency", label: "Валюта", type: "text", optional: true, defaultValue: "RUB" },
{ key: "payer_display_name", label: "Плательщик (ФИО / компания)", type: "text", required: true },
{ key: "payer_details", label: "Реквизиты (JSON, шифруется)", type: "json", optional: true, omitIfEmpty: true, placeholder: "{\"inn\":\"...\"}" },
];
}
if (tableKey === "quotes") { if (tableKey === "quotes") {
return [ return [
{ key: "author", label: "Автор", type: "text", required: true }, { key: "author", label: "Автор", type: "text", required: true },
@ -1115,6 +1294,8 @@
return [ return [
{ key: "code", label: "Код", type: "text", required: true }, { key: "code", label: "Код", type: "text", required: true },
{ key: "name", label: "Название", type: "text", required: true }, { key: "name", label: "Название", type: "text", required: true },
{ key: "kind", label: "Тип", type: "enum", required: true, options: getStatusKindOptions, defaultValue: "DEFAULT" },
{ key: "invoice_template", label: "Шаблон счета", type: "textarea", optional: true, placeholder: "Доступные поля: {track_number}, {client_name}, {topic_code}, {amount}" },
{ key: "enabled", label: "Активен", type: "boolean", defaultValue: "true" }, { key: "enabled", label: "Активен", type: "boolean", defaultValue: "true" },
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" }, { key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
{ key: "is_terminal", label: "Терминальный", type: "boolean", defaultValue: "false" }, { key: "is_terminal", label: "Терминальный", type: "boolean", defaultValue: "false" },
@ -1188,9 +1369,33 @@
{ key: "topic_code", label: "Дополнительная тема", type: "reference", required: true, options: getTopicOptions }, { key: "topic_code", label: "Дополнительная тема", type: "reference", required: true, options: getTopicOptions },
]; ];
} }
return []; const meta = tableCatalogMap[tableKey];
if (!meta || !Array.isArray(meta.columns)) return [];
return (meta.columns || [])
.filter((column) => column && column.name && column.editable)
.map((column) => {
const key = String(column.name || "");
const requiredOnCreate = Boolean(column.required_on_create);
return {
key,
label: String(column.label || humanizeKey(key)),
type: metaKindToRecordType(column.kind),
requiredOnCreate,
optional: !requiredOnCreate,
};
});
}, },
[getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions] [
tableCatalogMap,
getFormFieldKeyOptions,
getFormFieldTypeOptions,
getInvoiceStatusOptions,
getLawyerOptions,
getRoleOptions,
getStatusKindOptions,
getStatusOptions,
getTopicOptions,
]
); );
const getFieldDef = useCallback( const getFieldDef = useCallback(
@ -1228,7 +1433,7 @@
const loadTable = useCallback( const loadTable = useCallback(
async (tableKey, options, tokenOverride) => { async (tableKey, options, tokenOverride) => {
const opts = options || {}; const opts = options || {};
const config = TABLE_SERVER_CONFIG[tableKey]; const config = resolveTableConfig(tableKey);
if (!config) return false; if (!config) return false;
const current = tablesRef.current[tableKey] || createTableState(); const current = tablesRef.current[tableKey] || createTableState();
@ -1318,7 +1523,7 @@
}); });
} }
if (tableKey === "formFields") { if (tableKey === "formFields" || tableKey === "form_fields") {
setDictionaries((prev) => { setDictionaries((prev) => {
const set = new Set(DEFAULT_FORM_FIELD_TYPES); const set = new Set(DEFAULT_FORM_FIELD_TYPES);
(next.rows || []).forEach((row) => { (next.rows || []).forEach((row) => {
@ -1336,7 +1541,7 @@
}); });
} }
if (tableKey === "users") { if (tableKey === "users" || tableKey === "admin_users") {
setDictionaries((prev) => { setDictionaries((prev) => {
const map = new Map((prev.users || []).map((user) => [user.id, user])); const map = new Map((prev.users || []).map((user) => [user.id, user]));
(next.rows || []).forEach((row) => { (next.rows || []).forEach((row) => {
@ -1359,12 +1564,16 @@
return false; return false;
} }
}, },
[api, setStatus, setTableState] [api, resolveTableConfig, setStatus, setTableState]
); );
const loadCurrentConfigTable = useCallback( const loadCurrentConfigTable = useCallback(
async (resetOffset, tokenOverride, keyOverride) => { async (resetOffset, tokenOverride, keyOverride) => {
const currentKey = keyOverride || configActiveKey; const currentKey = keyOverride || configActiveKey;
if (!currentKey) {
setStatus("config", "Выберите справочник", "");
return false;
}
setStatus("config", "Загрузка...", ""); setStatus("config", "Загрузка...", "");
const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride); const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride);
if (ok) { if (ok) {
@ -1438,6 +1647,7 @@
if (!(tokenOverride !== undefined ? tokenOverride : token)) return; if (!(tokenOverride !== undefined ? tokenOverride : token)) return;
if (section === "dashboard") return loadDashboard(tokenOverride); if (section === "dashboard") return loadDashboard(tokenOverride);
if (section === "requests") return loadTable("requests", {}, tokenOverride); if (section === "requests") return loadTable("requests", {}, tokenOverride);
if (section === "invoices") return loadTable("invoices", {}, tokenOverride);
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride); if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride); if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
if (section === "meta") return loadMeta(tokenOverride); if (section === "meta") return loadMeta(tokenOverride);
@ -1451,18 +1661,28 @@
...prev, ...prev,
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })), statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
})); }));
setTableCatalog([]);
if (roleOverride !== "ADMIN") return; if (roleOverride !== "ADMIN") return;
try { try {
const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0); const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0);
const usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0); const usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0);
const [topicsData, statusesData, fieldsData, usersData] = await Promise.all([ const [catalogData, topicsData, statusesData, fieldsData, usersData] = await Promise.all([
api("/api/admin/crud/meta/tables", {}, tokenOverride),
api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride), api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride),
api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride), api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride),
api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride), api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride),
api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride), api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride),
]); ]);
const catalogRows = (catalogData.tables || [])
.filter((row) => row && row.table)
.map((row) => {
const tableName = String(row.table || "");
const key = TABLE_KEY_ALIASES[tableName] || String(row.key || tableName);
return { ...row, key, table: tableName };
});
setTableCatalog(catalogRows);
const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }])); const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }]));
(statusesData.rows || []).forEach((row) => { (statusesData.rows || []).forEach((row) => {
@ -1630,6 +1850,7 @@
if (field.type === "json") { if (field.type === "json") {
const text = String(raw || "").trim(); const text = String(raw || "").trim();
if (!text) { if (!text) {
if (field.omitIfEmpty) return;
if (field.optional) payload[field.key] = null; if (field.optional) payload[field.key] = null;
else payload[field.key] = {}; else payload[field.key] = {};
return; return;
@ -1656,6 +1877,7 @@
}); });
if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {}; if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {};
if (tableKey === "invoices" && mode === "edit") delete payload.request_track_number;
return payload; return payload;
}, },
[getRecordFields] [getRecordFields]
@ -1666,7 +1888,7 @@
event.preventDefault(); event.preventDefault();
const tableKey = recordModal.tableKey; const tableKey = recordModal.tableKey;
if (!tableKey) return; if (!tableKey) return;
const endpoints = TABLE_MUTATION_CONFIG[tableKey]; const endpoints = resolveMutationConfig(tableKey);
if (!endpoints) return; if (!endpoints) return;
try { try {
setStatus("recordForm", "Сохранение...", ""); setStatus("recordForm", "Сохранение...", "");
@ -1683,12 +1905,12 @@
setStatus("recordForm", "Ошибка: " + error.message, "error"); setStatus("recordForm", "Ошибка: " + error.message, "error");
} }
}, },
[api, buildRecordPayload, closeRecordModal, loadTable, recordModal, setStatus] [api, buildRecordPayload, closeRecordModal, loadTable, recordModal, resolveMutationConfig, setStatus]
); );
const deleteRecord = useCallback( const deleteRecord = useCallback(
async (tableKey, id) => { async (tableKey, id) => {
const endpoints = TABLE_MUTATION_CONFIG[tableKey]; const endpoints = resolveMutationConfig(tableKey);
if (!endpoints) return; if (!endpoints) return;
if (!confirm("Удалить запись?")) return; if (!confirm("Удалить запись?")) return;
try { try {
@ -1699,7 +1921,7 @@
setStatus(tableKey, "Ошибка удаления: " + error.message, "error"); setStatus(tableKey, "Ошибка удаления: " + error.message, "error");
} }
}, },
[api, loadTable, setStatus] [api, loadTable, resolveMutationConfig, setStatus]
); );
const claimRequest = useCallback( const claimRequest = useCallback(
@ -1717,6 +1939,57 @@
[api, loadTable, setStatus] [api, loadTable, setStatus]
); );
const openInvoiceRequest = useCallback(
async (row) => {
if (!row || !row.request_id) return;
try {
setActiveSection("requests");
await loadTable("requests", {});
await openRequestDetails(row.request_id);
} catch (_) {
// Ignore navigation errors and keep current state.
}
},
[loadTable, openRequestDetails]
);
const downloadInvoicePdf = useCallback(
async (row) => {
if (!row || !row.id || !token) return;
try {
setStatus("invoices", "Формируем PDF...", "");
const response = await fetch("/api/admin/invoices/" + row.id + "/pdf", {
headers: { Authorization: "Bearer " + token },
});
if (!response.ok) {
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch (_) {
payload = { raw: text };
}
const message = payload.detail || payload.error || payload.raw || ("HTTP " + response.status);
throw new Error(translateApiError(String(message)));
}
const blob = await response.blob();
const fileName = (row.invoice_number || "invoice") + ".pdf";
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setStatus("invoices", "PDF скачан", "ok");
} catch (error) {
setStatus("invoices", "Ошибка скачивания: " + error.message, "error");
}
},
[setStatus, token]
);
const openReassignModal = useCallback( const openReassignModal = useCallback(
(row) => { (row) => {
const options = getLawyerOptions(); const options = getLawyerOptions();
@ -2025,10 +2298,12 @@
setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" }); setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" });
setDashboardData({ scope: "", cards: [], byStatus: {}, lawyerLoads: [], myUnreadByEvent: {} }); setDashboardData({ scope: "", cards: [], byStatus: {}, lawyerLoads: [], myUnreadByEvent: {} });
setMetaJson(""); setMetaJson("");
setConfigActiveKey("quotes"); setConfigActiveKey("");
setReferencesExpanded(true); setReferencesExpanded(true);
setTableCatalog([]);
setTables({ setTables({
requests: createTableState(), requests: createTableState(),
invoices: createTableState(),
quotes: createTableState(), quotes: createTableState(),
topics: createTableState(), topics: createTableState(),
statuses: createTableState(), statuses: createTableState(),
@ -2110,6 +2385,15 @@
}; };
}, [bootstrapReferenceData, refreshSection, role, token]); }, [bootstrapReferenceData, refreshSection, role, token]);
useEffect(() => {
if (!dictionaryTableItems.length) {
if (configActiveKey) setConfigActiveKey("");
return;
}
const hasCurrent = dictionaryTableItems.some((item) => item.key === configActiveKey);
if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key);
}, [configActiveKey, dictionaryTableItems]);
const anyOverlayOpen = requestModal.open || recordModal.open || filterModal.open || reassignModal.open; const anyOverlayOpen = requestModal.open || recordModal.open || filterModal.open || reassignModal.open;
useEffect(() => { useEffect(() => {
document.body.classList.toggle("modal-open", anyOverlayOpen); document.body.classList.toggle("modal-open", anyOverlayOpen);
@ -2132,6 +2416,7 @@
return [ return [
{ key: "dashboard", label: "Обзор" }, { key: "dashboard", label: "Обзор" },
{ key: "requests", label: "Заявки" }, { key: "requests", label: "Заявки" },
{ key: "invoices", label: "Счета" },
{ key: "meta", label: "Метаданные" }, { key: "meta", label: "Метаданные" },
]; ];
}, []); }, []);
@ -2145,10 +2430,39 @@
const recordModalFields = useMemo(() => { const recordModalFields = useMemo(() => {
const all = getRecordFields(recordModal.tableKey); const all = getRecordFields(recordModal.tableKey);
if (recordModal.mode !== "create") return all; if (recordModal.mode !== "create") return all.filter((field) => !field.createOnly);
return all.filter((field) => !field.autoCreate); return all.filter((field) => !field.autoCreate);
}, [getRecordFields, recordModal.mode, recordModal.tableKey]); }, [getRecordFields, recordModal.mode, recordModal.tableKey]);
const activeConfigTableState = useMemo(() => {
return tables[configActiveKey] || createTableState();
}, [configActiveKey, tables]);
const activeConfigMeta = useMemo(() => tableCatalogMap[configActiveKey] || null, [configActiveKey, tableCatalogMap]);
const activeConfigActions = useMemo(() => {
return Array.isArray(activeConfigMeta?.actions) ? activeConfigMeta.actions : [];
}, [activeConfigMeta]);
const canCreateInConfig = activeConfigActions.includes("create");
const canUpdateInConfig = activeConfigActions.includes("update");
const canDeleteInConfig = activeConfigActions.includes("delete");
const genericConfigHeaders = useMemo(() => {
if (!activeConfigMeta || !Array.isArray(activeConfigMeta.columns)) return [];
const headers = (activeConfigMeta.columns || [])
.filter((column) => column && column.name)
.map((column) => {
const name = String(column.name);
return {
key: name,
label: String(column.label || humanizeKey(name)),
sortable: Boolean(column.sortable !== false),
field: name,
};
});
if (canUpdateInConfig || canDeleteInConfig) headers.push({ key: "actions", label: "Действия" });
return headers;
}, [activeConfigMeta, canDeleteInConfig, canUpdateInConfig]);
return ( return (
<> <>
<div className="layout"> <div className="layout">
@ -2182,69 +2496,16 @@
</button> </button>
{referencesExpanded ? ( {referencesExpanded ? (
<div className="menu-tree"> <div className="menu-tree">
<button {dictionaryTableItems.map((item) => (
type="button" <button
className={activeSection === "config" && configActiveKey === "quotes" ? "active" : ""} key={item.key}
onClick={() => selectConfigNode("quotes")} type="button"
> className={activeSection === "config" && configActiveKey === item.key ? "active" : ""}
Цитаты onClick={() => selectConfigNode(item.key)}
</button> >
<button {getTableLabel(item.key)}
type="button" </button>
className={activeSection === "config" && configActiveKey === "topics" ? "active" : ""} ))}
onClick={() => selectConfigNode("topics")}
>
Темы
</button>
<button
type="button"
className={activeSection === "config" && configActiveKey === "statuses" ? "active" : ""}
onClick={() => selectConfigNode("statuses")}
>
Статусы
</button>
<button
type="button"
className={activeSection === "config" && configActiveKey === "formFields" ? "active" : ""}
onClick={() => selectConfigNode("formFields")}
>
Поля формы
</button>
<button
type="button"
className={activeSection === "config" && configActiveKey === "topicRequiredFields" ? "active" : ""}
onClick={() => selectConfigNode("topicRequiredFields")}
>
Обязательные поля темы
</button>
<button
type="button"
className={activeSection === "config" && configActiveKey === "topicDataTemplates" ? "active" : ""}
onClick={() => selectConfigNode("topicDataTemplates")}
>
Шаблоны дозапроса
</button>
<button
type="button"
className={activeSection === "config" && configActiveKey === "statusTransitions" ? "active" : ""}
onClick={() => selectConfigNode("statusTransitions")}
>
Переходы статусов
</button>
<button
type="button"
className={activeSection === "config" && configActiveKey === "users" ? "active" : ""}
onClick={() => selectConfigNode("users")}
>
Пользователи
</button>
<button
type="button"
className={activeSection === "config" && configActiveKey === "userTopics" ? "active" : ""}
onClick={() => selectConfigNode("userTopics")}
>
Темы юристов
</button>
</div> </div>
) : null} ) : null}
</> </>
@ -2422,6 +2683,81 @@
<StatusLine status={getStatus("requests")} /> <StatusLine status={getStatus("requests")} />
</Section> </Section>
<Section active={activeSection === "invoices"} id="section-invoices">
<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={() => loadTable("invoices", { resetOffset: true })}>
Обновить
</button>
<button className="btn" type="button" onClick={() => openCreateRecordModal("invoices")}>
Новый счет
</button>
</div>
</div>
<FilterToolbar
filters={tables.invoices.filters}
onOpen={() => openFilterModal("invoices")}
onRemove={(index) => removeFilterChip("invoices", index)}
onEdit={(index) => openFilterEditModal("invoices", index)}
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={tables.invoices.rows}
emptyColspan={9}
onSort={(field) => toggleTableSort("invoices", field)}
sortClause={(tables.invoices.sort && tables.invoices.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_track_number || row.request_id || "-"}</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="Открыть заявку" onClick={() => openInvoiceRequest(row)} />
<IconButton icon="⬇" tooltip="Скачать PDF" onClick={() => downloadInvoicePdf(row)} />
<IconButton icon="✎" tooltip="Редактировать счет" onClick={() => openEditRecordModal("invoices", row)} />
{role === "ADMIN" ? (
<IconButton icon="🗑" tooltip="Удалить счет" onClick={() => deleteRecord("invoices", row.id)} tone="danger" />
) : null}
</div>
</td>
</tr>
)}
/>
<TablePager
tableState={tables.invoices}
onPrev={() => loadPrevPage("invoices")}
onNext={() => loadNextPage("invoices")}
onLoadAll={() => loadAllRows("invoices")}
/>
<StatusLine status={getStatus("invoices")} />
</Section>
<Section active={activeSection === "quotes"} id="section-quotes"> <Section active={activeSection === "quotes"} id="section-quotes">
<div className="section-head"> <div className="section-head">
<div> <div>
@ -2501,13 +2837,15 @@
<div className="config-panel"> <div className="config-panel">
<div className="block"> <div className="block">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }}>
<h3 style={{ margin: 0 }}>{getTableLabel(configActiveKey)}</h3> <h3 style={{ margin: 0 }}>{configActiveKey ? getTableLabel(configActiveKey) : "Справочник не выбран"}</h3>
<button className="btn" type="button" onClick={() => openCreateRecordModal(configActiveKey)}> {canCreateInConfig && configActiveKey ? (
Добавить <button className="btn" type="button" onClick={() => openCreateRecordModal(configActiveKey)}>
</button> Добавить
</button>
) : null}
</div> </div>
<FilterToolbar <FilterToolbar
filters={tables[configActiveKey].filters} filters={activeConfigTableState.filters}
onOpen={() => openFilterModal(configActiveKey)} onOpen={() => openFilterModal(configActiveKey)}
onRemove={(index) => removeFilterChip(configActiveKey, index)} onRemove={(index) => removeFilterChip(configActiveKey, index)}
onEdit={(index) => openFilterEditModal(configActiveKey, index)} onEdit={(index) => openFilterEditModal(configActiveKey, index)}
@ -2591,13 +2929,15 @@
headers={[ headers={[
{ key: "code", label: "Код", sortable: true, field: "code" }, { key: "code", label: "Код", sortable: true, field: "code" },
{ key: "name", label: "Название", sortable: true, field: "name" }, { key: "name", label: "Название", sortable: true, field: "name" },
{ key: "kind", label: "Тип", sortable: true, field: "kind" },
{ key: "enabled", label: "Активен", sortable: true, field: "enabled" }, { key: "enabled", label: "Активен", sortable: true, field: "enabled" },
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" }, { key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
{ key: "is_terminal", label: "Терминальный", sortable: true, field: "is_terminal" }, { key: "is_terminal", label: "Терминальный", sortable: true, field: "is_terminal" },
{ key: "invoice_template", label: "Шаблон счета" },
{ key: "actions", label: "Действия" }, { key: "actions", label: "Действия" },
]} ]}
rows={tables.statuses.rows} rows={tables.statuses.rows}
emptyColspan={6} emptyColspan={8}
onSort={(field) => toggleTableSort("statuses", field)} onSort={(field) => toggleTableSort("statuses", field)}
sortClause={(tables.statuses.sort && tables.statuses.sort[0]) || TABLE_SERVER_CONFIG.statuses.sort[0]} sortClause={(tables.statuses.sort && tables.statuses.sort[0]) || TABLE_SERVER_CONFIG.statuses.sort[0]}
renderRow={(row) => ( renderRow={(row) => (
@ -2606,9 +2946,11 @@
<code>{row.code || "-"}</code> <code>{row.code || "-"}</code>
</td> </td>
<td>{row.name || "-"}</td> <td>{row.name || "-"}</td>
<td>{statusKindLabel(row.kind)}</td>
<td>{boolLabel(row.enabled)}</td> <td>{boolLabel(row.enabled)}</td>
<td>{String(row.sort_order ?? 0)}</td> <td>{String(row.sort_order ?? 0)}</td>
<td>{boolLabel(row.is_terminal)}</td> <td>{boolLabel(row.is_terminal)}</td>
<td>{row.invoice_template || "-"}</td>
<td> <td>
<div className="table-actions"> <div className="table-actions">
<IconButton icon="✎" tooltip="Редактировать статус" onClick={() => openEditRecordModal("statuses", row)} /> <IconButton icon="✎" tooltip="Редактировать статус" onClick={() => openEditRecordModal("statuses", row)} />
@ -2866,8 +3208,44 @@
}} }}
/> />
) : null} ) : 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>;
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 <TablePager
tableState={tables[configActiveKey]} tableState={activeConfigTableState}
onPrev={() => loadPrevPage(configActiveKey)} onPrev={() => loadPrevPage(configActiveKey)}
onNext={() => loadNextPage(configActiveKey)} onNext={() => loadNextPage(configActiveKey)}
onLoadAll={() => loadAllRows(configActiveKey)} onLoadAll={() => loadAllRows(configActiveKey)}

View file

@ -35,6 +35,7 @@
color: var(--text); color: var(--text);
font-family: "Manrope", sans-serif; font-family: "Manrope", sans-serif;
scroll-behavior: smooth; scroll-behavior: smooth;
overflow-x: hidden;
} }
body.modal-open { overflow: hidden; } body.modal-open { overflow: hidden; }
@ -51,10 +52,12 @@
} }
.wrap { .wrap {
width: min(var(--maxw), calc(100% - 2rem)); width: min(var(--maxw), calc(100% - 1.5rem));
margin: 0 auto; margin: 0 auto;
} }
section { scroll-margin-top: 84px; }
.topbar { .topbar {
position: sticky; position: sticky;
top: 0; top: 0;
@ -493,6 +496,7 @@
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
color: #ecf2fb; color: #ecf2fb;
font: inherit; font: inherit;
font-size: 16px;
padding: 0.72rem 0.8rem; padding: 0.72rem 0.8rem;
} }
@ -590,6 +594,7 @@
color: #d8e3f3; color: #d8e3f3;
line-height: 1.5; line-height: 1.5;
font-size: 0.92rem; font-size: 0.92rem;
overflow-wrap: anywhere;
} }
.simple-item time { .simple-item time {
@ -615,6 +620,15 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.file-row input[type="file"] {
max-width: 100%;
}
.brand,
.meta-row b {
overflow-wrap: anywhere;
}
@keyframes rise { @keyframes rise {
to { to {
opacity: 1; opacity: 1;
@ -637,8 +651,21 @@
padding: 0.72rem 0; padding: 0.72rem 0;
} }
.nav {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.nav a,
.nav .btn {
width: 100%;
text-align: center;
}
.hero { .hero {
padding-top: 3.6rem; padding-top: 2.7rem;
} }
.stats { .stats {
@ -656,6 +683,88 @@
.cabinet-meta { .cabinet-meta {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.hero-actions .btn {
width: 100%;
}
.simple-list {
max-height: 220px;
}
}
@media (max-width: 520px) {
.wrap {
width: calc(100% - 1rem);
}
.topbar {
position: static;
}
section {
scroll-margin-top: 0;
}
.brand {
font-size: 0.78rem;
max-width: none;
}
.nav {
grid-template-columns: 1fr;
}
.hero {
padding-top: 1.4rem;
}
.panel,
.card,
.expert,
.cabinet-card {
padding: 0.85rem;
}
.file-row {
flex-direction: column;
align-items: stretch;
}
.file-row .btn {
width: 100%;
}
.modal-backdrop {
padding: 0;
}
.modal {
width: 100%;
max-height: 100vh;
min-height: 100vh;
border-radius: 0;
border: none;
padding: 0.95rem;
}
.modal-head {
position: sticky;
top: 0;
z-index: 2;
background: linear-gradient(160deg, #18222e, #121a23);
padding-bottom: 0.5rem;
margin-bottom: 0.7rem;
}
.close {
width: 38px;
height: 38px;
}
.form-foot .btn {
width: 100%;
}
} }
</style> </style>
</head> </head>
@ -854,6 +963,11 @@
</div> </div>
</article> </article>
<article class="cabinet-card">
<h3>Счета и оплата</h3>
<ul class="simple-list" id="cabinet-invoices"></ul>
</article>
<article class="cabinet-card"> <article class="cabinet-card">
<h3>История изменений</h3> <h3>История изменений</h3>
<ul class="simple-list" id="cabinet-timeline"></ul> <ul class="simple-list" id="cabinet-timeline"></ul>
@ -927,6 +1041,7 @@
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated"); const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
const cabinetMessages = document.getElementById("cabinet-messages"); const cabinetMessages = document.getElementById("cabinet-messages");
const cabinetFiles = document.getElementById("cabinet-files"); const cabinetFiles = document.getElementById("cabinet-files");
const cabinetInvoices = document.getElementById("cabinet-invoices");
const cabinetTimeline = document.getElementById("cabinet-timeline"); const cabinetTimeline = document.getElementById("cabinet-timeline");
const cabinetChatForm = document.getElementById("cabinet-chat-form"); const cabinetChatForm = document.getElementById("cabinet-chat-form");
const cabinetChatBody = document.getElementById("cabinet-chat-body"); const cabinetChatBody = document.getElementById("cabinet-chat-body");
@ -1060,6 +1175,44 @@
}); });
} }
function renderInvoices(items) {
cabinetInvoices.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) {
clearList(cabinetInvoices, "Счета пока не выставлены.");
return;
}
items.forEach((item) => {
const li = document.createElement("li");
li.className = "simple-item";
const time = document.createElement("time");
time.textContent = "Сформирован: " + formatDate(item.issued_at);
li.appendChild(time);
const p = document.createElement("p");
const amount = Number(item.amount || 0).toLocaleString("ru-RU");
p.textContent =
(item.invoice_number || "Счет") +
" • " +
(item.status_label || item.status || "-") +
" • " +
amount +
" " +
(item.currency || "RUB");
li.appendChild(p);
const link = document.createElement("a");
link.href = item.download_url;
link.textContent = "Открыть / скачать PDF";
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.color = "#f6d7a8";
li.appendChild(link);
cabinetInvoices.appendChild(li);
});
}
function renderTimeline(items) { function renderTimeline(items) {
cabinetTimeline.innerHTML = ""; cabinetTimeline.innerHTML = "";
if (!Array.isArray(items) || items.length === 0) { if (!Array.isArray(items) || items.length === 0) {
@ -1169,22 +1322,26 @@
async function refreshCabinetData() { async function refreshCabinetData() {
if (!activeTrack) return; if (!activeTrack) return;
const [messagesRes, filesRes, timelineRes] = await Promise.all([ const [messagesRes, filesRes, invoicesRes, timelineRes] = await Promise.all([
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"), fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"), fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/invoices"),
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline") fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline")
]); ]);
const messagesData = await parseJsonSafe(messagesRes); const messagesData = await parseJsonSafe(messagesRes);
const filesData = await parseJsonSafe(filesRes); const filesData = await parseJsonSafe(filesRes);
const invoicesData = await parseJsonSafe(invoicesRes);
const timelineData = await parseJsonSafe(timelineRes); const timelineData = await parseJsonSafe(timelineRes);
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения")); if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы")); if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета"));
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю")); if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
renderMessages(messagesData); renderMessages(messagesData);
renderFiles(filesData); renderFiles(filesData);
renderInvoices(invoicesData);
renderTimeline(timelineData); renderTimeline(timelineData);
} }
@ -1366,6 +1523,7 @@
setCabinetEnabled(false); setCabinetEnabled(false);
clearList(cabinetMessages, "Сообщений пока нет."); clearList(cabinetMessages, "Сообщений пока нет.");
clearList(cabinetFiles, "Файлы пока не загружены."); clearList(cabinetFiles, "Файлы пока не загружены.");
clearList(cabinetInvoices, "Счета пока не выставлены.");
clearList(cabinetTimeline, "История пока пуста."); clearList(cabinetTimeline, "История пока пуста.");
})(); })();
</script> </script>

View file

@ -41,14 +41,17 @@ def auto_assign_unclaimed():
lawyer_load: dict[str, int] = {str(lawyer_id): int(count) for lawyer_id, count in active_load_rows if lawyer_id} lawyer_load: dict[str, int] = {str(lawyer_id): int(count) for lawyer_id, count in active_load_rows if lawyer_id}
active_lawyers = ( active_lawyers = (
db.query(AdminUser.id, AdminUser.primary_topic_code) db.query(AdminUser.id, AdminUser.primary_topic_code, AdminUser.default_rate)
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True)) .filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
.all() .all()
) )
active_lawyer_ids = {str(lawyer_id) for lawyer_id, _ in active_lawyers if lawyer_id} active_lawyer_ids = {str(lawyer_id) for lawyer_id, _, _ in active_lawyers if lawyer_id}
lawyer_default_rate: dict[str, float | None] = {
str(lawyer_id): default_rate for lawyer_id, _, default_rate in active_lawyers if lawyer_id
}
primary_by_topic: dict[str, list[str]] = {} primary_by_topic: dict[str, list[str]] = {}
for lawyer_id, primary_topic_code in active_lawyers: for lawyer_id, primary_topic_code, _ in active_lawyers:
topic_code = str(primary_topic_code or "").strip() topic_code = str(primary_topic_code or "").strip()
if not topic_code: if not topic_code:
continue continue
@ -96,6 +99,8 @@ def auto_assign_unclaimed():
continue continue
selected = min(candidates, key=lambda lawyer_id: (lawyer_load.get(lawyer_id, 0), lawyer_id)) selected = min(candidates, key=lambda lawyer_id: (lawyer_load.get(lawyer_id, 0), lawyer_id))
req.assigned_lawyer_id = selected req.assigned_lawyer_id = selected
if req.effective_rate is None:
req.effective_rate = lawyer_default_rate.get(selected)
req.updated_at = now req.updated_at = now
req.responsible = "Администратор системы" req.responsible = "Администратор системы"
lawyer_load[selected] = lawyer_load.get(selected, 0) + 1 lawyer_load[selected] = lawyer_load.get(selected, 0) + 1

Binary file not shown.

View file

@ -59,6 +59,21 @@
- invoice is attached/sent to client through platform notification channel - invoice is attached/sent to client through platform notification channel
- billing status can be included in topic-specific flow as regular transition node - billing status can be included in topic-specific flow as regular transition node
### Implemented Billing Status Flow (`P25`)
- `statuses.kind` supports:
- `DEFAULT` (regular status)
- `INVOICE` (billing step: выставление счета)
- `PAID` (business payment fact status)
- `statuses.invoice_template` stores admin-managed invoice template with placeholders (`{track_number}`, `{client_name}`, `{topic_code}`, `{amount}` and others).
- On request status transition to `INVOICE` kind:
- system auto-creates waiting invoice (`WAITING_PAYMENT`) from template
- invoice is linked to request and available in ADMIN/LAWYER + public cabinet
- On request status transition to `PAID` kind:
- only ADMIN can perform this transition
- latest waiting invoice is marked as paid
- request payment fields are fixed (`invoice_amount`, `paid_at`, `paid_by_admin_id`)
- Multiple billing cycles in the same request are supported (sequential invoice->paid events).
### Implemented SLA Transition Config (`P18`) ### Implemented SLA Transition Config (`P18`)
- SLA configuration is stored in `topic_status_transitions.sla_hours` - SLA configuration is stored in `topic_status_transitions.sla_hours`
- `sla_hours` is optional but if set must be integer > 0 - `sla_hours` is optional but if set must be integer > 0
@ -113,6 +128,12 @@
- Payment event stores who changed status and when (for salary/month reports) - Payment event stores who changed status and when (for salary/month reports)
- A request may contain more than one payment event (multiple invoice-payment cycles) - A request may contain more than one payment event (multiple invoice-payment cycles)
### Implemented Rate Rules (`P24`)
- On first assignment (`claim`, `reassign`, `auto-assign`, create/update request with assigned lawyer), `requests.effective_rate` is auto-filled from `admin_users.default_rate` if request rate is empty.
- If request already has `effective_rate`, assignment/reassignment does not overwrite it.
- LAWYER role cannot create/update request financial fields (`effective_rate`, `invoice_amount`, `paid_at`, `paid_by_admin_id`).
- Public client API does not expose internal request financial fields.
### Implemented Baseline For Dashboard (`P21`) ### Implemented Baseline For Dashboard (`P21`)
- Financial profile fields are persisted: - Financial profile fields are persisted:
- `admin_users.default_rate` - `admin_users.default_rate`
@ -148,3 +169,10 @@
- Salary calculation base: - Salary calculation base:
- paid event = ADMIN changes request status to "Оплачено" - paid event = ADMIN changes request status to "Оплачено"
- salary = paid request amount * lawyer salary percent - salary = paid request amount * lawyer salary percent
## Implemented File Security Audit (`P26`)
- Added dedicated immutable security log table: `security_audit_log`.
- File operations in admin/public upload APIs now produce security events:
- `UPLOAD_INIT`, `UPLOAD_COMPLETE`, `DOWNLOAD_OBJECT`.
- Both successful and denied attempts are logged (including RBAC denials on download).
- `security_audit_log` is exposed in admin dictionaries as read-only (query/read only, no update/delete via universal CRUD).

View file

@ -3,7 +3,7 @@
## Public ## Public
- OTP verification required for request creation and request access - OTP verification required for request creation and request access
- JWT in httpOnly cookie (7 days) - JWT in httpOnly cookie (7 days)
- Rate limiting - Rate limiting by IP + phone + track number (OTP send/verify)
- Protection from brute force - Protection from brute force
## Admin ## Admin
@ -14,8 +14,9 @@
## Data Protection ## Data Protection
- Messages and attachments from previous statuses are immutable after status change - Messages and attachments from previous statuses are immutable after status change
- All actions logged - All actions logged
- HTTP hardening headers and request correlation (`X-Request-ID`) are added at middleware level
## S3 & Personal Data (planned hardening) ## S3 & Personal Data (baseline)
- Files in S3 are treated as personal data (PII/ПДн) - Files in S3 are treated as personal data (PII/ПДн)
- Security baseline for implementation: - Security baseline for implementation:
- Access model: - Access model:
@ -37,3 +38,14 @@
- Compliance posture: - Compliance posture:
- map controls to РФ requirements for personal data protection and internal cyber policies - map controls to РФ requirements for personal data protection and internal cyber policies
- formalize security checklist for release gates (threat review + access review + logging verification) - formalize security checklist for release gates (threat review + access review + logging verification)
## Implemented Security Audit (`P26`)
- Added dedicated table `security_audit_log` (migration `0014_security_audit_log`) with fields:
- actor role/subject/ip, action, scope, object key, request/attachment IDs, allow/deny result, reason, details.
- File operations now write security events:
- `UPLOAD_INIT`, `UPLOAD_COMPLETE`, `DOWNLOAD_OBJECT` for admin and public upload/download flows.
- Denied attempts are logged too (including RBAC denials and invalid object access).
- RBAC hardening:
- universal CRUD for `security_audit_log` is read-only for ADMIN (`query`, `read`), no update/delete to preserve immutability.
- Suspicious activity signal:
- repeated denied `DOWNLOAD_OBJECT` events per subject/IP in short window emit server warning log.

View file

@ -40,19 +40,19 @@
| P19 | сделано | SLA-check и overdue | Реализовать `sla_check`: контроль просрочек по переходам, расчет FRT/времени в статусе | Метрики и флаги просрочек обновляются по расписанию | | P19 | сделано | SLA-check и overdue | Реализовать `sla_check`: контроль просрочек по переходам, расчет FRT/времени в статусе | Метрики и флаги просрочек обновляются по расписанию |
| P20 | сделано | Уведомления | Уведомления в Telegram (если подключен) + внутренние уведомления сайта по изменениям | При событиях (сообщения/файлы/статусы/SLA) уведомления доставляются | | P20 | сделано | Уведомления | Уведомления в Telegram (если подключен) + внутренние уведомления сайта по изменениям | При событиях (сообщения/файлы/статусы/SLA) уведомления доставляются |
| P21 | сделано | Dashboard LAWYER/ADMIN | Расширить дашборды: назначенные/неназначенные, активные по статусам, непрочитанные, SLA, по каждому юристу: активная загрузка, сумма активных заявок, вал оплаченных за месяц, зарплата за месяц | Дашборды соответствуют ролям и данным из БД | | P21 | сделано | Dashboard LAWYER/ADMIN | Расширить дашборды: назначенные/неназначенные, активные по статусам, непрочитанные, SLA, по каждому юристу: активная загрузка, сумма активных заявок, вал оплаченных за месяц, зарплата за месяц | Дашборды соответствуют ролям и данным из БД |
| P22 | к разработке | Тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers | Набор автотестов фиксирует регрессии критичных сценариев | | P22 | сделано | Hardening/release | Полировка безопасности, логирования, лимитов, отказоустойчивости, документации API/UI и runbook | Проект готов к стабилизации и приемке |
| P23 | к разработке | Hardening/release | Полировка безопасности, логирования, лимитов, отказоустойчивости, документации API/UI и runbook | Проект готов к стабилизации и приемке | | P23 | сделано | Mobile UX | Мобильная адаптация лендинга и клиентских форм (заявка, OTP, кабинет клиента: чат, файлы, история) | UI корректно работает на 320-768px, элементы доступны и читаемы без горизонтального скролла |
| P24 | к разработке | Mobile UX | Мобильная адаптация лендинга и клиентских форм (заявка, OTP, кабинет клиента: чат, файлы, история) | UI корректно работает на 320-768px, элементы доступны и читаемы без горизонтального скролла | | P24 | сделано | Тарифы юристов | Добавить ставку и процент юриста (по умолчанию в профиле), а также фиксируемые в заявке поля ставки/суммы (override админом) | Финансовые поля заявки фиксируются и не зависят от последующих правок профиля; клиенту не показываются |
| P25 | к разработке | Тарифы юристов | Добавить ставку и процент юриста (по умолчанию в профиле), а также фиксируемые в заявке поля ставки/суммы (override админом) | Финансовые поля заявки фиксируются и не зависят от последующих правок профиля; клиенту не показываются | | P25 | сделано | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) |
| P26 | к разработке | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) | | P26 | сделано | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов |
| P27 | к разработке | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов | | P27 | сделано | Итоговое тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers и выполнить финальный регрессионный прогон | Набор автотестов фиксирует регрессии критичных сценариев и подтверждает готовность перед приемкой |
## Критический маршрут (обязательный порядок) ## Критический маршрут (обязательный порядок)
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения). 1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
2. `P11 -> P12 -> P13` (публичный клиентский контур). 2. `P11 -> P12 -> P13` (публичный клиентский контур).
3. `P14 -> P15 -> P16` (процесс работы по заявке). 3. `P14 -> P15 -> P16` (процесс работы по заявке).
4. `P17 -> P18 -> P25 -> P26 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика). 4. `P17 -> P18 -> P24 -> P25 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика).
5. `P22 -> P23 -> P24 -> P27` (стабилизация, mobile UX, security-аудит). 5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце).
## Правила выполнения для ИИ-агента ## Правила выполнения для ИИ-агента
1. Не менять бизнес-правила без обновления `context/*.md`. 1. Не менять бизнес-правила без обновления `context/*.md`.

View file

@ -1,7 +1,7 @@
# Runbook Проверок (Тесты и Валидация по Плану) # Runbook Проверок (Тесты и Валидация по Плану)
## Назначение ## Назначение
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P23` и как их запускать. Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P27` и как их запускать.
Использовать перед переводом пункта в статус `сделано`. Использовать перед переводом пункта в статус `сделано`.
## Базовые команды ## Базовые команды
@ -36,7 +36,7 @@ docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cac
| 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` |
| P10 | Auto-assign v2 приоритетов | `tests/test_auto_assign.py` | команда как для `P05` | | P10 | Auto-assign v2 приоритетов | `tests/test_auto_assign.py` | команда как для `P05` |
| P11 | OTP create/view + 7-day cookie | `tests/test_public_requests.py` | `docker compose exec -T backend python -m unittest tests.test_public_requests -v` | | P11 | OTP create/view + 7-day cookie + rate-limit | `tests/test_public_requests.py`, `tests/test_otp_rate_limit.py` | `docker compose exec -T backend python -m unittest tests.test_public_requests tests.test_otp_rate_limit -v` |
| P12 | Публичный кабинет (статус/чат/файлы/таймлайн) | `tests/test_public_cabinet.py` | `docker compose exec -T backend python -m unittest tests.test_public_cabinet -v` | | P12 | Публичный кабинет (статус/чат/файлы/таймлайн) | `tests/test_public_cabinet.py` | `docker compose exec -T backend python -m unittest tests.test_public_cabinet -v` |
| P13 | Read/unread маркеры | `tests/test_public_requests.py`, `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py` | запустить 3 набора: `test_public_requests`, `test_admin_universal_crud`, `test_uploads_s3` | | P13 | Read/unread маркеры | `tests/test_public_requests.py`, `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py` | запустить 3 набора: `test_public_requests`, `test_admin_universal_crud`, `test_uploads_s3` |
| P14 | Валидация флоу статусов по темам | `tests/test_admin_universal_crud.py` (status-flow тесты) | команда как для `P03` | | P14 | Валидация флоу статусов по темам | `tests/test_admin_universal_crud.py` (status-flow тесты) | команда как для `P03` |
@ -47,12 +47,12 @@ docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cac
| P19 | SLA overdue/FRT расчеты | `tests/test_worker_maintenance.py`, `tests/test_admin_universal_crud.py` (metrics) | `docker compose exec -T backend python -m unittest tests.test_worker_maintenance tests.test_admin_universal_crud -v`; проверить `overdue_by_transition` | | P19 | SLA overdue/FRT расчеты | `tests/test_worker_maintenance.py`, `tests/test_admin_universal_crud.py` (metrics) | `docker compose exec -T backend python -m unittest tests.test_worker_maintenance tests.test_admin_universal_crud -v`; проверить `overdue_by_transition` |
| 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 | E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон | | 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 | Hardening/release | весь regression + compile + миграции + UI build | базовые команды 1-4 | | P23 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать `admin.jsx` при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла |
| P24 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать `admin.jsx` при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла | | P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_rates tests.test_admin_universal_crud -v`; проверка что public API не отдает поля ставок/процентов |
| P25 | Ставки юриста и ставка заявки | новые тесты `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | прогон `test_rates` + `test_admin_universal_crud`; проверка что public API не отдает поля ставок/процентов | | P25 | Billing-статус и шаблон счета | `tests/test_billing_flow.py`, `tests/test_invoices.py` + e2e статусных переходов | `docker compose exec -T backend python -m unittest tests.test_billing_flow tests.test_invoices tests.test_admin_universal_crud -v`; валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) |
| P26 | Billing-статус и шаблон счета | новые тесты `tests/test_billing_flow.py` + e2e статусных переходов | прогон `test_billing_flow` + `test_admin_universal_crud`; валидация генерации счета и фиксации оплаты при 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` |
| P27 | Security audit S3/ПДн | новые тесты `tests/test_security_audit.py` + `tests/test_uploads_s3.py` | прогон `test_security_audit` + `test_uploads_s3`; проверка логирования и ограничений доступа | | P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон |
## Минимальный чеклист закрытия пункта ## Минимальный чеклист закрытия пункта
1. Выполнить миграции (если были изменения схемы). 1. Выполнить миграции (если были изменения схемы).
@ -61,3 +61,6 @@ docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cac
4. Выполнить `compileall`. 4. Выполнить `compileall`.
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose. 5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose.
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`. 6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
## Последний регрессионный прогон
- `python -m unittest discover -s tests -p 'test_*.py' -v``91 tests OK`.

View file

@ -166,6 +166,41 @@ class AdminUniversalCrudTests(unittest.TestCase):
actions = [row.action for row in db.query(AuditLog).filter(AuditLog.entity == "quotes", AuditLog.entity_id == quote_id).all()] actions = [row.action for row in db.query(AuditLog).filter(AuditLog.entity == "quotes", AuditLog.entity_id == quote_id).all()]
self.assertEqual(set(actions), {"CREATE", "UPDATE", "DELETE"}) self.assertEqual(set(actions), {"CREATE", "UPDATE", "DELETE"})
def test_admin_table_catalog_lists_db_tables_for_dynamic_references(self):
admin_headers = self._auth_headers("ADMIN")
response = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
self.assertEqual(response.status_code, 200)
payload = response.json()
tables = payload.get("tables") or []
self.assertTrue(tables)
by_table = {row["table"]: row for row in tables}
self.assertIn("requests", by_table)
self.assertIn("invoices", by_table)
self.assertIn("quotes", by_table)
self.assertIn("statuses", by_table)
self.assertEqual(by_table["requests"]["section"], "main")
self.assertEqual(by_table["invoices"]["section"], "main")
self.assertEqual(by_table["quotes"]["section"], "dictionary")
self.assertTrue(by_table["quotes"]["default_sort"])
self.assertEqual(by_table["quotes"]["label"], "Цитаты")
self.assertEqual(by_table["request_data_requirements"]["label"], "Требования данных заявки")
quotes_columns = {col["name"]: col for col in (by_table["quotes"].get("columns") or [])}
self.assertEqual(quotes_columns["author"]["label"], "Автор")
self.assertEqual(quotes_columns["sort_order"]["label"], "Порядок")
self.assertTrue(all(str(col.get("label") or "").strip() for col in (by_table["quotes"].get("columns") or [])))
for table_name, table_meta in by_table.items():
expected_section = "main" if table_name in {"requests", "invoices"} else "dictionary"
self.assertEqual(table_meta.get("section"), expected_section)
admin_users_cols = {col["name"] for col in (by_table["admin_users"].get("columns") or [])}
self.assertNotIn("password_hash", admin_users_cols)
lawyer_headers = self._auth_headers("LAWYER")
forbidden = self.client.get("/api/admin/crud/meta/tables", headers=lawyer_headers)
self.assertEqual(forbidden.status_code, 403)
def test_lawyer_permissions_and_request_crud(self): def test_lawyer_permissions_and_request_crud(self):
lawyer_headers = self._auth_headers("LAWYER") lawyer_headers = self._auth_headers("LAWYER")

356
tests/test_billing_flow.py Normal file
View file

@ -0,0 +1,356 @@
import os
import unittest
from datetime import timedelta
from uuid import UUID, uuid4
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.core.security import create_jwt
from app.db.session import get_db
from app.main import app
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
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.status import Status
from app.models.status_history import StatusHistory
from app.services.invoice_crypto import decrypt_requisites
class BillingFlowTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
Status.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine)
StatusHistory.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
Invoice.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
Invoice.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine)
StatusHistory.__table__.drop(bind=cls.engine)
Attachment.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
Status.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(Invoice))
db.execute(delete(Notification))
db.execute(delete(StatusHistory))
db.execute(delete(Attachment))
db.execute(delete(Message))
db.execute(delete(Request))
db.execute(delete(Status))
db.execute(delete(AdminUser))
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
@staticmethod
def _auth_headers(role: str, email: str, sub: str | None = None) -> dict[str, str]:
token = create_jwt(
{"sub": str(sub or uuid4()), "email": email, "role": role},
settings.ADMIN_JWT_SECRET,
timedelta(minutes=30),
)
return {"Authorization": f"Bearer {token}"}
def _seed_statuses(self):
with self.SessionLocal() as db:
db.add_all(
[
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False, kind="DEFAULT"),
Status(
code="BILLING",
name="Выставление счета",
enabled=True,
sort_order=1,
is_terminal=False,
kind="INVOICE",
invoice_template="Счет по заявке {track_number}; клиент {client_name}; сумма {amount}",
),
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=2, is_terminal=False, kind="DEFAULT"),
Status(code="PAID", name="Оплачено", enabled=True, sort_order=3, is_terminal=False, kind="PAID"),
]
)
db.commit()
def test_entering_billing_status_creates_waiting_invoice_from_template(self):
self._seed_statuses()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-BILL-1",
client_name="ООО Клиент",
client_phone="+79990000021",
status_code="NEW",
topic_code=None,
description="billing",
extra_fields={},
effective_rate=4300,
)
db.add(req)
db.commit()
request_id = str(req.id)
admin_headers = self._auth_headers("ADMIN", "root@example.com")
changed = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={"status_code": "BILLING"},
)
self.assertEqual(changed.status_code, 200)
with self.SessionLocal() as db:
req = db.get(Request, UUID(request_id))
self.assertIsNotNone(req)
self.assertEqual(req.status_code, "BILLING")
self.assertAlmostEqual(float(req.invoice_amount or 0), 4300.0, places=2)
rows = db.query(Invoice).filter(Invoice.request_id == req.id).all()
self.assertEqual(len(rows), 1)
invoice = rows[0]
self.assertEqual(invoice.status, "WAITING_PAYMENT")
self.assertEqual(invoice.payer_display_name, "ООО Клиент")
self.assertAlmostEqual(float(invoice.amount or 0), 4300.0, places=2)
details = decrypt_requisites(invoice.payer_details_encrypted)
rendered = str((details or {}).get("template_rendered") or "")
self.assertIn("TRK-BILL-1", rendered)
self.assertIn("ООО Клиент", rendered)
def test_paid_status_requires_admin_and_marks_waiting_invoice_paid(self):
self._seed_statuses()
with self.SessionLocal() as db:
lawyer = AdminUser(
role="LAWYER",
name="Юрист",
email="lawyer-paid@example.com",
password_hash="hash",
is_active=True,
)
req = Request(
track_number="TRK-BILL-2",
client_name="Клиент",
client_phone="+79990000022",
status_code="BILLING",
topic_code=None,
description="billing",
extra_fields={},
)
db.add_all([lawyer, req])
db.flush()
invoice = Invoice(
request_id=req.id,
invoice_number="INV-MANUAL-1",
status="WAITING_PAYMENT",
amount=7500,
currency="RUB",
payer_display_name=req.client_name,
payer_details_encrypted=None,
issued_by_admin_user_id=None,
issued_by_role="ADMIN",
issued_at=req.created_at,
paid_at=None,
responsible="root@example.com",
)
db.add(invoice)
db.commit()
request_id = str(req.id)
lawyer_id = str(lawyer.id)
invoice_id = str(invoice.id)
lawyer_headers = self._auth_headers("LAWYER", "lawyer-paid@example.com", sub=lawyer_id)
blocked = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=lawyer_headers,
json={"status_code": "PAID"},
)
self.assertEqual(blocked.status_code, 403)
admin_headers = self._auth_headers("ADMIN", "root@example.com")
paid = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={"status_code": "PAID"},
)
self.assertEqual(paid.status_code, 200)
with self.SessionLocal() as db:
req = db.get(Request, UUID(request_id))
inv = db.get(Invoice, UUID(invoice_id))
self.assertIsNotNone(req)
self.assertIsNotNone(inv)
self.assertEqual(inv.status, "PAID")
self.assertIsNotNone(inv.paid_at)
self.assertEqual(req.status_code, "PAID")
self.assertIsNotNone(req.paid_at)
self.assertEqual(str(req.paid_at), str(inv.paid_at))
self.assertIsNotNone(req.paid_by_admin_id)
self.assertAlmostEqual(float(req.invoice_amount or 0), 7500.0, places=2)
def test_paid_status_without_waiting_invoice_returns_400(self):
self._seed_statuses()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-BILL-3",
client_name="Клиент",
client_phone="+79990000023",
status_code="IN_PROGRESS",
topic_code=None,
description="billing",
extra_fields={},
)
db.add(req)
db.commit()
request_id = str(req.id)
admin_headers = self._auth_headers("ADMIN", "root@example.com")
blocked = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={"status_code": "PAID"},
)
self.assertEqual(blocked.status_code, 400)
self.assertIn("Ожидает оплату", blocked.json().get("detail", ""))
def test_multiple_billing_cycles_are_supported(self):
self._seed_statuses()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-BILL-4",
client_name="Клиент",
client_phone="+79990000024",
status_code="NEW",
topic_code=None,
description="billing",
extra_fields={},
effective_rate=1000,
)
db.add(req)
db.commit()
request_id = str(req.id)
admin_headers = self._auth_headers("ADMIN", "root@example.com")
first_billing = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={"status_code": "BILLING"},
)
self.assertEqual(first_billing.status_code, 200)
with self.SessionLocal() as db:
req = db.get(Request, UUID(request_id))
first_invoice = (
db.query(Invoice)
.filter(Invoice.request_id == req.id)
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
.first()
)
self.assertIsNotNone(first_invoice)
first_invoice_id = str(first_invoice.id)
tune_first_amount = self.client.patch(
f"/api/admin/invoices/{first_invoice_id}",
headers=admin_headers,
json={"amount": 1100},
)
self.assertEqual(tune_first_amount.status_code, 200)
first_paid = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={"status_code": "PAID"},
)
self.assertEqual(first_paid.status_code, 200)
back_to_work = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={"status_code": "IN_PROGRESS"},
)
self.assertEqual(back_to_work.status_code, 200)
set_second_amount = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={"invoice_amount": 2500},
)
self.assertEqual(set_second_amount.status_code, 200)
second_billing = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={"status_code": "BILLING"},
)
self.assertEqual(second_billing.status_code, 200)
second_paid = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=admin_headers,
json={"status_code": "PAID"},
)
self.assertEqual(second_paid.status_code, 200)
with self.SessionLocal() as db:
req = db.get(Request, UUID(request_id))
self.assertIsNotNone(req)
invoices = (
db.query(Invoice)
.filter(Invoice.request_id == req.id)
.order_by(Invoice.issued_at.asc(), Invoice.created_at.asc(), Invoice.id.asc())
.all()
)
self.assertEqual(len(invoices), 2)
self.assertEqual(invoices[0].status, "PAID")
self.assertEqual(invoices[1].status, "PAID")
self.assertIsNotNone(invoices[0].paid_at)
self.assertIsNotNone(invoices[1].paid_at)
self.assertAlmostEqual(float(invoices[0].amount or 0), 1100.0, places=2)
self.assertAlmostEqual(float(invoices[1].amount or 0), 2500.0, places=2)
self.assertAlmostEqual(float(req.invoice_amount or 0), 2500.0, places=2)
self.assertEqual(str(req.paid_at), str(invoices[1].paid_at))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,59 @@
import os
import unittest
from fastapi.testclient import TestClient
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.main import app
class HttpHardeningTests(unittest.TestCase):
def setUp(self):
self.client = TestClient(app)
def tearDown(self):
self.client.close()
def test_health_has_security_headers_and_request_id(self):
response = self.client.get("/health")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers.get("x-content-type-options"), "nosniff")
self.assertEqual(response.headers.get("x-frame-options"), "DENY")
self.assertEqual(response.headers.get("referrer-policy"), "no-referrer")
self.assertEqual(response.headers.get("x-permitted-cross-domain-policies"), "none")
self.assertEqual(response.headers.get("cross-origin-opener-policy"), "same-origin")
request_id = response.headers.get("x-request-id")
self.assertIsNotNone(request_id)
self.assertRegex(str(request_id), r"^[A-Za-z0-9._-]{1,128}$")
def test_valid_request_id_is_preserved(self):
external_request_id = "release-check-2026_02_23"
response = self.client.get("/health", headers={"X-Request-ID": external_request_id})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers.get("x-request-id"), external_request_id)
def test_invalid_request_id_is_replaced(self):
bad_request_id = "bad id with spaces"
response = self.client.get("/health", headers={"X-Request-ID": bad_request_id})
self.assertEqual(response.status_code, 200)
response_request_id = response.headers.get("x-request-id")
self.assertIsNotNone(response_request_id)
self.assertNotEqual(response_request_id, bad_request_id)
self.assertRegex(str(response_request_id), r"^[A-Za-z0-9._-]{1,128}$")
def test_error_response_keeps_security_headers_and_request_id(self):
# No public cookie => 401 from dependency, middleware headers must still be present.
response = self.client.get("/api/public/requests/TRK-UNKNOWN")
self.assertEqual(response.status_code, 401)
self.assertEqual(response.headers.get("x-content-type-options"), "nosniff")
self.assertEqual(response.headers.get("x-frame-options"), "DENY")
self.assertTrue(bool(response.headers.get("x-request-id")))

295
tests/test_invoices.py Normal file
View file

@ -0,0 +1,295 @@
import os
import unittest
from datetime import timedelta
from uuid import UUID
from uuid import uuid4
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.core.security import create_jwt
from app.db.session import get_db
from app.main import app
from app.models.admin_user import AdminUser
from app.models.invoice import Invoice
from app.models.request import Request
from app.services.invoice_crypto import decrypt_requisites
class InvoiceApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Invoice.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
Invoice.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(Invoice))
db.execute(delete(Request))
db.execute(delete(AdminUser))
db.commit()
self.admin = AdminUser(
role="ADMIN",
name="Админ",
email="admin@example.com",
password_hash="hash",
is_active=True,
)
self.lawyer_a = AdminUser(
role="LAWYER",
name="Юрист А",
email="lawyer-a@example.com",
password_hash="hash",
is_active=True,
)
self.lawyer_b = AdminUser(
role="LAWYER",
name="Юрист Б",
email="lawyer-b@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([self.admin, self.lawyer_a, self.lawyer_b])
db.flush()
self.request_a = Request(
track_number="TRK-INV-A",
client_name="Клиент А",
client_phone="+79991110000",
topic_code="consulting",
status_code="NEW",
description="Заявка А",
extra_fields={},
assigned_lawyer_id=str(self.lawyer_a.id),
)
self.request_b = Request(
track_number="TRK-INV-B",
client_name="Клиент Б",
client_phone="+79992220000",
topic_code="consulting",
status_code="NEW",
description="Заявка Б",
extra_fields={},
assigned_lawyer_id=str(self.lawyer_b.id),
)
db.add_all([self.request_a, self.request_b])
db.commit()
self.admin_id = str(self.admin.id)
self.lawyer_a_id = str(self.lawyer_a.id)
self.lawyer_b_id = str(self.lawyer_b.id)
self.request_a_id = str(self.request_a.id)
self.request_b_id = str(self.request_b.id)
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
@staticmethod
def _admin_headers(sub: str, role: str, email: str) -> dict[str, str]:
token = create_jwt(
{"sub": str(sub), "email": email, "role": role},
settings.ADMIN_JWT_SECRET,
timedelta(minutes=30),
)
return {"Authorization": f"Bearer {token}"}
@staticmethod
def _public_cookie(track_number: str) -> dict[str, str]:
token = create_jwt(
{"sub": track_number, "purpose": "VIEW_REQUEST"},
settings.PUBLIC_JWT_SECRET,
timedelta(days=1),
)
return {settings.PUBLIC_COOKIE_NAME: token}
def test_admin_creates_invoice_and_data_is_encrypted(self):
headers = self._admin_headers(self.admin_id, "ADMIN", "admin@example.com")
payload = {
"request_id": self.request_a_id,
"amount": 12345.67,
"currency": "RUB",
"payer_display_name": 'ООО "Ромашка"',
"payer_details": {"inn": "7700000000", "kpp": "770001001"},
}
created = self.client.post("/api/admin/invoices", headers=headers, json=payload)
self.assertEqual(created.status_code, 201)
body = created.json()
self.assertEqual(body["request_id"], self.request_a_id)
self.assertEqual(body["request_track_number"], "TRK-INV-A")
self.assertEqual(body["status"], "WAITING_PAYMENT")
self.assertEqual(body["amount"], 12345.67)
self.assertTrue(str(body["invoice_number"]).startswith("INV-"))
invoice_id = body["id"]
with self.SessionLocal() as db:
row = db.get(Invoice, UUID(invoice_id))
self.assertIsNotNone(row)
self.assertIsNotNone(row.payer_details_encrypted)
self.assertNotIn("7700000000", str(row.payer_details_encrypted))
decrypted = decrypt_requisites(row.payer_details_encrypted)
self.assertEqual(decrypted["inn"], "7700000000")
self.assertEqual(decrypted["kpp"], "770001001")
def test_lawyer_scope_and_paid_restriction(self):
admin_headers = self._admin_headers(self.admin_id, "ADMIN", "admin@example.com")
lawyer_a_headers = self._admin_headers(self.lawyer_a_id, "LAWYER", "lawyer-a@example.com")
own_created = self.client.post(
"/api/admin/invoices",
headers=lawyer_a_headers,
json={
"request_id": self.request_a_id,
"amount": 5000,
"payer_display_name": "ИП Иванов",
},
)
self.assertEqual(own_created.status_code, 201)
own_invoice_id = own_created.json()["id"]
blocked_paid_create = self.client.post(
"/api/admin/invoices",
headers=lawyer_a_headers,
json={
"request_id": self.request_a_id,
"amount": 6000,
"status": "PAID",
"payer_display_name": "ИП Иванов",
},
)
self.assertEqual(blocked_paid_create.status_code, 403)
blocked_paid_update = self.client.patch(
f"/api/admin/invoices/{own_invoice_id}",
headers=lawyer_a_headers,
json={"status": "PAID"},
)
self.assertEqual(blocked_paid_update.status_code, 403)
foreign_created = self.client.post(
"/api/admin/invoices",
headers=admin_headers,
json={"request_id": self.request_b_id, "amount": 7000, "payer_display_name": "ООО Бета"},
)
self.assertEqual(foreign_created.status_code, 201)
foreign_invoice_id = foreign_created.json()["id"]
listed = self.client.post(
"/api/admin/invoices/query",
headers=lawyer_a_headers,
json={"filters": [], "sort": [{"field": "created_at", "dir": "desc"}], "page": {"limit": 50, "offset": 0}},
)
self.assertEqual(listed.status_code, 200)
rows = listed.json()["rows"]
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["id"], own_invoice_id)
foreign_get = self.client.get(f"/api/admin/invoices/{foreign_invoice_id}", headers=lawyer_a_headers)
self.assertEqual(foreign_get.status_code, 403)
foreign_pdf = self.client.get(f"/api/admin/invoices/{foreign_invoice_id}/pdf", headers=lawyer_a_headers)
self.assertEqual(foreign_pdf.status_code, 403)
def test_admin_marks_invoice_paid_and_request_is_updated(self):
headers = self._admin_headers(self.admin_id, "ADMIN", "admin@example.com")
created = self.client.post(
"/api/admin/invoices",
headers=headers,
json={"request_id": self.request_a_id, "amount": 10000, "payer_display_name": "ООО Плательщик"},
)
self.assertEqual(created.status_code, 201)
invoice_id = created.json()["id"]
paid = self.client.patch(
f"/api/admin/invoices/{invoice_id}",
headers=headers,
json={"status": "PAID"},
)
self.assertEqual(paid.status_code, 200)
paid_body = paid.json()
self.assertEqual(paid_body["status"], "PAID")
self.assertIsNotNone(paid_body["paid_at"])
with self.SessionLocal() as db:
req = db.get(Request, UUID(self.request_a_id))
self.assertIsNotNone(req)
self.assertEqual(float(req.invoice_amount or 0), 10000.0)
self.assertIsNotNone(req.paid_at)
self.assertEqual(req.paid_by_admin_id, self.admin_id)
def test_public_invoice_list_and_pdf_available_in_cabinet(self):
with self.SessionLocal() as db:
row = Invoice(
request_id=UUID(self.request_a_id),
invoice_number=f"INV-TEST-{uuid4().hex[:6].upper()}",
status="WAITING_PAYMENT",
amount=9900,
currency="RUB",
payer_display_name="ООО Клиент",
payer_details_encrypted="",
issued_by_admin_user_id=UUID(self.admin_id),
issued_by_role="ADMIN",
issued_at=db.get(Request, UUID(self.request_a_id)).created_at,
responsible="admin@example.com",
)
db.add(row)
db.commit()
db.refresh(row)
invoice_id = str(row.id)
unauthorized = self.client.get("/api/public/requests/TRK-INV-A/invoices")
self.assertEqual(unauthorized.status_code, 401)
cookies = self._public_cookie("TRK-INV-A")
listed = self.client.get("/api/public/requests/TRK-INV-A/invoices", cookies=cookies)
self.assertEqual(listed.status_code, 200)
rows = listed.json()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["id"], invoice_id)
self.assertIn("/api/public/requests/TRK-INV-A/invoices/", rows[0]["download_url"])
pdf = self.client.get(f"/api/public/requests/TRK-INV-A/invoices/{invoice_id}/pdf", cookies=cookies)
self.assertEqual(pdf.status_code, 200)
self.assertEqual(pdf.headers.get("content-type"), "application/pdf")
self.assertTrue(pdf.content.startswith(b"%PDF"))
denied = self.client.get(
f"/api/public/requests/TRK-INV-A/invoices/{invoice_id}/pdf",
cookies=self._public_cookie("TRK-INV-B"),
)
self.assertEqual(denied.status_code, 403)

View file

@ -96,6 +96,8 @@ class MigrationTests(unittest.TestCase):
"admin_user_topics", "admin_user_topics",
"topic_status_transitions", "topic_status_transitions",
"notifications", "notifications",
"invoices",
"security_audit_log",
"alembic_version", "alembic_version",
} }
tables = set(self.inspector.get_table_names()) tables = set(self.inspector.get_table_names())
@ -104,7 +106,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, "0011_dashboard_financial_fields") self.assertEqual(version, "0014_security_audit_log")
def test_responsible_column_exists_in_all_domain_tables(self): def test_responsible_column_exists_in_all_domain_tables(self):
tables = { tables = {
@ -125,6 +127,8 @@ class MigrationTests(unittest.TestCase):
"admin_user_topics", "admin_user_topics",
"topic_status_transitions", "topic_status_transitions",
"notifications", "notifications",
"invoices",
"security_audit_log",
} }
for table in tables: for table in tables:
columns = {column["name"] for column in self.inspector.get_columns(table)} columns = {column["name"] for column in self.inspector.get_columns(table)}
@ -171,3 +175,22 @@ class MigrationTests(unittest.TestCase):
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_invoices_contains_core_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("invoices")}
self.assertIn("request_id", columns)
self.assertIn("invoice_number", columns)
self.assertIn("status", columns)
self.assertIn("amount", columns)
self.assertIn("currency", columns)
self.assertIn("payer_display_name", columns)
self.assertIn("payer_details_encrypted", columns)
self.assertIn("issued_by_admin_user_id", columns)
self.assertIn("issued_by_role", columns)
self.assertIn("issued_at", columns)
self.assertIn("paid_at", columns)
def test_statuses_contains_billing_columns(self):
columns = {column["name"] for column in self.inspector.get_columns("statuses")}
self.assertIn("kind", columns)
self.assertIn("invoice_template", columns)

View file

@ -0,0 +1,141 @@
import os
import unittest
from unittest.mock import patch
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.db.session import get_db
from app.main import app
from app.models.otp_session import OtpSession
from app.models.request import Request
from app.services.rate_limit import InMemoryRateLimiter
class OtpRateLimitTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
Request.__table__.create(bind=cls.engine)
OtpSession.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
OtpSession.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(OtpSession))
db.execute(delete(Request))
db.commit()
db.add(
Request(
track_number="TRK-OTP-RATE",
client_name="Тест",
client_phone="+79995550000",
topic_code="consulting",
status_code="NEW",
description="otp rate",
extra_fields={},
)
)
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
self.limiter = InMemoryRateLimiter()
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
def test_send_is_limited_by_phone(self):
with (
patch("app.api.public.otp.get_rate_limiter", return_value=self.limiter),
patch("app.api.public.otp.settings.OTP_RATE_LIMIT_WINDOW_SECONDS", 60),
patch("app.api.public.otp.settings.OTP_SEND_RATE_LIMIT", 1),
patch("app.api.public.otp._generate_code", return_value="111111"),
):
first = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_phone": "+79991110000"},
)
self.assertEqual(first.status_code, 200)
second = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_phone": "+79991110000"},
)
self.assertEqual(second.status_code, 429)
self.assertIn("Слишком много OTP-запросов", second.json().get("detail", ""))
def test_send_is_limited_by_ip(self):
with (
patch("app.api.public.otp.get_rate_limiter", return_value=self.limiter),
patch("app.api.public.otp.settings.OTP_RATE_LIMIT_WINDOW_SECONDS", 60),
patch("app.api.public.otp.settings.OTP_SEND_RATE_LIMIT", 1),
patch("app.api.public.otp._generate_code", return_value="111111"),
):
first = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_phone": "+79991110001"},
)
self.assertEqual(first.status_code, 200)
# Same IP (testclient), other phone => blocked by IP bucket.
second = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_phone": "+79991110002"},
)
self.assertEqual(second.status_code, 429)
def test_verify_is_limited(self):
with (
patch("app.api.public.otp.get_rate_limiter", return_value=self.limiter),
patch("app.api.public.otp.settings.OTP_RATE_LIMIT_WINDOW_SECONDS", 60),
patch("app.api.public.otp.settings.OTP_SEND_RATE_LIMIT", 10),
patch("app.api.public.otp.settings.OTP_VERIFY_RATE_LIMIT", 1),
patch("app.api.public.otp._generate_code", return_value="222222"),
):
sent = self.client.post(
"/api/public/otp/send",
json={"purpose": "CREATE_REQUEST", "client_phone": "+79992220000"},
)
self.assertEqual(sent.status_code, 200)
wrong_first = self.client.post(
"/api/public/otp/verify",
json={"purpose": "CREATE_REQUEST", "client_phone": "+79992220000", "code": "000000"},
)
self.assertEqual(wrong_first.status_code, 400)
wrong_second = self.client.post(
"/api/public/otp/verify",
json={"purpose": "CREATE_REQUEST", "client_phone": "+79992220000", "code": "111111"},
)
self.assertEqual(wrong_second.status_code, 429)
self.assertIn("Слишком много OTP-запросов", wrong_second.json().get("detail", ""))

508
tests/test_rates.py Normal file
View file

@ -0,0 +1,508 @@
import os
import unittest
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.core.security import create_jwt
from app.db.session import get_db
from app.main import app
from app.models.admin_user import AdminUser
from app.models.admin_user_topic import AdminUserTopic
from app.models.audit_log import AuditLog
from app.models.notification import Notification
from app.models.request import Request
from app.models.status import Status
from app.models.topic_required_field import TopicRequiredField
from app.workers.tasks import assign as assign_task
class RequestRatesTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
AdminUserTopic.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Status.__table__.create(bind=cls.engine)
TopicRequiredField.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
AuditLog.__table__.create(bind=cls.engine)
cls._old_session_local = assign_task.SessionLocal
assign_task.SessionLocal = cls.SessionLocal
@classmethod
def tearDownClass(cls):
assign_task.SessionLocal = cls._old_session_local
AuditLog.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine)
TopicRequiredField.__table__.drop(bind=cls.engine)
Status.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
AdminUserTopic.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(AuditLog))
db.execute(delete(Notification))
db.execute(delete(TopicRequiredField))
db.execute(delete(Status))
db.execute(delete(Request))
db.execute(delete(AdminUserTopic))
db.execute(delete(AdminUser))
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
@staticmethod
def _auth_headers(role: str, email: str, sub: str | None = None) -> dict[str, str]:
token = create_jwt(
{"sub": str(sub or uuid4()), "email": email, "role": role},
settings.ADMIN_JWT_SECRET,
timedelta(minutes=30),
)
return {"Authorization": f"Bearer {token}"}
def test_claim_sets_effective_rate_from_lawyer_profile(self):
with self.SessionLocal() as db:
lawyer = AdminUser(
role="LAWYER",
name="Юрист",
email="lawyer-rate@example.com",
password_hash="hash",
is_active=True,
default_rate=5000,
)
req = Request(
track_number="TRK-RATE-CLAIM-1",
client_name="Клиент",
client_phone="+79990000001",
status_code="NEW",
description="claim",
extra_fields={},
)
db.add_all([lawyer, req])
db.commit()
lawyer_id = str(lawyer.id)
request_id = str(req.id)
headers = self._auth_headers("LAWYER", "lawyer-rate@example.com", sub=lawyer_id)
response = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=headers)
self.assertEqual(response.status_code, 200)
with self.SessionLocal() as db:
row = db.get(Request, UUID(request_id))
self.assertIsNotNone(row)
self.assertEqual(row.assigned_lawyer_id, lawyer_id)
self.assertAlmostEqual(float(row.effective_rate or 0), 5000.0, places=2)
def test_claim_keeps_existing_effective_rate(self):
with self.SessionLocal() as db:
lawyer = AdminUser(
role="LAWYER",
name="Юрист",
email="lawyer-fixed@example.com",
password_hash="hash",
is_active=True,
default_rate=5000,
)
req = Request(
track_number="TRK-RATE-CLAIM-2",
client_name="Клиент",
client_phone="+79990000002",
status_code="NEW",
description="claim fixed",
extra_fields={},
effective_rate=7777,
)
db.add_all([lawyer, req])
db.commit()
lawyer_id = str(lawyer.id)
request_id = str(req.id)
headers = self._auth_headers("LAWYER", "lawyer-fixed@example.com", sub=lawyer_id)
response = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=headers)
self.assertEqual(response.status_code, 200)
with self.SessionLocal() as db:
row = db.get(Request, UUID(request_id))
self.assertIsNotNone(row)
self.assertAlmostEqual(float(row.effective_rate or 0), 7777.0, places=2)
def test_reassign_sets_effective_rate_only_when_missing(self):
with self.SessionLocal() as db:
from_lawyer = AdminUser(
role="LAWYER",
name="From",
email="from-rate@example.com",
password_hash="hash",
is_active=True,
default_rate=1000,
)
to_lawyer = AdminUser(
role="LAWYER",
name="To",
email="to-rate@example.com",
password_hash="hash",
is_active=True,
default_rate=9000,
)
db.add_all([from_lawyer, to_lawyer])
db.flush()
fixed_req = Request(
track_number="TRK-RATE-REASSIGN-1",
client_name="Клиент",
client_phone="+79990000003",
status_code="NEW",
description="fixed",
extra_fields={},
assigned_lawyer_id=str(from_lawyer.id),
effective_rate=6500,
)
missing_req = Request(
track_number="TRK-RATE-REASSIGN-2",
client_name="Клиент",
client_phone="+79990000004",
status_code="NEW",
description="missing",
extra_fields={},
assigned_lawyer_id=str(from_lawyer.id),
effective_rate=None,
)
db.add_all([fixed_req, missing_req])
db.commit()
to_lawyer_id = str(to_lawyer.id)
fixed_id = str(fixed_req.id)
missing_id = str(missing_req.id)
admin_headers = self._auth_headers("ADMIN", "root@example.com")
fixed_reassign = self.client.post(
f"/api/admin/requests/{fixed_id}/reassign",
headers=admin_headers,
json={"lawyer_id": to_lawyer_id},
)
self.assertEqual(fixed_reassign.status_code, 200)
missing_reassign = self.client.post(
f"/api/admin/requests/{missing_id}/reassign",
headers=admin_headers,
json={"lawyer_id": to_lawyer_id},
)
self.assertEqual(missing_reassign.status_code, 200)
with self.SessionLocal() as db:
fixed = db.get(Request, UUID(fixed_id))
missing = db.get(Request, UUID(missing_id))
self.assertIsNotNone(fixed)
self.assertIsNotNone(missing)
self.assertAlmostEqual(float(fixed.effective_rate or 0), 6500.0, places=2)
self.assertAlmostEqual(float(missing.effective_rate or 0), 9000.0, places=2)
def test_auto_assign_sets_effective_rate_when_missing(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=99, is_terminal=True),
]
)
lawyer = AdminUser(
role="LAWYER",
name="Auto",
email="auto-rate@example.com",
password_hash="hash",
is_active=True,
primary_topic_code="family",
default_rate=4200,
)
db.add(lawyer)
db.flush()
req_missing = Request(
track_number="TRK-RATE-AUTO-1",
client_name="Клиент",
client_phone="+79990000005",
topic_code="family",
status_code="NEW",
description="auto-missing",
extra_fields={},
created_at=now - timedelta(hours=30),
updated_at=now - timedelta(hours=30),
)
req_fixed = Request(
track_number="TRK-RATE-AUTO-2",
client_name="Клиент",
client_phone="+79990000006",
topic_code="family",
status_code="NEW",
description="auto-fixed",
extra_fields={},
effective_rate=3333,
created_at=now - timedelta(hours=29),
updated_at=now - timedelta(hours=29),
)
db.add_all([req_missing, req_fixed])
db.commit()
missing_id = str(req_missing.id)
fixed_id = str(req_fixed.id)
lawyer_id = str(lawyer.id)
result = assign_task.auto_assign_unclaimed()
self.assertEqual(result["assigned"], 2)
with self.SessionLocal() as db:
missing = db.get(Request, UUID(missing_id))
fixed = db.get(Request, UUID(fixed_id))
self.assertIsNotNone(missing)
self.assertIsNotNone(fixed)
self.assertEqual(missing.assigned_lawyer_id, lawyer_id)
self.assertEqual(fixed.assigned_lawyer_id, lawyer_id)
self.assertAlmostEqual(float(missing.effective_rate or 0), 4200.0, places=2)
self.assertAlmostEqual(float(fixed.effective_rate or 0), 3333.0, places=2)
def test_lawyer_cannot_write_financial_fields(self):
with self.SessionLocal() as db:
lawyer = AdminUser(
role="LAWYER",
name="Lawyer",
email="lawyer-finance@example.com",
password_hash="hash",
is_active=True,
)
db.add(lawyer)
db.commit()
lawyer_id = str(lawyer.id)
headers = self._auth_headers("LAWYER", "lawyer-finance@example.com", sub=lawyer_id)
blocked_create_legacy = self.client.post(
"/api/admin/requests",
headers=headers,
json={
"client_name": "Клиент",
"client_phone": "+79990000007",
"status_code": "NEW",
"description": "legacy",
"effective_rate": 100,
},
)
self.assertEqual(blocked_create_legacy.status_code, 403)
blocked_create_crud = self.client.post(
"/api/admin/crud/requests",
headers=headers,
json={
"client_name": "Клиент",
"client_phone": "+79990000008",
"status_code": "NEW",
"description": "crud",
"invoice_amount": 500,
},
)
self.assertEqual(blocked_create_crud.status_code, 403)
created = self.client.post(
"/api/admin/requests",
headers=headers,
json={
"client_name": "Клиент",
"client_phone": "+79990000009",
"status_code": "NEW",
"description": "allowed",
},
)
self.assertEqual(created.status_code, 201)
request_id = created.json()["id"]
blocked_patch_legacy = self.client.patch(
f"/api/admin/requests/{request_id}",
headers=headers,
json={"effective_rate": 200},
)
self.assertEqual(blocked_patch_legacy.status_code, 403)
blocked_patch_crud = self.client.patch(
f"/api/admin/crud/requests/{request_id}",
headers=headers,
json={"invoice_amount": 900},
)
self.assertEqual(blocked_patch_crud.status_code, 403)
def test_admin_assignment_autofills_effective_rate_in_create_and_update(self):
with self.SessionLocal() as db:
lawyer = AdminUser(
role="LAWYER",
name="Rate",
email="admin-assign-rate@example.com",
password_hash="hash",
is_active=True,
default_rate=6100,
)
db.add(lawyer)
db.commit()
lawyer_id = str(lawyer.id)
admin_headers = self._auth_headers("ADMIN", "root@example.com")
created_legacy = self.client.post(
"/api/admin/requests",
headers=admin_headers,
json={
"client_name": "Клиент A",
"client_phone": "+79990000010",
"status_code": "NEW",
"description": "legacy create",
"assigned_lawyer_id": lawyer_id,
},
)
self.assertEqual(created_legacy.status_code, 201)
legacy_id = created_legacy.json()["id"]
created_crud = self.client.post(
"/api/admin/crud/requests",
headers=admin_headers,
json={
"client_name": "Клиент B",
"client_phone": "+79990000011",
"status_code": "NEW",
"description": "crud create",
"assigned_lawyer_id": lawyer_id,
},
)
self.assertEqual(created_crud.status_code, 201)
crud_id = created_crud.json()["id"]
created_manual = self.client.post(
"/api/admin/requests",
headers=admin_headers,
json={
"client_name": "Клиент C",
"client_phone": "+79990000012",
"status_code": "NEW",
"description": "manual rate",
"assigned_lawyer_id": lawyer_id,
"effective_rate": 7300,
},
)
self.assertEqual(created_manual.status_code, 201)
manual_id = created_manual.json()["id"]
created_unassigned_legacy = self.client.post(
"/api/admin/requests",
headers=admin_headers,
json={
"client_name": "Клиент D",
"client_phone": "+79990000013",
"status_code": "NEW",
"description": "legacy update",
},
)
self.assertEqual(created_unassigned_legacy.status_code, 201)
unassigned_legacy_id = created_unassigned_legacy.json()["id"]
created_unassigned_crud = self.client.post(
"/api/admin/crud/requests",
headers=admin_headers,
json={
"client_name": "Клиент E",
"client_phone": "+79990000014",
"status_code": "NEW",
"description": "crud update",
},
)
self.assertEqual(created_unassigned_crud.status_code, 201)
unassigned_crud_id = created_unassigned_crud.json()["id"]
patched_legacy = self.client.patch(
f"/api/admin/requests/{unassigned_legacy_id}",
headers=admin_headers,
json={"assigned_lawyer_id": lawyer_id},
)
self.assertEqual(patched_legacy.status_code, 200)
patched_crud = self.client.patch(
f"/api/admin/crud/requests/{unassigned_crud_id}",
headers=admin_headers,
json={"assigned_lawyer_id": lawyer_id},
)
self.assertEqual(patched_crud.status_code, 200)
with self.SessionLocal() as db:
legacy_row = db.get(Request, UUID(legacy_id))
crud_row = db.get(Request, UUID(crud_id))
manual_row = db.get(Request, UUID(manual_id))
patched_legacy_row = db.get(Request, UUID(unassigned_legacy_id))
patched_crud_row = db.get(Request, UUID(unassigned_crud_id))
self.assertAlmostEqual(float(legacy_row.effective_rate or 0), 6100.0, places=2)
self.assertAlmostEqual(float(crud_row.effective_rate or 0), 6100.0, places=2)
self.assertAlmostEqual(float(manual_row.effective_rate or 0), 7300.0, places=2)
self.assertAlmostEqual(float(patched_legacy_row.effective_rate or 0), 6100.0, places=2)
self.assertAlmostEqual(float(patched_crud_row.effective_rate or 0), 6100.0, places=2)
def test_public_request_read_does_not_expose_financial_fields(self):
with self.SessionLocal() as db:
req = Request(
track_number="TRK-RATE-PUBLIC-1",
client_name="Клиент",
client_phone="+79990000015",
status_code="IN_PROGRESS",
description="public",
extra_fields={},
effective_rate=8800,
invoice_amount=12500,
)
db.add(req)
db.commit()
public_token = create_jwt(
{"sub": "TRK-RATE-PUBLIC-1", "purpose": "VIEW_REQUEST"},
settings.PUBLIC_JWT_SECRET,
timedelta(days=1),
)
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
response = self.client.get("/api/public/requests/TRK-RATE-PUBLIC-1", cookies=cookies)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertNotIn("effective_rate", body)
self.assertNotIn("invoice_amount", body)
self.assertNotIn("paid_at", body)
self.assertNotIn("paid_by_admin_id", body)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,226 @@
import os
import unittest
from datetime import timedelta
from uuid import UUID
from unittest.mock import patch
from botocore.exceptions import ClientError
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, delete
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
os.environ.setdefault("S3_ACCESS_KEY", "test")
os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test")
from app.core.config import settings
from app.core.security import create_jwt
from app.db.session import get_db
from app.main import app
from app.models.admin_user import AdminUser
from app.models.attachment import Attachment
from app.models.message import Message
from app.models.notification import Notification
from app.models.request import Request
from app.models.security_audit_log import SecurityAuditLog
class _FakeBody:
def __init__(self, payload: bytes):
self.payload = payload
def iter_chunks(self, chunk_size=65536):
for i in range(0, len(self.payload), chunk_size):
yield self.payload[i : i + chunk_size]
class _FakeS3Storage:
def __init__(self):
self.objects = {}
def get_object(self, key: str) -> dict:
obj = self.objects.get(key)
if obj is None:
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "GetObject")
return {"Body": _FakeBody(obj["content"]), "ContentType": obj["mime"], "ContentLength": obj["size"]}
class SecurityAuditTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
AdminUser.__table__.create(bind=cls.engine)
Request.__table__.create(bind=cls.engine)
Notification.__table__.create(bind=cls.engine)
Message.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine)
SecurityAuditLog.__table__.create(bind=cls.engine)
@classmethod
def tearDownClass(cls):
SecurityAuditLog.__table__.drop(bind=cls.engine)
Attachment.__table__.drop(bind=cls.engine)
Message.__table__.drop(bind=cls.engine)
Notification.__table__.drop(bind=cls.engine)
Request.__table__.drop(bind=cls.engine)
AdminUser.__table__.drop(bind=cls.engine)
cls.engine.dispose()
def setUp(self):
with self.SessionLocal() as db:
db.execute(delete(SecurityAuditLog))
db.execute(delete(Notification))
db.execute(delete(Attachment))
db.execute(delete(Message))
db.execute(delete(Request))
db.execute(delete(AdminUser))
db.commit()
def override_get_db():
db = self.SessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self.client = TestClient(app)
def tearDown(self):
self.client.close()
app.dependency_overrides.clear()
@staticmethod
def _admin_headers(sub: str, role: str = "ADMIN", email: str = "admin@example.com") -> dict[str, str]:
token = create_jwt(
{"sub": sub, "email": email, "role": role},
settings.ADMIN_JWT_SECRET,
timedelta(minutes=30),
)
return {"Authorization": f"Bearer {token}"}
def test_public_attachment_download_writes_security_allow_event(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
req = Request(
track_number="TRK-SEC-PUB-1",
client_name="Клиент",
client_phone="+79990001010",
topic_code="civil-law",
status_code="NEW",
extra_fields={},
total_attachments_bytes=0,
)
db.add(req)
db.flush()
key = f"requests/{req.id}/doc.pdf"
att = Attachment(
request_id=req.id,
message_id=None,
file_name="doc.pdf",
mime_type="application/pdf",
size_bytes=1024,
s3_key=key,
responsible="Клиент",
)
db.add(att)
db.commit()
attachment_id = str(att.id)
track_number = req.track_number
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
public_token = create_jwt({"sub": track_number, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
response = self.client.get(f"/api/public/uploads/object/{attachment_id}", cookies=cookies)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"x" * 1024)
with self.SessionLocal() as db:
rows = (
db.query(SecurityAuditLog)
.filter(SecurityAuditLog.action == "DOWNLOAD_OBJECT", SecurityAuditLog.actor_role == "CLIENT")
.all()
)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertTrue(row.allowed)
self.assertEqual(row.object_key, key)
self.assertEqual(str(row.attachment_id), attachment_id)
self.assertEqual(row.scope, "REQUEST_ATTACHMENT")
def test_admin_object_proxy_denied_writes_security_deny_event(self):
fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db:
lawyer_a = AdminUser(
role="LAWYER",
name="Юрист А",
email="sec-lawyer-a@example.com",
password_hash="hash",
is_active=True,
)
lawyer_b = AdminUser(
role="LAWYER",
name="Юрист Б",
email="sec-lawyer-b@example.com",
password_hash="hash",
is_active=True,
)
db.add_all([lawyer_a, lawyer_b])
db.flush()
req = Request(
track_number="TRK-SEC-ADM-1",
client_name="Клиент",
client_phone="+79990002020",
topic_code="civil-law",
status_code="IN_PROGRESS",
assigned_lawyer_id=str(lawyer_b.id),
extra_fields={},
total_attachments_bytes=0,
)
db.add(req)
db.flush()
key = f"requests/{req.id}/proof.pdf"
db.add(
Attachment(
request_id=req.id,
file_name="proof.pdf",
mime_type="application/pdf",
size_bytes=1024,
s3_key=key,
)
)
db.commit()
lawyer_a_id = str(lawyer_a.id)
token = self._admin_headers(sub=lawyer_a_id, role="LAWYER", email="sec-lawyer-a@example.com")["Authorization"].replace(
"Bearer ", ""
)
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
response = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
self.assertEqual(response.status_code, 403)
with self.SessionLocal() as db:
rows = (
db.query(SecurityAuditLog)
.filter(SecurityAuditLog.action == "DOWNLOAD_OBJECT", SecurityAuditLog.actor_role == "LAWYER")
.all()
)
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertFalse(row.allowed)
self.assertEqual(row.object_key, key)
self.assertIn("Недостаточно прав", str(row.reason or ""))
self.assertEqual(str(row.request_id), key.split("/")[1])
UUID(str(row.id))

View file

@ -28,6 +28,16 @@
CLOSED: "\u0417\u0430\u043A\u0440\u044B\u0442\u0430", CLOSED: "\u0417\u0430\u043A\u0440\u044B\u0442\u0430",
REJECTED: "\u041E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u0430" REJECTED: "\u041E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u0430"
}; };
const INVOICE_STATUS_LABELS = {
WAITING_PAYMENT: "\u041E\u0436\u0438\u0434\u0430\u0435\u0442 \u043E\u043F\u043B\u0430\u0442\u0443",
PAID: "\u041E\u043F\u043B\u0430\u0447\u0435\u043D",
CANCELED: "\u041E\u0442\u043C\u0435\u043D\u0435\u043D"
};
const STATUS_KIND_LABELS = {
DEFAULT: "\u041E\u0431\u044B\u0447\u043D\u044B\u0439",
INVOICE: "\u0412\u044B\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u0441\u0447\u0435\u0442\u0430",
PAID: "\u041E\u043F\u043B\u0430\u0447\u0435\u043D\u043E"
};
const REQUEST_UPDATE_EVENT_LABELS = { const REQUEST_UPDATE_EVENT_LABELS = {
MESSAGE: "\u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435", MESSAGE: "\u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435",
ATTACHMENT: "\u0444\u0430\u0439\u043B", ATTACHMENT: "\u0444\u0430\u0439\u043B",
@ -39,6 +49,11 @@
endpoint: "/api/admin/crud/requests/query", endpoint: "/api/admin/crud/requests/query",
sort: [{ field: "created_at", dir: "desc" }] sort: [{ field: "created_at", dir: "desc" }]
}, },
invoices: {
table: "invoices",
endpoint: "/api/admin/invoices/query",
sort: [{ field: "issued_at", dir: "desc" }]
},
quotes: { quotes: {
table: "quotes", table: "quotes",
endpoint: "/api/admin/crud/quotes/query", endpoint: "/api/admin/crud/quotes/query",
@ -95,6 +110,31 @@
} }
]) ])
); );
TABLE_MUTATION_CONFIG.invoices = {
create: "/api/admin/invoices",
update: (id) => "/api/admin/invoices/" + id,
delete: (id) => "/api/admin/invoices/" + id
};
const TABLE_KEY_ALIASES = {
form_fields: "formFields",
topic_required_fields: "topicRequiredFields",
topic_data_templates: "topicDataTemplates",
topic_status_transitions: "statusTransitions",
admin_users: "users",
admin_user_topics: "userTopics"
};
const TABLE_UNALIASES = Object.fromEntries(Object.entries(TABLE_KEY_ALIASES).map(([table, alias]) => [alias, table]));
const KNOWN_CONFIG_TABLE_KEYS = /* @__PURE__ */ new Set([
"quotes",
"topics",
"statuses",
"formFields",
"topicRequiredFields",
"topicDataTemplates",
"statusTransitions",
"users",
"userTopics"
]);
function createTableState() { function createTableState() {
return { return {
filters: [], filters: [],
@ -105,6 +145,23 @@
rows: [] rows: []
}; };
} }
function humanizeKey(value) {
const text = String(value || "").replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
if (!text) return "-";
return text.charAt(0).toUpperCase() + text.slice(1);
}
function metaKindToFilterType(kind) {
if (kind === "boolean") return "boolean";
if (kind === "number") return "number";
if (kind === "date" || kind === "datetime") return "date";
return "text";
}
function metaKindToRecordType(kind) {
if (kind === "boolean") return "boolean";
if (kind === "number") return "number";
if (kind === "json") return "json";
return "text";
}
function decodeJwtPayload(token) { function decodeJwtPayload(token) {
try { try {
const payload = token.split(".")[1] || ""; const payload = token.split(".")[1] || "";
@ -126,6 +183,12 @@
function statusLabel(code) { function statusLabel(code) {
return STATUS_LABELS[code] || code || "-"; return STATUS_LABELS[code] || code || "-";
} }
function invoiceStatusLabel(code) {
return INVOICE_STATUS_LABELS[code] || code || "-";
}
function statusKindLabel(code) {
return STATUS_KIND_LABELS[code] || code || "-";
}
function boolLabel(value) { function boolLabel(value) {
return value ? "\u0414\u0430" : "\u041D\u0435\u0442"; return value ? "\u0414\u0430" : "\u041D\u0435\u0442";
} }
@ -206,6 +269,10 @@
\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435: row.description || null, \u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435: row.description || null,
"\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F": row.extra_fields || {}, "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F": row.extra_fields || {},
"\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0439 \u044E\u0440\u0438\u0441\u0442 (ID)": row.assigned_lawyer_id || null, "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0439 \u044E\u0440\u0438\u0441\u0442 (ID)": row.assigned_lawyer_id || null,
"\u0421\u0442\u0430\u0432\u043A\u0430 (\u0444\u0438\u043A\u0441.)": row.effective_rate ?? null,
"\u0421\u0443\u043C\u043C\u0430 \u0441\u0447\u0435\u0442\u0430": row.invoice_amount ?? null,
"\u041E\u043F\u043B\u0430\u0447\u0435\u043D\u043E": row.paid_at ? fmtDate(row.paid_at) : null,
"\u041E\u043F\u043B\u0430\u0442\u0443 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043B (ID)": row.paid_by_admin_id || null,
"\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u043A\u043B\u0438\u0435\u043D\u0442\u043E\u043C": boolLabel(Boolean(row.client_has_unread_updates)), "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u043A\u043B\u0438\u0435\u043D\u0442\u043E\u043C": boolLabel(Boolean(row.client_has_unread_updates)),
"\u0422\u0438\u043F \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F \u0434\u043B\u044F \u043A\u043B\u0438\u0435\u043D\u0442\u0430": row.client_unread_event_type ? REQUEST_UPDATE_EVENT_LABELS[row.client_unread_event_type] || row.client_unread_event_type : null, "\u0422\u0438\u043F \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F \u0434\u043B\u044F \u043A\u043B\u0438\u0435\u043D\u0442\u0430": row.client_unread_event_type ? REQUEST_UPDATE_EVENT_LABELS[row.client_unread_event_type] || row.client_unread_event_type : null,
"\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u044E\u0440\u0438\u0441\u0442\u043E\u043C": boolLabel(Boolean(row.lawyer_has_unread_updates)), "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u044E\u0440\u0438\u0441\u0442\u043E\u043C": boolLabel(Boolean(row.lawyer_has_unread_updates)),
@ -467,9 +534,16 @@
const [role, setRole] = useState(""); const [role, setRole] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [activeSection, setActiveSection] = useState("dashboard"); const [activeSection, setActiveSection] = useState("dashboard");
const [dashboardData, setDashboardData] = useState({ cards: [], byStatus: {}, lawyerLoads: [] }); const [dashboardData, setDashboardData] = useState({
scope: "",
cards: [],
byStatus: {},
lawyerLoads: [],
myUnreadByEvent: {}
});
const [tables, setTables] = useState({ const [tables, setTables] = useState({
requests: createTableState(), requests: createTableState(),
invoices: createTableState(),
quotes: createTableState(), quotes: createTableState(),
topics: createTableState(), topics: createTableState(),
statuses: createTableState(), statuses: createTableState(),
@ -480,6 +554,7 @@
users: createTableState(), users: createTableState(),
userTopics: createTableState() userTopics: createTableState()
}); });
const [tableCatalog, setTableCatalog] = useState([]);
const [dictionaries, setDictionaries] = useState({ const [dictionaries, setDictionaries] = useState({
topics: [], topics: [],
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })), statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
@ -496,7 +571,7 @@
rowId: null, rowId: null,
form: {} form: {}
}); });
const [configActiveKey, setConfigActiveKey] = useState("quotes"); const [configActiveKey, setConfigActiveKey] = useState("");
const [referencesExpanded, setReferencesExpanded] = useState(true); const [referencesExpanded, setReferencesExpanded] = useState(true);
const [metaEntity, setMetaEntity] = useState("quotes"); const [metaEntity, setMetaEntity] = useState("quotes");
const [metaJson, setMetaJson] = useState(""); const [metaJson, setMetaJson] = useState("");
@ -554,6 +629,12 @@
const getStatusOptions = useCallback(() => { const getStatusOptions = useCallback(() => {
return (dictionaries.statuses || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" })); return (dictionaries.statuses || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" }));
}, [dictionaries.statuses]); }, [dictionaries.statuses]);
const getInvoiceStatusOptions = useCallback(() => {
return Object.entries(INVOICE_STATUS_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
}, []);
const getStatusKindOptions = useCallback(() => {
return Object.entries(STATUS_KIND_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
}, []);
const getTopicOptions = useCallback(() => { const getTopicOptions = useCallback(() => {
return (dictionaries.topics || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || item.code) + " (" + item.code + ")" })); return (dictionaries.topics || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || item.code) + " (" + item.code + ")" }));
}, [dictionaries.topics]); }, [dictionaries.topics]);
@ -572,6 +653,45 @@
const getRoleOptions = useCallback(() => { const getRoleOptions = useCallback(() => {
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" })); return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" }));
}, []); }, []);
const tableCatalogMap = useMemo(() => {
const map = {};
(tableCatalog || []).forEach((item) => {
if (!item || !item.key) return;
map[item.key] = item;
});
return map;
}, [tableCatalog]);
const dictionaryTableItems = useMemo(() => {
return (tableCatalog || []).filter((item) => item && item.section === "dictionary" && Array.isArray(item.actions) && item.actions.includes("query")).sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru"));
}, [tableCatalog]);
const resolveTableConfig = useCallback(
(tableKey) => {
if (TABLE_SERVER_CONFIG[tableKey]) return TABLE_SERVER_CONFIG[tableKey];
const meta = tableCatalogMap[tableKey];
if (!meta || !meta.table) return null;
const tableName = String(meta.table || tableKey);
return {
table: tableName,
endpoint: String(meta.query_endpoint || "/api/admin/crud/" + tableName + "/query"),
sort: Array.isArray(meta.default_sort) && meta.default_sort.length ? meta.default_sort : [{ field: "created_at", dir: "desc" }]
};
},
[tableCatalogMap]
);
const resolveMutationConfig = useCallback(
(tableKey) => {
if (TABLE_MUTATION_CONFIG[tableKey]) return TABLE_MUTATION_CONFIG[tableKey];
const meta = tableCatalogMap[tableKey];
if (!meta || !meta.table) return null;
const tableName = String(meta.table || tableKey);
return {
create: String(meta.create_endpoint || "/api/admin/crud/" + tableName),
update: (id) => String(meta.update_endpoint_template || "/api/admin/crud/" + tableName + "/{id}").replace("{id}", String(id)),
delete: (id) => String(meta.delete_endpoint_template || "/api/admin/crud/" + tableName + "/{id}").replace("{id}", String(id))
};
},
[tableCatalogMap]
);
const getFilterFields = useCallback( const getFilterFields = useCallback(
(tableKey) => { (tableKey) => {
if (tableKey === "requests") { if (tableKey === "requests") {
@ -581,6 +701,23 @@
{ field: "client_phone", label: "\u0422\u0435\u043B\u0435\u0444\u043E\u043D", type: "text" }, { field: "client_phone", label: "\u0422\u0435\u043B\u0435\u0444\u043E\u043D", type: "text" },
{ field: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", type: "reference", options: getStatusOptions }, { field: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", type: "reference", options: getStatusOptions },
{ field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions }, { field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions },
{ field: "invoice_amount", label: "\u0421\u0443\u043C\u043C\u0430 \u0441\u0447\u0435\u0442\u0430", type: "number" },
{ field: "effective_rate", label: "\u0421\u0442\u0430\u0432\u043A\u0430", type: "number" },
{ field: "paid_at", label: "\u041E\u043F\u043B\u0430\u0447\u0435\u043D\u043E", type: "date" },
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
];
}
if (tableKey === "invoices") {
return [
{ field: "invoice_number", label: "\u041D\u043E\u043C\u0435\u0440 \u0441\u0447\u0435\u0442\u0430", type: "text" },
{ field: "status", label: "\u0421\u0442\u0430\u0442\u0443\u0441", type: "enum", options: getInvoiceStatusOptions },
{ field: "amount", label: "\u0421\u0443\u043C\u043C\u0430", type: "number" },
{ field: "currency", label: "\u0412\u0430\u043B\u044E\u0442\u0430", type: "text" },
{ field: "payer_display_name", label: "\u041F\u043B\u0430\u0442\u0435\u043B\u044C\u0449\u0438\u043A", type: "text" },
{ field: "request_id", label: "ID \u0437\u0430\u044F\u0432\u043A\u0438", type: "text" },
{ field: "issued_by_admin_user_id", label: "ID \u0441\u043E\u0442\u0440\u0443\u0434\u043D\u0438\u043A\u0430", type: "text" },
{ field: "issued_at", label: "\u0414\u0430\u0442\u0430 \u0444\u043E\u0440\u043C\u0438\u0440\u043E\u0432\u0430\u043D\u0438\u044F", type: "date" },
{ field: "paid_at", label: "\u0414\u0430\u0442\u0430 \u043E\u043F\u043B\u0430\u0442\u044B", type: "date" },
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" } { field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
]; ];
} }
@ -606,6 +743,7 @@
return [ return [
{ field: "code", label: "\u041A\u043E\u0434", type: "text" }, { field: "code", label: "\u041A\u043E\u0434", type: "text" },
{ field: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text" }, { field: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text" },
{ field: "kind", label: "\u0422\u0438\u043F", type: "enum", options: getStatusKindOptions },
{ field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" }, { field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" },
{ field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" }, { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" },
{ field: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", type: "boolean" } { field: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", type: "boolean" }
@ -646,6 +784,7 @@
{ field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions }, { field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions },
{ field: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", type: "reference", options: getStatusOptions }, { field: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", type: "reference", options: getStatusOptions },
{ field: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", type: "reference", options: getStatusOptions }, { field: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", type: "reference", options: getStatusOptions },
{ field: "sla_hours", label: "SLA (\u0447\u0430\u0441\u044B)", type: "number" },
{ field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" }, { field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" },
{ field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" } { field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" }
]; ];
@ -656,6 +795,8 @@
{ field: "email", label: "Email", type: "text" }, { field: "email", label: "Email", type: "text" },
{ field: "role", label: "\u0420\u043E\u043B\u044C", type: "enum", options: getRoleOptions }, { field: "role", label: "\u0420\u043E\u043B\u044C", type: "enum", options: getRoleOptions },
{ field: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", type: "reference", options: getTopicOptions }, { field: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", type: "reference", options: getTopicOptions },
{ field: "default_rate", label: "\u0421\u0442\u0430\u0432\u043A\u0430 \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E", type: "number" },
{ field: "salary_percent", label: "\u041F\u0440\u043E\u0446\u0435\u043D\u0442 \u0437\u0430\u0440\u043F\u043B\u0430\u0442\u044B", type: "number" },
{ field: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" }, { field: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" },
{ field: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", type: "text" }, { field: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", type: "text" },
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" } { field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
@ -669,12 +810,34 @@
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" } { field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
]; ];
} }
return []; const meta = tableCatalogMap[tableKey];
if (!meta || !Array.isArray(meta.columns)) return [];
return (meta.columns || []).filter((column) => column && column.name && column.filterable !== false).map((column) => {
const name = String(column.name);
const label = String(column.label || humanizeKey(name));
if (name === "topic_code") return { field: name, label, type: "reference", options: getTopicOptions };
if (name === "status_code" || name === "from_status" || name === "to_status") {
return { field: name, label, type: "reference", options: getStatusOptions };
}
if (name === "field_key") return { field: name, label, type: "reference", options: getFormFieldKeyOptions };
return { field: name, label, type: metaKindToFilterType(column.kind) };
});
}, },
[getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions] [
tableCatalogMap,
getFormFieldKeyOptions,
getFormFieldTypeOptions,
getInvoiceStatusOptions,
getLawyerOptions,
getRoleOptions,
getStatusKindOptions,
getStatusOptions,
getTopicOptions
]
); );
const getTableLabel = useCallback((tableKey) => { const getTableLabel = useCallback((tableKey) => {
if (tableKey === "requests") return "\u0417\u0430\u044F\u0432\u043A\u0438"; if (tableKey === "requests") return "\u0417\u0430\u044F\u0432\u043A\u0438";
if (tableKey === "invoices") return "\u0421\u0447\u0435\u0442\u0430";
if (tableKey === "quotes") return "\u0426\u0438\u0442\u0430\u0442\u044B"; if (tableKey === "quotes") return "\u0426\u0438\u0442\u0430\u0442\u044B";
if (tableKey === "topics") return "\u0422\u0435\u043C\u044B"; if (tableKey === "topics") return "\u0422\u0435\u043C\u044B";
if (tableKey === "statuses") return "\u0421\u0442\u0430\u0442\u0443\u0441\u044B"; if (tableKey === "statuses") return "\u0421\u0442\u0430\u0442\u0443\u0441\u044B";
@ -684,8 +847,11 @@
if (tableKey === "statusTransitions") return "\u041F\u0435\u0440\u0435\u0445\u043E\u0434\u044B \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432"; if (tableKey === "statusTransitions") return "\u041F\u0435\u0440\u0435\u0445\u043E\u0434\u044B \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432";
if (tableKey === "users") return "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0438"; if (tableKey === "users") return "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0438";
if (tableKey === "userTopics") return "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u0442\u0435\u043C\u044B \u044E\u0440\u0438\u0441\u0442\u043E\u0432"; if (tableKey === "userTopics") return "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u0442\u0435\u043C\u044B \u044E\u0440\u0438\u0441\u0442\u043E\u0432";
return "\u0422\u0430\u0431\u043B\u0438\u0446\u0430"; const meta = tableCatalogMap[tableKey];
}, []); if (meta && meta.label) return String(meta.label);
const raw = TABLE_UNALIASES[tableKey] || tableKey;
return humanizeKey(raw);
}, [tableCatalogMap]);
const getRecordFields = useCallback( const getRecordFields = useCallback(
(tableKey) => { (tableKey) => {
if (tableKey === "requests") { if (tableKey === "requests") {
@ -698,9 +864,24 @@
{ key: "description", label: "\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435", type: "textarea", optional: true }, { key: "description", label: "\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435", type: "textarea", optional: true },
{ key: "extra_fields", label: "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F (JSON)", type: "json", optional: true, defaultValue: "{}" }, { key: "extra_fields", label: "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F (JSON)", type: "json", optional: true, defaultValue: "{}" },
{ key: "assigned_lawyer_id", label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0439 \u044E\u0440\u0438\u0441\u0442 (ID)", type: "text", optional: true }, { key: "assigned_lawyer_id", label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0439 \u044E\u0440\u0438\u0441\u0442 (ID)", type: "text", optional: true },
{ key: "effective_rate", label: "\u0421\u0442\u0430\u0432\u043A\u0430 (\u0444\u0438\u043A\u0441.)", type: "number", optional: true },
{ key: "invoice_amount", label: "\u0421\u0443\u043C\u043C\u0430 \u0441\u0447\u0435\u0442\u0430", type: "number", optional: true },
{ key: "paid_at", label: "\u0414\u0430\u0442\u0430 \u043E\u043F\u043B\u0430\u0442\u044B (ISO)", type: "text", optional: true, placeholder: "2026-02-23T12:00:00+03:00" },
{ key: "paid_by_admin_id", label: "\u041E\u043F\u043B\u0430\u0442\u0443 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043B (ID)", type: "text", optional: true },
{ key: "total_attachments_bytes", label: "\u0420\u0430\u0437\u043C\u0435\u0440 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439 (\u0431\u0430\u0439\u0442)", type: "number", optional: true, defaultValue: "0" } { key: "total_attachments_bytes", label: "\u0420\u0430\u0437\u043C\u0435\u0440 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439 (\u0431\u0430\u0439\u0442)", type: "number", optional: true, defaultValue: "0" }
]; ];
} }
if (tableKey === "invoices") {
return [
{ key: "request_track_number", label: "\u041D\u043E\u043C\u0435\u0440 \u0437\u0430\u044F\u0432\u043A\u0438", type: "text", required: true, createOnly: true },
{ key: "invoice_number", label: "\u041D\u043E\u043C\u0435\u0440 \u0441\u0447\u0435\u0442\u0430", type: "text", optional: true, placeholder: "\u041E\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u043F\u0443\u0441\u0442\u044B\u043C \u0434\u043B\u044F \u0430\u0432\u0442\u043E\u0433\u0435\u043D\u0435\u0440\u0430\u0446\u0438\u0438" },
{ key: "status", label: "\u0421\u0442\u0430\u0442\u0443\u0441", type: "enum", required: true, options: getInvoiceStatusOptions, defaultValue: "WAITING_PAYMENT" },
{ key: "amount", label: "\u0421\u0443\u043C\u043C\u0430", type: "number", required: true },
{ key: "currency", label: "\u0412\u0430\u043B\u044E\u0442\u0430", type: "text", optional: true, defaultValue: "RUB" },
{ key: "payer_display_name", label: "\u041F\u043B\u0430\u0442\u0435\u043B\u044C\u0449\u0438\u043A (\u0424\u0418\u041E / \u043A\u043E\u043C\u043F\u0430\u043D\u0438\u044F)", type: "text", required: true },
{ key: "payer_details", label: "\u0420\u0435\u043A\u0432\u0438\u0437\u0438\u0442\u044B (JSON, \u0448\u0438\u0444\u0440\u0443\u0435\u0442\u0441\u044F)", type: "json", optional: true, omitIfEmpty: true, placeholder: '{"inn":"..."}' }
];
}
if (tableKey === "quotes") { if (tableKey === "quotes") {
return [ return [
{ key: "author", label: "\u0410\u0432\u0442\u043E\u0440", type: "text", required: true }, { key: "author", label: "\u0410\u0432\u0442\u043E\u0440", type: "text", required: true },
@ -722,6 +903,8 @@
return [ return [
{ key: "code", label: "\u041A\u043E\u0434", type: "text", required: true }, { key: "code", label: "\u041A\u043E\u0434", type: "text", required: true },
{ key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text", required: true }, { key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text", required: true },
{ key: "kind", label: "\u0422\u0438\u043F", type: "enum", required: true, options: getStatusKindOptions, defaultValue: "DEFAULT" },
{ key: "invoice_template", label: "\u0428\u0430\u0431\u043B\u043E\u043D \u0441\u0447\u0435\u0442\u0430", type: "textarea", optional: true, placeholder: "\u0414\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0435 \u043F\u043E\u043B\u044F: {track_number}, {client_name}, {topic_code}, {amount}" },
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" }, { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" },
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" }, { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" },
{ key: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", type: "boolean", defaultValue: "false" } { key: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", type: "boolean", defaultValue: "false" }
@ -763,6 +946,7 @@
{ key: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions }, { key: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions },
{ key: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", type: "reference", required: true, options: getStatusOptions }, { key: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", type: "reference", required: true, options: getStatusOptions },
{ key: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", type: "reference", required: true, options: getStatusOptions }, { key: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", type: "reference", required: true, options: getStatusOptions },
{ key: "sla_hours", label: "SLA (\u0447\u0430\u0441\u044B)", type: "number", optional: true },
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" }, { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" },
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" } { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" }
]; ];
@ -782,6 +966,8 @@
accept: "image/*" accept: "image/*"
}, },
{ key: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", type: "reference", optional: true, options: getTopicOptions }, { key: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", type: "reference", optional: true, options: getTopicOptions },
{ key: "default_rate", label: "\u0421\u0442\u0430\u0432\u043A\u0430 \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E", type: "number", optional: true },
{ key: "salary_percent", label: "\u041F\u0440\u043E\u0446\u0435\u043D\u0442 \u0437\u0430\u0440\u043F\u043B\u0430\u0442\u044B", type: "number", optional: true },
{ key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" }, { key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" },
{ key: "password", label: "\u041F\u0430\u0440\u043E\u043B\u044C", type: "password", requiredOnCreate: true, optional: true, omitIfEmpty: true, placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043F\u0430\u0440\u043E\u043B\u044C" } { key: "password", label: "\u041F\u0430\u0440\u043E\u043B\u044C", type: "password", requiredOnCreate: true, optional: true, omitIfEmpty: true, placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043F\u0430\u0440\u043E\u043B\u044C" }
]; ];
@ -792,9 +978,31 @@
{ key: "topic_code", label: "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u0430\u044F \u0442\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions } { key: "topic_code", label: "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u0430\u044F \u0442\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions }
]; ];
} }
return []; const meta = tableCatalogMap[tableKey];
if (!meta || !Array.isArray(meta.columns)) return [];
return (meta.columns || []).filter((column) => column && column.name && column.editable).map((column) => {
const key = String(column.name || "");
const requiredOnCreate = Boolean(column.required_on_create);
return {
key,
label: String(column.label || humanizeKey(key)),
type: metaKindToRecordType(column.kind),
requiredOnCreate,
optional: !requiredOnCreate
};
});
}, },
[getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions] [
tableCatalogMap,
getFormFieldKeyOptions,
getFormFieldTypeOptions,
getInvoiceStatusOptions,
getLawyerOptions,
getRoleOptions,
getStatusKindOptions,
getStatusOptions,
getTopicOptions
]
); );
const getFieldDef = useCallback( const getFieldDef = useCallback(
(tableKey, fieldName) => { (tableKey, fieldName) => {
@ -827,7 +1035,7 @@
const loadTable = useCallback( const loadTable = useCallback(
async (tableKey, options, tokenOverride) => { async (tableKey, options, tokenOverride) => {
const opts = options || {}; const opts = options || {};
const config = TABLE_SERVER_CONFIG[tableKey]; const config = resolveTableConfig(tableKey);
if (!config) return false; if (!config) return false;
const current = tablesRef.current[tableKey] || createTableState(); const current = tablesRef.current[tableKey] || createTableState();
const next = { const next = {
@ -905,7 +1113,7 @@
return { ...prev, statuses: sortByName(Array.from(map.values())) }; return { ...prev, statuses: sortByName(Array.from(map.values())) };
}); });
} }
if (tableKey === "formFields") { if (tableKey === "formFields" || tableKey === "form_fields") {
setDictionaries((prev) => { setDictionaries((prev) => {
const set = new Set(DEFAULT_FORM_FIELD_TYPES); const set = new Set(DEFAULT_FORM_FIELD_TYPES);
(next.rows || []).forEach((row) => { (next.rows || []).forEach((row) => {
@ -919,7 +1127,7 @@
}; };
}); });
} }
if (tableKey === "users") { if (tableKey === "users" || tableKey === "admin_users") {
setDictionaries((prev) => { setDictionaries((prev) => {
const map = new Map((prev.users || []).map((user) => [user.id, user])); const map = new Map((prev.users || []).map((user) => [user.id, user]));
(next.rows || []).forEach((row) => { (next.rows || []).forEach((row) => {
@ -941,11 +1149,15 @@
return false; return false;
} }
}, },
[api, setStatus, setTableState] [api, resolveTableConfig, setStatus, setTableState]
); );
const loadCurrentConfigTable = useCallback( const loadCurrentConfigTable = useCallback(
async (resetOffset, tokenOverride, keyOverride) => { async (resetOffset, tokenOverride, keyOverride) => {
const currentKey = keyOverride || configActiveKey; const currentKey = keyOverride || configActiveKey;
if (!currentKey) {
setStatus("config", "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A", "");
return false;
}
setStatus("config", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", ""); setStatus("config", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", "");
const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride); const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride);
if (ok) { if (ok) {
@ -961,23 +1173,38 @@
setStatus("dashboard", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", ""); setStatus("dashboard", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", "");
try { try {
const data = await api("/api/admin/metrics/overview", {}, tokenOverride); const data = await api("/api/admin/metrics/overview", {}, tokenOverride);
const cards = [ const scope = String(data.scope || role || "");
const cards = scope === "LAWYER" ? [
{ label: "\u041C\u043E\u0438 \u0437\u0430\u044F\u0432\u043A\u0438", value: data.assigned_total ?? 0 },
{ label: "\u041C\u043E\u0438 \u0430\u043A\u0442\u0438\u0432\u043D\u044B\u0435", value: data.active_assigned_total ?? 0 },
{ label: "\u041D\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.unassigned_total ?? 0 },
{ label: "\u041C\u043E\u0438 \u043D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043D\u044B\u0435", value: data.my_unread_updates ?? 0 },
{ label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: data.sla_overdue ?? 0 }
] : [
{ label: "\u041D\u043E\u0432\u044B\u0435", value: data.new ?? 0 }, { label: "\u041D\u043E\u0432\u044B\u0435", value: data.new ?? 0 },
{ label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.assigned_total ?? 0 },
{ label: "\u041D\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.unassigned_total ?? 0 },
{ label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: data.sla_overdue ?? 0 }, { label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: data.sla_overdue ?? 0 },
{ label: "\u0421\u0440\u0435\u0434\u043D\u0438\u0439 FRT (\u043C\u0438\u043D)", value: data.frt_avg_minutes ?? "-" }, { label: "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u044E\u0440\u0438\u0441\u0442\u0430\u043C\u0438", value: data.unread_for_lawyers ?? 0 },
{ label: "\u0413\u0440\u0443\u043F\u043F \u043F\u043E \u0441\u0442\u0430\u0442\u0443\u0441\u0430\u043C", value: Object.keys(data.by_status || {}).length } { label: "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u043A\u043B\u0438\u0435\u043D\u0442\u0430\u043C\u0438", value: data.unread_for_clients ?? 0 }
]; ];
const localized = {}; const localized = {};
Object.entries(data.by_status || {}).forEach(([code, count]) => { Object.entries(data.by_status || {}).forEach(([code, count]) => {
localized[statusLabel(code)] = count; localized[statusLabel(code)] = count;
}); });
setDashboardData({ cards, byStatus: localized, lawyerLoads: data.lawyer_loads || [] }); setDashboardData({
scope,
cards,
byStatus: localized,
lawyerLoads: data.lawyer_loads || [],
myUnreadByEvent: data.my_unread_by_event || {}
});
setStatus("dashboard", "\u0414\u0430\u043D\u043D\u044B\u0435 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u044B", "ok"); setStatus("dashboard", "\u0414\u0430\u043D\u043D\u044B\u0435 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u044B", "ok");
} catch (error) { } catch (error) {
setStatus("dashboard", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error"); setStatus("dashboard", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error");
} }
}, },
[api, setStatus] [api, role, setStatus]
); );
const loadMeta = useCallback( const loadMeta = useCallback(
async (tokenOverride) => { async (tokenOverride) => {
@ -998,6 +1225,7 @@
if (!(tokenOverride !== void 0 ? tokenOverride : token)) return; if (!(tokenOverride !== void 0 ? tokenOverride : token)) return;
if (section === "dashboard") return loadDashboard(tokenOverride); if (section === "dashboard") return loadDashboard(tokenOverride);
if (section === "requests") return loadTable("requests", {}, tokenOverride); if (section === "requests") return loadTable("requests", {}, tokenOverride);
if (section === "invoices") return loadTable("invoices", {}, tokenOverride);
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride); if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride); if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
if (section === "meta") return loadMeta(tokenOverride); if (section === "meta") return loadMeta(tokenOverride);
@ -1010,16 +1238,24 @@
...prev, ...prev,
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })) statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name }))
})); }));
setTableCatalog([]);
if (roleOverride !== "ADMIN") return; if (roleOverride !== "ADMIN") return;
try { try {
const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0); const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0);
const usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0); const usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0);
const [topicsData, statusesData, fieldsData, usersData] = await Promise.all([ const [catalogData, topicsData, statusesData, fieldsData, usersData] = await Promise.all([
api("/api/admin/crud/meta/tables", {}, tokenOverride),
api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride), api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride),
api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride), api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride),
api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride), api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride),
api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride) api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride)
]); ]);
const catalogRows = (catalogData.tables || []).filter((row) => row && row.table).map((row) => {
const tableName = String(row.table || "");
const key = TABLE_KEY_ALIASES[tableName] || String(row.key || tableName);
return { ...row, key, table: tableName };
});
setTableCatalog(catalogRows);
const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }])); const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }]));
(statusesData.rows || []).forEach((row) => { (statusesData.rows || []).forEach((row) => {
if (!row.code) return; if (!row.code) return;
@ -1172,6 +1408,7 @@
if (field.type === "json") { if (field.type === "json") {
const text = String(raw || "").trim(); const text = String(raw || "").trim();
if (!text) { if (!text) {
if (field.omitIfEmpty) return;
if (field.optional) payload[field.key] = null; if (field.optional) payload[field.key] = null;
else payload[field.key] = {}; else payload[field.key] = {};
return; return;
@ -1196,6 +1433,7 @@
payload[field.key] = value; payload[field.key] = value;
}); });
if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {}; if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {};
if (tableKey === "invoices" && mode === "edit") delete payload.request_track_number;
return payload; return payload;
}, },
[getRecordFields] [getRecordFields]
@ -1205,7 +1443,7 @@
event.preventDefault(); event.preventDefault();
const tableKey = recordModal.tableKey; const tableKey = recordModal.tableKey;
if (!tableKey) return; if (!tableKey) return;
const endpoints = TABLE_MUTATION_CONFIG[tableKey]; const endpoints = resolveMutationConfig(tableKey);
if (!endpoints) return; if (!endpoints) return;
try { try {
setStatus("recordForm", "\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435...", ""); setStatus("recordForm", "\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435...", "");
@ -1222,11 +1460,11 @@
setStatus("recordForm", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error"); setStatus("recordForm", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error");
} }
}, },
[api, buildRecordPayload, closeRecordModal, loadTable, recordModal, setStatus] [api, buildRecordPayload, closeRecordModal, loadTable, recordModal, resolveMutationConfig, setStatus]
); );
const deleteRecord = useCallback( const deleteRecord = useCallback(
async (tableKey, id) => { async (tableKey, id) => {
const endpoints = TABLE_MUTATION_CONFIG[tableKey]; const endpoints = resolveMutationConfig(tableKey);
if (!endpoints) return; if (!endpoints) return;
if (!confirm("\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C?")) return; if (!confirm("\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C?")) return;
try { try {
@ -1237,7 +1475,7 @@
setStatus(tableKey, "\u041E\u0448\u0438\u0431\u043A\u0430 \u0443\u0434\u0430\u043B\u0435\u043D\u0438\u044F: " + error.message, "error"); setStatus(tableKey, "\u041E\u0448\u0438\u0431\u043A\u0430 \u0443\u0434\u0430\u043B\u0435\u043D\u0438\u044F: " + error.message, "error");
} }
}, },
[api, loadTable, setStatus] [api, loadTable, resolveMutationConfig, setStatus]
); );
const claimRequest = useCallback( const claimRequest = useCallback(
async (requestId) => { async (requestId) => {
@ -1253,6 +1491,54 @@
}, },
[api, loadTable, setStatus] [api, loadTable, setStatus]
); );
const openInvoiceRequest = useCallback(
async (row) => {
if (!row || !row.request_id) return;
try {
setActiveSection("requests");
await loadTable("requests", {});
await openRequestDetails(row.request_id);
} catch (_) {
}
},
[loadTable, openRequestDetails]
);
const downloadInvoicePdf = useCallback(
async (row) => {
if (!row || !row.id || !token) return;
try {
setStatus("invoices", "\u0424\u043E\u0440\u043C\u0438\u0440\u0443\u0435\u043C PDF...", "");
const response = await fetch("/api/admin/invoices/" + row.id + "/pdf", {
headers: { Authorization: "Bearer " + token }
});
if (!response.ok) {
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch (_) {
payload = { raw: text };
}
const message = payload.detail || payload.error || payload.raw || "HTTP " + response.status;
throw new Error(translateApiError(String(message)));
}
const blob = await response.blob();
const fileName = (row.invoice_number || "invoice") + ".pdf";
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setStatus("invoices", "PDF \u0441\u043A\u0430\u0447\u0430\u043D", "ok");
} catch (error) {
setStatus("invoices", "\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u043A\u0430\u0447\u0438\u0432\u0430\u043D\u0438\u044F: " + error.message, "error");
}
},
[setStatus, token]
);
const openReassignModal = useCallback( const openReassignModal = useCallback(
(row) => { (row) => {
const options = getLawyerOptions(); const options = getLawyerOptions();
@ -1532,12 +1818,14 @@
setRequestModal({ open: false, jsonText: "" }); setRequestModal({ open: false, jsonText: "" });
setFilterModal({ open: false, tableKey: null, field: "", op: "=", rawValue: "", editIndex: null }); setFilterModal({ open: false, tableKey: null, field: "", op: "=", rawValue: "", editIndex: null });
setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" }); setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" });
setDashboardData({ cards: [], byStatus: {}, lawyerLoads: [] }); setDashboardData({ scope: "", cards: [], byStatus: {}, lawyerLoads: [], myUnreadByEvent: {} });
setMetaJson(""); setMetaJson("");
setConfigActiveKey("quotes"); setConfigActiveKey("");
setReferencesExpanded(true); setReferencesExpanded(true);
setTableCatalog([]);
setTables({ setTables({
requests: createTableState(), requests: createTableState(),
invoices: createTableState(),
quotes: createTableState(), quotes: createTableState(),
topics: createTableState(), topics: createTableState(),
statuses: createTableState(), statuses: createTableState(),
@ -1611,6 +1899,14 @@
cancelled = true; cancelled = true;
}; };
}, [bootstrapReferenceData, refreshSection, role, token]); }, [bootstrapReferenceData, refreshSection, role, token]);
useEffect(() => {
if (!dictionaryTableItems.length) {
if (configActiveKey) setConfigActiveKey("");
return;
}
const hasCurrent = dictionaryTableItems.some((item) => item.key === configActiveKey);
if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key);
}, [configActiveKey, dictionaryTableItems]);
const anyOverlayOpen = requestModal.open || recordModal.open || filterModal.open || reassignModal.open; const anyOverlayOpen = requestModal.open || recordModal.open || filterModal.open || reassignModal.open;
useEffect(() => { useEffect(() => {
document.body.classList.toggle("modal-open", anyOverlayOpen); document.body.classList.toggle("modal-open", anyOverlayOpen);
@ -1631,6 +1927,7 @@
return [ return [
{ key: "dashboard", label: "\u041E\u0431\u0437\u043E\u0440" }, { key: "dashboard", label: "\u041E\u0431\u0437\u043E\u0440" },
{ key: "requests", label: "\u0417\u0430\u044F\u0432\u043A\u0438" }, { key: "requests", label: "\u0417\u0430\u044F\u0432\u043A\u0438" },
{ key: "invoices", label: "\u0421\u0447\u0435\u0442\u0430" },
{ key: "meta", label: "\u041C\u0435\u0442\u0430\u0434\u0430\u043D\u043D\u044B\u0435" } { key: "meta", label: "\u041C\u0435\u0442\u0430\u0434\u0430\u043D\u043D\u044B\u0435" }
]; ];
}, []); }, []);
@ -1641,9 +1938,33 @@
const filterTableLabel = useMemo(() => getTableLabel(filterModal.tableKey), [filterModal.tableKey, getTableLabel]); const filterTableLabel = useMemo(() => getTableLabel(filterModal.tableKey), [filterModal.tableKey, getTableLabel]);
const recordModalFields = useMemo(() => { const recordModalFields = useMemo(() => {
const all = getRecordFields(recordModal.tableKey); const all = getRecordFields(recordModal.tableKey);
if (recordModal.mode !== "create") return all; if (recordModal.mode !== "create") return all.filter((field) => !field.createOnly);
return all.filter((field) => !field.autoCreate); return all.filter((field) => !field.autoCreate);
}, [getRecordFields, recordModal.mode, recordModal.tableKey]); }, [getRecordFields, recordModal.mode, recordModal.tableKey]);
const activeConfigTableState = useMemo(() => {
return tables[configActiveKey] || createTableState();
}, [configActiveKey, tables]);
const activeConfigMeta = useMemo(() => tableCatalogMap[configActiveKey] || null, [configActiveKey, tableCatalogMap]);
const activeConfigActions = useMemo(() => {
return Array.isArray(activeConfigMeta?.actions) ? activeConfigMeta.actions : [];
}, [activeConfigMeta]);
const canCreateInConfig = activeConfigActions.includes("create");
const canUpdateInConfig = activeConfigActions.includes("update");
const canDeleteInConfig = activeConfigActions.includes("delete");
const genericConfigHeaders = useMemo(() => {
if (!activeConfigMeta || !Array.isArray(activeConfigMeta.columns)) return [];
const headers = (activeConfigMeta.columns || []).filter((column) => column && column.name).map((column) => {
const name = String(column.name);
return {
key: name,
label: String(column.label || humanizeKey(name)),
sortable: Boolean(column.sortable !== false),
field: name
};
});
if (canUpdateInConfig || canDeleteInConfig) headers.push({ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" });
return headers;
}, [activeConfigMeta, canDeleteInConfig, canUpdateInConfig]);
return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "layout" }, /* @__PURE__ */ React.createElement("aside", { className: "sidebar" }, /* @__PURE__ */ React.createElement("div", { className: "logo" }, /* @__PURE__ */ React.createElement("a", { href: "/" }, "\u041F\u0440\u0430\u0432\u043E\u0432\u043E\u0439 \u0442\u0440\u0435\u043A\u0435\u0440")), /* @__PURE__ */ React.createElement("nav", { className: "menu" }, menuItems.map((item) => /* @__PURE__ */ React.createElement( return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "layout" }, /* @__PURE__ */ React.createElement("aside", { className: "sidebar" }, /* @__PURE__ */ React.createElement("div", { className: "logo" }, /* @__PURE__ */ React.createElement("a", { href: "/" }, "\u041F\u0440\u0430\u0432\u043E\u0432\u043E\u0439 \u0442\u0440\u0435\u043A\u0435\u0440")), /* @__PURE__ */ React.createElement("nav", { className: "menu" }, menuItems.map((item) => /* @__PURE__ */ React.createElement(
"button", "button",
{ {
@ -1665,79 +1986,16 @@
} }
}, },
"\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438 " + (referencesExpanded ? "\u25BE" : "\u25B8") "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438 " + (referencesExpanded ? "\u25BE" : "\u25B8")
), referencesExpanded ? /* @__PURE__ */ React.createElement("div", { className: "menu-tree" }, /* @__PURE__ */ React.createElement( ), referencesExpanded ? /* @__PURE__ */ React.createElement("div", { className: "menu-tree" }, dictionaryTableItems.map((item) => /* @__PURE__ */ React.createElement(
"button", "button",
{ {
key: item.key,
type: "button", type: "button",
className: activeSection === "config" && configActiveKey === "quotes" ? "active" : "", className: activeSection === "config" && configActiveKey === item.key ? "active" : "",
onClick: () => selectConfigNode("quotes") onClick: () => selectConfigNode(item.key)
}, },
"\u0426\u0438\u0442\u0430\u0442\u044B" getTableLabel(item.key)
), /* @__PURE__ */ React.createElement( ))) : null) : null), /* @__PURE__ */ React.createElement("div", { className: "auth-box" }, token && role ? /* @__PURE__ */ React.createElement(React.Fragment, null, "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044C: ", /* @__PURE__ */ React.createElement("b", null, email), /* @__PURE__ */ React.createElement("br", null), "\u0420\u043E\u043B\u044C: ", /* @__PURE__ */ React.createElement("b", null, roleLabel(role))) : "\u041D\u0435 \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u043E\u0432\u0430\u043D"), /* @__PURE__ */ React.createElement("div", { style: { marginTop: "0.75rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: refreshAll }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn danger", type: "button", onClick: logout }, "\u0412\u044B\u0439\u0442\u0438"))), /* @__PURE__ */ React.createElement("main", { className: "main" }, /* @__PURE__ */ React.createElement("div", { className: "topbar" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h1", null, "\u041F\u0430\u043D\u0435\u043B\u044C \u0430\u0434\u043C\u0438\u043D\u0438\u0441\u0442\u0440\u0430\u0442\u043E\u0440\u0430"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "UniversalQuery, RBAC \u0438 \u0430\u0443\u0434\u0438\u0442 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0439 \u043F\u043E \u043A\u043B\u044E\u0447\u0435\u0432\u044B\u043C \u0441\u0443\u0449\u043D\u043E\u0441\u0442\u044F\u043C \u0441\u0438\u0441\u0442\u0435\u043C\u044B.")), /* @__PURE__ */ React.createElement("span", { className: "badge" }, "\u0440\u043E\u043B\u044C: ", roleLabel(role))), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "dashboard", id: "section-dashboard" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u041E\u0431\u0437\u043E\u0440 \u043C\u0435\u0442\u0440\u0438\u043A"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0421\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435 \u0437\u0430\u044F\u0432\u043E\u043A \u0438 SLA-\u043C\u043E\u043D\u0438\u0442\u043E\u0440\u0438\u043D\u0433."))), /* @__PURE__ */ React.createElement("div", { className: "cards" }, dashboardData.cards.map((card) => /* @__PURE__ */ React.createElement("div", { className: "card", key: card.label }, /* @__PURE__ */ React.createElement("p", null, card.label), /* @__PURE__ */ React.createElement("b", null, card.value)))), /* @__PURE__ */ React.createElement("div", { className: "json" }, JSON.stringify(dashboardData.byStatus || {}, null, 2)), dashboardData.scope === "LAWYER" ? /* @__PURE__ */ React.createElement("div", { className: "json", style: { marginTop: "0.5rem" } }, JSON.stringify(dashboardData.myUnreadByEvent || {}, null, 2)) : null, /* @__PURE__ */ React.createElement("div", { style: { marginTop: "0.85rem" } }, /* @__PURE__ */ React.createElement("h3", { style: { margin: "0 0 0.55rem" } }, "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u044E\u0440\u0438\u0441\u0442\u043E\u0432"), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: activeSection === "config" && configActiveKey === "topics" ? "active" : "",
onClick: () => selectConfigNode("topics")
},
"\u0422\u0435\u043C\u044B"
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: activeSection === "config" && configActiveKey === "statuses" ? "active" : "",
onClick: () => selectConfigNode("statuses")
},
"\u0421\u0442\u0430\u0442\u0443\u0441\u044B"
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: activeSection === "config" && configActiveKey === "formFields" ? "active" : "",
onClick: () => selectConfigNode("formFields")
},
"\u041F\u043E\u043B\u044F \u0444\u043E\u0440\u043C\u044B"
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: activeSection === "config" && configActiveKey === "topicRequiredFields" ? "active" : "",
onClick: () => selectConfigNode("topicRequiredFields")
},
"\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F \u0442\u0435\u043C\u044B"
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: activeSection === "config" && configActiveKey === "topicDataTemplates" ? "active" : "",
onClick: () => selectConfigNode("topicDataTemplates")
},
"\u0428\u0430\u0431\u043B\u043E\u043D\u044B \u0434\u043E\u0437\u0430\u043F\u0440\u043E\u0441\u0430"
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: activeSection === "config" && configActiveKey === "statusTransitions" ? "active" : "",
onClick: () => selectConfigNode("statusTransitions")
},
"\u041F\u0435\u0440\u0435\u0445\u043E\u0434\u044B \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432"
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: activeSection === "config" && configActiveKey === "users" ? "active" : "",
onClick: () => selectConfigNode("users")
},
"\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0438"
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
className: activeSection === "config" && configActiveKey === "userTopics" ? "active" : "",
onClick: () => selectConfigNode("userTopics")
},
"\u0422\u0435\u043C\u044B \u044E\u0440\u0438\u0441\u0442\u043E\u0432"
)) : null) : null), /* @__PURE__ */ React.createElement("div", { className: "auth-box" }, token && role ? /* @__PURE__ */ React.createElement(React.Fragment, null, "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044C: ", /* @__PURE__ */ React.createElement("b", null, email), /* @__PURE__ */ React.createElement("br", null), "\u0420\u043E\u043B\u044C: ", /* @__PURE__ */ React.createElement("b", null, roleLabel(role))) : "\u041D\u0435 \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u043E\u0432\u0430\u043D"), /* @__PURE__ */ React.createElement("div", { style: { marginTop: "0.75rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: refreshAll }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn danger", type: "button", onClick: logout }, "\u0412\u044B\u0439\u0442\u0438"))), /* @__PURE__ */ React.createElement("main", { className: "main" }, /* @__PURE__ */ React.createElement("div", { className: "topbar" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h1", null, "\u041F\u0430\u043D\u0435\u043B\u044C \u0430\u0434\u043C\u0438\u043D\u0438\u0441\u0442\u0440\u0430\u0442\u043E\u0440\u0430"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "UniversalQuery, RBAC \u0438 \u0430\u0443\u0434\u0438\u0442 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0439 \u043F\u043E \u043A\u043B\u044E\u0447\u0435\u0432\u044B\u043C \u0441\u0443\u0449\u043D\u043E\u0441\u0442\u044F\u043C \u0441\u0438\u0441\u0442\u0435\u043C\u044B.")), /* @__PURE__ */ React.createElement("span", { className: "badge" }, "\u0440\u043E\u043B\u044C: ", roleLabel(role))), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "dashboard", id: "section-dashboard" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u041E\u0431\u0437\u043E\u0440 \u043C\u0435\u0442\u0440\u0438\u043A"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0421\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435 \u0437\u0430\u044F\u0432\u043E\u043A \u0438 SLA-\u043C\u043E\u043D\u0438\u0442\u043E\u0440\u0438\u043D\u0433."))), /* @__PURE__ */ React.createElement("div", { className: "cards" }, dashboardData.cards.map((card) => /* @__PURE__ */ React.createElement("div", { className: "card", key: card.label }, /* @__PURE__ */ React.createElement("p", null, card.label), /* @__PURE__ */ React.createElement("b", null, card.value)))), /* @__PURE__ */ React.createElement("div", { className: "json" }, JSON.stringify(dashboardData.byStatus || {}, null, 2)), /* @__PURE__ */ React.createElement("div", { style: { marginTop: "0.85rem" } }, /* @__PURE__ */ React.createElement("h3", { style: { margin: "0 0 0.55rem" } }, "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u044E\u0440\u0438\u0441\u0442\u043E\u0432"), /* @__PURE__ */ React.createElement(
DataTable, DataTable,
{ {
headers: [ headers: [
@ -1745,11 +2003,14 @@
{ key: "email", label: "Email" }, { key: "email", label: "Email" },
{ key: "primary_topic_code", label: "\u041E\u0441\u043D\u043E\u0432\u043D\u0430\u044F \u0442\u0435\u043C\u0430" }, { key: "primary_topic_code", label: "\u041E\u0441\u043D\u043E\u0432\u043D\u0430\u044F \u0442\u0435\u043C\u0430" },
{ key: "active_load", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u044B\u0435 \u0437\u0430\u044F\u0432\u043A\u0438" }, { key: "active_load", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u044B\u0435 \u0437\u0430\u044F\u0432\u043A\u0438" },
{ key: "total_assigned", label: "\u0412\u0441\u0435\u0433\u043E \u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E" } { key: "total_assigned", label: "\u0412\u0441\u0435\u0433\u043E \u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E" },
{ key: "active_amount", label: "\u0421\u0443\u043C\u043C\u0430 \u0430\u043A\u0442\u0438\u0432\u043D\u044B\u0445" },
{ key: "monthly_paid_gross", label: "\u0412\u0430\u043B \u043E\u043F\u043B\u0430\u0442 \u0437\u0430 \u043C\u0435\u0441\u044F\u0446" },
{ key: "monthly_salary", label: "\u0417\u0430\u0440\u043F\u043B\u0430\u0442\u0430 \u0437\u0430 \u043C\u0435\u0441\u044F\u0446" }
], ],
rows: dashboardData.lawyerLoads || [], rows: dashboardData.lawyerLoads || [],
emptyColspan: 5, emptyColspan: 8,
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.lawyer_id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "user-identity" }, /* @__PURE__ */ React.createElement(UserAvatar, { name: row.name, email: row.email, avatarUrl: row.avatar_url, accessToken: token, size: 32 }), /* @__PURE__ */ React.createElement("div", { className: "user-identity-text" }, /* @__PURE__ */ React.createElement("b", null, row.name || "-")))), /* @__PURE__ */ React.createElement("td", null, row.email || "-"), /* @__PURE__ */ React.createElement("td", null, row.primary_topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, String(row.active_load ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.total_assigned ?? 0))) renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.lawyer_id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "user-identity" }, /* @__PURE__ */ React.createElement(UserAvatar, { name: row.name, email: row.email, avatarUrl: row.avatar_url, accessToken: token, size: 32 }), /* @__PURE__ */ React.createElement("div", { className: "user-identity-text" }, /* @__PURE__ */ React.createElement("b", null, row.name || "-")))), /* @__PURE__ */ React.createElement("td", null, row.email || "-"), /* @__PURE__ */ React.createElement("td", null, row.primary_topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, String(row.active_load ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.total_assigned ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.active_amount ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.monthly_paid_gross ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.monthly_salary ?? 0)))
} }
)), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("dashboard") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "requests", id: "section-requests" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0417\u0430\u044F\u0432\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0421\u0435\u0440\u0432\u0435\u0440\u043D\u0430\u044F \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u0446\u0438\u044F \u0438 \u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440 \u043A\u043B\u0438\u0435\u043D\u0442\u0441\u043A\u0438\u0445 \u0437\u0430\u044F\u0432\u043E\u043A.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("requests", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("requests") }, "\u041D\u043E\u0432\u0430\u044F \u0437\u0430\u044F\u0432\u043A\u0430"))), /* @__PURE__ */ React.createElement( )), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("dashboard") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "requests", id: "section-requests" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0417\u0430\u044F\u0432\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0421\u0435\u0440\u0432\u0435\u0440\u043D\u0430\u044F \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u0446\u0438\u044F \u0438 \u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440 \u043A\u043B\u0438\u0435\u043D\u0442\u0441\u043A\u0438\u0445 \u0437\u0430\u044F\u0432\u043E\u043A.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("requests", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("requests") }, "\u041D\u043E\u0432\u0430\u044F \u0437\u0430\u044F\u0432\u043A\u0430"))), /* @__PURE__ */ React.createElement(
FilterToolbar, FilterToolbar,
@ -1773,15 +2034,17 @@
{ key: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "status_code" }, { key: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "status_code" },
{ key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" }, { key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" },
{ key: "assigned_lawyer_id", label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D", sortable: true, field: "assigned_lawyer_id" }, { key: "assigned_lawyer_id", label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D", sortable: true, field: "assigned_lawyer_id" },
{ key: "invoice_amount", label: "\u0421\u0447\u0435\u0442", sortable: true, field: "invoice_amount" },
{ key: "paid_at", label: "\u041E\u043F\u043B\u0430\u0447\u0435\u043D\u043E", sortable: true, field: "paid_at" },
{ key: "updates", label: "\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F" }, { key: "updates", label: "\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F" },
{ key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D\u0430", sortable: true, field: "created_at" }, { key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D\u0430", sortable: true, field: "created_at" },
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
], ],
rows: tables.requests.rows, rows: tables.requests.rows,
emptyColspan: 9, emptyColspan: 11,
onSort: (field) => toggleTableSort("requests", field), onSort: (field) => toggleTableSort("requests", field),
sortClause: tables.requests.sort && tables.requests.sort[0] || TABLE_SERVER_CONFIG.requests.sort[0], sortClause: tables.requests.sort && tables.requests.sort[0] || TABLE_SERVER_CONFIG.requests.sort[0],
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.track_number || "-")), /* @__PURE__ */ React.createElement("td", null, row.client_name || "-"), /* @__PURE__ */ React.createElement("td", null, row.client_phone || "-"), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.status_code)), /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.assigned_lawyer_id || "-"), /* @__PURE__ */ React.createElement("td", null, renderRequestUpdatesCell(row, role)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, role === "LAWYER" && !row.assigned_lawyer_id ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F4E5}", tooltip: "\u0412\u0437\u044F\u0442\u044C \u0432 \u0440\u0430\u0431\u043E\u0442\u0443", onClick: () => claimRequest(row.id) }) : null, role === "ADMIN" && row.assigned_lawyer_id ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u21C4", tooltip: "\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0438\u0442\u044C", onClick: () => openReassignModal(row) }) : null, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F441}", tooltip: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openRequestDetails(row.id) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openEditRecordModal("requests", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => deleteRecord("requests", row.id), tone: "danger" })))) renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.track_number || "-")), /* @__PURE__ */ React.createElement("td", null, row.client_name || "-"), /* @__PURE__ */ React.createElement("td", null, row.client_phone || "-"), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.status_code)), /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.assigned_lawyer_id || "-"), /* @__PURE__ */ React.createElement("td", null, row.invoice_amount == null ? "-" : String(row.invoice_amount)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.paid_at)), /* @__PURE__ */ React.createElement("td", null, renderRequestUpdatesCell(row, role)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, role === "LAWYER" && !row.assigned_lawyer_id ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F4E5}", tooltip: "\u0412\u0437\u044F\u0442\u044C \u0432 \u0440\u0430\u0431\u043E\u0442\u0443", onClick: () => claimRequest(row.id) }) : null, role === "ADMIN" && row.assigned_lawyer_id ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u21C4", tooltip: "\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0438\u0442\u044C", onClick: () => openReassignModal(row) }) : null, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F441}", tooltip: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openRequestDetails(row.id) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openEditRecordModal("requests", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => deleteRecord("requests", row.id), tone: "danger" }))))
} }
), /* @__PURE__ */ React.createElement( ), /* @__PURE__ */ React.createElement(
TablePager, TablePager,
@ -1791,7 +2054,47 @@
onNext: () => loadNextPage("requests"), onNext: () => loadNextPage("requests"),
onLoadAll: () => loadAllRows("requests") onLoadAll: () => loadAllRows("requests")
} }
), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("requests") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "quotes", id: "section-quotes" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0426\u0438\u0442\u0430\u0442\u044B"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0423\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u043F\u0443\u0431\u043B\u0438\u0447\u043D\u043E\u0439 \u043B\u0435\u043D\u0442\u043E\u0439 \u0446\u0438\u0442\u0430\u0442 \u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u043D\u044B\u043C\u0438 \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u043C\u0438.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("quotes", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("quotes") }, "\u041D\u043E\u0432\u0430\u044F \u0446\u0438\u0442\u0430\u0442\u0430"))), /* @__PURE__ */ React.createElement( ), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("requests") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "invoices", id: "section-invoices" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u0447\u0435\u0442\u0430"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0412\u044B\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u043D\u044B\u0435 \u0441\u0447\u0435\u0442\u0430 \u043A\u043B\u0438\u0435\u043D\u0442\u0430\u043C, \u0441\u0442\u0430\u0442\u0443\u0441\u044B \u043E\u043F\u043B\u0430\u0442\u044B \u0438 \u0432\u044B\u0433\u0440\u0443\u0437\u043A\u0430 PDF.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("invoices", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("invoices") }, "\u041D\u043E\u0432\u044B\u0439 \u0441\u0447\u0435\u0442"))), /* @__PURE__ */ React.createElement(
FilterToolbar,
{
filters: tables.invoices.filters,
onOpen: () => openFilterModal("invoices"),
onRemove: (index) => removeFilterChip("invoices", index),
onEdit: (index) => openFilterEditModal("invoices", index),
getChipLabel: (clause) => {
const fieldDef = getFieldDef("invoices", clause.field);
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("invoices", clause);
}
}
), /* @__PURE__ */ React.createElement(
DataTable,
{
headers: [
{ key: "invoice_number", label: "\u041D\u043E\u043C\u0435\u0440", sortable: true, field: "invoice_number" },
{ key: "status", label: "\u0421\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "status" },
{ key: "amount", label: "\u0421\u0443\u043C\u043C\u0430", sortable: true, field: "amount" },
{ key: "payer_display_name", label: "\u041F\u043B\u0430\u0442\u0435\u043B\u044C\u0449\u0438\u043A", sortable: true, field: "payer_display_name" },
{ key: "request_track_number", label: "\u0417\u0430\u044F\u0432\u043A\u0430" },
{ key: "issued_by_name", label: "\u0412\u044B\u0441\u0442\u0430\u0432\u0438\u043B", sortable: true, field: "issued_by_admin_user_id" },
{ key: "issued_at", label: "\u0421\u0444\u043E\u0440\u043C\u0438\u0440\u043E\u0432\u0430\u043D", sortable: true, field: "issued_at" },
{ key: "paid_at", label: "\u041E\u043F\u043B\u0430\u0447\u0435\u043D", sortable: true, field: "paid_at" },
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
],
rows: tables.invoices.rows,
emptyColspan: 9,
onSort: (field) => toggleTableSort("invoices", field),
sortClause: tables.invoices.sort && tables.invoices.sort[0] || TABLE_SERVER_CONFIG.invoices.sort[0],
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.invoice_number || "-")), /* @__PURE__ */ React.createElement("td", null, row.status_label || invoiceStatusLabel(row.status)), /* @__PURE__ */ React.createElement("td", null, row.amount == null ? "-" : String(row.amount) + " " + String(row.currency || "RUB")), /* @__PURE__ */ React.createElement("td", null, row.payer_display_name || "-"), /* @__PURE__ */ React.createElement("td", null, row.request_track_number || row.request_id || "-"), /* @__PURE__ */ React.createElement("td", null, row.issued_by_name || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.issued_at)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.paid_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F441}", tooltip: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openInvoiceRequest(row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u2B07", tooltip: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C PDF", onClick: () => downloadInvoicePdf(row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0447\u0435\u0442", onClick: () => openEditRecordModal("invoices", row) }), role === "ADMIN" ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0447\u0435\u0442", onClick: () => deleteRecord("invoices", row.id), tone: "danger" }) : null)))
}
), /* @__PURE__ */ React.createElement(
TablePager,
{
tableState: tables.invoices,
onPrev: () => loadPrevPage("invoices"),
onNext: () => loadNextPage("invoices"),
onLoadAll: () => loadAllRows("invoices")
}
), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("invoices") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "quotes", id: "section-quotes" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0426\u0438\u0442\u0430\u0442\u044B"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0423\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u043F\u0443\u0431\u043B\u0438\u0447\u043D\u043E\u0439 \u043B\u0435\u043D\u0442\u043E\u0439 \u0446\u0438\u0442\u0430\u0442 \u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u043D\u044B\u043C\u0438 \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u043C\u0438.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("quotes", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("quotes") }, "\u041D\u043E\u0432\u0430\u044F \u0446\u0438\u0442\u0430\u0442\u0430"))), /* @__PURE__ */ React.createElement(
FilterToolbar, FilterToolbar,
{ {
filters: tables.quotes.filters, filters: tables.quotes.filters,
@ -1829,10 +2132,10 @@
onNext: () => loadNextPage("quotes"), onNext: () => loadNextPage("quotes"),
onLoadAll: () => loadAllRows("quotes") onLoadAll: () => loadAllRows("quotes")
} }
), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("quotes") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "config", id: "section-config" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u0432 \u0434\u0435\u0440\u0435\u0432\u0435 \u0441\u043B\u0435\u0432\u0430.")), /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadCurrentConfigTable(true) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C")), /* @__PURE__ */ React.createElement("div", { className: "config-layout" }, /* @__PURE__ */ React.createElement("div", { className: "config-panel" }, /* @__PURE__ */ React.createElement("div", { className: "block" }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" } }, /* @__PURE__ */ React.createElement("h3", { style: { margin: 0 } }, getTableLabel(configActiveKey)), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal(configActiveKey) }, "\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C")), /* @__PURE__ */ React.createElement( ), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("quotes") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "config", id: "section-config" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u0432 \u0434\u0435\u0440\u0435\u0432\u0435 \u0441\u043B\u0435\u0432\u0430.")), /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadCurrentConfigTable(true) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C")), /* @__PURE__ */ React.createElement("div", { className: "config-layout" }, /* @__PURE__ */ React.createElement("div", { className: "config-panel" }, /* @__PURE__ */ React.createElement("div", { className: "block" }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" } }, /* @__PURE__ */ React.createElement("h3", { style: { margin: 0 } }, configActiveKey ? getTableLabel(configActiveKey) : "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D"), canCreateInConfig && configActiveKey ? /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal(configActiveKey) }, "\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C") : null), /* @__PURE__ */ React.createElement(
FilterToolbar, FilterToolbar,
{ {
filters: tables[configActiveKey].filters, filters: activeConfigTableState.filters,
onOpen: () => openFilterModal(configActiveKey), onOpen: () => openFilterModal(configActiveKey),
onRemove: (index) => removeFilterChip(configActiveKey, index), onRemove: (index) => removeFilterChip(configActiveKey, index),
onEdit: (index) => openFilterEditModal(configActiveKey, index), onEdit: (index) => openFilterEditModal(configActiveKey, index),
@ -1881,16 +2184,18 @@
headers: [ headers: [
{ key: "code", label: "\u041A\u043E\u0434", sortable: true, field: "code" }, { key: "code", label: "\u041A\u043E\u0434", sortable: true, field: "code" },
{ key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", sortable: true, field: "name" }, { key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", sortable: true, field: "name" },
{ key: "kind", label: "\u0422\u0438\u043F", sortable: true, field: "kind" },
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "enabled" }, { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "enabled" },
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" },
{ key: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", sortable: true, field: "is_terminal" }, { key: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", sortable: true, field: "is_terminal" },
{ key: "invoice_template", label: "\u0428\u0430\u0431\u043B\u043E\u043D \u0441\u0447\u0435\u0442\u0430" },
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
], ],
rows: tables.statuses.rows, rows: tables.statuses.rows,
emptyColspan: 6, emptyColspan: 8,
onSort: (field) => toggleTableSort("statuses", field), onSort: (field) => toggleTableSort("statuses", field),
sortClause: tables.statuses.sort && tables.statuses.sort[0] || TABLE_SERVER_CONFIG.statuses.sort[0], sortClause: tables.statuses.sort && tables.statuses.sort[0] || TABLE_SERVER_CONFIG.statuses.sort[0],
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.code || "-")), /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_terminal)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => openEditRecordModal("statuses", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => deleteRecord("statuses", row.id), tone: "danger" })))) renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.code || "-")), /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, statusKindLabel(row.kind)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_terminal)), /* @__PURE__ */ React.createElement("td", null, row.invoice_template || "-"), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => openEditRecordModal("statuses", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => deleteRecord("statuses", row.id), tone: "danger" }))))
} }
) : null, configActiveKey === "formFields" ? /* @__PURE__ */ React.createElement( ) : null, configActiveKey === "formFields" ? /* @__PURE__ */ React.createElement(
DataTable, DataTable,
@ -1970,15 +2275,16 @@
{ key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" }, { key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" },
{ key: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", sortable: true, field: "from_status" }, { key: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", sortable: true, field: "from_status" },
{ key: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "to_status" }, { key: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "to_status" },
{ key: "sla_hours", label: "SLA (\u0447\u0430\u0441\u044B)", sortable: true, field: "sla_hours" },
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "enabled" }, { key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "enabled" },
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" }, { key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" },
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
], ],
rows: tables.statusTransitions.rows, rows: tables.statusTransitions.rows,
emptyColspan: 6, emptyColspan: 7,
onSort: (field) => toggleTableSort("statusTransitions", field), onSort: (field) => toggleTableSort("statusTransitions", field),
sortClause: tables.statusTransitions.sort && tables.statusTransitions.sort[0] || TABLE_SERVER_CONFIG.statusTransitions.sort[0], sortClause: tables.statusTransitions.sort && tables.statusTransitions.sort[0] || TABLE_SERVER_CONFIG.statusTransitions.sort[0],
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.from_status)), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.to_status)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement( renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.from_status)), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.to_status)), /* @__PURE__ */ React.createElement("td", null, row.sla_hours == null ? "-" : String(row.sla_hours)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(
IconButton, IconButton,
{ {
icon: "\u270E", icon: "\u270E",
@ -2003,16 +2309,18 @@
{ key: "email", label: "Email", sortable: true, field: "email" }, { key: "email", label: "Email", sortable: true, field: "email" },
{ key: "role", label: "\u0420\u043E\u043B\u044C", sortable: true, field: "role" }, { key: "role", label: "\u0420\u043E\u043B\u044C", sortable: true, field: "role" },
{ key: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", sortable: true, field: "primary_topic_code" }, { key: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", sortable: true, field: "primary_topic_code" },
{ key: "default_rate", label: "\u0421\u0442\u0430\u0432\u043A\u0430", sortable: true, field: "default_rate" },
{ key: "salary_percent", label: "\u041F\u0440\u043E\u0446\u0435\u043D\u0442", sortable: true, field: "salary_percent" },
{ key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "is_active" }, { key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "is_active" },
{ key: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", sortable: true, field: "responsible" }, { key: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", sortable: true, field: "responsible" },
{ key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D", sortable: true, field: "created_at" }, { key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D", sortable: true, field: "created_at" },
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } { key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
], ],
rows: tables.users.rows, rows: tables.users.rows,
emptyColspan: 8, emptyColspan: 10,
onSort: (field) => toggleTableSort("users", field), onSort: (field) => toggleTableSort("users", field),
sortClause: tables.users.sort && tables.users.sort[0] || TABLE_SERVER_CONFIG.users.sort[0], sortClause: tables.users.sort && tables.users.sort[0] || TABLE_SERVER_CONFIG.users.sort[0],
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "user-identity" }, /* @__PURE__ */ React.createElement(UserAvatar, { name: row.name, email: row.email, avatarUrl: row.avatar_url, accessToken: token, size: 32 }), /* @__PURE__ */ React.createElement("div", { className: "user-identity-text" }, /* @__PURE__ */ React.createElement("b", null, row.name || "-")))), /* @__PURE__ */ React.createElement("td", null, row.email || "-"), /* @__PURE__ */ React.createElement("td", null, roleLabel(row.role)), /* @__PURE__ */ React.createElement("td", null, row.primary_topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_active)), /* @__PURE__ */ React.createElement("td", null, row.responsible || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", onClick: () => openEditRecordModal("users", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", onClick: () => deleteRecord("users", row.id), tone: "danger" })))) renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "user-identity" }, /* @__PURE__ */ React.createElement(UserAvatar, { name: row.name, email: row.email, avatarUrl: row.avatar_url, accessToken: token, size: 32 }), /* @__PURE__ */ React.createElement("div", { className: "user-identity-text" }, /* @__PURE__ */ React.createElement("b", null, row.name || "-")))), /* @__PURE__ */ React.createElement("td", null, row.email || "-"), /* @__PURE__ */ React.createElement("td", null, roleLabel(row.role)), /* @__PURE__ */ React.createElement("td", null, row.primary_topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.default_rate == null ? "-" : String(row.default_rate)), /* @__PURE__ */ React.createElement("td", null, row.salary_percent == null ? "-" : String(row.salary_percent)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_active)), /* @__PURE__ */ React.createElement("td", null, row.responsible || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", onClick: () => openEditRecordModal("users", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", onClick: () => deleteRecord("users", row.id), tone: "danger" }))))
} }
) : null, configActiveKey === "userTopics" ? /* @__PURE__ */ React.createElement( ) : null, configActiveKey === "userTopics" ? /* @__PURE__ */ React.createElement(
DataTable, DataTable,
@ -2034,10 +2342,27 @@
return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, lawyerLabel), /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.responsible || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0432\u044F\u0437\u044C", onClick: () => openEditRecordModal("userTopics", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0432\u044F\u0437\u044C", onClick: () => deleteRecord("userTopics", row.id), tone: "danger" })))); return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, lawyerLabel), /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.responsible || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0432\u044F\u0437\u044C", onClick: () => openEditRecordModal("userTopics", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0432\u044F\u0437\u044C", onClick: () => deleteRecord("userTopics", row.id), tone: "danger" }))));
} }
} }
) : null, configActiveKey && !KNOWN_CONFIG_TABLE_KEYS.has(configActiveKey) ? /* @__PURE__ */ React.createElement(
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) => /* @__PURE__ */ React.createElement("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 /* @__PURE__ */ React.createElement("td", { key }, boolLabel(Boolean(value)));
if (column.kind === "date" || column.kind === "datetime") return /* @__PURE__ */ React.createElement("td", { key }, fmtDate(value));
if (column.kind === "json") return /* @__PURE__ */ React.createElement("td", { key }, value == null ? "-" : JSON.stringify(value));
return /* @__PURE__ */ React.createElement("td", { key }, value == null || value === "" ? "-" : String(value));
}), canUpdateInConfig || canDeleteInConfig ? /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, canUpdateInConfig ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C", onClick: () => openEditRecordModal(configActiveKey, row) }) : null, canDeleteInConfig ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C", onClick: () => deleteRecord(configActiveKey, row.id), tone: "danger" }) : null)) : null)
}
) : null, /* @__PURE__ */ React.createElement( ) : null, /* @__PURE__ */ React.createElement(
TablePager, TablePager,
{ {
tableState: tables[configActiveKey], tableState: activeConfigTableState,
onPrev: () => loadPrevPage(configActiveKey), onPrev: () => loadPrevPage(configActiveKey),
onNext: () => loadNextPage(configActiveKey), onNext: () => loadNextPage(configActiveKey),
onLoadAll: () => loadAllRows(configActiveKey) onLoadAll: () => loadAllRows(configActiveKey)