diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..022d0ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/tmp/ +*.idea +.env + diff --git a/alembic/env.py b/alembic/env.py index d6ea437..d15bac0 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -18,6 +18,8 @@ from app.models.otp_session import OtpSession from app.models.quote import Quote from app.models.admin_user_topic import AdminUserTopic from app.models.notification import Notification +from app.models.invoice import Invoice +from app.models.security_audit_log import SecurityAuditLog config = context.config fileConfig(config.config_file_name) diff --git a/alembic/versions/0012_add_invoices_table.py b/alembic/versions/0012_add_invoices_table.py new file mode 100644 index 0000000..316263c --- /dev/null +++ b/alembic/versions/0012_add_invoices_table.py @@ -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") diff --git a/alembic/versions/0013_add_status_kind_for_billing.py b/alembic/versions/0013_add_status_kind_for_billing.py new file mode 100644 index 0000000..33c6521 --- /dev/null +++ b/alembic/versions/0013_add_status_kind_for_billing.py @@ -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") diff --git a/alembic/versions/0014_add_security_audit_log.py b/alembic/versions/0014_add_security_audit_log.py new file mode 100644 index 0000000..8983845 --- /dev/null +++ b/alembic/versions/0014_add_security_audit_log.py @@ -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") diff --git a/app/api/admin/config.py b/app/api/admin/config.py index c502a2a..cea74cd 100644 --- a/app/api/admin/config.py +++ b/app/api/admin/config.py @@ -25,6 +25,8 @@ def _status_row(row: Status): "enabled": row.enabled, "sort_order": row.sort_order, "is_terminal": row.is_terminal, + "kind": row.kind, + "invoice_template": row.invoice_template, } diff --git a/app/api/admin/crud.py b/app/api/admin/crud.py index 8fb026a..8aaefbe 100644 --- a/app/api/admin/crud.py +++ b/app/api/admin/crud.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.exc import IntegrityError from sqlalchemy.inspection import inspect as sa_inspect from sqlalchemy.orm import Session +from sqlalchemy.sql.sqltypes import Boolean, Date, DateTime, Float, Integer, JSON, Numeric import app.models as models_pkg 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.status_flow import transition_allowed_for_topic 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 router = APIRouter() @@ -62,6 +64,7 @@ SYSTEM_FIELDS = { "lawyer_has_unread_updates", "lawyer_unread_event_type", } +REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"} # Per-table RBAC: table -> role -> actions. @@ -76,6 +79,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = { "statuses": {"ADMIN": set(CRUD_ACTIONS)}, "form_fields": {"ADMIN": set(CRUD_ACTIONS)}, "audit_log": {"ADMIN": {"query", "read"}}, + "security_audit_log": {"ADMIN": {"query", "read"}}, "otp_sessions": {"ADMIN": {"query", "read"}}, "admin_users": {"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} +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]: if table_name == "admin_users": return {"password_hash"} @@ -270,6 +527,14 @@ def _normalize_optional_string(value: Any) -> str | 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]: data = dict(payload) if "password_hash" in data: @@ -466,6 +731,16 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any]) 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 = { "а": "a", "б": "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") def query_table( table_name: str, @@ -711,14 +1016,27 @@ def create_row( ): normalized, model = _resolve_table_model(table_name) _require_table_action(admin, normalized, "create") - if normalized == "requests" and _is_lawyer(admin): - assigned_lawyer_id = payload.get("assigned_lawyer_id") if isinstance(payload, dict) else None + if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): + assigned_lawyer_id = payload.get("assigned_lawyer_id") if str(assigned_lawyer_id or "").strip(): 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) if normalized == "requests": 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) clean_payload = _sanitize_payload( model, @@ -737,6 +1055,8 @@ def create_row( clean_payload = _apply_request_data_requirements_fields(db, clean_payload) if normalized == "topic_status_transitions": 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): clean_payload["responsible"] = _resolve_responsible(admin) row = model(**clean_payload) @@ -766,8 +1086,12 @@ def update_row( ): normalized, model = _resolve_table_model(table_name) _require_table_action(admin, normalized, "update") - if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict) and "assigned_lawyer_id" in payload: - raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') + if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict): + 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) if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)): raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования") @@ -791,6 +1115,17 @@ def update_row( clean_payload = _apply_request_data_requirements_fields(db, clean_payload) if normalized == "topic_status_transitions": 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) if normalized == "topic_status_transitions": next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip() @@ -807,6 +1142,14 @@ def update_row( detail="Переход статуса не разрешен для выбранной темы", ) 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) apply_status_change_effects( db, @@ -822,7 +1165,7 @@ def update_row( event_type=NOTIFICATION_EVENT_STATUS, actor_role=_actor_role(admin), actor_admin_user_id=admin.get("sub"), - body=f"{before_status} -> {after_status}", + body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")), responsible=_resolve_responsible(admin), ) for key, value in clean_payload.items(): diff --git a/app/api/admin/invoices.py b/app/api/admin/invoices.py new file mode 100644 index 0000000..425fef6 --- /dev/null +++ b/app/api/admin/invoices.py @@ -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) diff --git a/app/api/admin/requests.py b/app/api/admin/requests.py index 9a26925..5ea4acd 100644 --- a/app/api/admin/requests.py +++ b/app/api/admin/requests.py @@ -4,7 +4,7 @@ from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError -from sqlalchemy import update +from sqlalchemy import case, update from app.db.session import get_db 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.status_flow import transition_allowed_for_topic 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 router = APIRouter() +REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"} 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="Некорректный идентификатор заявки") +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: role = str(admin.get("role") or "").upper() if role != "LAWYER": @@ -99,11 +112,23 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe @router.post("", status_code=201) 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="Юрист не может назначать заявку при создании") + 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) track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}" 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( track_number=track, 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, description=payload.description, extra_fields=payload.extra_fields, - assigned_lawyer_id=payload.assigned_lawyer_id, - effective_rate=payload.effective_rate, + assigned_lawyer_id=assigned_lawyer_id, + effective_rate=effective_rate, invoice_amount=payload.invoice_amount, paid_at=payload.paid_at, paid_by_admin_id=payload.paid_by_admin_id, @@ -142,26 +167,49 @@ def update_request( if not row: raise HTTPException(status_code=404, detail="Заявка не найдена") changes = payload.model_dump(exclude_unset=True) - if str(admin.get("role") or "").upper() == "LAWYER" and "assigned_lawyer_id" in changes: - raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"') + actor_role = str(admin.get("role") or "").upper() + 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 "") responsible = str(admin.get("email") or "").strip() or "Администратор системы" for key, value in changes.items(): setattr(row, key, value) 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( db, str(row.topic_code or "").strip() or None, old_status, - str(changes.get("status_code") or ""), + next_status, ): 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) apply_status_change_effects( db, row, from_status=old_status, - to_status=str(changes.get("status_code") or ""), + to_status=next_status, admin=admin, responsible=responsible, ) @@ -171,7 +219,7 @@ def update_request( event_type=NOTIFICATION_EVENT_STATUS, actor_role=str(admin.get("role") or "").upper() or "ADMIN", actor_admin_user_id=admin.get("sub"), - body=f"{old_status} -> {str(changes.get('status_code') or '').strip()}", + body=(f"{old_status} -> {next_status}" + (f"\n{billing_note}" if billing_note else "")), responsible=responsible, ) 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)) .values( assigned_lawyer_id=str(lawyer_uuid), + effective_rate=case((Request.effective_rate.is_(None), lawyer.default_rate), else_=Request.effective_rate), updated_at=now, responsible=responsible, ) @@ -346,6 +395,7 @@ def reassign_request( .where(Request.id == request_uuid, Request.assigned_lawyer_id == old_assigned) .values( 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, responsible=responsible, ) diff --git a/app/api/admin/router.py b/app/api/admin/router.py index 43edaf4..b54af52 100644 --- a/app/api/admin/router.py +++ b/app/api/admin/router.py @@ -1,5 +1,5 @@ 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.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(metrics.router, prefix="/metrics", tags=["AdminMetrics"]) 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"]) diff --git a/app/api/admin/uploads.py b/app/api/admin/uploads.py index 9232ea5..1bd5139 100644 --- a/app/api/admin/uploads.py +++ b/app/api/admin/uploads.py @@ -4,7 +4,7 @@ import uuid from typing import Tuple 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 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.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.security_audit import record_file_security_event from app.services.s3_storage import build_object_key, get_s3_storage router = APIRouter() @@ -77,169 +78,327 @@ def _uuid_or_none(raw: str) -> uuid.UUID | 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) def upload_init( payload: UploadInitPayload, + http_request: FastapiRequest, db: Session = Depends(get_db), admin: dict = Depends(require_role("ADMIN", "LAWYER")), ): - _validate_size_or_400(payload.size_bytes) - storage = get_s3_storage() - role = str(admin.get("role") or "") - actor_id = str(admin.get("sub") or "") + role = str(admin.get("role") or "").upper() or "UNKNOWN" + actor_id = str(admin.get("sub") or "").strip() + actor_ip = _client_ip(http_request) + 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: - 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_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)) + try: + _validate_size_or_400(payload.size_bytes) + storage = get_s3_storage() - 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) - return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type)) + if payload.scope == UploadScope.REQUEST_ATTACHMENT: + 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_case_capacity_or_400(request, payload.size_bytes) + key = build_object_key(f"requests/{request.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, + 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) def upload_complete( payload: UploadCompletePayload, + http_request: FastapiRequest, db: Session = Depends(get_db), admin: dict = Depends(require_role("ADMIN", "LAWYER")), ): - _validate_size_or_400(payload.size_bytes) - storage = get_s3_storage() - role = str(admin.get("role") or "") + role = str(admin.get("role") or "").upper() or "UNKNOWN" actor_id = str(admin.get("sub") or "") + actor_ip = _client_ip(http_request) responsible = str(admin.get("email") or "").strip() or "Администратор системы" + scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope) + try: - head = storage.head_object(payload.key) - except ClientError: - raise HTTPException(status_code=400, detail="Файл не найден в хранилище") + _validate_size_or_400(payload.size_bytes) + storage = get_s3_storage() + 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) - if actual_size <= 0: - raise HTTPException(status_code=400, detail="Некорректный размер файла") - if actual_size > _max_file_bytes(): - raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") + actual_size = int(head.get("ContentLength") or payload.size_bytes) + if actual_size <= 0: + raise HTTPException(status_code=400, detail="Некорректный размер файла") + if actual_size > _max_file_bytes(): + raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") - if payload.scope == UploadScope.REQUEST_ATTACHMENT: - 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_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") - _ensure_case_capacity_or_400(request, actual_size) + if payload.scope == UploadScope.REQUEST_ATTACHMENT: + 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_object_key_prefix_or_400(payload.key, f"requests/{request.id}/") + _ensure_case_capacity_or_400(request, actual_size) - message_uuid = None - if payload.message_id: - message_uuid = _uuid_or_400(payload.message_id, "message_id") - message = db.get(Message, message_uuid) - if message is None or message.request_id != request.id: - raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки") - if bool(message.immutable): - raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению") + message_uuid = None + if payload.message_id: + message_uuid = _uuid_or_400(payload.message_id, "message_id") + message = db.get(Message, message_uuid) + if message is None or message.request_id != request.id: + raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки") + if bool(message.immutable): + raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению") - row = Attachment( - request_id=request.id, - message_id=message_uuid, - file_name=payload.file_name, - mime_type=payload.mime_type, - size_bytes=actual_size, - s3_key=payload.key, - responsible=responsible, - ) - mark_unread_for_client(request, EVENT_ATTACHMENT) - notify_request_event( + row = Attachment( + request_id=request.id, + message_id=message_uuid, + file_name=payload.file_name, + mime_type=payload.mime_type, + size_bytes=actual_size, + s3_key=payload.key, + responsible=responsible, + ) + mark_unread_for_client(request, EVENT_ATTACHMENT) + 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, - 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}', + actor_role=role, + actor_subject=actor_id, + actor_ip=actor_ip, + action="UPLOAD_COMPLETE", + 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, + persist_now=True, ) - request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size - 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") + raise @router.get("/object/{object_key:path}") -def get_object_proxy(object_key: str, token: str = Query(...), db: Session = Depends(get_db)): - try: - claims = decode_jwt(token, settings.ADMIN_JWT_SECRET) - except Exception: - raise HTTPException(status_code=401, detail="Некорректный токен") - role = str(claims.get("role") or "").upper() - if role not in {"ADMIN", "LAWYER"}: - raise HTTPException(status_code=403, detail="Недостаточно прав") - +def get_object_proxy( + object_key: str, + http_request: FastapiRequest, + token: str = Query(...), + db: Session = Depends(get_db), +): key = str(object_key or "").strip() - if not key: - raise HTTPException(status_code=400, detail="Некорректный ключ объекта") + scope = "UNKNOWN" + 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) - if role == "LAWYER": - actor_id = _uuid_or_none(claims.get("sub")) - if actor_id is None: + try: + try: + claims = decode_jwt(token, settings.ADMIN_JWT_SECRET) + except Exception: raise HTTPException(status_code=401, detail="Некорректный токен") - scoped_uuid = _uuid_or_none(scoped_id_raw) - 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: + actor_role = str(claims.get("role") or "").upper() + actor_subject = str(claims.get("sub") or "").strip() + responsible = str(claims.get("email") or "").strip() or "Администратор системы" + if actor_role not in {"ADMIN", "LAWYER"}: raise HTTPException(status_code=403, detail="Недостаточно прав") - try: - obj = get_s3_storage().get_object(key) - except ClientError: - raise HTTPException(status_code=404, detail="Файл не найден") + if not key: + raise HTTPException(status_code=400, detail="Некорректный ключ объекта") - 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) + scope, scoped_id_raw = _parse_scoped_object_key(key) + scoped_uuid = _uuid_or_none(scoped_id_raw) + if actor_role == "LAWYER": + actor_id = _uuid_or_none(claims.get("sub")) + if actor_id is None: + raise HTTPException(status_code=401, detail="Некорректный токен") + 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 diff --git a/app/api/public/otp.py b/app/api/public/otp.py index 21046e2..ec4bca3 100644 --- a/app/api/public/otp.py +++ b/app/api/public/otp.py @@ -1,17 +1,19 @@ from __future__ import annotations import secrets +import hashlib 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 app.core.config import settings from app.core.security import create_jwt, hash_password, verify_password from app.db.session import get_db 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.services.rate_limit import get_rate_limiter router = APIRouter() @@ -55,6 +57,45 @@ def _generate_code() -> str: 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: token = create_jwt( {"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") -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) if purpose not in ALLOWED_PURPOSES: 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) if not track_number: raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST') - request = db.query(Request).filter(Request.track_number == track_number).first() - if request is None: + request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first() + if request_row is None: raise HTTPException(status_code=404, detail="Заявка не найдена") - phone = _normalize_phone(request.client_phone) + phone = _normalize_phone(request_row.client_phone) if not phone: 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() now = _now_utc() 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") -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) if purpose not in ALLOWED_PURPOSES: 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: 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( OtpSession.purpose == purpose, OtpSession.track_number == track_number, diff --git a/app/api/public/requests.py b/app/api/public/requests.py index 32c95de..c295147 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -5,16 +5,21 @@ from uuid import UUID from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from app.core.config import settings from app.core.deps import get_public_session from app.core.security import create_jwt from app.db.session import get_db +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.request import Request 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 ( EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE, get_client_notification, @@ -39,6 +44,11 @@ router = APIRouter() OTP_CREATE_PURPOSE = "CREATE_REQUEST" OTP_VIEW_PURPOSE = "VIEW_REQUEST" +INVOICE_STATUS_LABELS = { + "WAITING_PAYMENT": "Ожидает оплату", + "PAID": "Оплачен", + "CANCELED": "Отменен", +} 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 +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) def create_request( 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]) def list_status_history_by_track( track_number: str, diff --git a/app/api/public/uploads.py b/app/api/public/uploads.py index 323dd6b..5175cab 100644 --- a/app/api/public/uploads.py +++ b/app/api/public/uploads.py @@ -4,7 +4,7 @@ import uuid from urllib.parse import quote 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 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.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.security_audit import record_file_security_event from app.services.s3_storage import build_object_key, get_s3_storage router = APIRouter() @@ -64,101 +65,236 @@ def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session 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) -def upload_init(payload: UploadInitPayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)): - 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} МБ)") +def upload_init( + payload: UploadInitPayload, + http_request: FastapiRequest, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + 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 = db.get(Request, request_uuid) - if request is None: - raise HTTPException(status_code=404, detail="Заявка не найдена") - _ensure_public_request_access_or_403(request, session) + 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) - current = int(request.total_attachments_bytes or 0) - if current + int(payload.size_bytes) > _max_case_bytes(): - raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)") + current = int(request.total_attachments_bytes or 0) + if current + int(payload.size_bytes) > _max_case_bytes(): + raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)") - key = build_object_key(f"requests/{request.id}", payload.file_name) - presigned_url = get_s3_storage().create_presigned_put_url(key, payload.mime_type) - return UploadInitResponse(key=key, presigned_url=presigned_url) + key = build_object_key(f"requests/{request.id}", payload.file_name) + presigned_url = get_s3_storage().create_presigned_put_url(key, payload.mime_type) + 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) -def upload_complete(payload: UploadCompletePayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)): - if payload.scope != UploadScope.REQUEST_ATTACHMENT: - raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT") - 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}/") - - storage = get_s3_storage() +def upload_complete( + payload: UploadCompletePayload, + http_request: FastapiRequest, + db: Session = Depends(get_db), + session: dict = Depends(get_public_session), +): + 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: - head = storage.head_object(payload.key) - except ClientError: - raise HTTPException(status_code=400, detail="Файл не найден в хранилище") + if payload.scope != UploadScope.REQUEST_ATTACHMENT: + raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT") + 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) - if actual_size <= 0: - raise HTTPException(status_code=400, detail="Некорректный размер файла") - if actual_size > _max_file_bytes(): - raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") - if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes(): - raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)") + storage = get_s3_storage() + try: + head = storage.head_object(payload.key) + except ClientError: + raise HTTPException(status_code=400, detail="Файл не найден в хранилище") - row = Attachment( - request_id=request.id, - message_id=None, - file_name=payload.file_name, - mime_type=payload.mime_type, - size_bytes=actual_size, - s3_key=payload.key, - responsible="Клиент", - ) - mark_unread_for_lawyer(request, EVENT_ATTACHMENT) - notify_request_event( - db, - request=request, - event_type=NOTIFICATION_EVENT_ATTACHMENT, - actor_role="CLIENT", - body=f'Файл: {payload.file_name}', - responsible="Клиент", - ) - request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size - request.responsible = "Клиент" - db.add(row) - db.add(request) - db.commit() - db.refresh(row) - return UploadCompleteResponse(status="ok", attachment_id=str(row.id)) + actual_size = int(head.get("ContentLength") or payload.size_bytes or 0) + if actual_size <= 0: + raise HTTPException(status_code=400, detail="Некорректный размер файла") + if actual_size > _max_file_bytes(): + raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)") + 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( + request_id=request.id, + message_id=None, + file_name=payload.file_name, + mime_type=payload.mime_type, + size_bytes=actual_size, + s3_key=payload.key, + responsible="Клиент", + ) + mark_unread_for_lawyer(request, EVENT_ATTACHMENT) + notify_request_event( + db, + request=request, + event_type=NOTIFICATION_EVENT_ATTACHMENT, + actor_role="CLIENT", + body=f'Файл: {payload.file_name}', + 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}") def get_public_attachment_object( attachment_id: str, + http_request: FastapiRequest, db: Session = Depends(get_db), 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: - obj = get_s3_storage().get_object(attachment.s3_key) - except ClientError: - raise HTTPException(status_code=404, detail="Файл не найден в хранилище") + attachment = _load_attachment_with_access_or_4xx(attachment_id, db, session) + key = attachment.s3_key + 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"] - 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) + record_file_security_event( + db, + actor_role="CLIENT", + actor_subject=actor_subject, + actor_ip=actor_ip, + action="DOWNLOAD_OBJECT", + scope="REQUEST_ATTACHMENT", + allowed=True, + object_key=key, + 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 diff --git a/app/core/config.py b/app/core/config.py index 4029e81..07419c9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -29,6 +29,9 @@ class Settings(BaseSettings): TELEGRAM_CHAT_ID: str = "0" SMS_PROVIDER: str = "dummy" 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 def cors_origins_list(self) -> List[str]: diff --git a/app/core/http_hardening.py b/app/core/http_hardening.py new file mode 100644 index 0000000..ad916cb --- /dev/null +++ b/app/core/http_hardening.py @@ -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 diff --git a/app/main.py b/app/main.py index d0d258a..16d671b 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse 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.admin.router import router as admin_router @@ -17,6 +18,7 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) +install_http_hardening(app) app.include_router(public_router, prefix="/api/public") app.include_router(admin_router, prefix="/api/admin") diff --git a/app/models/invoice.py b/app/models/invoice.py new file mode 100644 index 0000000..dcdf521 --- /dev/null +++ b/app/models/invoice.py @@ -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) diff --git a/app/models/security_audit_log.py b/app/models/security_audit_log.py new file mode 100644 index 0000000..1235313 --- /dev/null +++ b/app/models/security_audit_log.py @@ -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) diff --git a/app/models/status.py b/app/models/status.py index eeba0a1..5a54324 100644 --- a/app/models/status.py +++ b/app/models/status.py @@ -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 app.db.session import Base 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) sort_order: Mapped[int] = mapped_column(Integer, default=0, 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) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index b18b7c5..5fd6c29 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from typing import Optional class AdminLogin(BaseModel): @@ -32,6 +32,22 @@ class StatusUpsert(BaseModel): enabled: bool = True sort_order: int = 0 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): diff --git a/app/services/billing_flow.py b/app/services/billing_flow.py new file mode 100644 index 0000000..7bfeaf1 --- /dev/null +++ b/app/services/billing_flow.py @@ -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 diff --git a/app/services/invoice_crypto.py b/app/services/invoice_crypto.py new file mode 100644 index 0000000..803dbe6 --- /dev/null +++ b/app/services/invoice_crypto.py @@ -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 {} diff --git a/app/services/invoice_pdf.py b/app/services/invoice_pdf.py new file mode 100644 index 0000000..f01fe03 --- /dev/null +++ b/app/services/invoice_pdf.py @@ -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 diff --git a/app/services/rate_limit.py b/app/services/rate_limit.py new file mode 100644 index 0000000..a051e6d --- /dev/null +++ b/app/services/rate_limit.py @@ -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 diff --git a/app/services/security_audit.py b/app/services/security_audit.py new file mode 100644 index 0000000..f6706d0 --- /dev/null +++ b/app/services/security_audit.py @@ -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 diff --git a/app/web/admin.jsx b/app/web/admin.jsx index b9b8d78..a11fd08 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -29,6 +29,16 @@ CLOSED: "Закрыта", REJECTED: "Отклонена", }; + const INVOICE_STATUS_LABELS = { + WAITING_PAYMENT: "Ожидает оплату", + PAID: "Оплачен", + CANCELED: "Отменен", + }; + const STATUS_KIND_LABELS = { + DEFAULT: "Обычный", + INVOICE: "Выставление счета", + PAID: "Оплачено", + }; const REQUEST_UPDATE_EVENT_LABELS = { MESSAGE: "сообщение", @@ -42,6 +52,11 @@ endpoint: "/api/admin/crud/requests/query", sort: [{ field: "created_at", dir: "desc" }], }, + invoices: { + table: "invoices", + endpoint: "/api/admin/invoices/query", + sort: [{ field: "issued_at", dir: "desc" }], + }, quotes: { table: "quotes", endpoint: "/api/admin/crud/quotes/query", @@ -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() { 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) { try { const payload = token.split(".")[1] || ""; @@ -139,6 +202,14 @@ 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) { return value ? "Да" : "Нет"; } @@ -821,6 +892,7 @@ const [tables, setTables] = useState({ requests: createTableState(), + invoices: createTableState(), quotes: createTableState(), topics: createTableState(), statuses: createTableState(), @@ -831,6 +903,7 @@ users: createTableState(), userTopics: createTableState(), }); + const [tableCatalog, setTableCatalog] = useState([]); const [dictionaries, setDictionaries] = useState({ topics: [], @@ -851,7 +924,7 @@ form: {}, }); - const [configActiveKey, setConfigActiveKey] = useState("quotes"); + const [configActiveKey, setConfigActiveKey] = useState(""); const [referencesExpanded, setReferencesExpanded] = useState(true); const [metaEntity, setMetaEntity] = useState("quotes"); @@ -924,6 +997,14 @@ .map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" })); }, [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(() => { return (dictionaries.topics || []) .filter((item) => item && item.code) @@ -953,6 +1034,51 @@ 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( (tableKey) => { if (tableKey === "requests") { @@ -968,6 +1094,20 @@ { 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") { return [ { field: "author", label: "Автор", type: "text" }, @@ -990,6 +1130,7 @@ return [ { field: "code", label: "Код", type: "text" }, { field: "name", label: "Название", type: "text" }, + { field: "kind", label: "Тип", type: "enum", options: getStatusKindOptions }, { field: "enabled", label: "Активен", type: "boolean" }, { field: "sort_order", label: "Порядок", type: "number" }, { field: "is_terminal", label: "Терминальный", type: "boolean" }, @@ -1056,13 +1197,37 @@ { 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) => { if (tableKey === "requests") return "Заявки"; + if (tableKey === "invoices") return "Счета"; if (tableKey === "quotes") return "Цитаты"; if (tableKey === "topics") return "Темы"; if (tableKey === "statuses") return "Статусы"; @@ -1072,8 +1237,11 @@ if (tableKey === "statusTransitions") return "Переходы статусов"; if (tableKey === "users") 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( (tableKey) => { @@ -1094,6 +1262,17 @@ { 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") { return [ { key: "author", label: "Автор", type: "text", required: true }, @@ -1115,6 +1294,8 @@ return [ { key: "code", 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: "sort_order", label: "Порядок", type: "number", defaultValue: "0" }, { key: "is_terminal", label: "Терминальный", type: "boolean", defaultValue: "false" }, @@ -1188,9 +1369,33 @@ { 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( @@ -1228,7 +1433,7 @@ const loadTable = useCallback( async (tableKey, options, tokenOverride) => { const opts = options || {}; - const config = TABLE_SERVER_CONFIG[tableKey]; + const config = resolveTableConfig(tableKey); if (!config) return false; const current = tablesRef.current[tableKey] || createTableState(); @@ -1318,7 +1523,7 @@ }); } - if (tableKey === "formFields") { + if (tableKey === "formFields" || tableKey === "form_fields") { setDictionaries((prev) => { const set = new Set(DEFAULT_FORM_FIELD_TYPES); (next.rows || []).forEach((row) => { @@ -1336,7 +1541,7 @@ }); } - if (tableKey === "users") { + if (tableKey === "users" || tableKey === "admin_users") { setDictionaries((prev) => { const map = new Map((prev.users || []).map((user) => [user.id, user])); (next.rows || []).forEach((row) => { @@ -1359,12 +1564,16 @@ return false; } }, - [api, setStatus, setTableState] + [api, resolveTableConfig, setStatus, setTableState] ); const loadCurrentConfigTable = useCallback( async (resetOffset, tokenOverride, keyOverride) => { const currentKey = keyOverride || configActiveKey; + if (!currentKey) { + setStatus("config", "Выберите справочник", ""); + return false; + } setStatus("config", "Загрузка...", ""); const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride); if (ok) { @@ -1438,6 +1647,7 @@ if (!(tokenOverride !== undefined ? tokenOverride : token)) return; if (section === "dashboard") return loadDashboard(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 === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride); if (section === "meta") return loadMeta(tokenOverride); @@ -1451,18 +1661,28 @@ ...prev, statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })), })); + setTableCatalog([]); if (roleOverride !== "ADMIN") return; try { const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 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/statuses/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), ]); + 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 }])); (statusesData.rows || []).forEach((row) => { @@ -1630,6 +1850,7 @@ if (field.type === "json") { const text = String(raw || "").trim(); if (!text) { + if (field.omitIfEmpty) return; if (field.optional) payload[field.key] = null; else payload[field.key] = {}; return; @@ -1656,6 +1877,7 @@ }); if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {}; + if (tableKey === "invoices" && mode === "edit") delete payload.request_track_number; return payload; }, [getRecordFields] @@ -1666,7 +1888,7 @@ event.preventDefault(); const tableKey = recordModal.tableKey; if (!tableKey) return; - const endpoints = TABLE_MUTATION_CONFIG[tableKey]; + const endpoints = resolveMutationConfig(tableKey); if (!endpoints) return; try { setStatus("recordForm", "Сохранение...", ""); @@ -1683,12 +1905,12 @@ setStatus("recordForm", "Ошибка: " + error.message, "error"); } }, - [api, buildRecordPayload, closeRecordModal, loadTable, recordModal, setStatus] + [api, buildRecordPayload, closeRecordModal, loadTable, recordModal, resolveMutationConfig, setStatus] ); const deleteRecord = useCallback( async (tableKey, id) => { - const endpoints = TABLE_MUTATION_CONFIG[tableKey]; + const endpoints = resolveMutationConfig(tableKey); if (!endpoints) return; if (!confirm("Удалить запись?")) return; try { @@ -1699,7 +1921,7 @@ setStatus(tableKey, "Ошибка удаления: " + error.message, "error"); } }, - [api, loadTable, setStatus] + [api, loadTable, resolveMutationConfig, setStatus] ); const claimRequest = useCallback( @@ -1717,6 +1939,57 @@ [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( (row) => { const options = getLawyerOptions(); @@ -2025,10 +2298,12 @@ setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" }); setDashboardData({ scope: "", cards: [], byStatus: {}, lawyerLoads: [], myUnreadByEvent: {} }); setMetaJson(""); - setConfigActiveKey("quotes"); + setConfigActiveKey(""); setReferencesExpanded(true); + setTableCatalog([]); setTables({ requests: createTableState(), + invoices: createTableState(), quotes: createTableState(), topics: createTableState(), statuses: createTableState(), @@ -2110,6 +2385,15 @@ }; }, [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; useEffect(() => { document.body.classList.toggle("modal-open", anyOverlayOpen); @@ -2132,6 +2416,7 @@ return [ { key: "dashboard", label: "Обзор" }, { key: "requests", label: "Заявки" }, + { key: "invoices", label: "Счета" }, { key: "meta", label: "Метаданные" }, ]; }, []); @@ -2145,10 +2430,39 @@ const recordModalFields = useMemo(() => { 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); }, [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 ( <>
@@ -2182,69 +2496,16 @@ {referencesExpanded ? (
- - - - - - - - - + {dictionaryTableItems.map((item) => ( + + ))}
) : null} @@ -2422,6 +2683,81 @@ +
+
+
+

Счета

+

Выставленные счета клиентам, статусы оплаты и выгрузка PDF.

+
+
+ + +
+
+ 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); + }} + /> + toggleTableSort("invoices", field)} + sortClause={(tables.invoices.sort && tables.invoices.sort[0]) || TABLE_SERVER_CONFIG.invoices.sort[0]} + renderRow={(row) => ( + + + {row.invoice_number || "-"} + + {row.status_label || invoiceStatusLabel(row.status)} + {row.amount == null ? "-" : String(row.amount) + " " + String(row.currency || "RUB")} + {row.payer_display_name || "-"} + {row.request_track_number || row.request_id || "-"} + {row.issued_by_name || "-"} + {fmtDate(row.issued_at)} + {fmtDate(row.paid_at)} + +
+ openInvoiceRequest(row)} /> + downloadInvoicePdf(row)} /> + openEditRecordModal("invoices", row)} /> + {role === "ADMIN" ? ( + deleteRecord("invoices", row.id)} tone="danger" /> + ) : null} +
+ + + )} + /> + loadPrevPage("invoices")} + onNext={() => loadNextPage("invoices")} + onLoadAll={() => loadAllRows("invoices")} + /> + +
+
@@ -2501,13 +2837,15 @@
-

{getTableLabel(configActiveKey)}

- +

{configActiveKey ? getTableLabel(configActiveKey) : "Справочник не выбран"}

+ {canCreateInConfig && configActiveKey ? ( + + ) : null}
openFilterModal(configActiveKey)} onRemove={(index) => removeFilterChip(configActiveKey, index)} onEdit={(index) => openFilterEditModal(configActiveKey, index)} @@ -2591,13 +2929,15 @@ headers={[ { key: "code", label: "Код", sortable: true, field: "code" }, { key: "name", label: "Название", sortable: true, field: "name" }, + { key: "kind", label: "Тип", sortable: true, field: "kind" }, { key: "enabled", label: "Активен", sortable: true, field: "enabled" }, { key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" }, { key: "is_terminal", label: "Терминальный", sortable: true, field: "is_terminal" }, + { key: "invoice_template", label: "Шаблон счета" }, { key: "actions", label: "Действия" }, ]} rows={tables.statuses.rows} - emptyColspan={6} + emptyColspan={8} onSort={(field) => toggleTableSort("statuses", field)} sortClause={(tables.statuses.sort && tables.statuses.sort[0]) || TABLE_SERVER_CONFIG.statuses.sort[0]} renderRow={(row) => ( @@ -2606,9 +2946,11 @@ {row.code || "-"} {row.name || "-"} + {statusKindLabel(row.kind)} {boolLabel(row.enabled)} {String(row.sort_order ?? 0)} {boolLabel(row.is_terminal)} + {row.invoice_template || "-"}
openEditRecordModal("statuses", row)} /> @@ -2866,8 +3208,44 @@ }} /> ) : null} + {configActiveKey && !KNOWN_CONFIG_TABLE_KEYS.has(configActiveKey) ? ( + toggleTableSort(configActiveKey, field)} + sortClause={ + (activeConfigTableState.sort && activeConfigTableState.sort[0]) || + ((resolveTableConfig(configActiveKey)?.sort || [])[0]) + } + renderRow={(row) => ( + + {(activeConfigMeta?.columns || []).map((column) => { + const key = String(column.name || ""); + const value = row[key]; + if (column.kind === "boolean") return {boolLabel(Boolean(value))}; + if (column.kind === "date" || column.kind === "datetime") return {fmtDate(value)}; + if (column.kind === "json") return {value == null ? "-" : JSON.stringify(value)}; + return {value == null || value === "" ? "-" : String(value)}; + })} + {canUpdateInConfig || canDeleteInConfig ? ( + +
+ {canUpdateInConfig ? ( + openEditRecordModal(configActiveKey, row)} /> + ) : null} + {canDeleteInConfig ? ( + deleteRecord(configActiveKey, row.id)} tone="danger" /> + ) : null} +
+ + ) : null} + + )} + /> + ) : null} loadPrevPage(configActiveKey)} onNext={() => loadNextPage(configActiveKey)} onLoadAll={() => loadAllRows(configActiveKey)} diff --git a/app/web/landing.html b/app/web/landing.html index c0b071d..e83421b 100644 --- a/app/web/landing.html +++ b/app/web/landing.html @@ -35,6 +35,7 @@ color: var(--text); font-family: "Manrope", sans-serif; scroll-behavior: smooth; + overflow-x: hidden; } body.modal-open { overflow: hidden; } @@ -51,10 +52,12 @@ } .wrap { - width: min(var(--maxw), calc(100% - 2rem)); + width: min(var(--maxw), calc(100% - 1.5rem)); margin: 0 auto; } + section { scroll-margin-top: 84px; } + .topbar { position: sticky; top: 0; @@ -493,6 +496,7 @@ background: rgba(255, 255, 255, 0.03); color: #ecf2fb; font: inherit; + font-size: 16px; padding: 0.72rem 0.8rem; } @@ -590,6 +594,7 @@ color: #d8e3f3; line-height: 1.5; font-size: 0.92rem; + overflow-wrap: anywhere; } .simple-item time { @@ -615,6 +620,15 @@ flex-wrap: wrap; } + .file-row input[type="file"] { + max-width: 100%; + } + + .brand, + .meta-row b { + overflow-wrap: anywhere; + } + @keyframes rise { to { opacity: 1; @@ -637,8 +651,21 @@ 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 { - padding-top: 3.6rem; + padding-top: 2.7rem; } .stats { @@ -656,6 +683,88 @@ .cabinet-meta { 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%; + } } @@ -854,6 +963,11 @@
+
+

Счета и оплата

+
    +
    +

    История изменений

      @@ -927,6 +1041,7 @@ const cabinetRequestUpdated = document.getElementById("cabinet-request-updated"); const cabinetMessages = document.getElementById("cabinet-messages"); const cabinetFiles = document.getElementById("cabinet-files"); + const cabinetInvoices = document.getElementById("cabinet-invoices"); const cabinetTimeline = document.getElementById("cabinet-timeline"); const cabinetChatForm = document.getElementById("cabinet-chat-form"); 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) { cabinetTimeline.innerHTML = ""; if (!Array.isArray(items) || items.length === 0) { @@ -1169,22 +1322,26 @@ async function refreshCabinetData() { 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) + "/attachments"), + fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/invoices"), fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline") ]); const messagesData = await parseJsonSafe(messagesRes); const filesData = await parseJsonSafe(filesRes); + const invoicesData = await parseJsonSafe(invoicesRes); const timelineData = await parseJsonSafe(timelineRes); if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения")); if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы")); + if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета")); if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю")); renderMessages(messagesData); renderFiles(filesData); + renderInvoices(invoicesData); renderTimeline(timelineData); } @@ -1366,6 +1523,7 @@ setCabinetEnabled(false); clearList(cabinetMessages, "Сообщений пока нет."); clearList(cabinetFiles, "Файлы пока не загружены."); + clearList(cabinetInvoices, "Счета пока не выставлены."); clearList(cabinetTimeline, "История пока пуста."); })(); diff --git a/app/workers/tasks/assign.py b/app/workers/tasks/assign.py index f8ac923..632c782 100644 --- a/app/workers/tasks/assign.py +++ b/app/workers/tasks/assign.py @@ -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} 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)) .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]] = {} - 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() if not topic_code: continue @@ -96,6 +99,8 @@ def auto_assign_unclaimed(): continue selected = min(candidates, key=lambda lawyer_id: (lawyer_load.get(lawyer_id, 0), lawyer_id)) req.assigned_lawyer_id = selected + if req.effective_rate is None: + req.effective_rate = lawyer_default_rate.get(selected) req.updated_at = now req.responsible = "Администратор системы" lawyer_load[selected] = lawyer_load.get(selected, 0) + 1 diff --git a/celerybeat-schedule b/celerybeat-schedule index e207585..4ddb96e 100644 Binary files a/celerybeat-schedule and b/celerybeat-schedule differ diff --git a/context/03_admin_panel_service.md b/context/03_admin_panel_service.md index 528a280..93109e5 100644 --- a/context/03_admin_panel_service.md +++ b/context/03_admin_panel_service.md @@ -59,6 +59,21 @@ - invoice is attached/sent to client through platform notification channel - 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`) - SLA configuration is stored in `topic_status_transitions.sla_hours` - `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) - 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`) - Financial profile fields are persisted: - `admin_users.default_rate` @@ -148,3 +169,10 @@ - Salary calculation base: - paid event = ADMIN changes request status to "Оплачено" - 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). diff --git a/context/08_security_model.md b/context/08_security_model.md index 091ee0c..7dc41e4 100644 --- a/context/08_security_model.md +++ b/context/08_security_model.md @@ -3,7 +3,7 @@ ## Public - OTP verification required for request creation and request access - JWT in httpOnly cookie (7 days) -- Rate limiting +- Rate limiting by IP + phone + track number (OTP send/verify) - Protection from brute force ## Admin @@ -14,8 +14,9 @@ ## Data Protection - Messages and attachments from previous statuses are immutable after status change - 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/ПДн) - Security baseline for implementation: - Access model: @@ -37,3 +38,14 @@ - Compliance posture: - map controls to РФ requirements for personal data protection and internal cyber policies - 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. diff --git a/context/10_development_execution_plan.md b/context/10_development_execution_plan.md index 3209575..4c86bc5 100644 --- a/context/10_development_execution_plan.md +++ b/context/10_development_execution_plan.md @@ -40,19 +40,19 @@ | P19 | сделано | SLA-check и overdue | Реализовать `sla_check`: контроль просрочек по переходам, расчет FRT/времени в статусе | Метрики и флаги просрочек обновляются по расписанию | | P20 | сделано | Уведомления | Уведомления в Telegram (если подключен) + внутренние уведомления сайта по изменениям | При событиях (сообщения/файлы/статусы/SLA) уведомления доставляются | | P21 | сделано | Dashboard LAWYER/ADMIN | Расширить дашборды: назначенные/неназначенные, активные по статусам, непрочитанные, SLA, по каждому юристу: активная загрузка, сумма активных заявок, вал оплаченных за месяц, зарплата за месяц | Дашборды соответствуют ролям и данным из БД | -| P22 | к разработке | Тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers | Набор автотестов фиксирует регрессии критичных сценариев | -| P23 | к разработке | Hardening/release | Полировка безопасности, логирования, лимитов, отказоустойчивости, документации API/UI и runbook | Проект готов к стабилизации и приемке | -| P24 | к разработке | Mobile UX | Мобильная адаптация лендинга и клиентских форм (заявка, OTP, кабинет клиента: чат, файлы, история) | UI корректно работает на 320-768px, элементы доступны и читаемы без горизонтального скролла | -| P25 | к разработке | Тарифы юристов | Добавить ставку и процент юриста (по умолчанию в профиле), а также фиксируемые в заявке поля ставки/суммы (override админом) | Финансовые поля заявки фиксируются и не зависят от последующих правок профиля; клиенту не показываются | -| P26 | к разработке | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) | -| P27 | к разработке | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов | +| P22 | сделано | Hardening/release | Полировка безопасности, логирования, лимитов, отказоустойчивости, документации API/UI и runbook | Проект готов к стабилизации и приемке | +| P23 | сделано | Mobile UX | Мобильная адаптация лендинга и клиентских форм (заявка, OTP, кабинет клиента: чат, файлы, история) | UI корректно работает на 320-768px, элементы доступны и читаемы без горизонтального скролла | +| P24 | сделано | Тарифы юристов | Добавить ставку и процент юриста (по умолчанию в профиле), а также фиксируемые в заявке поля ставки/суммы (override админом) | Финансовые поля заявки фиксируются и не зависят от последующих правок профиля; клиенту не показываются | +| P25 | сделано | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) | +| P26 | сделано | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов | +| P27 | сделано | Итоговое тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers и выполнить финальный регрессионный прогон | Набор автотестов фиксирует регрессии критичных сценариев и подтверждает готовность перед приемкой | ## Критический маршрут (обязательный порядок) 1. `P07 -> P08 -> P09 -> P10` (полный контур назначения). 2. `P11 -> P12 -> P13` (публичный клиентский контур). 3. `P14 -> P15 -> P16` (процесс работы по заявке). -4. `P17 -> P18 -> P25 -> P26 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика). -5. `P22 -> P23 -> P24 -> P27` (стабилизация, mobile UX, security-аудит). +4. `P17 -> P18 -> P24 -> P25 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика). +5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце). ## Правила выполнения для ИИ-агента 1. Не менять бизнес-правила без обновления `context/*.md`. diff --git a/context/11_test_runbook.md b/context/11_test_runbook.md index 6eb13b4..d5d676e 100644 --- a/context/11_test_runbook.md +++ b/context/11_test_runbook.md @@ -1,7 +1,7 @@ # 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` | | P09 | ADMIN-only переназначение | `tests/test_admin_universal_crud.py` (reassign-тесты) | команда как для `P03` | | 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` | | 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` | @@ -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` | | P20 | Уведомления | `tests/test_notifications.py`, а также регрессии `tests/test_public_cabinet.py`, `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_notifications tests.test_public_cabinet tests.test_uploads_s3 tests.test_worker_maintenance -v`; затем полный прогон | | P21 | Dashboard ADMIN/LAWYER | `tests/test_admin_universal_crud.py` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance tests.test_admin_universal_crud -v`; проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц | -| P22 | E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон | -| P23 | Hardening/release | весь regression + compile + миграции + UI build | базовые команды 1-4 | -| P24 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать `admin.jsx` при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла | -| P25 | Ставки юриста и ставка заявки | новые тесты `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | прогон `test_rates` + `test_admin_universal_crud`; проверка что public API не отдает поля ставок/процентов | -| P26 | Billing-статус и шаблон счета | новые тесты `tests/test_billing_flow.py` + e2e статусных переходов | прогон `test_billing_flow` + `test_admin_universal_crud`; валидация генерации счета и фиксации оплаты при ADMIN->\"Оплачено\" (в т.ч. множественные оплаты в одной заявке) | -| P27 | Security audit S3/ПДн | новые тесты `tests/test_security_audit.py` + `tests/test_uploads_s3.py` | прогон `test_security_audit` + `test_uploads_s3`; проверка логирования и ограничений доступа | +| P22 | Hardening/release | `tests/test_http_hardening.py` + весь regression + compile + миграции + UI build | `docker compose exec -T backend python -m unittest tests.test_http_hardening -v`; затем базовые команды 1-4 | +| P23 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать `admin.jsx` при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла | +| P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_rates tests.test_admin_universal_crud -v`; проверка что public API не отдает поля ставок/процентов | +| P25 | Billing-статус и шаблон счета | `tests/test_billing_flow.py`, `tests/test_invoices.py` + e2e статусных переходов | `docker compose exec -T backend python -m unittest tests.test_billing_flow tests.test_invoices tests.test_admin_universal_crud -v`; валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) | +| P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` | +| P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон | ## Минимальный чеклист закрытия пункта 1. Выполнить миграции (если были изменения схемы). @@ -61,3 +61,6 @@ docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cac 4. Выполнить `compileall`. 5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose. 6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`. + +## Последний регрессионный прогон +- `python -m unittest discover -s tests -p 'test_*.py' -v` — `91 tests OK`. diff --git a/tests/test_admin_universal_crud.py b/tests/test_admin_universal_crud.py index 389f5be..0bceb43 100644 --- a/tests/test_admin_universal_crud.py +++ b/tests/test_admin_universal_crud.py @@ -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()] 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): lawyer_headers = self._auth_headers("LAWYER") diff --git a/tests/test_billing_flow.py b/tests/test_billing_flow.py new file mode 100644 index 0000000..71c1f13 --- /dev/null +++ b/tests/test_billing_flow.py @@ -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() diff --git a/tests/test_http_hardening.py b/tests/test_http_hardening.py new file mode 100644 index 0000000..7d8ab36 --- /dev/null +++ b/tests/test_http_hardening.py @@ -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"))) diff --git a/tests/test_invoices.py b/tests/test_invoices.py new file mode 100644 index 0000000..88b17c4 --- /dev/null +++ b/tests/test_invoices.py @@ -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) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 0ffe383..a8ecf6a 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -96,6 +96,8 @@ class MigrationTests(unittest.TestCase): "admin_user_topics", "topic_status_transitions", "notifications", + "invoices", + "security_audit_log", "alembic_version", } tables = set(self.inspector.get_table_names()) @@ -104,7 +106,7 @@ class MigrationTests(unittest.TestCase): def test_alembic_version_is_set(self): with self.engine.connect() as conn: version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() - self.assertEqual(version, "0011_dashboard_financial_fields") + self.assertEqual(version, "0014_security_audit_log") def test_responsible_column_exists_in_all_domain_tables(self): tables = { @@ -125,6 +127,8 @@ class MigrationTests(unittest.TestCase): "admin_user_topics", "topic_status_transitions", "notifications", + "invoices", + "security_audit_log", } for table in tables: 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("paid_at", 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) diff --git a/tests/test_otp_rate_limit.py b/tests/test_otp_rate_limit.py new file mode 100644 index 0000000..09d403d --- /dev/null +++ b/tests/test_otp_rate_limit.py @@ -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", "")) diff --git a/tests/test_rates.py b/tests/test_rates.py new file mode 100644 index 0000000..8284668 --- /dev/null +++ b/tests/test_rates.py @@ -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() diff --git a/tests/test_security_audit.py b/tests/test_security_audit.py new file mode 100644 index 0000000..9a9f24b --- /dev/null +++ b/tests/test_security_audit.py @@ -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)) diff --git a/tmp/admin.bundle.js b/tmp/admin.bundle.js index b25ad2b..bc54af9 100644 --- a/tmp/admin.bundle.js +++ b/tmp/admin.bundle.js @@ -28,6 +28,16 @@ CLOSED: "\u0417\u0430\u043A\u0440\u044B\u0442\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 = { MESSAGE: "\u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435", ATTACHMENT: "\u0444\u0430\u0439\u043B", @@ -39,6 +49,11 @@ endpoint: "/api/admin/crud/requests/query", sort: [{ field: "created_at", dir: "desc" }] }, + invoices: { + table: "invoices", + endpoint: "/api/admin/invoices/query", + sort: [{ field: "issued_at", dir: "desc" }] + }, quotes: { table: "quotes", endpoint: "/api/admin/crud/quotes/query", @@ -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() { return { filters: [], @@ -105,6 +145,23 @@ 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) { try { const payload = token.split(".")[1] || ""; @@ -126,6 +183,12 @@ function statusLabel(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) { return value ? "\u0414\u0430" : "\u041D\u0435\u0442"; } @@ -206,6 +269,10 @@ \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 || {}, "\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)), "\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)), @@ -467,9 +534,16 @@ const [role, setRole] = useState(""); const [email, setEmail] = useState(""); 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({ requests: createTableState(), + invoices: createTableState(), quotes: createTableState(), topics: createTableState(), statuses: createTableState(), @@ -480,6 +554,7 @@ users: createTableState(), userTopics: createTableState() }); + const [tableCatalog, setTableCatalog] = useState([]); const [dictionaries, setDictionaries] = useState({ topics: [], statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })), @@ -496,7 +571,7 @@ rowId: null, form: {} }); - const [configActiveKey, setConfigActiveKey] = useState("quotes"); + const [configActiveKey, setConfigActiveKey] = useState(""); const [referencesExpanded, setReferencesExpanded] = useState(true); const [metaEntity, setMetaEntity] = useState("quotes"); const [metaJson, setMetaJson] = useState(""); @@ -554,6 +629,12 @@ const getStatusOptions = useCallback(() => { return (dictionaries.statuses || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" })); }, [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(() => { return (dictionaries.topics || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || item.code) + " (" + item.code + ")" })); }, [dictionaries.topics]); @@ -572,6 +653,45 @@ const getRoleOptions = useCallback(() => { 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( (tableKey) => { if (tableKey === "requests") { @@ -581,6 +701,23 @@ { 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: "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" } ]; } @@ -606,6 +743,7 @@ return [ { field: "code", label: "\u041A\u043E\u0434", 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: "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" } @@ -646,6 +784,7 @@ { 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: "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: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" } ]; @@ -656,6 +795,8 @@ { field: "email", label: "Email", type: "text" }, { 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: "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: "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" } @@ -669,12 +810,34 @@ { 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) => { 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 === "topics") return "\u0422\u0435\u043C\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 === "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"; - 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( (tableKey) => { if (tableKey === "requests") { @@ -698,9 +864,24 @@ { 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: "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" } ]; } + 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") { return [ { key: "author", label: "\u0410\u0432\u0442\u043E\u0440", type: "text", required: true }, @@ -722,6 +903,8 @@ return [ { 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: "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: "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" } @@ -763,6 +946,7 @@ { 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: "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: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" } ]; @@ -782,6 +966,8 @@ 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: "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: "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 } ]; } - 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( (tableKey, fieldName) => { @@ -827,7 +1035,7 @@ const loadTable = useCallback( async (tableKey, options, tokenOverride) => { const opts = options || {}; - const config = TABLE_SERVER_CONFIG[tableKey]; + const config = resolveTableConfig(tableKey); if (!config) return false; const current = tablesRef.current[tableKey] || createTableState(); const next = { @@ -905,7 +1113,7 @@ return { ...prev, statuses: sortByName(Array.from(map.values())) }; }); } - if (tableKey === "formFields") { + if (tableKey === "formFields" || tableKey === "form_fields") { setDictionaries((prev) => { const set = new Set(DEFAULT_FORM_FIELD_TYPES); (next.rows || []).forEach((row) => { @@ -919,7 +1127,7 @@ }; }); } - if (tableKey === "users") { + if (tableKey === "users" || tableKey === "admin_users") { setDictionaries((prev) => { const map = new Map((prev.users || []).map((user) => [user.id, user])); (next.rows || []).forEach((row) => { @@ -941,11 +1149,15 @@ return false; } }, - [api, setStatus, setTableState] + [api, resolveTableConfig, setStatus, setTableState] ); const loadCurrentConfigTable = useCallback( async (resetOffset, tokenOverride, keyOverride) => { 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...", ""); const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride); if (ok) { @@ -961,23 +1173,38 @@ setStatus("dashboard", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", ""); try { 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\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: "\u0421\u0440\u0435\u0434\u043D\u0438\u0439 FRT (\u043C\u0438\u043D)", value: data.frt_avg_minutes ?? "-" }, - { 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 \u044E\u0440\u0438\u0441\u0442\u0430\u043C\u0438", value: data.unread_for_lawyers ?? 0 }, + { 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 = {}; Object.entries(data.by_status || {}).forEach(([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"); } catch (error) { setStatus("dashboard", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error"); } }, - [api, setStatus] + [api, role, setStatus] ); const loadMeta = useCallback( async (tokenOverride) => { @@ -998,6 +1225,7 @@ if (!(tokenOverride !== void 0 ? tokenOverride : token)) return; if (section === "dashboard") return loadDashboard(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 === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride); if (section === "meta") return loadMeta(tokenOverride); @@ -1010,16 +1238,24 @@ ...prev, statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })) })); + setTableCatalog([]); if (roleOverride !== "ADMIN") return; try { const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 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/statuses/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) ]); + 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 }])); (statusesData.rows || []).forEach((row) => { if (!row.code) return; @@ -1172,6 +1408,7 @@ if (field.type === "json") { const text = String(raw || "").trim(); if (!text) { + if (field.omitIfEmpty) return; if (field.optional) payload[field.key] = null; else payload[field.key] = {}; return; @@ -1196,6 +1433,7 @@ payload[field.key] = value; }); if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {}; + if (tableKey === "invoices" && mode === "edit") delete payload.request_track_number; return payload; }, [getRecordFields] @@ -1205,7 +1443,7 @@ event.preventDefault(); const tableKey = recordModal.tableKey; if (!tableKey) return; - const endpoints = TABLE_MUTATION_CONFIG[tableKey]; + const endpoints = resolveMutationConfig(tableKey); if (!endpoints) return; try { 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"); } }, - [api, buildRecordPayload, closeRecordModal, loadTable, recordModal, setStatus] + [api, buildRecordPayload, closeRecordModal, loadTable, recordModal, resolveMutationConfig, setStatus] ); const deleteRecord = useCallback( async (tableKey, id) => { - const endpoints = TABLE_MUTATION_CONFIG[tableKey]; + const endpoints = resolveMutationConfig(tableKey); if (!endpoints) return; if (!confirm("\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C?")) return; try { @@ -1237,7 +1475,7 @@ 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( async (requestId) => { @@ -1253,6 +1491,54 @@ }, [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( (row) => { const options = getLawyerOptions(); @@ -1532,12 +1818,14 @@ setRequestModal({ open: false, jsonText: "" }); setFilterModal({ open: false, tableKey: null, field: "", op: "=", rawValue: "", editIndex: null }); setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" }); - setDashboardData({ cards: [], byStatus: {}, lawyerLoads: [] }); + setDashboardData({ scope: "", cards: [], byStatus: {}, lawyerLoads: [], myUnreadByEvent: {} }); setMetaJson(""); - setConfigActiveKey("quotes"); + setConfigActiveKey(""); setReferencesExpanded(true); + setTableCatalog([]); setTables({ requests: createTableState(), + invoices: createTableState(), quotes: createTableState(), topics: createTableState(), statuses: createTableState(), @@ -1611,6 +1899,14 @@ cancelled = true; }; }, [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; useEffect(() => { document.body.classList.toggle("modal-open", anyOverlayOpen); @@ -1631,6 +1927,7 @@ return [ { key: "dashboard", label: "\u041E\u0431\u0437\u043E\u0440" }, { 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" } ]; }, []); @@ -1641,9 +1938,33 @@ const filterTableLabel = useMemo(() => getTableLabel(filterModal.tableKey), [filterModal.tableKey, getTableLabel]); const recordModalFields = useMemo(() => { 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); }, [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( "button", { @@ -1665,79 +1986,16 @@ } }, "\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", { + key: item.key, type: "button", - className: activeSection === "config" && configActiveKey === "quotes" ? "active" : "", - onClick: () => selectConfigNode("quotes") + className: activeSection === "config" && configActiveKey === item.key ? "active" : "", + onClick: () => selectConfigNode(item.key) }, - "\u0426\u0438\u0442\u0430\u0442\u044B" - ), /* @__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( + getTableLabel(item.key) + ))) : 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( DataTable, { headers: [ @@ -1745,11 +2003,14 @@ { key: "email", label: "Email" }, { 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: "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 || [], - emptyColspan: 5, - 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))) + 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)), /* @__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( FilterToolbar, @@ -1773,15 +2034,17 @@ { 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: "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: "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" } ], rows: tables.requests.rows, - emptyColspan: 9, + emptyColspan: 11, onSort: (field) => toggleTableSort("requests", field), 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( TablePager, @@ -1791,7 +2054,47 @@ onNext: () => loadNextPage("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, { filters: tables.quotes.filters, @@ -1829,10 +2132,10 @@ onNext: () => loadNextPage("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, { - filters: tables[configActiveKey].filters, + filters: activeConfigTableState.filters, onOpen: () => openFilterModal(configActiveKey), onRemove: (index) => removeFilterChip(configActiveKey, index), onEdit: (index) => openFilterEditModal(configActiveKey, index), @@ -1881,16 +2184,18 @@ headers: [ { 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: "kind", label: "\u0422\u0438\u043F", sortable: true, field: "kind" }, { 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: "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" } ], rows: tables.statuses.rows, - emptyColspan: 6, + emptyColspan: 8, onSort: (field) => toggleTableSort("statuses", field), 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( DataTable, @@ -1970,15 +2275,16 @@ { 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: "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: "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" } ], rows: tables.statusTransitions.rows, - emptyColspan: 6, + emptyColspan: 7, onSort: (field) => toggleTableSort("statusTransitions", field), 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, { icon: "\u270E", @@ -2003,16 +2309,18 @@ { key: "email", label: "Email", sortable: true, field: "email" }, { 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: "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: "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: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" } ], rows: tables.users.rows, - emptyColspan: 8, + emptyColspan: 10, onSort: (field) => toggleTableSort("users", field), 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( 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" })))); } } + ) : 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( TablePager, { - tableState: tables[configActiveKey], + tableState: activeConfigTableState, onPrev: () => loadPrevPage(configActiveKey), onNext: () => loadNextPage(configActiveKey), onLoadAll: () => loadAllRows(configActiveKey)