mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
Third commit
This commit is contained in:
parent
fb13d93ab3
commit
96649f8cc7
43 changed files with 5194 additions and 456 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/tmp/
|
||||||
|
*.idea
|
||||||
|
.env
|
||||||
|
|
||||||
|
|
@ -18,6 +18,8 @@ from app.models.otp_session import OtpSession
|
||||||
from app.models.quote import Quote
|
from app.models.quote import Quote
|
||||||
from app.models.admin_user_topic import AdminUserTopic
|
from app.models.admin_user_topic import AdminUserTopic
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
|
from app.models.invoice import Invoice
|
||||||
|
from app.models.security_audit_log import SecurityAuditLog
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
|
||||||
56
alembic/versions/0012_add_invoices_table.py
Normal file
56
alembic/versions/0012_add_invoices_table.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""add invoices table
|
||||||
|
|
||||||
|
Revision ID: 0012_add_invoices_table
|
||||||
|
Revises: 0011_dashboard_financial_fields
|
||||||
|
Create Date: 2026-02-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0012_add_invoices_table"
|
||||||
|
down_revision = "0011_dashboard_financial_fields"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"invoices",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
|
||||||
|
sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("invoice_number", sa.String(length=40), nullable=False, unique=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default="WAITING_PAYMENT"),
|
||||||
|
sa.Column("amount", sa.Numeric(14, 2), nullable=False),
|
||||||
|
sa.Column("currency", sa.String(length=3), nullable=False, server_default="RUB"),
|
||||||
|
sa.Column("payer_display_name", sa.String(length=300), nullable=False),
|
||||||
|
sa.Column("payer_details_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("issued_by_admin_user_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("issued_by_role", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("paid_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_invoices_request_id", "invoices", ["request_id"])
|
||||||
|
op.create_index("ix_invoices_invoice_number", "invoices", ["invoice_number"], unique=True)
|
||||||
|
op.create_index("ix_invoices_status", "invoices", ["status"])
|
||||||
|
op.create_index("ix_invoices_issued_by_admin_user_id", "invoices", ["issued_by_admin_user_id"])
|
||||||
|
op.create_check_constraint("ck_invoices_amount_non_negative", "invoices", "amount >= 0")
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_invoices_status_allowed",
|
||||||
|
"invoices",
|
||||||
|
"status IN ('WAITING_PAYMENT', 'PAID', 'CANCELED')",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_constraint("ck_invoices_status_allowed", "invoices", type_="check")
|
||||||
|
op.drop_constraint("ck_invoices_amount_non_negative", "invoices", type_="check")
|
||||||
|
op.drop_index("ix_invoices_issued_by_admin_user_id", table_name="invoices")
|
||||||
|
op.drop_index("ix_invoices_status", table_name="invoices")
|
||||||
|
op.drop_index("ix_invoices_invoice_number", table_name="invoices")
|
||||||
|
op.drop_index("ix_invoices_request_id", table_name="invoices")
|
||||||
|
op.drop_table("invoices")
|
||||||
33
alembic/versions/0013_add_status_kind_for_billing.py
Normal file
33
alembic/versions/0013_add_status_kind_for_billing.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""add status kind and invoice template for billing flow
|
||||||
|
|
||||||
|
Revision ID: 0013_status_kind_billing
|
||||||
|
Revises: 0012_add_invoices_table
|
||||||
|
Create Date: 2026-02-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0013_status_kind_billing"
|
||||||
|
down_revision = "0012_add_invoices_table"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("statuses", sa.Column("kind", sa.String(length=20), nullable=False, server_default="DEFAULT"))
|
||||||
|
op.add_column("statuses", sa.Column("invoice_template", sa.Text(), nullable=True))
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_statuses_kind_allowed",
|
||||||
|
"statuses",
|
||||||
|
"kind IN ('DEFAULT', 'INVOICE', 'PAID')",
|
||||||
|
)
|
||||||
|
op.create_index("ix_statuses_kind", "statuses", ["kind"])
|
||||||
|
op.alter_column("statuses", "kind", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_statuses_kind", table_name="statuses")
|
||||||
|
op.drop_constraint("ck_statuses_kind_allowed", "statuses", type_="check")
|
||||||
|
op.drop_column("statuses", "invoice_template")
|
||||||
|
op.drop_column("statuses", "kind")
|
||||||
51
alembic/versions/0014_add_security_audit_log.py
Normal file
51
alembic/versions/0014_add_security_audit_log.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""add security audit log table for file access events
|
||||||
|
|
||||||
|
Revision ID: 0014_security_audit_log
|
||||||
|
Revises: 0013_status_kind_billing
|
||||||
|
Create Date: 2026-02-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0014_security_audit_log"
|
||||||
|
down_revision = "0013_status_kind_billing"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"security_audit_log",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
|
||||||
|
sa.Column("actor_role", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("actor_subject", sa.String(length=200), nullable=False, server_default=""),
|
||||||
|
sa.Column("actor_ip", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("action", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("scope", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("object_key", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("request_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("attachment_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("allowed", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("reason", sa.String(length=400), nullable=True),
|
||||||
|
sa.Column("details", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_security_audit_log_created_at", "security_audit_log", ["created_at"])
|
||||||
|
op.create_index("ix_security_audit_log_allowed", "security_audit_log", ["allowed"])
|
||||||
|
op.create_index("ix_security_audit_log_action", "security_audit_log", ["action"])
|
||||||
|
op.create_index("ix_security_audit_log_actor_subject", "security_audit_log", ["actor_subject"])
|
||||||
|
op.alter_column("security_audit_log", "details", server_default=None)
|
||||||
|
op.alter_column("security_audit_log", "allowed", server_default=None)
|
||||||
|
op.alter_column("security_audit_log", "actor_subject", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_security_audit_log_actor_subject", table_name="security_audit_log")
|
||||||
|
op.drop_index("ix_security_audit_log_action", table_name="security_audit_log")
|
||||||
|
op.drop_index("ix_security_audit_log_allowed", table_name="security_audit_log")
|
||||||
|
op.drop_index("ix_security_audit_log_created_at", table_name="security_audit_log")
|
||||||
|
op.drop_table("security_audit_log")
|
||||||
|
|
@ -25,6 +25,8 @@ def _status_row(row: Status):
|
||||||
"enabled": row.enabled,
|
"enabled": row.enabled,
|
||||||
"sort_order": row.sort_order,
|
"sort_order": row.sort_order,
|
||||||
"is_terminal": row.is_terminal,
|
"is_terminal": row.is_terminal,
|
||||||
|
"kind": row.kind,
|
||||||
|
"invoice_template": row.invoice_template,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.inspection import inspect as sa_inspect
|
from sqlalchemy.inspection import inspect as sa_inspect
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.sql.sqltypes import Boolean, Date, DateTime, Float, Integer, JSON, Numeric
|
||||||
|
|
||||||
import app.models as models_pkg
|
import app.models as models_pkg
|
||||||
from app.core.deps import get_current_admin
|
from app.core.deps import get_current_admin
|
||||||
|
|
@ -47,6 +48,7 @@ from app.services.request_read_markers import (
|
||||||
from app.services.request_status import apply_status_change_effects
|
from app.services.request_status import apply_status_change_effects
|
||||||
from app.services.status_flow import transition_allowed_for_topic
|
from app.services.status_flow import transition_allowed_for_topic
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
|
from app.services.billing_flow import apply_billing_transition_effects, normalize_status_kind_or_400
|
||||||
from app.services.universal_query import apply_universal_query
|
from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -62,6 +64,7 @@ SYSTEM_FIELDS = {
|
||||||
"lawyer_has_unread_updates",
|
"lawyer_has_unread_updates",
|
||||||
"lawyer_unread_event_type",
|
"lawyer_unread_event_type",
|
||||||
}
|
}
|
||||||
|
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
|
||||||
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
|
ALLOWED_ADMIN_ROLES = {"ADMIN", "LAWYER"}
|
||||||
|
|
||||||
# Per-table RBAC: table -> role -> actions.
|
# Per-table RBAC: table -> role -> actions.
|
||||||
|
|
@ -76,6 +79,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
|
||||||
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
|
"statuses": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"audit_log": {"ADMIN": {"query", "read"}},
|
"audit_log": {"ADMIN": {"query", "read"}},
|
||||||
|
"security_audit_log": {"ADMIN": {"query", "read"}},
|
||||||
"otp_sessions": {"ADMIN": {"query", "read"}},
|
"otp_sessions": {"ADMIN": {"query", "read"}},
|
||||||
"admin_users": {"ADMIN": set(CRUD_ACTIONS)},
|
"admin_users": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
|
"admin_user_topics": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
|
@ -168,6 +172,259 @@ def _columns_map(model: type) -> dict[str, Any]:
|
||||||
return {column.key: column for column in mapper.columns}
|
return {column.key: column for column in mapper.columns}
|
||||||
|
|
||||||
|
|
||||||
|
def _column_kind(column: Any) -> str:
|
||||||
|
col_type = column.type
|
||||||
|
if isinstance(col_type, Boolean):
|
||||||
|
return "boolean"
|
||||||
|
if isinstance(col_type, (Integer, Numeric, Float)):
|
||||||
|
return "number"
|
||||||
|
if isinstance(col_type, DateTime):
|
||||||
|
return "datetime"
|
||||||
|
if isinstance(col_type, Date):
|
||||||
|
return "date"
|
||||||
|
if isinstance(col_type, JSON):
|
||||||
|
return "json"
|
||||||
|
try:
|
||||||
|
python_type = col_type.python_type
|
||||||
|
except Exception:
|
||||||
|
python_type = None
|
||||||
|
if python_type is uuid.UUID:
|
||||||
|
return "uuid"
|
||||||
|
return "text"
|
||||||
|
|
||||||
|
|
||||||
|
def _table_label(table_name: str) -> str:
|
||||||
|
normalized = _normalize_table_name(table_name)
|
||||||
|
if not normalized:
|
||||||
|
return "Таблица"
|
||||||
|
|
||||||
|
explicit_labels = {
|
||||||
|
"requests": "Заявки",
|
||||||
|
"invoices": "Счета",
|
||||||
|
"quotes": "Цитаты",
|
||||||
|
"topics": "Темы",
|
||||||
|
"statuses": "Статусы",
|
||||||
|
"form_fields": "Поля формы",
|
||||||
|
"topic_required_fields": "Обязательные поля темы",
|
||||||
|
"topic_data_templates": "Шаблоны данных темы",
|
||||||
|
"topic_status_transitions": "Переходы статусов темы",
|
||||||
|
"admin_users": "Пользователи",
|
||||||
|
"admin_user_topics": "Дополнительные темы юристов",
|
||||||
|
"attachments": "Вложения",
|
||||||
|
"messages": "Сообщения",
|
||||||
|
"audit_log": "Журнал аудита",
|
||||||
|
"security_audit_log": "Журнал безопасности файлов",
|
||||||
|
"status_history": "История статусов",
|
||||||
|
"request_data_requirements": "Требования данных заявки",
|
||||||
|
"otp_sessions": "OTP-сессии",
|
||||||
|
"notifications": "Уведомления",
|
||||||
|
}
|
||||||
|
if normalized in explicit_labels:
|
||||||
|
return explicit_labels[normalized]
|
||||||
|
|
||||||
|
return _humanize_identifier_ru(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _humanize_identifier_ru(identifier: str) -> str:
|
||||||
|
normalized = _normalize_table_name(identifier)
|
||||||
|
if not normalized:
|
||||||
|
return "Таблица"
|
||||||
|
|
||||||
|
token_labels = {
|
||||||
|
"request": "заявка",
|
||||||
|
"requests": "заявки",
|
||||||
|
"invoice": "счет",
|
||||||
|
"invoices": "счета",
|
||||||
|
"topic": "тема",
|
||||||
|
"topics": "темы",
|
||||||
|
"status": "статус",
|
||||||
|
"statuses": "статусы",
|
||||||
|
"transition": "переход",
|
||||||
|
"transitions": "переходы",
|
||||||
|
"required": "обязательные",
|
||||||
|
"form": "формы",
|
||||||
|
"field": "поле",
|
||||||
|
"fields": "поля",
|
||||||
|
"template": "шаблон",
|
||||||
|
"templates": "шаблоны",
|
||||||
|
"data": "данных",
|
||||||
|
"requirement": "требование",
|
||||||
|
"requirements": "требования",
|
||||||
|
"admin": "админ",
|
||||||
|
"user": "пользователь",
|
||||||
|
"users": "пользователи",
|
||||||
|
"quote": "цитата",
|
||||||
|
"quotes": "цитаты",
|
||||||
|
"message": "сообщение",
|
||||||
|
"messages": "сообщения",
|
||||||
|
"attachment": "вложение",
|
||||||
|
"attachments": "вложения",
|
||||||
|
"notification": "уведомление",
|
||||||
|
"notifications": "уведомления",
|
||||||
|
"audit": "аудита",
|
||||||
|
"security": "безопасности",
|
||||||
|
"log": "журнал",
|
||||||
|
"history": "история",
|
||||||
|
"otp": "OTP",
|
||||||
|
"session": "сессия",
|
||||||
|
"sessions": "сессии",
|
||||||
|
"id": "ID",
|
||||||
|
}
|
||||||
|
words = [token_labels.get(token, token) for token in normalized.split("_") if token]
|
||||||
|
if not words:
|
||||||
|
return "Таблица"
|
||||||
|
phrase = " ".join(words).strip()
|
||||||
|
return phrase[:1].upper() + phrase[1:] if phrase else "Таблица"
|
||||||
|
|
||||||
|
|
||||||
|
def _column_label(table_name: str, column_name: str) -> str:
|
||||||
|
normalized_table = _normalize_table_name(table_name)
|
||||||
|
normalized_column = _normalize_table_name(column_name)
|
||||||
|
if not normalized_column:
|
||||||
|
return "Поле"
|
||||||
|
|
||||||
|
table_overrides = {
|
||||||
|
("invoices", "request_id"): "ID заявки",
|
||||||
|
("invoices", "issued_by_admin_user_id"): "ID сотрудника",
|
||||||
|
("request_data_requirements", "request_id"): "ID заявки",
|
||||||
|
}
|
||||||
|
if (normalized_table, normalized_column) in table_overrides:
|
||||||
|
return table_overrides[(normalized_table, normalized_column)]
|
||||||
|
|
||||||
|
explicit = {
|
||||||
|
"id": "ID",
|
||||||
|
"code": "Код",
|
||||||
|
"key": "Ключ",
|
||||||
|
"name": "Название",
|
||||||
|
"label": "Метка",
|
||||||
|
"text": "Текст",
|
||||||
|
"description": "Описание",
|
||||||
|
"author": "Автор",
|
||||||
|
"source": "Источник",
|
||||||
|
"email": "Email",
|
||||||
|
"role": "Роль",
|
||||||
|
"kind": "Тип",
|
||||||
|
"status": "Статус",
|
||||||
|
"status_code": "Статус",
|
||||||
|
"topic_code": "Тема",
|
||||||
|
"from_status": "Из статуса",
|
||||||
|
"to_status": "В статус",
|
||||||
|
"track_number": "Номер заявки",
|
||||||
|
"invoice_number": "Номер счета",
|
||||||
|
"invoice_template": "Шаблон счета",
|
||||||
|
"amount": "Сумма",
|
||||||
|
"currency": "Валюта",
|
||||||
|
"client_name": "Клиент",
|
||||||
|
"client_phone": "Телефон",
|
||||||
|
"payer_display_name": "Плательщик",
|
||||||
|
"payer_details_encrypted": "Реквизиты (шифр.)",
|
||||||
|
"issued_at": "Дата формирования",
|
||||||
|
"paid_at": "Дата оплаты",
|
||||||
|
"created_at": "Дата создания",
|
||||||
|
"updated_at": "Дата обновления",
|
||||||
|
"responsible": "Ответственный",
|
||||||
|
"sort_order": "Порядок",
|
||||||
|
"is_active": "Активен",
|
||||||
|
"enabled": "Активен",
|
||||||
|
"required": "Обязательное",
|
||||||
|
"nullable": "Может быть пустым",
|
||||||
|
"is_terminal": "Терминальный",
|
||||||
|
"request_id": "ID заявки",
|
||||||
|
"admin_user_id": "ID пользователя",
|
||||||
|
"assigned_lawyer_id": "Назначенный юрист",
|
||||||
|
"issued_by_admin_user_id": "ID сотрудника",
|
||||||
|
"primary_topic_code": "Профильная тема",
|
||||||
|
"default_rate": "Ставка по умолчанию",
|
||||||
|
"effective_rate": "Ставка (фикс.)",
|
||||||
|
"salary_percent": "Процент зарплаты",
|
||||||
|
"invoice_amount": "Сумма счета",
|
||||||
|
"paid_by_admin_id": "Оплату подтвердил",
|
||||||
|
"extra_fields": "Доп. поля",
|
||||||
|
"total_attachments_bytes": "Размер вложений (байт)",
|
||||||
|
"type": "Тип",
|
||||||
|
"options": "Опции",
|
||||||
|
"field_key": "Поле формы",
|
||||||
|
"sla_hours": "SLA (часы)",
|
||||||
|
"avatar_url": "Аватар",
|
||||||
|
"file_name": "Имя файла",
|
||||||
|
"mime_type": "MIME-тип",
|
||||||
|
"size_bytes": "Размер (байт)",
|
||||||
|
"s3_key": "Ключ S3",
|
||||||
|
"author_type": "Автор",
|
||||||
|
"is_fulfilled": "Выполнено",
|
||||||
|
"requested_by_admin_user_id": "Запросил сотрудник",
|
||||||
|
"fulfilled_at": "Дата выполнения",
|
||||||
|
"title": "Заголовок",
|
||||||
|
"body": "Текст",
|
||||||
|
"event_type": "Тип события",
|
||||||
|
"is_read": "Прочитано",
|
||||||
|
"read_at": "Дата прочтения",
|
||||||
|
"notified_at": "Дата уведомления",
|
||||||
|
"otp_code": "OTP-код",
|
||||||
|
"phone": "Телефон",
|
||||||
|
"verified_at": "Подтверждено",
|
||||||
|
"expires_at": "Истекает",
|
||||||
|
"jwt_token": "JWT-токен",
|
||||||
|
"action": "Действие",
|
||||||
|
"entity": "Сущность",
|
||||||
|
"entity_id": "ID сущности",
|
||||||
|
"actor_admin_id": "ID автора",
|
||||||
|
"actor_role": "Роль субъекта",
|
||||||
|
"actor_subject": "Субъект",
|
||||||
|
"actor_ip": "IP адрес",
|
||||||
|
"allowed": "Разрешено",
|
||||||
|
"reason": "Причина",
|
||||||
|
"diff": "Изменения",
|
||||||
|
"details": "Детали",
|
||||||
|
}
|
||||||
|
if normalized_column in explicit:
|
||||||
|
return explicit[normalized_column]
|
||||||
|
|
||||||
|
return _humanize_identifier_ru(normalized_column)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_sort_for_table(model: type) -> list[dict[str, str]]:
|
||||||
|
columns = _columns_map(model)
|
||||||
|
if "sort_order" in columns:
|
||||||
|
return [{"field": "sort_order", "dir": "asc"}]
|
||||||
|
if "created_at" in columns:
|
||||||
|
return [{"field": "created_at", "dir": "desc"}]
|
||||||
|
pk = sa_inspect(model).primary_key
|
||||||
|
if pk:
|
||||||
|
return [{"field": pk[0].key, "dir": "asc"}]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _table_columns_meta(table_name: str, model: type) -> list[dict[str, Any]]:
|
||||||
|
mapper = sa_inspect(model)
|
||||||
|
hidden = _hidden_response_fields(table_name)
|
||||||
|
protected = _protected_input_fields(table_name)
|
||||||
|
primary_keys = {column.key for column in mapper.primary_key}
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for column in mapper.columns:
|
||||||
|
name = column.key
|
||||||
|
if name in hidden:
|
||||||
|
continue
|
||||||
|
kind = _column_kind(column)
|
||||||
|
has_default = column.default is not None or column.server_default is not None or name in primary_keys
|
||||||
|
editable = name not in SYSTEM_FIELDS and name not in protected and name not in primary_keys
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"label": _column_label(table_name, name),
|
||||||
|
"kind": kind,
|
||||||
|
"nullable": bool(column.nullable),
|
||||||
|
"editable": bool(editable),
|
||||||
|
"sortable": True,
|
||||||
|
"filterable": kind != "json",
|
||||||
|
"required_on_create": not bool(column.nullable) and not bool(has_default) and bool(editable),
|
||||||
|
"has_default": bool(has_default),
|
||||||
|
"is_primary_key": name in primary_keys,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _hidden_response_fields(table_name: str) -> set[str]:
|
def _hidden_response_fields(table_name: str) -> set[str]:
|
||||||
if table_name == "admin_users":
|
if table_name == "admin_users":
|
||||||
return {"password_hash"}
|
return {"password_hash"}
|
||||||
|
|
@ -270,6 +527,14 @@ def _normalize_optional_string(value: Any) -> str | None:
|
||||||
return text or None
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _active_lawyer_or_400(db: Session, lawyer_id: Any) -> AdminUser:
|
||||||
|
lawyer_uuid = _parse_uuid_or_400(lawyer_id, "assigned_lawyer_id")
|
||||||
|
lawyer = db.get(AdminUser, lawyer_uuid)
|
||||||
|
if lawyer is None or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active):
|
||||||
|
raise HTTPException(status_code=400, detail="Можно назначить только активного юриста")
|
||||||
|
return lawyer
|
||||||
|
|
||||||
|
|
||||||
def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, Any]:
|
def _apply_admin_user_fields_for_create(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
data = dict(payload)
|
data = dict(payload)
|
||||||
if "password_hash" in data:
|
if "password_hash" in data:
|
||||||
|
|
@ -466,6 +731,16 @@ def _apply_topic_status_transitions_fields(db: Session, payload: dict[str, Any])
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_status_fields(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = dict(payload)
|
||||||
|
if "kind" in data:
|
||||||
|
data["kind"] = normalize_status_kind_or_400(data.get("kind"))
|
||||||
|
if "invoice_template" in data:
|
||||||
|
text = str(data.get("invoice_template") or "").strip()
|
||||||
|
data["invoice_template"] = text or None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
_RU_TO_LATIN = {
|
_RU_TO_LATIN = {
|
||||||
"а": "a",
|
"а": "a",
|
||||||
"б": "b",
|
"б": "b",
|
||||||
|
|
@ -655,6 +930,36 @@ def _apply_create_side_effects(db: Session, *, table_name: str, row: Any, admin:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meta/tables")
|
||||||
|
def list_tables_meta(admin: dict = Depends(get_current_admin)):
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
if role != "ADMIN":
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
|
||||||
|
table_models = _table_model_map()
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for table_name in sorted(table_models.keys()):
|
||||||
|
model = table_models[table_name]
|
||||||
|
actions = sorted(_allowed_actions(role, table_name))
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"key": table_name,
|
||||||
|
"table": table_name,
|
||||||
|
"label": _table_label(table_name),
|
||||||
|
"section": "main" if table_name in {"requests", "invoices"} else "dictionary",
|
||||||
|
"actions": actions,
|
||||||
|
"query_endpoint": f"/api/admin/crud/{table_name}/query",
|
||||||
|
"create_endpoint": f"/api/admin/crud/{table_name}",
|
||||||
|
"update_endpoint_template": f"/api/admin/crud/{table_name}" + "/{id}",
|
||||||
|
"delete_endpoint_template": f"/api/admin/crud/{table_name}" + "/{id}",
|
||||||
|
"default_sort": _default_sort_for_table(model),
|
||||||
|
"columns": _table_columns_meta(table_name, model),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"tables": rows}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{table_name}/query")
|
@router.post("/{table_name}/query")
|
||||||
def query_table(
|
def query_table(
|
||||||
table_name: str,
|
table_name: str,
|
||||||
|
|
@ -711,14 +1016,27 @@ def create_row(
|
||||||
):
|
):
|
||||||
normalized, model = _resolve_table_model(table_name)
|
normalized, model = _resolve_table_model(table_name)
|
||||||
_require_table_action(admin, normalized, "create")
|
_require_table_action(admin, normalized, "create")
|
||||||
if normalized == "requests" and _is_lawyer(admin):
|
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
|
||||||
assigned_lawyer_id = payload.get("assigned_lawyer_id") if isinstance(payload, dict) else None
|
assigned_lawyer_id = payload.get("assigned_lawyer_id")
|
||||||
if str(assigned_lawyer_id or "").strip():
|
if str(assigned_lawyer_id or "").strip():
|
||||||
raise HTTPException(status_code=403, detail='Юрист не может назначать заявку при создании')
|
raise HTTPException(status_code=403, detail='Юрист не может назначать заявку при создании')
|
||||||
|
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys())))
|
||||||
|
if forbidden_fields:
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||||||
|
|
||||||
prepared = _prepare_create_payload(normalized, payload)
|
prepared = _prepare_create_payload(normalized, payload)
|
||||||
if normalized == "requests":
|
if normalized == "requests":
|
||||||
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
|
validate_required_topic_fields_or_400(db, prepared.get("topic_code"), prepared.get("extra_fields"))
|
||||||
|
if not _is_lawyer(admin):
|
||||||
|
assigned_raw = prepared.get("assigned_lawyer_id")
|
||||||
|
if assigned_raw is None or not str(assigned_raw).strip():
|
||||||
|
if "assigned_lawyer_id" in prepared:
|
||||||
|
prepared["assigned_lawyer_id"] = None
|
||||||
|
else:
|
||||||
|
assigned_lawyer = _active_lawyer_or_400(db, assigned_raw)
|
||||||
|
prepared["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||||||
|
if prepared.get("effective_rate") is None:
|
||||||
|
prepared["effective_rate"] = assigned_lawyer.default_rate
|
||||||
prepared = _apply_auto_fields_for_create(db, model, normalized, prepared)
|
prepared = _apply_auto_fields_for_create(db, model, normalized, prepared)
|
||||||
clean_payload = _sanitize_payload(
|
clean_payload = _sanitize_payload(
|
||||||
model,
|
model,
|
||||||
|
|
@ -737,6 +1055,8 @@ def create_row(
|
||||||
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
||||||
if normalized == "topic_status_transitions":
|
if normalized == "topic_status_transitions":
|
||||||
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||||
|
if normalized == "statuses":
|
||||||
|
clean_payload = _apply_status_fields(clean_payload)
|
||||||
if "responsible" in _columns_map(model):
|
if "responsible" in _columns_map(model):
|
||||||
clean_payload["responsible"] = _resolve_responsible(admin)
|
clean_payload["responsible"] = _resolve_responsible(admin)
|
||||||
row = model(**clean_payload)
|
row = model(**clean_payload)
|
||||||
|
|
@ -766,8 +1086,12 @@ def update_row(
|
||||||
):
|
):
|
||||||
normalized, model = _resolve_table_model(table_name)
|
normalized, model = _resolve_table_model(table_name)
|
||||||
_require_table_action(admin, normalized, "update")
|
_require_table_action(admin, normalized, "update")
|
||||||
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict) and "assigned_lawyer_id" in payload:
|
if normalized == "requests" and _is_lawyer(admin) and isinstance(payload, dict):
|
||||||
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
if "assigned_lawyer_id" in payload:
|
||||||
|
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
||||||
|
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.keys())))
|
||||||
|
if forbidden_fields:
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||||||
row = _load_row_or_404(db, model, row_id)
|
row = _load_row_or_404(db, model, row_id)
|
||||||
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
|
if normalized in {"messages", "attachments"} and bool(getattr(row, "immutable", False)):
|
||||||
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования")
|
raise HTTPException(status_code=400, detail="Запись зафиксирована и недоступна для редактирования")
|
||||||
|
|
@ -791,6 +1115,17 @@ def update_row(
|
||||||
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
clean_payload = _apply_request_data_requirements_fields(db, clean_payload)
|
||||||
if normalized == "topic_status_transitions":
|
if normalized == "topic_status_transitions":
|
||||||
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
clean_payload = _apply_topic_status_transitions_fields(db, clean_payload)
|
||||||
|
if normalized == "statuses":
|
||||||
|
clean_payload = _apply_status_fields(clean_payload)
|
||||||
|
if normalized == "requests" and not _is_lawyer(admin) and "assigned_lawyer_id" in clean_payload:
|
||||||
|
assigned_raw = clean_payload.get("assigned_lawyer_id")
|
||||||
|
if assigned_raw is None or not str(assigned_raw).strip():
|
||||||
|
clean_payload["assigned_lawyer_id"] = None
|
||||||
|
else:
|
||||||
|
assigned_lawyer = _active_lawyer_or_400(db, assigned_raw)
|
||||||
|
clean_payload["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||||||
|
if isinstance(row, Request) and row.effective_rate is None and "effective_rate" not in clean_payload:
|
||||||
|
clean_payload["effective_rate"] = assigned_lawyer.default_rate
|
||||||
before = _row_to_dict(row)
|
before = _row_to_dict(row)
|
||||||
if normalized == "topic_status_transitions":
|
if normalized == "topic_status_transitions":
|
||||||
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
|
next_from = str(clean_payload.get("from_status", before.get("from_status") or "")).strip()
|
||||||
|
|
@ -807,6 +1142,14 @@ def update_row(
|
||||||
detail="Переход статуса не разрешен для выбранной темы",
|
detail="Переход статуса не разрешен для выбранной темы",
|
||||||
)
|
)
|
||||||
if before_status != after_status and isinstance(row, Request):
|
if before_status != after_status and isinstance(row, Request):
|
||||||
|
billing_note = apply_billing_transition_effects(
|
||||||
|
db,
|
||||||
|
req=row,
|
||||||
|
from_status=before_status,
|
||||||
|
to_status=after_status,
|
||||||
|
admin=admin,
|
||||||
|
responsible=_resolve_responsible(admin),
|
||||||
|
)
|
||||||
mark_unread_for_client(row, EVENT_STATUS)
|
mark_unread_for_client(row, EVENT_STATUS)
|
||||||
apply_status_change_effects(
|
apply_status_change_effects(
|
||||||
db,
|
db,
|
||||||
|
|
@ -822,7 +1165,7 @@ def update_row(
|
||||||
event_type=NOTIFICATION_EVENT_STATUS,
|
event_type=NOTIFICATION_EVENT_STATUS,
|
||||||
actor_role=_actor_role(admin),
|
actor_role=_actor_role(admin),
|
||||||
actor_admin_user_id=admin.get("sub"),
|
actor_admin_user_id=admin.get("sub"),
|
||||||
body=f"{before_status} -> {after_status}",
|
body=(f"{before_status} -> {after_status}" + (f"\n{billing_note}" if billing_note else "")),
|
||||||
responsible=_resolve_responsible(admin),
|
responsible=_resolve_responsible(admin),
|
||||||
)
|
)
|
||||||
for key, value in clean_payload.items():
|
for key, value in clean_payload.items():
|
||||||
|
|
|
||||||
439
app/api/admin/invoices.py
Normal file
439
app/api/admin/invoices.py
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.deps import require_role
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.invoice import Invoice
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.schemas.universal import UniversalQuery
|
||||||
|
from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites
|
||||||
|
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
||||||
|
from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
STATUS_WAITING = "WAITING_PAYMENT"
|
||||||
|
STATUS_PAID = "PAID"
|
||||||
|
STATUS_CANCELED = "CANCELED"
|
||||||
|
ALLOWED_STATUSES = {STATUS_WAITING, STATUS_PAID, STATUS_CANCELED}
|
||||||
|
STATUS_LABELS = {
|
||||||
|
STATUS_WAITING: "Ожидает оплату",
|
||||||
|
STATUS_PAID: "Оплачен",
|
||||||
|
STATUS_CANCELED: "Отменен",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_iso(value: datetime | None) -> str | None:
|
||||||
|
return value.isoformat() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_uuid_or_401(admin: dict) -> UUID:
|
||||||
|
try:
|
||||||
|
return UUID(str(admin.get("sub") or ""))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_or_400(raw: str | None, field: str) -> UUID:
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Поле "{field}" обязательно')
|
||||||
|
try:
|
||||||
|
return UUID(str(raw))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Некорректное поле "{field}"')
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_status(raw: str | None) -> str:
|
||||||
|
value = str(raw or STATUS_WAITING).strip().upper()
|
||||||
|
if value not in ALLOWED_STATUSES:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный статус счета")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_currency(raw: str | None) -> str:
|
||||||
|
value = str(raw or "RUB").strip().upper()[:3]
|
||||||
|
return value or "RUB"
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_or_400(raw) -> float:
|
||||||
|
value = _to_float(raw)
|
||||||
|
if value is None:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "amount" обязательно и должно быть числом')
|
||||||
|
if value < 0:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "amount" не может быть отрицательным')
|
||||||
|
return round(value, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _invoice_number(db: Session) -> str:
|
||||||
|
prefix = _now_utc().strftime("%Y%m%d")
|
||||||
|
candidate = f"INV-{prefix}-{uuid4().hex[:8].upper()}"
|
||||||
|
exists = db.query(Invoice.id).filter(Invoice.invoice_number == candidate).first()
|
||||||
|
if exists is None:
|
||||||
|
return candidate
|
||||||
|
return f"INV-{prefix}-{uuid4().hex[:12].upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_requisites(raw) -> dict:
|
||||||
|
if raw is None:
|
||||||
|
return {}
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return dict(raw)
|
||||||
|
text = str(raw).strip()
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "payer_details" должно быть JSON-объектом')
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "payer_details" должно быть JSON-объектом')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_lawyer_owns_request_or_403(role: str, actor_id: UUID, req: Request) -> None:
|
||||||
|
if role != "LAWYER":
|
||||||
|
return
|
||||||
|
assigned = str(req.assigned_lawyer_id or "").strip()
|
||||||
|
if not assigned or assigned != str(actor_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист видит и изменяет только свои счета")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_invoice(
|
||||||
|
row: Invoice,
|
||||||
|
request_track: str | None,
|
||||||
|
issuer_name: str | None,
|
||||||
|
*,
|
||||||
|
include_payer_details: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
payload = {
|
||||||
|
"id": str(row.id),
|
||||||
|
"invoice_number": row.invoice_number,
|
||||||
|
"request_id": str(row.request_id),
|
||||||
|
"request_track_number": request_track,
|
||||||
|
"status": row.status,
|
||||||
|
"status_label": STATUS_LABELS.get(str(row.status or "").upper(), row.status),
|
||||||
|
"amount": _to_float(row.amount),
|
||||||
|
"currency": row.currency,
|
||||||
|
"payer_display_name": row.payer_display_name,
|
||||||
|
"issued_by_admin_user_id": str(row.issued_by_admin_user_id) if row.issued_by_admin_user_id else None,
|
||||||
|
"issued_by_name": issuer_name,
|
||||||
|
"issued_by_role": row.issued_by_role,
|
||||||
|
"issued_at": _to_iso(row.issued_at),
|
||||||
|
"paid_at": _to_iso(row.paid_at),
|
||||||
|
"created_at": _to_iso(row.created_at),
|
||||||
|
"updated_at": _to_iso(row.updated_at),
|
||||||
|
"responsible": row.responsible,
|
||||||
|
"pdf_url": f"/api/admin/invoices/{row.id}/pdf",
|
||||||
|
}
|
||||||
|
if include_payer_details:
|
||||||
|
payload["payer_details"] = decrypt_requisites(row.payer_details_encrypted)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_paid_flags(req: Request, invoice: Invoice, *, admin_id: UUID | None) -> None:
|
||||||
|
req.invoice_amount = invoice.amount
|
||||||
|
req.paid_at = invoice.paid_at
|
||||||
|
req.paid_by_admin_id = str(admin_id) if admin_id else None
|
||||||
|
|
||||||
|
|
||||||
|
def _request_from_payload_or_404(db: Session, payload: dict) -> Request:
|
||||||
|
request_id_raw = payload.get("request_id")
|
||||||
|
track_number_raw = str(payload.get("request_track_number") or "").strip().upper()
|
||||||
|
|
||||||
|
if request_id_raw:
|
||||||
|
request_id = _uuid_or_400(request_id_raw, "request_id")
|
||||||
|
req = db.get(Request, request_id)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
return req
|
||||||
|
|
||||||
|
if track_number_raw:
|
||||||
|
req = db.query(Request).filter(Request.track_number == track_number_raw).first()
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
return req
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "request_id" или "request_track_number" обязательно')
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_or_400(db: Session, detail: str) -> None:
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/query")
|
||||||
|
def query_invoices(
|
||||||
|
uq: UniversalQuery,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
actor_id = _actor_uuid_or_401(admin)
|
||||||
|
|
||||||
|
query = db.query(Invoice)
|
||||||
|
if role == "LAWYER":
|
||||||
|
query = query.join(Request, Request.id == Invoice.request_id).filter(Request.assigned_lawyer_id == str(actor_id))
|
||||||
|
query = apply_universal_query(query, Invoice, uq)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
rows = query.offset(uq.page.offset).limit(uq.page.limit).all()
|
||||||
|
|
||||||
|
request_ids = {row.request_id for row in rows}
|
||||||
|
requests = db.query(Request.id, Request.track_number).filter(Request.id.in_(request_ids)).all() if request_ids else []
|
||||||
|
request_map = {str(row_id): track for row_id, track in requests}
|
||||||
|
|
||||||
|
issuer_ids = {row.issued_by_admin_user_id for row in rows if row.issued_by_admin_user_id}
|
||||||
|
users = db.query(AdminUser.id, AdminUser.name, AdminUser.email).filter(AdminUser.id.in_(issuer_ids)).all() if issuer_ids else []
|
||||||
|
issuer_map = {str(user_id): (name or email or str(user_id)) for user_id, name, email in users}
|
||||||
|
|
||||||
|
data = [
|
||||||
|
_serialize_invoice(
|
||||||
|
row,
|
||||||
|
request_track=request_map.get(str(row.request_id)),
|
||||||
|
issuer_name=issuer_map.get(str(row.issued_by_admin_user_id)) if row.issued_by_admin_user_id else None,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return {"rows": data, "total": int(total)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{invoice_id}")
|
||||||
|
def get_invoice(
|
||||||
|
invoice_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
actor_id = _actor_uuid_or_401(admin)
|
||||||
|
|
||||||
|
invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id"))
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Счет не найден")
|
||||||
|
|
||||||
|
req = db.get(Request, invoice.request_id)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
|
||||||
|
|
||||||
|
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
|
||||||
|
return _serialize_invoice(
|
||||||
|
invoice,
|
||||||
|
request_track=req.track_number,
|
||||||
|
issuer_name=issuer.name if issuer else None,
|
||||||
|
include_payer_details=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
def create_invoice(
|
||||||
|
payload: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
actor_id = _actor_uuid_or_401(admin)
|
||||||
|
actor_email = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
|
||||||
|
req = _request_from_payload_or_404(db, payload)
|
||||||
|
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
|
||||||
|
|
||||||
|
status = _normalize_status(payload.get("status"))
|
||||||
|
if role == "LAWYER" and status == STATUS_PAID:
|
||||||
|
raise HTTPException(status_code=403, detail='Юрист не может ставить статус "Оплачен"')
|
||||||
|
|
||||||
|
payer_display_name = str(payload.get("payer_display_name") or "").strip()
|
||||||
|
if not payer_display_name:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "payer_display_name" обязательно')
|
||||||
|
|
||||||
|
invoice = Invoice(
|
||||||
|
request_id=req.id,
|
||||||
|
invoice_number=str(payload.get("invoice_number") or "").strip() or _invoice_number(db),
|
||||||
|
status=status,
|
||||||
|
amount=_amount_or_400(payload.get("amount")),
|
||||||
|
currency=_normalize_currency(payload.get("currency")),
|
||||||
|
payer_display_name=payer_display_name,
|
||||||
|
payer_details_encrypted=encrypt_requisites(_parse_requisites(payload.get("payer_details"))),
|
||||||
|
issued_by_admin_user_id=actor_id,
|
||||||
|
issued_by_role=role,
|
||||||
|
issued_at=_now_utc(),
|
||||||
|
paid_at=None,
|
||||||
|
responsible=actor_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
req.invoice_amount = invoice.amount
|
||||||
|
req.responsible = actor_email
|
||||||
|
|
||||||
|
if status == STATUS_PAID:
|
||||||
|
invoice.paid_at = _now_utc()
|
||||||
|
_apply_paid_flags(req, invoice, admin_id=actor_id if role == "ADMIN" else None)
|
||||||
|
|
||||||
|
db.add(invoice)
|
||||||
|
db.add(req)
|
||||||
|
_commit_or_400(db, "Счет с таким номером уже существует")
|
||||||
|
db.refresh(invoice)
|
||||||
|
|
||||||
|
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
|
||||||
|
return _serialize_invoice(invoice, request_track=req.track_number, issuer_name=issuer.name if issuer else None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{invoice_id}")
|
||||||
|
def update_invoice(
|
||||||
|
invoice_id: str,
|
||||||
|
payload: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
actor_id = _actor_uuid_or_401(admin)
|
||||||
|
actor_email = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
|
||||||
|
invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id"))
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Счет не найден")
|
||||||
|
|
||||||
|
req = db.get(Request, invoice.request_id)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
|
||||||
|
|
||||||
|
prev_status = str(invoice.status or "").upper()
|
||||||
|
prev_paid_at = invoice.paid_at
|
||||||
|
|
||||||
|
if "amount" in payload:
|
||||||
|
invoice.amount = _amount_or_400(payload.get("amount"))
|
||||||
|
req.invoice_amount = invoice.amount
|
||||||
|
if prev_status == STATUS_PAID:
|
||||||
|
req.paid_at = invoice.paid_at
|
||||||
|
if "currency" in payload:
|
||||||
|
invoice.currency = _normalize_currency(payload.get("currency"))
|
||||||
|
if "payer_display_name" in payload:
|
||||||
|
name = str(payload.get("payer_display_name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "payer_display_name" не может быть пустым')
|
||||||
|
invoice.payer_display_name = name
|
||||||
|
if "payer_details" in payload:
|
||||||
|
invoice.payer_details_encrypted = encrypt_requisites(_parse_requisites(payload.get("payer_details")))
|
||||||
|
if "invoice_number" in payload and str(payload.get("invoice_number") or "").strip():
|
||||||
|
invoice.invoice_number = str(payload.get("invoice_number") or "").strip()
|
||||||
|
|
||||||
|
if "status" in payload:
|
||||||
|
next_status = _normalize_status(payload.get("status"))
|
||||||
|
if role == "LAWYER" and next_status == STATUS_PAID:
|
||||||
|
raise HTTPException(status_code=403, detail='Юрист не может ставить статус "Оплачен"')
|
||||||
|
if role == "LAWYER" and prev_status == STATUS_PAID and next_status != STATUS_PAID:
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист не может менять статус уже оплаченного счета")
|
||||||
|
|
||||||
|
invoice.status = next_status
|
||||||
|
if next_status == STATUS_PAID:
|
||||||
|
if role != "ADMIN":
|
||||||
|
raise HTTPException(status_code=403, detail='Юрист не может ставить статус "Оплачен"')
|
||||||
|
invoice.paid_at = _now_utc()
|
||||||
|
_apply_paid_flags(req, invoice, admin_id=actor_id)
|
||||||
|
else:
|
||||||
|
invoice.paid_at = None
|
||||||
|
req.invoice_amount = invoice.amount
|
||||||
|
if prev_paid_at is not None and req.paid_at == prev_paid_at:
|
||||||
|
req.paid_at = None
|
||||||
|
req.paid_by_admin_id = None
|
||||||
|
|
||||||
|
invoice.responsible = actor_email
|
||||||
|
req.responsible = actor_email
|
||||||
|
|
||||||
|
db.add(invoice)
|
||||||
|
db.add(req)
|
||||||
|
_commit_or_400(db, "Счет с таким номером уже существует")
|
||||||
|
db.refresh(invoice)
|
||||||
|
|
||||||
|
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
|
||||||
|
return _serialize_invoice(invoice, request_track=req.track_number, issuer_name=issuer.name if issuer else None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{invoice_id}")
|
||||||
|
def delete_invoice(
|
||||||
|
invoice_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN")),
|
||||||
|
):
|
||||||
|
actor_email = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
|
||||||
|
invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id"))
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Счет не найден")
|
||||||
|
|
||||||
|
req = db.get(Request, invoice.request_id)
|
||||||
|
if req is not None:
|
||||||
|
if invoice.paid_at is not None and req.paid_at == invoice.paid_at:
|
||||||
|
req.paid_at = None
|
||||||
|
req.paid_by_admin_id = None
|
||||||
|
req.responsible = actor_email
|
||||||
|
db.add(req)
|
||||||
|
|
||||||
|
db.delete(invoice)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "удалено", "id": invoice_id, "responsible": actor_email}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{invoice_id}/pdf")
|
||||||
|
def download_invoice_pdf(
|
||||||
|
invoice_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
|
):
|
||||||
|
role = str(admin.get("role") or "").upper()
|
||||||
|
actor_id = _actor_uuid_or_401(admin)
|
||||||
|
|
||||||
|
invoice = db.get(Invoice, _uuid_or_400(invoice_id, "invoice_id"))
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Счет не найден")
|
||||||
|
|
||||||
|
req = db.get(Request, invoice.request_id)
|
||||||
|
if req is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
|
||||||
|
|
||||||
|
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
|
||||||
|
requisites = decrypt_requisites(invoice.payer_details_encrypted)
|
||||||
|
pdf_bytes = build_invoice_pdf_bytes(
|
||||||
|
invoice_number=invoice.invoice_number,
|
||||||
|
amount=_to_float(invoice.amount) or 0.0,
|
||||||
|
currency=invoice.currency,
|
||||||
|
status=STATUS_LABELS.get(str(invoice.status or "").upper(), invoice.status or "-"),
|
||||||
|
issued_at=invoice.issued_at,
|
||||||
|
paid_at=invoice.paid_at,
|
||||||
|
payer_display_name=invoice.payer_display_name,
|
||||||
|
request_track_number=req.track_number,
|
||||||
|
issued_by_name=(issuer.name if issuer else None),
|
||||||
|
requisites=requisites,
|
||||||
|
)
|
||||||
|
|
||||||
|
file_name = f"{invoice.invoice_number}.pdf"
|
||||||
|
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
|
||||||
|
return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers)
|
||||||
|
|
@ -4,7 +4,7 @@ from uuid import UUID, uuid4
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy import update
|
from sqlalchemy import case, update
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
|
|
@ -30,9 +30,11 @@ from app.services.request_read_markers import EVENT_STATUS, clear_unread_for_law
|
||||||
from app.services.request_status import actor_admin_uuid, apply_status_change_effects
|
from app.services.request_status import actor_admin_uuid, apply_status_change_effects
|
||||||
from app.services.status_flow import transition_allowed_for_topic
|
from app.services.status_flow import transition_allowed_for_topic
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
|
from app.services.billing_flow import apply_billing_transition_effects
|
||||||
from app.services.universal_query import apply_universal_query
|
from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
REQUEST_FINANCIAL_FIELDS = {"effective_rate", "invoice_amount", "paid_at", "paid_by_admin_id"}
|
||||||
|
|
||||||
|
|
||||||
def _request_uuid_or_400(request_id: str) -> UUID:
|
def _request_uuid_or_400(request_id: str) -> UUID:
|
||||||
|
|
@ -42,6 +44,17 @@ def _request_uuid_or_400(request_id: str) -> UUID:
|
||||||
raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки")
|
raise HTTPException(status_code=400, detail="Некорректный идентификатор заявки")
|
||||||
|
|
||||||
|
|
||||||
|
def _active_lawyer_or_400(db: Session, lawyer_id: str) -> AdminUser:
|
||||||
|
try:
|
||||||
|
lawyer_uuid = UUID(str(lawyer_id))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный идентификатор юриста")
|
||||||
|
lawyer = db.get(AdminUser, lawyer_uuid)
|
||||||
|
if not lawyer or str(lawyer.role or "").upper() != "LAWYER" or not bool(lawyer.is_active):
|
||||||
|
raise HTTPException(status_code=400, detail="Можно назначить только активного юриста")
|
||||||
|
return lawyer
|
||||||
|
|
||||||
|
|
||||||
def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
|
def _ensure_lawyer_can_manage_request_or_403(admin: dict, req: Request) -> None:
|
||||||
role = str(admin.get("role") or "").upper()
|
role = str(admin.get("role") or "").upper()
|
||||||
if role != "LAWYER":
|
if role != "LAWYER":
|
||||||
|
|
@ -99,11 +112,23 @@ def query_requests(uq: UniversalQuery, db: Session = Depends(get_db), admin=Depe
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER"))):
|
||||||
if str(admin.get("role") or "").upper() == "LAWYER" and str(payload.assigned_lawyer_id or "").strip():
|
actor_role = str(admin.get("role") or "").upper()
|
||||||
|
if actor_role == "LAWYER" and str(payload.assigned_lawyer_id or "").strip():
|
||||||
raise HTTPException(status_code=403, detail="Юрист не может назначать заявку при создании")
|
raise HTTPException(status_code=403, detail="Юрист не может назначать заявку при создании")
|
||||||
|
if actor_role == "LAWYER":
|
||||||
|
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(payload.model_fields_set)))
|
||||||
|
if forbidden_fields:
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||||||
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
||||||
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
|
track = payload.track_number or f"TRK-{uuid4().hex[:10].upper()}"
|
||||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
assigned_lawyer_id = str(payload.assigned_lawyer_id or "").strip() or None
|
||||||
|
effective_rate = payload.effective_rate
|
||||||
|
if assigned_lawyer_id:
|
||||||
|
assigned_lawyer = _active_lawyer_or_400(db, assigned_lawyer_id)
|
||||||
|
assigned_lawyer_id = str(assigned_lawyer.id)
|
||||||
|
if effective_rate is None:
|
||||||
|
effective_rate = assigned_lawyer.default_rate
|
||||||
row = Request(
|
row = Request(
|
||||||
track_number=track,
|
track_number=track,
|
||||||
client_name=payload.client_name,
|
client_name=payload.client_name,
|
||||||
|
|
@ -112,8 +137,8 @@ def create_request(payload: RequestAdminCreate, db: Session = Depends(get_db), a
|
||||||
status_code=payload.status_code,
|
status_code=payload.status_code,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
extra_fields=payload.extra_fields,
|
extra_fields=payload.extra_fields,
|
||||||
assigned_lawyer_id=payload.assigned_lawyer_id,
|
assigned_lawyer_id=assigned_lawyer_id,
|
||||||
effective_rate=payload.effective_rate,
|
effective_rate=effective_rate,
|
||||||
invoice_amount=payload.invoice_amount,
|
invoice_amount=payload.invoice_amount,
|
||||||
paid_at=payload.paid_at,
|
paid_at=payload.paid_at,
|
||||||
paid_by_admin_id=payload.paid_by_admin_id,
|
paid_by_admin_id=payload.paid_by_admin_id,
|
||||||
|
|
@ -142,26 +167,49 @@ def update_request(
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
changes = payload.model_dump(exclude_unset=True)
|
changes = payload.model_dump(exclude_unset=True)
|
||||||
if str(admin.get("role") or "").upper() == "LAWYER" and "assigned_lawyer_id" in changes:
|
actor_role = str(admin.get("role") or "").upper()
|
||||||
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
if actor_role == "LAWYER":
|
||||||
|
if "assigned_lawyer_id" in changes:
|
||||||
|
raise HTTPException(status_code=403, detail='Назначение доступно только через действие "Взять в работу"')
|
||||||
|
forbidden_fields = sorted(REQUEST_FINANCIAL_FIELDS.intersection(set(changes.keys())))
|
||||||
|
if forbidden_fields:
|
||||||
|
raise HTTPException(status_code=403, detail="Юрист не может изменять финансовые поля заявки")
|
||||||
|
if actor_role == "ADMIN" and "assigned_lawyer_id" in changes:
|
||||||
|
assigned_raw = changes.get("assigned_lawyer_id")
|
||||||
|
if assigned_raw is None or not str(assigned_raw).strip():
|
||||||
|
changes["assigned_lawyer_id"] = None
|
||||||
|
else:
|
||||||
|
assigned_lawyer = _active_lawyer_or_400(db, str(assigned_raw))
|
||||||
|
changes["assigned_lawyer_id"] = str(assigned_lawyer.id)
|
||||||
|
if row.effective_rate is None and "effective_rate" not in changes:
|
||||||
|
changes["effective_rate"] = assigned_lawyer.default_rate
|
||||||
old_status = str(row.status_code or "")
|
old_status = str(row.status_code or "")
|
||||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
for key, value in changes.items():
|
for key, value in changes.items():
|
||||||
setattr(row, key, value)
|
setattr(row, key, value)
|
||||||
if "status_code" in changes and str(changes.get("status_code") or "") != old_status:
|
if "status_code" in changes and str(changes.get("status_code") or "") != old_status:
|
||||||
|
next_status = str(changes.get("status_code") or "")
|
||||||
if not transition_allowed_for_topic(
|
if not transition_allowed_for_topic(
|
||||||
db,
|
db,
|
||||||
str(row.topic_code or "").strip() or None,
|
str(row.topic_code or "").strip() or None,
|
||||||
old_status,
|
old_status,
|
||||||
str(changes.get("status_code") or ""),
|
next_status,
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
raise HTTPException(status_code=400, detail="Переход статуса не разрешен для выбранной темы")
|
||||||
|
billing_note = apply_billing_transition_effects(
|
||||||
|
db,
|
||||||
|
req=row,
|
||||||
|
from_status=old_status,
|
||||||
|
to_status=next_status,
|
||||||
|
admin=admin,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
mark_unread_for_client(row, EVENT_STATUS)
|
mark_unread_for_client(row, EVENT_STATUS)
|
||||||
apply_status_change_effects(
|
apply_status_change_effects(
|
||||||
db,
|
db,
|
||||||
row,
|
row,
|
||||||
from_status=old_status,
|
from_status=old_status,
|
||||||
to_status=str(changes.get("status_code") or ""),
|
to_status=next_status,
|
||||||
admin=admin,
|
admin=admin,
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
|
|
@ -171,7 +219,7 @@ def update_request(
|
||||||
event_type=NOTIFICATION_EVENT_STATUS,
|
event_type=NOTIFICATION_EVENT_STATUS,
|
||||||
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||||||
actor_admin_user_id=admin.get("sub"),
|
actor_admin_user_id=admin.get("sub"),
|
||||||
body=f"{old_status} -> {str(changes.get('status_code') or '').strip()}",
|
body=(f"{old_status} -> {next_status}" + (f"\n{billing_note}" if billing_note else "")),
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|
@ -263,6 +311,7 @@ def claim_request(request_id: str, db: Session = Depends(get_db), admin=Depends(
|
||||||
.where(Request.id == request_uuid, Request.assigned_lawyer_id.is_(None))
|
.where(Request.id == request_uuid, Request.assigned_lawyer_id.is_(None))
|
||||||
.values(
|
.values(
|
||||||
assigned_lawyer_id=str(lawyer_uuid),
|
assigned_lawyer_id=str(lawyer_uuid),
|
||||||
|
effective_rate=case((Request.effective_rate.is_(None), lawyer.default_rate), else_=Request.effective_rate),
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
|
|
@ -346,6 +395,7 @@ def reassign_request(
|
||||||
.where(Request.id == request_uuid, Request.assigned_lawyer_id == old_assigned)
|
.where(Request.id == request_uuid, Request.assigned_lawyer_id == old_assigned)
|
||||||
.values(
|
.values(
|
||||||
assigned_lawyer_id=str(lawyer_uuid),
|
assigned_lawyer_id=str(lawyer_uuid),
|
||||||
|
effective_rate=case((Request.effective_rate.is_(None), target_lawyer.default_rate), else_=Request.effective_rate),
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications
|
from app.api.admin import auth, requests, quotes, meta, config, uploads, metrics, crud, notifications, invoices
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
router.include_router(auth.router, prefix="/auth", tags=["AdminAuth"])
|
||||||
|
|
@ -10,4 +10,5 @@ router.include_router(config.router, prefix="/config", tags=["AdminConfig"])
|
||||||
router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"])
|
router.include_router(uploads.router, prefix="/uploads", tags=["AdminFiles"])
|
||||||
router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
|
router.include_router(metrics.router, prefix="/metrics", tags=["AdminMetrics"])
|
||||||
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
|
router.include_router(notifications.router, prefix="/notifications", tags=["AdminNotifications"])
|
||||||
|
router.include_router(invoices.router, prefix="/invoices", tags=["AdminInvoices"])
|
||||||
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
|
router.include_router(crud.router, prefix="/crud", tags=["AdminCrud"])
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import uuid
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from botocore.exceptions import ClientError
|
from botocore.exceptions import ClientError
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request as FastapiRequest
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ from app.models.request import Request
|
||||||
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
||||||
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
||||||
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
|
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client
|
||||||
|
from app.services.security_audit import record_file_security_event
|
||||||
from app.services.s3_storage import build_object_key, get_s3_storage
|
from app.services.s3_storage import build_object_key, get_s3_storage
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -77,169 +78,327 @@ def _uuid_or_none(raw: str) -> uuid.UUID | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(http_request: FastapiRequest) -> str | None:
|
||||||
|
if http_request is None:
|
||||||
|
return None
|
||||||
|
forwarded = str(http_request.headers.get("x-forwarded-for") or "").strip()
|
||||||
|
if forwarded:
|
||||||
|
first = forwarded.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
if http_request.client and http_request.client.host:
|
||||||
|
return str(http_request.client.host)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/init", response_model=UploadInitResponse)
|
@router.post("/init", response_model=UploadInitResponse)
|
||||||
def upload_init(
|
def upload_init(
|
||||||
payload: UploadInitPayload,
|
payload: UploadInitPayload,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
):
|
):
|
||||||
_validate_size_or_400(payload.size_bytes)
|
role = str(admin.get("role") or "").upper() or "UNKNOWN"
|
||||||
storage = get_s3_storage()
|
actor_id = str(admin.get("sub") or "").strip()
|
||||||
role = str(admin.get("role") or "")
|
actor_ip = _client_ip(http_request)
|
||||||
actor_id = str(admin.get("sub") or "")
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
|
||||||
|
|
||||||
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
|
try:
|
||||||
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
_validate_size_or_400(payload.size_bytes)
|
||||||
request = db.get(Request, request_uuid)
|
storage = get_s3_storage()
|
||||||
if request is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
|
||||||
_ensure_case_capacity_or_400(request, payload.size_bytes)
|
|
||||||
key = build_object_key(f"requests/{request.id}", payload.file_name)
|
|
||||||
return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
|
|
||||||
|
|
||||||
if payload.scope == UploadScope.USER_AVATAR:
|
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
|
||||||
target_user_id = str(payload.user_id or actor_id)
|
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
||||||
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
request = db.get(Request, request_uuid)
|
||||||
if role != "ADMIN" and str(target_uuid) != actor_id:
|
if request is None:
|
||||||
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
user = db.get(AdminUser, target_uuid)
|
_ensure_case_capacity_or_400(request, payload.size_bytes)
|
||||||
if user is None:
|
key = build_object_key(f"requests/{request.id}", payload.file_name)
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
response = UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
|
||||||
key = build_object_key(f"avatars/{user.id}", payload.file_name)
|
record_file_security_event(
|
||||||
return UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=actor_id,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="UPLOAD_INIT",
|
||||||
|
scope=scope_name,
|
||||||
|
allowed=True,
|
||||||
|
object_key=key,
|
||||||
|
request_id=request.id,
|
||||||
|
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||||||
|
responsible=responsible,
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
|
if payload.scope == UploadScope.USER_AVATAR:
|
||||||
|
target_user_id = str(payload.user_id or actor_id)
|
||||||
|
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
||||||
|
if role != "ADMIN" and str(target_uuid) != actor_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
|
||||||
|
user = db.get(AdminUser, target_uuid)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
key = build_object_key(f"avatars/{user.id}", payload.file_name)
|
||||||
|
response = UploadInitResponse(key=key, presigned_url=storage.create_presigned_put_url(key, payload.mime_type))
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=actor_id,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="UPLOAD_INIT",
|
||||||
|
scope=scope_name,
|
||||||
|
allowed=True,
|
||||||
|
object_key=key,
|
||||||
|
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||||||
|
responsible=responsible,
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
|
||||||
|
except HTTPException as exc:
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=actor_id,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="UPLOAD_INIT",
|
||||||
|
scope=scope_name,
|
||||||
|
allowed=False,
|
||||||
|
reason=str(exc.detail),
|
||||||
|
request_id=_uuid_or_none(payload.request_id),
|
||||||
|
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||||||
|
responsible=responsible,
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.post("/complete", response_model=UploadCompleteResponse)
|
@router.post("/complete", response_model=UploadCompleteResponse)
|
||||||
def upload_complete(
|
def upload_complete(
|
||||||
payload: UploadCompletePayload,
|
payload: UploadCompletePayload,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
):
|
):
|
||||||
_validate_size_or_400(payload.size_bytes)
|
role = str(admin.get("role") or "").upper() or "UNKNOWN"
|
||||||
storage = get_s3_storage()
|
|
||||||
role = str(admin.get("role") or "")
|
|
||||||
actor_id = str(admin.get("sub") or "")
|
actor_id = str(admin.get("sub") or "")
|
||||||
|
actor_ip = _client_ip(http_request)
|
||||||
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
responsible = str(admin.get("email") or "").strip() or "Администратор системы"
|
||||||
|
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
head = storage.head_object(payload.key)
|
_validate_size_or_400(payload.size_bytes)
|
||||||
except ClientError:
|
storage = get_s3_storage()
|
||||||
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
|
try:
|
||||||
|
head = storage.head_object(payload.key)
|
||||||
|
except ClientError:
|
||||||
|
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
|
||||||
|
|
||||||
actual_size = int(head.get("ContentLength") or payload.size_bytes)
|
actual_size = int(head.get("ContentLength") or payload.size_bytes)
|
||||||
if actual_size <= 0:
|
if actual_size <= 0:
|
||||||
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
||||||
if actual_size > _max_file_bytes():
|
if actual_size > _max_file_bytes():
|
||||||
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
||||||
|
|
||||||
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
|
if payload.scope == UploadScope.REQUEST_ATTACHMENT:
|
||||||
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
||||||
request = db.get(Request, request_uuid)
|
request = db.get(Request, request_uuid)
|
||||||
if request is None:
|
if request is None:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
|
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
|
||||||
_ensure_case_capacity_or_400(request, actual_size)
|
_ensure_case_capacity_or_400(request, actual_size)
|
||||||
|
|
||||||
message_uuid = None
|
message_uuid = None
|
||||||
if payload.message_id:
|
if payload.message_id:
|
||||||
message_uuid = _uuid_or_400(payload.message_id, "message_id")
|
message_uuid = _uuid_or_400(payload.message_id, "message_id")
|
||||||
message = db.get(Message, message_uuid)
|
message = db.get(Message, message_uuid)
|
||||||
if message is None or message.request_id != request.id:
|
if message is None or message.request_id != request.id:
|
||||||
raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки")
|
raise HTTPException(status_code=400, detail="Сообщение не найдено для указанной заявки")
|
||||||
if bool(message.immutable):
|
if bool(message.immutable):
|
||||||
raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению")
|
raise HTTPException(status_code=400, detail="Нельзя прикрепить файл к зафиксированному сообщению")
|
||||||
|
|
||||||
row = Attachment(
|
row = Attachment(
|
||||||
request_id=request.id,
|
request_id=request.id,
|
||||||
message_id=message_uuid,
|
message_id=message_uuid,
|
||||||
file_name=payload.file_name,
|
file_name=payload.file_name,
|
||||||
mime_type=payload.mime_type,
|
mime_type=payload.mime_type,
|
||||||
size_bytes=actual_size,
|
size_bytes=actual_size,
|
||||||
s3_key=payload.key,
|
s3_key=payload.key,
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
)
|
)
|
||||||
mark_unread_for_client(request, EVENT_ATTACHMENT)
|
mark_unread_for_client(request, EVENT_ATTACHMENT)
|
||||||
notify_request_event(
|
notify_request_event(
|
||||||
|
db,
|
||||||
|
request=request,
|
||||||
|
event_type=NOTIFICATION_EVENT_ATTACHMENT,
|
||||||
|
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
||||||
|
actor_admin_user_id=admin.get("sub"),
|
||||||
|
body=f'Файл: {payload.file_name}',
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
|
||||||
|
request.responsible = responsible
|
||||||
|
db.add(row)
|
||||||
|
db.add(request)
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=actor_id,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="UPLOAD_COMPLETE",
|
||||||
|
scope=scope_name,
|
||||||
|
allowed=True,
|
||||||
|
object_key=payload.key,
|
||||||
|
request_id=request.id,
|
||||||
|
details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)},
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
|
||||||
|
|
||||||
|
if payload.scope == UploadScope.USER_AVATAR:
|
||||||
|
target_user_id = str(payload.user_id or actor_id)
|
||||||
|
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
||||||
|
if role != "ADMIN" and str(target_uuid) != actor_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
|
||||||
|
user = db.get(AdminUser, target_uuid)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
|
||||||
|
user.avatar_url = f"s3://{payload.key}"
|
||||||
|
user.responsible = responsible
|
||||||
|
db.add(user)
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=actor_id,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="UPLOAD_COMPLETE",
|
||||||
|
scope=scope_name,
|
||||||
|
allowed=True,
|
||||||
|
object_key=payload.key,
|
||||||
|
details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)},
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url)
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
|
||||||
|
except HTTPException as exc:
|
||||||
|
record_file_security_event(
|
||||||
db,
|
db,
|
||||||
request=request,
|
actor_role=role,
|
||||||
event_type=NOTIFICATION_EVENT_ATTACHMENT,
|
actor_subject=actor_id,
|
||||||
actor_role=str(admin.get("role") or "").upper() or "ADMIN",
|
actor_ip=actor_ip,
|
||||||
actor_admin_user_id=admin.get("sub"),
|
action="UPLOAD_COMPLETE",
|
||||||
body=f'Файл: {payload.file_name}',
|
scope=scope_name,
|
||||||
|
allowed=False,
|
||||||
|
reason=str(exc.detail),
|
||||||
|
object_key=payload.key,
|
||||||
|
request_id=_uuid_or_none(payload.request_id),
|
||||||
|
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||||||
responsible=responsible,
|
responsible=responsible,
|
||||||
|
persist_now=True,
|
||||||
)
|
)
|
||||||
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
|
raise
|
||||||
request.responsible = responsible
|
|
||||||
db.add(row)
|
|
||||||
db.add(request)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(row)
|
|
||||||
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
|
|
||||||
|
|
||||||
if payload.scope == UploadScope.USER_AVATAR:
|
|
||||||
target_user_id = str(payload.user_id or actor_id)
|
|
||||||
target_uuid = _uuid_or_400(target_user_id, "user_id")
|
|
||||||
if role != "ADMIN" and str(target_uuid) != actor_id:
|
|
||||||
raise HTTPException(status_code=403, detail="Недостаточно прав для загрузки аватара")
|
|
||||||
user = db.get(AdminUser, target_uuid)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
|
||||||
_ensure_object_key_prefix_or_400(payload.key, f"avatars/{user.id}/")
|
|
||||||
user.avatar_url = f"s3://{payload.key}"
|
|
||||||
user.responsible = responsible
|
|
||||||
db.add(user)
|
|
||||||
db.commit()
|
|
||||||
return UploadCompleteResponse(status="ok", avatar_url=user.avatar_url)
|
|
||||||
|
|
||||||
raise HTTPException(status_code=400, detail="Неподдерживаемый scope")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/object/{object_key:path}")
|
@router.get("/object/{object_key:path}")
|
||||||
def get_object_proxy(object_key: str, token: str = Query(...), db: Session = Depends(get_db)):
|
def get_object_proxy(
|
||||||
try:
|
object_key: str,
|
||||||
claims = decode_jwt(token, settings.ADMIN_JWT_SECRET)
|
http_request: FastapiRequest,
|
||||||
except Exception:
|
token: str = Query(...),
|
||||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
db: Session = Depends(get_db),
|
||||||
role = str(claims.get("role") or "").upper()
|
):
|
||||||
if role not in {"ADMIN", "LAWYER"}:
|
|
||||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
|
||||||
|
|
||||||
key = str(object_key or "").strip()
|
key = str(object_key or "").strip()
|
||||||
if not key:
|
scope = "UNKNOWN"
|
||||||
raise HTTPException(status_code=400, detail="Некорректный ключ объекта")
|
scoped_uuid: uuid.UUID | None = None
|
||||||
|
actor_role = "UNKNOWN"
|
||||||
|
actor_subject = ""
|
||||||
|
actor_ip = _client_ip(http_request)
|
||||||
|
responsible = "Администратор системы"
|
||||||
|
|
||||||
scope, scoped_id_raw = _parse_scoped_object_key(key)
|
try:
|
||||||
if role == "LAWYER":
|
try:
|
||||||
actor_id = _uuid_or_none(claims.get("sub"))
|
claims = decode_jwt(token, settings.ADMIN_JWT_SECRET)
|
||||||
if actor_id is None:
|
except Exception:
|
||||||
raise HTTPException(status_code=401, detail="Некорректный токен")
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
scoped_uuid = _uuid_or_none(scoped_id_raw)
|
actor_role = str(claims.get("role") or "").upper()
|
||||||
if scope == "avatars":
|
actor_subject = str(claims.get("sub") or "").strip()
|
||||||
if scoped_uuid is None or scoped_uuid != actor_id:
|
responsible = str(claims.get("email") or "").strip() or "Администратор системы"
|
||||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
if actor_role not in {"ADMIN", "LAWYER"}:
|
||||||
elif scope == "requests":
|
|
||||||
if scoped_uuid is None:
|
|
||||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
|
||||||
# LAWYER can download files from own or unassigned requests only.
|
|
||||||
request = db.get(Request, scoped_uuid)
|
|
||||||
if request is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
|
||||||
assigned = str(request.assigned_lawyer_id or "").strip()
|
|
||||||
if assigned and assigned != str(actor_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
|
||||||
try:
|
if not key:
|
||||||
obj = get_s3_storage().get_object(key)
|
raise HTTPException(status_code=400, detail="Некорректный ключ объекта")
|
||||||
except ClientError:
|
|
||||||
raise HTTPException(status_code=404, detail="Файл не найден")
|
|
||||||
|
|
||||||
body = obj["Body"]
|
scope, scoped_id_raw = _parse_scoped_object_key(key)
|
||||||
content_length = obj.get("ContentLength")
|
scoped_uuid = _uuid_or_none(scoped_id_raw)
|
||||||
media_type = obj.get("ContentType") or "application/octet-stream"
|
if actor_role == "LAWYER":
|
||||||
headers = {}
|
actor_id = _uuid_or_none(claims.get("sub"))
|
||||||
if content_length is not None:
|
if actor_id is None:
|
||||||
headers["Content-Length"] = str(content_length)
|
raise HTTPException(status_code=401, detail="Некорректный токен")
|
||||||
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)
|
if scope == "avatars":
|
||||||
|
if scoped_uuid is None or scoped_uuid != actor_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
elif scope == "requests":
|
||||||
|
if scoped_uuid is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
# LAWYER can download files from own or unassigned requests only.
|
||||||
|
request = db.get(Request, scoped_uuid)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
assigned = str(request.assigned_lawyer_id or "").strip()
|
||||||
|
if assigned and assigned != str(actor_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||||
|
|
||||||
|
try:
|
||||||
|
obj = get_s3_storage().get_object(key)
|
||||||
|
except ClientError:
|
||||||
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role=actor_role,
|
||||||
|
actor_subject=actor_subject,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="DOWNLOAD_OBJECT",
|
||||||
|
scope=scope,
|
||||||
|
allowed=True,
|
||||||
|
object_key=key,
|
||||||
|
request_id=scoped_uuid if scope == "requests" else None,
|
||||||
|
details={},
|
||||||
|
responsible=responsible,
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
body = obj["Body"]
|
||||||
|
content_length = obj.get("ContentLength")
|
||||||
|
media_type = obj.get("ContentType") or "application/octet-stream"
|
||||||
|
headers = {}
|
||||||
|
if content_length is not None:
|
||||||
|
headers["Content-Length"] = str(content_length)
|
||||||
|
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)
|
||||||
|
except HTTPException as exc:
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role=actor_role,
|
||||||
|
actor_subject=actor_subject,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="DOWNLOAD_OBJECT",
|
||||||
|
scope=scope,
|
||||||
|
allowed=False,
|
||||||
|
reason=str(exc.detail),
|
||||||
|
object_key=key or None,
|
||||||
|
request_id=scoped_uuid if scope == "requests" else None,
|
||||||
|
details={},
|
||||||
|
responsible=responsible,
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
import hashlib
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import create_jwt, hash_password, verify_password
|
from app.core.security import create_jwt, hash_password, verify_password
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.otp_session import OtpSession
|
from app.models.otp_session import OtpSession
|
||||||
from app.models.request import Request
|
from app.models.request import Request as RequestModel
|
||||||
from app.schemas.public import OtpSend, OtpVerify
|
from app.schemas.public import OtpSend, OtpVerify
|
||||||
|
from app.services.rate_limit import get_rate_limiter
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -55,6 +57,45 @@ def _generate_code() -> str:
|
||||||
return f"{secrets.randbelow(1_000_000):06d}"
|
return f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
xff = str(request.headers.get("x-forwarded-for") or "").strip()
|
||||||
|
if xff:
|
||||||
|
first = xff.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
client = request.client
|
||||||
|
return str(client.host if client else "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_key_part(value: str | None) -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return "-"
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20]
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit_or_429(action: str, *, purpose: str, client_ip: str, phone: str | None, track_number: str | None) -> None:
|
||||||
|
limiter = get_rate_limiter()
|
||||||
|
window = int(max(settings.OTP_RATE_LIMIT_WINDOW_SECONDS, 1))
|
||||||
|
limit = int(max(settings.OTP_SEND_RATE_LIMIT if action == "send" else settings.OTP_VERIFY_RATE_LIMIT, 1))
|
||||||
|
purpose_norm = str(purpose or "").strip().upper()
|
||||||
|
keys = [
|
||||||
|
f"otp:{action}:ip:{_hash_key_part(client_ip)}:purpose:{purpose_norm}",
|
||||||
|
]
|
||||||
|
if phone:
|
||||||
|
keys.append(f"otp:{action}:phone:{_hash_key_part(phone)}:purpose:{purpose_norm}")
|
||||||
|
if track_number:
|
||||||
|
keys.append(f"otp:{action}:track:{_hash_key_part(track_number)}:purpose:{purpose_norm}")
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
result = limiter.hit(key, limit=limit, window_seconds=window)
|
||||||
|
if not result.allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"Слишком много OTP-запросов. Повторите через {max(result.retry_after_seconds, 1)} сек.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> None:
|
def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> None:
|
||||||
token = create_jwt(
|
token = create_jwt(
|
||||||
{"sub": subject, "purpose": purpose},
|
{"sub": subject, "purpose": purpose},
|
||||||
|
|
@ -82,7 +123,7 @@ def _mock_sms_send(phone: str, code: str, purpose: str, track_number: str | None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send")
|
@router.post("/send")
|
||||||
def send_otp(payload: OtpSend, db: Session = Depends(get_db)):
|
def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
||||||
purpose = _normalize_purpose(payload.purpose)
|
purpose = _normalize_purpose(payload.purpose)
|
||||||
if purpose not in ALLOWED_PURPOSES:
|
if purpose not in ALLOWED_PURPOSES:
|
||||||
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
||||||
|
|
@ -97,13 +138,21 @@ def send_otp(payload: OtpSend, db: Session = Depends(get_db)):
|
||||||
track_number = _normalize_track(payload.track_number)
|
track_number = _normalize_track(payload.track_number)
|
||||||
if not track_number:
|
if not track_number:
|
||||||
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
||||||
request = db.query(Request).filter(Request.track_number == track_number).first()
|
request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first()
|
||||||
if request is None:
|
if request_row is None:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
phone = _normalize_phone(request.client_phone)
|
phone = _normalize_phone(request_row.client_phone)
|
||||||
if not phone:
|
if not phone:
|
||||||
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
|
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
|
||||||
|
|
||||||
|
_rate_limit_or_429(
|
||||||
|
"send",
|
||||||
|
purpose=purpose,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
phone=phone or None,
|
||||||
|
track_number=track_number,
|
||||||
|
)
|
||||||
|
|
||||||
code = _generate_code()
|
code = _generate_code()
|
||||||
now = _now_utc()
|
now = _now_utc()
|
||||||
expires_at = now + timedelta(minutes=OTP_TTL_MINUTES)
|
expires_at = now + timedelta(minutes=OTP_TTL_MINUTES)
|
||||||
|
|
@ -139,7 +188,7 @@ def send_otp(payload: OtpSend, db: Session = Depends(get_db)):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify")
|
@router.post("/verify")
|
||||||
def verify_otp(payload: OtpVerify, response: Response, db: Session = Depends(get_db)):
|
def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||||
purpose = _normalize_purpose(payload.purpose)
|
purpose = _normalize_purpose(payload.purpose)
|
||||||
if purpose not in ALLOWED_PURPOSES:
|
if purpose not in ALLOWED_PURPOSES:
|
||||||
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
||||||
|
|
@ -155,6 +204,14 @@ def verify_otp(payload: OtpVerify, response: Response, db: Session = Depends(get
|
||||||
if not track_number:
|
if not track_number:
|
||||||
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
raise HTTPException(status_code=400, detail='Поле "track_number" обязательно для VIEW_REQUEST')
|
||||||
|
|
||||||
|
_rate_limit_or_429(
|
||||||
|
"verify",
|
||||||
|
purpose=purpose,
|
||||||
|
client_ip=_client_ip(request),
|
||||||
|
phone=phone,
|
||||||
|
track_number=track_number,
|
||||||
|
)
|
||||||
|
|
||||||
query = db.query(OtpSession).filter(
|
query = db.query(OtpSession).filter(
|
||||||
OtpSession.purpose == purpose,
|
OtpSession.purpose == purpose,
|
||||||
OtpSession.track_number == track_number,
|
OtpSession.track_number == track_number,
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,21 @@ from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.deps import get_public_session
|
from app.core.deps import get_public_session
|
||||||
from app.core.security import create_jwt
|
from app.core.security import create_jwt
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.invoice import Invoice
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.services.invoice_crypto import decrypt_requisites
|
||||||
|
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
||||||
from app.services.notifications import (
|
from app.services.notifications import (
|
||||||
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
|
EVENT_MESSAGE as NOTIFICATION_EVENT_MESSAGE,
|
||||||
get_client_notification,
|
get_client_notification,
|
||||||
|
|
@ -39,6 +44,11 @@ router = APIRouter()
|
||||||
|
|
||||||
OTP_CREATE_PURPOSE = "CREATE_REQUEST"
|
OTP_CREATE_PURPOSE = "CREATE_REQUEST"
|
||||||
OTP_VIEW_PURPOSE = "VIEW_REQUEST"
|
OTP_VIEW_PURPOSE = "VIEW_REQUEST"
|
||||||
|
INVOICE_STATUS_LABELS = {
|
||||||
|
"WAITING_PAYMENT": "Ожидает оплату",
|
||||||
|
"PAID": "Оплачен",
|
||||||
|
"CANCELED": "Отменен",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_phone(raw: str | None) -> str:
|
def _normalize_phone(raw: str | None) -> str:
|
||||||
|
|
@ -92,6 +102,22 @@ def _to_iso(value) -> str | None:
|
||||||
return value.isoformat() if value is not None else None
|
return value.isoformat() if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _public_invoice_payload(row: Invoice, track_number: str) -> dict:
|
||||||
|
status_code = str(row.status or "").upper()
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"invoice_number": row.invoice_number,
|
||||||
|
"status": row.status,
|
||||||
|
"status_label": INVOICE_STATUS_LABELS.get(status_code, row.status),
|
||||||
|
"amount": float(row.amount) if row.amount is not None else 0.0,
|
||||||
|
"currency": row.currency,
|
||||||
|
"payer_display_name": row.payer_display_name,
|
||||||
|
"issued_at": _to_iso(row.issued_at),
|
||||||
|
"paid_at": _to_iso(row.paid_at),
|
||||||
|
"download_url": f"/api/public/requests/{track_number}/invoices/{row.id}/pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=PublicRequestCreated, status_code=201)
|
@router.post("", response_model=PublicRequestCreated, status_code=201)
|
||||||
def create_request(
|
def create_request(
|
||||||
payload: PublicRequestCreate,
|
payload: PublicRequestCreate,
|
||||||
|
|
@ -258,6 +284,58 @@ def list_attachments_by_track(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_number}/invoices")
|
||||||
|
def list_invoices_by_track(
|
||||||
|
track_number: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
rows = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.request_id == req.id)
|
||||||
|
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_public_invoice_payload(row, req.track_number) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_number}/invoices/{invoice_id}/pdf")
|
||||||
|
def download_invoice_pdf_by_track(
|
||||||
|
track_number: str,
|
||||||
|
invoice_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
session: dict = Depends(get_public_session),
|
||||||
|
):
|
||||||
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
|
try:
|
||||||
|
invoice_uuid = UUID(str(invoice_id))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный invoice_id")
|
||||||
|
|
||||||
|
invoice = db.get(Invoice, invoice_uuid)
|
||||||
|
if invoice is None or str(invoice.request_id) != str(req.id):
|
||||||
|
raise HTTPException(status_code=404, detail="Счет не найден")
|
||||||
|
|
||||||
|
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
|
||||||
|
requisites = decrypt_requisites(invoice.payer_details_encrypted)
|
||||||
|
pdf_bytes = build_invoice_pdf_bytes(
|
||||||
|
invoice_number=invoice.invoice_number,
|
||||||
|
amount=float(invoice.amount) if invoice.amount is not None else 0.0,
|
||||||
|
currency=invoice.currency,
|
||||||
|
status=INVOICE_STATUS_LABELS.get(str(invoice.status or "").upper(), invoice.status or "-"),
|
||||||
|
issued_at=invoice.issued_at,
|
||||||
|
paid_at=invoice.paid_at,
|
||||||
|
payer_display_name=invoice.payer_display_name,
|
||||||
|
request_track_number=req.track_number,
|
||||||
|
issued_by_name=(issuer.name if issuer else invoice.issued_by_role),
|
||||||
|
requisites=requisites,
|
||||||
|
)
|
||||||
|
file_name = f"{invoice.invoice_number}.pdf"
|
||||||
|
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
|
||||||
|
return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead])
|
@router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead])
|
||||||
def list_status_history_by_track(
|
def list_status_history_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import uuid
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from botocore.exceptions import ClientError
|
from botocore.exceptions import ClientError
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -16,6 +16,7 @@ from app.models.request import Request
|
||||||
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, UploadInitPayload, UploadInitResponse, UploadScope
|
||||||
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event
|
||||||
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_lawyer
|
from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_lawyer
|
||||||
|
from app.services.security_audit import record_file_security_event
|
||||||
from app.services.s3_storage import build_object_key, get_s3_storage
|
from app.services.s3_storage import build_object_key, get_s3_storage
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -64,101 +65,236 @@ def _load_attachment_with_access_or_4xx(attachment_id: str, db: Session, session
|
||||||
return attachment
|
return attachment
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(http_request: FastapiRequest) -> str | None:
|
||||||
|
if http_request is None:
|
||||||
|
return None
|
||||||
|
forwarded = str(http_request.headers.get("x-forwarded-for") or "").strip()
|
||||||
|
if forwarded:
|
||||||
|
first = forwarded.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
if http_request.client and http_request.client.host:
|
||||||
|
return str(http_request.client.host)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/init", response_model=UploadInitResponse)
|
@router.post("/init", response_model=UploadInitResponse)
|
||||||
def upload_init(payload: UploadInitPayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)):
|
def upload_init(
|
||||||
if payload.scope != UploadScope.REQUEST_ATTACHMENT:
|
payload: UploadInitPayload,
|
||||||
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT")
|
http_request: FastapiRequest,
|
||||||
if int(payload.size_bytes or 0) <= 0:
|
db: Session = Depends(get_db),
|
||||||
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
session: dict = Depends(get_public_session),
|
||||||
if int(payload.size_bytes) > _max_file_bytes():
|
):
|
||||||
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
actor_subject = str(session.get("sub") or "").strip()
|
||||||
|
actor_ip = _client_ip(http_request)
|
||||||
|
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
|
||||||
|
try:
|
||||||
|
if payload.scope != UploadScope.REQUEST_ATTACHMENT:
|
||||||
|
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT")
|
||||||
|
if int(payload.size_bytes or 0) <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
||||||
|
if int(payload.size_bytes) > _max_file_bytes():
|
||||||
|
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
||||||
|
|
||||||
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
||||||
request = db.get(Request, request_uuid)
|
request = db.get(Request, request_uuid)
|
||||||
if request is None:
|
if request is None:
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
_ensure_public_request_access_or_403(request, session)
|
_ensure_public_request_access_or_403(request, session)
|
||||||
|
|
||||||
current = int(request.total_attachments_bytes or 0)
|
current = int(request.total_attachments_bytes or 0)
|
||||||
if current + int(payload.size_bytes) > _max_case_bytes():
|
if current + int(payload.size_bytes) > _max_case_bytes():
|
||||||
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
|
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
|
||||||
|
|
||||||
key = build_object_key(f"requests/{request.id}", payload.file_name)
|
key = build_object_key(f"requests/{request.id}", payload.file_name)
|
||||||
presigned_url = get_s3_storage().create_presigned_put_url(key, payload.mime_type)
|
presigned_url = get_s3_storage().create_presigned_put_url(key, payload.mime_type)
|
||||||
return UploadInitResponse(key=key, presigned_url=presigned_url)
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
actor_subject=actor_subject,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="UPLOAD_INIT",
|
||||||
|
scope=scope_name,
|
||||||
|
allowed=True,
|
||||||
|
object_key=key,
|
||||||
|
request_id=request.id,
|
||||||
|
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||||||
|
responsible="Клиент",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
return UploadInitResponse(key=key, presigned_url=presigned_url)
|
||||||
|
except HTTPException as exc:
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
actor_subject=actor_subject,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="UPLOAD_INIT",
|
||||||
|
scope=scope_name,
|
||||||
|
allowed=False,
|
||||||
|
reason=str(exc.detail),
|
||||||
|
object_key=None,
|
||||||
|
request_id=payload.request_id,
|
||||||
|
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||||||
|
responsible="Клиент",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.post("/complete", response_model=UploadCompleteResponse)
|
@router.post("/complete", response_model=UploadCompleteResponse)
|
||||||
def upload_complete(payload: UploadCompletePayload, db: Session = Depends(get_db), session: dict = Depends(get_public_session)):
|
def upload_complete(
|
||||||
if payload.scope != UploadScope.REQUEST_ATTACHMENT:
|
payload: UploadCompletePayload,
|
||||||
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT")
|
http_request: FastapiRequest,
|
||||||
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
db: Session = Depends(get_db),
|
||||||
request = db.get(Request, request_uuid)
|
session: dict = Depends(get_public_session),
|
||||||
if request is None:
|
):
|
||||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
actor_subject = str(session.get("sub") or "").strip()
|
||||||
_ensure_public_request_access_or_403(request, session)
|
actor_ip = _client_ip(http_request)
|
||||||
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
|
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
|
||||||
|
|
||||||
storage = get_s3_storage()
|
|
||||||
try:
|
try:
|
||||||
head = storage.head_object(payload.key)
|
if payload.scope != UploadScope.REQUEST_ATTACHMENT:
|
||||||
except ClientError:
|
raise HTTPException(status_code=400, detail="Публичная загрузка поддерживает только REQUEST_ATTACHMENT")
|
||||||
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
|
request_uuid = _uuid_or_400(payload.request_id, "request_id")
|
||||||
|
request = db.get(Request, request_uuid)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||||
|
_ensure_public_request_access_or_403(request, session)
|
||||||
|
_ensure_object_key_prefix_or_400(payload.key, f"requests/{request.id}/")
|
||||||
|
|
||||||
actual_size = int(head.get("ContentLength") or payload.size_bytes or 0)
|
storage = get_s3_storage()
|
||||||
if actual_size <= 0:
|
try:
|
||||||
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
head = storage.head_object(payload.key)
|
||||||
if actual_size > _max_file_bytes():
|
except ClientError:
|
||||||
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
raise HTTPException(status_code=400, detail="Файл не найден в хранилище")
|
||||||
if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes():
|
|
||||||
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
|
|
||||||
|
|
||||||
row = Attachment(
|
actual_size = int(head.get("ContentLength") or payload.size_bytes or 0)
|
||||||
request_id=request.id,
|
if actual_size <= 0:
|
||||||
message_id=None,
|
raise HTTPException(status_code=400, detail="Некорректный размер файла")
|
||||||
file_name=payload.file_name,
|
if actual_size > _max_file_bytes():
|
||||||
mime_type=payload.mime_type,
|
raise HTTPException(status_code=400, detail=f"Превышен лимит файла ({settings.MAX_FILE_MB} МБ)")
|
||||||
size_bytes=actual_size,
|
if int(request.total_attachments_bytes or 0) + actual_size > _max_case_bytes():
|
||||||
s3_key=payload.key,
|
raise HTTPException(status_code=400, detail=f"Превышен лимит вложений заявки ({settings.MAX_CASE_MB} МБ)")
|
||||||
responsible="Клиент",
|
|
||||||
)
|
row = Attachment(
|
||||||
mark_unread_for_lawyer(request, EVENT_ATTACHMENT)
|
request_id=request.id,
|
||||||
notify_request_event(
|
message_id=None,
|
||||||
db,
|
file_name=payload.file_name,
|
||||||
request=request,
|
mime_type=payload.mime_type,
|
||||||
event_type=NOTIFICATION_EVENT_ATTACHMENT,
|
size_bytes=actual_size,
|
||||||
actor_role="CLIENT",
|
s3_key=payload.key,
|
||||||
body=f'Файл: {payload.file_name}',
|
responsible="Клиент",
|
||||||
responsible="Клиент",
|
)
|
||||||
)
|
mark_unread_for_lawyer(request, EVENT_ATTACHMENT)
|
||||||
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
|
notify_request_event(
|
||||||
request.responsible = "Клиент"
|
db,
|
||||||
db.add(row)
|
request=request,
|
||||||
db.add(request)
|
event_type=NOTIFICATION_EVENT_ATTACHMENT,
|
||||||
db.commit()
|
actor_role="CLIENT",
|
||||||
db.refresh(row)
|
body=f'Файл: {payload.file_name}',
|
||||||
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
request.total_attachments_bytes = int(request.total_attachments_bytes or 0) + actual_size
|
||||||
|
request.responsible = "Клиент"
|
||||||
|
db.add(row)
|
||||||
|
db.add(request)
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
actor_subject=actor_subject,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="UPLOAD_COMPLETE",
|
||||||
|
scope=scope_name,
|
||||||
|
allowed=True,
|
||||||
|
object_key=payload.key,
|
||||||
|
request_id=request.id,
|
||||||
|
details={"mime_type": payload.mime_type, "size_bytes": int(actual_size)},
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return UploadCompleteResponse(status="ok", attachment_id=str(row.id))
|
||||||
|
except HTTPException as exc:
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
actor_subject=actor_subject,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="UPLOAD_COMPLETE",
|
||||||
|
scope=scope_name,
|
||||||
|
allowed=False,
|
||||||
|
reason=str(exc.detail),
|
||||||
|
object_key=payload.key,
|
||||||
|
request_id=payload.request_id,
|
||||||
|
details={"mime_type": payload.mime_type, "size_bytes": int(payload.size_bytes or 0)},
|
||||||
|
responsible="Клиент",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.get("/object/{attachment_id}")
|
@router.get("/object/{attachment_id}")
|
||||||
def get_public_attachment_object(
|
def get_public_attachment_object(
|
||||||
attachment_id: str,
|
attachment_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
attachment = _load_attachment_with_access_or_4xx(attachment_id, db, session)
|
actor_subject = str(session.get("sub") or "").strip()
|
||||||
|
actor_ip = _client_ip(http_request)
|
||||||
|
attachment_uuid = _uuid_or_400(attachment_id, "attachment_id")
|
||||||
|
request_id = None
|
||||||
|
key = None
|
||||||
try:
|
try:
|
||||||
obj = get_s3_storage().get_object(attachment.s3_key)
|
attachment = _load_attachment_with_access_or_4xx(attachment_id, db, session)
|
||||||
except ClientError:
|
key = attachment.s3_key
|
||||||
raise HTTPException(status_code=404, detail="Файл не найден в хранилище")
|
request_id = attachment.request_id
|
||||||
|
try:
|
||||||
|
obj = get_s3_storage().get_object(attachment.s3_key)
|
||||||
|
except ClientError:
|
||||||
|
raise HTTPException(status_code=404, detail="Файл не найден в хранилище")
|
||||||
|
|
||||||
body = obj["Body"]
|
record_file_security_event(
|
||||||
content_length = obj.get("ContentLength")
|
db,
|
||||||
media_type = obj.get("ContentType") or attachment.mime_type or "application/octet-stream"
|
actor_role="CLIENT",
|
||||||
encoded_name = quote(str(attachment.file_name or "file"), safe="")
|
actor_subject=actor_subject,
|
||||||
headers = {
|
actor_ip=actor_ip,
|
||||||
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_name}",
|
action="DOWNLOAD_OBJECT",
|
||||||
}
|
scope="REQUEST_ATTACHMENT",
|
||||||
if content_length is not None:
|
allowed=True,
|
||||||
headers["Content-Length"] = str(content_length)
|
object_key=key,
|
||||||
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)
|
request_id=request_id,
|
||||||
|
attachment_id=attachment.id,
|
||||||
|
details={},
|
||||||
|
responsible="Клиент",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
body = obj["Body"]
|
||||||
|
content_length = obj.get("ContentLength")
|
||||||
|
media_type = obj.get("ContentType") or attachment.mime_type or "application/octet-stream"
|
||||||
|
encoded_name = quote(str(attachment.file_name or "file"), safe="")
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_name}",
|
||||||
|
}
|
||||||
|
if content_length is not None:
|
||||||
|
headers["Content-Length"] = str(content_length)
|
||||||
|
return StreamingResponse(body.iter_chunks(chunk_size=64 * 1024), media_type=media_type, headers=headers)
|
||||||
|
except HTTPException as exc:
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
actor_subject=actor_subject,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action="DOWNLOAD_OBJECT",
|
||||||
|
scope="REQUEST_ATTACHMENT",
|
||||||
|
allowed=False,
|
||||||
|
reason=str(exc.detail),
|
||||||
|
object_key=key,
|
||||||
|
request_id=request_id,
|
||||||
|
attachment_id=attachment_uuid,
|
||||||
|
details={},
|
||||||
|
responsible="Клиент",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ class Settings(BaseSettings):
|
||||||
TELEGRAM_CHAT_ID: str = "0"
|
TELEGRAM_CHAT_ID: str = "0"
|
||||||
SMS_PROVIDER: str = "dummy"
|
SMS_PROVIDER: str = "dummy"
|
||||||
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
|
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
|
||||||
|
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
||||||
|
OTP_SEND_RATE_LIMIT: int = 8
|
||||||
|
OTP_VERIFY_RATE_LIMIT: int = 20
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins_list(self) -> List[str]:
|
def cors_origins_list(self) -> List[str]:
|
||||||
|
|
|
||||||
54
app/core/http_hardening.py
Normal file
54
app/core/http_hardening.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from time import perf_counter
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
REQUEST_ID_HEADER = "X-Request-ID"
|
||||||
|
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
||||||
|
_LOG = logging.getLogger("app.http")
|
||||||
|
|
||||||
|
SECURITY_HEADERS = {
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"X-Permitted-Cross-Domain-Policies": "none",
|
||||||
|
"Cross-Origin-Opener-Policy": "same-origin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _request_id_from_header(raw: str | None) -> str:
|
||||||
|
value = str(raw or "").strip()
|
||||||
|
if not value:
|
||||||
|
return uuid4().hex
|
||||||
|
if not _REQUEST_ID_RE.fullmatch(value):
|
||||||
|
return uuid4().hex
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def install_http_hardening(app: FastAPI) -> None:
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _http_hardening_middleware(request: Request, call_next):
|
||||||
|
request_id = _request_id_from_header(request.headers.get(REQUEST_ID_HEADER))
|
||||||
|
request.state.request_id = request_id
|
||||||
|
started_at = perf_counter()
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
for key, value in SECURITY_HEADERS.items():
|
||||||
|
response.headers[key] = value
|
||||||
|
response.headers[REQUEST_ID_HEADER] = request_id
|
||||||
|
|
||||||
|
duration_ms = (perf_counter() - started_at) * 1000.0
|
||||||
|
_LOG.info(
|
||||||
|
"%s %s status=%s duration_ms=%.2f request_id=%s",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
response.status_code,
|
||||||
|
duration_ms,
|
||||||
|
request_id,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
@ -3,6 +3,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.http_hardening import install_http_hardening
|
||||||
from app.api.public.router import router as public_router
|
from app.api.public.router import router as public_router
|
||||||
from app.api.admin.router import router as admin_router
|
from app.api.admin.router import router as admin_router
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ app.add_middleware(
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
install_http_hardening(app)
|
||||||
|
|
||||||
app.include_router(public_router, prefix="/api/public")
|
app.include_router(public_router, prefix="/api/public")
|
||||||
app.include_router(admin_router, prefix="/api/admin")
|
app.include_router(admin_router, prefix="/api/admin")
|
||||||
|
|
|
||||||
25
app/models/invoice.py
Normal file
25
app/models/invoice.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Numeric, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.common import TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "invoices"
|
||||||
|
|
||||||
|
request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
|
||||||
|
invoice_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True, default="WAITING_PAYMENT")
|
||||||
|
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="RUB")
|
||||||
|
payer_display_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
payer_details_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
issued_by_admin_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True, nullable=True)
|
||||||
|
issued_by_role: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
29
app/models/security_audit_log.py
Normal file
29
app/models/security_audit_log.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, JSON, String
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.common import TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAuditLog(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "security_audit_log"
|
||||||
|
|
||||||
|
actor_role: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
actor_subject: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
||||||
|
actor_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
scope: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
object_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
request_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||||
|
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||||
|
|
||||||
|
allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
reason: Mapped[str | None] = mapped_column(String(400), nullable=True)
|
||||||
|
details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from sqlalchemy import String, Boolean, Integer
|
from sqlalchemy import String, Boolean, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
from app.models.common import UUIDMixin, TimestampMixin
|
from app.models.common import UUIDMixin, TimestampMixin
|
||||||
|
|
@ -10,3 +10,5 @@ class Status(Base, UUIDMixin, TimestampMixin):
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
kind: Mapped[str] = mapped_column(String(20), default="DEFAULT", nullable=False)
|
||||||
|
invoice_template: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
class AdminLogin(BaseModel):
|
class AdminLogin(BaseModel):
|
||||||
|
|
@ -32,6 +32,22 @@ class StatusUpsert(BaseModel):
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
is_terminal: bool = False
|
is_terminal: bool = False
|
||||||
|
kind: str = "DEFAULT"
|
||||||
|
invoice_template: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("kind")
|
||||||
|
@classmethod
|
||||||
|
def validate_kind(cls, value: str) -> str:
|
||||||
|
normalized = str(value or "DEFAULT").strip().upper()
|
||||||
|
if normalized not in {"DEFAULT", "INVOICE", "PAID"}:
|
||||||
|
raise ValueError('kind должен быть одним из: DEFAULT, INVOICE, PAID')
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@field_validator("invoice_template")
|
||||||
|
@classmethod
|
||||||
|
def normalize_template(cls, value: Optional[str]) -> Optional[str]:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
class FormFieldUpsert(BaseModel):
|
class FormFieldUpsert(BaseModel):
|
||||||
|
|
|
||||||
288
app/services/billing_flow.py
Normal file
288
app/services/billing_flow.py
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from string import Formatter
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.invoice import Invoice
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.services.invoice_crypto import encrypt_requisites
|
||||||
|
|
||||||
|
STATUS_KIND_DEFAULT = "DEFAULT"
|
||||||
|
STATUS_KIND_INVOICE = "INVOICE"
|
||||||
|
STATUS_KIND_PAID = "PAID"
|
||||||
|
ALLOWED_STATUS_KINDS = {STATUS_KIND_DEFAULT, STATUS_KIND_INVOICE, STATUS_KIND_PAID}
|
||||||
|
|
||||||
|
INVOICE_STATUS_WAITING = "WAITING_PAYMENT"
|
||||||
|
INVOICE_STATUS_PAID = "PAID"
|
||||||
|
|
||||||
|
FALLBACK_INVOICE_CODES = {"INVOICE", "BILLING", "WAITING_PAYMENT"}
|
||||||
|
FALLBACK_PAID_CODES = {"PAID", "ОПЛАЧЕНО"}
|
||||||
|
|
||||||
|
DEFAULT_INVOICE_TEMPLATE = (
|
||||||
|
"Счет по заявке {track_number}. "
|
||||||
|
"Клиент: {client_name}. "
|
||||||
|
"Тема: {topic_code}. "
|
||||||
|
"Сумма: {amount} RUB."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value: Any) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_uuid_or_none(admin: dict[str, Any] | None) -> UUID | None:
|
||||||
|
if not admin:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return UUID(str(admin.get("sub") or ""))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_kind(raw: str | None) -> str:
|
||||||
|
value = str(raw or STATUS_KIND_DEFAULT).strip().upper()
|
||||||
|
if value not in ALLOWED_STATUS_KINDS:
|
||||||
|
return STATUS_KIND_DEFAULT
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_status_kind_or_400(raw: str | None) -> str:
|
||||||
|
value = str(raw or STATUS_KIND_DEFAULT).strip().upper()
|
||||||
|
if value not in ALLOWED_STATUS_KINDS:
|
||||||
|
raise HTTPException(status_code=400, detail='Поле "kind" должно быть одним из: DEFAULT, INVOICE, PAID')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(db: Session, table_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
bind = db.get_bind()
|
||||||
|
if bind is None:
|
||||||
|
return False
|
||||||
|
return table_name in set(inspect(bind).get_table_names())
|
||||||
|
except SQLAlchemyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _status_kind(db: Session, status_code: str) -> str:
|
||||||
|
code = str(status_code or "").strip()
|
||||||
|
if not code:
|
||||||
|
return STATUS_KIND_DEFAULT
|
||||||
|
row = db.query(Status.kind).filter(Status.code == code).first()
|
||||||
|
if row and row[0]:
|
||||||
|
return _normalize_kind(row[0])
|
||||||
|
upper = code.upper()
|
||||||
|
if upper in FALLBACK_INVOICE_CODES:
|
||||||
|
return STATUS_KIND_INVOICE
|
||||||
|
if upper in FALLBACK_PAID_CODES:
|
||||||
|
return STATUS_KIND_PAID
|
||||||
|
return STATUS_KIND_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def _status_template(db: Session, status_code: str) -> str | None:
|
||||||
|
code = str(status_code or "").strip()
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
row = db.query(Status.invoice_template).filter(Status.code == code).first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
value = str(row[0] or "").strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _invoice_number(db: Session) -> str:
|
||||||
|
prefix = _now_utc().strftime("%Y%m%d")
|
||||||
|
candidate = f"INV-{prefix}-{uuid4().hex[:8].upper()}"
|
||||||
|
exists = db.query(Invoice.id).filter(Invoice.invoice_number == candidate).first()
|
||||||
|
if exists is None:
|
||||||
|
return candidate
|
||||||
|
return f"INV-{prefix}-{uuid4().hex[:12].upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_render_template(template: str, values: dict[str, Any]) -> str:
|
||||||
|
source = str(template or "").strip() or DEFAULT_INVOICE_TEMPLATE
|
||||||
|
allowed = {
|
||||||
|
"request_id",
|
||||||
|
"track_number",
|
||||||
|
"client_name",
|
||||||
|
"client_phone",
|
||||||
|
"topic_code",
|
||||||
|
"from_status",
|
||||||
|
"to_status",
|
||||||
|
"effective_rate",
|
||||||
|
"invoice_amount",
|
||||||
|
"amount",
|
||||||
|
}
|
||||||
|
formatter = Formatter()
|
||||||
|
out = source
|
||||||
|
for _, field_name, _, _ in formatter.parse(source):
|
||||||
|
if not field_name:
|
||||||
|
continue
|
||||||
|
if field_name not in allowed:
|
||||||
|
raise HTTPException(status_code=400, detail=f'Шаблон счета содержит недопустимый placeholder: "{field_name}"')
|
||||||
|
try:
|
||||||
|
out = source.format_map({key: values.get(key) for key in allowed})
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Ошибка рендера шаблона счета: {exc}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _create_waiting_invoice(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
req: Request,
|
||||||
|
to_status: str,
|
||||||
|
from_status: str,
|
||||||
|
admin: dict[str, Any] | None,
|
||||||
|
responsible: str,
|
||||||
|
) -> str:
|
||||||
|
waiting = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.request_id == req.id, Invoice.status == INVOICE_STATUS_WAITING)
|
||||||
|
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if waiting is not None:
|
||||||
|
return waiting.invoice_number
|
||||||
|
|
||||||
|
base_amount = _to_float(req.invoice_amount)
|
||||||
|
if base_amount is None or base_amount <= 0:
|
||||||
|
base_amount = _to_float(req.effective_rate)
|
||||||
|
amount = round(float(base_amount or 0.0), 2)
|
||||||
|
|
||||||
|
template = _status_template(db, to_status) or DEFAULT_INVOICE_TEMPLATE
|
||||||
|
rendered_template = _safe_render_template(
|
||||||
|
template,
|
||||||
|
{
|
||||||
|
"request_id": str(req.id),
|
||||||
|
"track_number": req.track_number,
|
||||||
|
"client_name": req.client_name,
|
||||||
|
"client_phone": req.client_phone,
|
||||||
|
"topic_code": req.topic_code,
|
||||||
|
"from_status": from_status,
|
||||||
|
"to_status": to_status,
|
||||||
|
"effective_rate": _to_float(req.effective_rate),
|
||||||
|
"invoice_amount": _to_float(req.invoice_amount),
|
||||||
|
"amount": amount,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
actor = _actor_uuid_or_none(admin)
|
||||||
|
role = str((admin or {}).get("role") or "").strip().upper() or None
|
||||||
|
invoice = Invoice(
|
||||||
|
request_id=req.id,
|
||||||
|
invoice_number=_invoice_number(db),
|
||||||
|
status=INVOICE_STATUS_WAITING,
|
||||||
|
amount=amount,
|
||||||
|
currency="RUB",
|
||||||
|
payer_display_name=str(req.client_name or "").strip() or "Клиент",
|
||||||
|
payer_details_encrypted=encrypt_requisites(
|
||||||
|
{
|
||||||
|
"template_rendered": rendered_template,
|
||||||
|
"request_track_number": req.track_number,
|
||||||
|
"topic_code": req.topic_code,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
issued_by_admin_user_id=actor,
|
||||||
|
issued_by_role=role,
|
||||||
|
issued_at=_now_utc(),
|
||||||
|
paid_at=None,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
db.add(invoice)
|
||||||
|
if req.invoice_amount is None:
|
||||||
|
req.invoice_amount = amount
|
||||||
|
req.responsible = responsible
|
||||||
|
db.add(req)
|
||||||
|
return invoice.invoice_number
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_waiting_invoice_paid_or_400(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
req: Request,
|
||||||
|
admin: dict[str, Any] | None,
|
||||||
|
responsible: str,
|
||||||
|
) -> tuple[str, float]:
|
||||||
|
actor = _actor_uuid_or_none(admin)
|
||||||
|
role = str((admin or {}).get("role") or "").strip().upper()
|
||||||
|
if role != "ADMIN":
|
||||||
|
raise HTTPException(status_code=403, detail='Статус "Оплачено" может поставить только администратор')
|
||||||
|
|
||||||
|
waiting = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.request_id == req.id, Invoice.status == INVOICE_STATUS_WAITING)
|
||||||
|
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if waiting is None:
|
||||||
|
raise HTTPException(status_code=400, detail='Для перехода в статус "Оплачено" нужен счет в статусе "Ожидает оплату"')
|
||||||
|
|
||||||
|
waiting.status = INVOICE_STATUS_PAID
|
||||||
|
waiting.paid_at = _now_utc()
|
||||||
|
waiting.responsible = responsible
|
||||||
|
db.add(waiting)
|
||||||
|
|
||||||
|
req.invoice_amount = waiting.amount
|
||||||
|
req.paid_at = waiting.paid_at
|
||||||
|
req.paid_by_admin_id = str(actor) if actor else None
|
||||||
|
req.responsible = responsible
|
||||||
|
db.add(req)
|
||||||
|
return waiting.invoice_number, round(float(_to_float(waiting.amount) or 0.0), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_billing_transition_effects(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
req: Request,
|
||||||
|
from_status: str,
|
||||||
|
to_status: str,
|
||||||
|
admin: dict[str, Any] | None,
|
||||||
|
responsible: str,
|
||||||
|
) -> str | None:
|
||||||
|
if not _table_exists(db, "invoices"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
from_kind = _status_kind(db, from_status)
|
||||||
|
to_kind = _status_kind(db, to_status)
|
||||||
|
|
||||||
|
if to_kind == STATUS_KIND_INVOICE and from_kind != STATUS_KIND_INVOICE:
|
||||||
|
number = _create_waiting_invoice(
|
||||||
|
db,
|
||||||
|
req=req,
|
||||||
|
to_status=to_status,
|
||||||
|
from_status=from_status,
|
||||||
|
admin=admin,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
return f"Выставлен счет {number}"
|
||||||
|
|
||||||
|
if to_kind == STATUS_KIND_PAID:
|
||||||
|
number, amount = _mark_waiting_invoice_paid_or_400(
|
||||||
|
db,
|
||||||
|
req=req,
|
||||||
|
admin=admin,
|
||||||
|
responsible=responsible,
|
||||||
|
)
|
||||||
|
return f"Оплачен счет {number} на сумму {amount:.2f}"
|
||||||
|
|
||||||
|
return None
|
||||||
56
app/services/invoice_crypto.py
Normal file
56
app/services/invoice_crypto.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_VERSION = b"v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _key() -> bytes:
|
||||||
|
secret = str(settings.DATA_ENCRYPTION_SECRET or "").strip()
|
||||||
|
if not secret or secret == "change_me_data_encryption":
|
||||||
|
secret = str(settings.ADMIN_JWT_SECRET or "change_me_admin")
|
||||||
|
return hashlib.sha256(secret.encode("utf-8")).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
||||||
|
return bytes(x ^ y for x, y in zip(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_requisites(data: dict[str, Any] | None) -> str:
|
||||||
|
payload = dict(data or {})
|
||||||
|
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
nonce = secrets.token_bytes(16)
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(raw))
|
||||||
|
cipher = _xor_bytes(raw, stream)
|
||||||
|
tag = hmac.new(_key(), _VERSION + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
token = _VERSION + nonce + tag + cipher
|
||||||
|
return base64.urlsafe_b64encode(token).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_requisites(token: str | None) -> dict[str, Any]:
|
||||||
|
encoded = str(token or "").strip()
|
||||||
|
if not encoded:
|
||||||
|
return {}
|
||||||
|
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||||
|
if len(blob) < 2 + 16 + 32:
|
||||||
|
raise ValueError("Некорректные зашифрованные реквизиты")
|
||||||
|
version = blob[:2]
|
||||||
|
nonce = blob[2:18]
|
||||||
|
tag = blob[18:50]
|
||||||
|
cipher = blob[50:]
|
||||||
|
if version != _VERSION:
|
||||||
|
raise ValueError("Неподдерживаемая версия шифрования")
|
||||||
|
expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
if not hmac.compare_digest(tag, expected):
|
||||||
|
raise ValueError("Поврежденные зашифрованные реквизиты")
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher))
|
||||||
|
raw = _xor_bytes(cipher, stream)
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
84
app/services/invoice_pdf.py
Normal file
84
app/services/invoice_pdf.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
|
def _ascii_text(value: Any) -> str:
|
||||||
|
text = str(value or "")
|
||||||
|
normalized = unicodedata.normalize("NFKD", text)
|
||||||
|
return normalized.encode("ascii", "ignore").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_pdf_text(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_content_stream(lines: list[str]) -> bytes:
|
||||||
|
safe_lines = [_escape_pdf_text(_ascii_text(line)) for line in lines]
|
||||||
|
if not safe_lines:
|
||||||
|
safe_lines = ["Invoice"]
|
||||||
|
parts = ["BT", "/F1 11 Tf", "50 800 Td"]
|
||||||
|
for index, line in enumerate(safe_lines):
|
||||||
|
if index == 0:
|
||||||
|
parts.append(f"({line}) Tj")
|
||||||
|
else:
|
||||||
|
parts.append("T*")
|
||||||
|
parts.append(f"({line}) Tj")
|
||||||
|
parts.append("ET")
|
||||||
|
return "\n".join(parts).encode("latin-1", errors="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
def build_invoice_pdf_bytes(
|
||||||
|
*,
|
||||||
|
invoice_number: str,
|
||||||
|
amount: float,
|
||||||
|
currency: str,
|
||||||
|
status: str,
|
||||||
|
issued_at: datetime | None,
|
||||||
|
paid_at: datetime | None,
|
||||||
|
payer_display_name: str,
|
||||||
|
request_track_number: str,
|
||||||
|
issued_by_name: str | None,
|
||||||
|
requisites: dict[str, Any] | None,
|
||||||
|
) -> bytes:
|
||||||
|
lines = [
|
||||||
|
f"Invoice: {invoice_number}",
|
||||||
|
f"Request: {request_track_number}",
|
||||||
|
f"Payer: {payer_display_name}",
|
||||||
|
f"Amount: {amount:.2f} {currency}",
|
||||||
|
f"Status: {status}",
|
||||||
|
f"Issued at: {issued_at.isoformat() if issued_at else '-'}",
|
||||||
|
f"Paid at: {paid_at.isoformat() if paid_at else '-'}",
|
||||||
|
f"Issued by: {issued_by_name or '-'}",
|
||||||
|
"Requisites:",
|
||||||
|
]
|
||||||
|
req = dict(requisites or {})
|
||||||
|
if req:
|
||||||
|
for key in sorted(req.keys()):
|
||||||
|
lines.append(f"{key}: {req.get(key)}")
|
||||||
|
else:
|
||||||
|
lines.append("-")
|
||||||
|
|
||||||
|
stream = _build_content_stream(lines)
|
||||||
|
objects = [
|
||||||
|
b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n",
|
||||||
|
b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n",
|
||||||
|
b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj\n",
|
||||||
|
b"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n",
|
||||||
|
f"5 0 obj << /Length {len(stream)} >> stream\n".encode("latin-1") + stream + b"\nendstream endobj\n",
|
||||||
|
]
|
||||||
|
|
||||||
|
body = b"%PDF-1.4\n"
|
||||||
|
offsets = [0]
|
||||||
|
for obj in objects:
|
||||||
|
offsets.append(len(body))
|
||||||
|
body += obj
|
||||||
|
xref_offset = len(body)
|
||||||
|
body += f"xref\n0 {len(objects)+1}\n".encode("latin-1")
|
||||||
|
body += b"0000000000 65535 f \n"
|
||||||
|
for offset in offsets[1:]:
|
||||||
|
body += f"{offset:010d} 00000 n \n".encode("latin-1")
|
||||||
|
body += f"trailer << /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("latin-1")
|
||||||
|
return body
|
||||||
87
app/services/rate_limit.py
Normal file
87
app/services/rate_limit.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_LOG = logging.getLogger("app.rate_limit")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RateLimitResult:
|
||||||
|
allowed: bool
|
||||||
|
retry_after_seconds: int
|
||||||
|
current_value: int
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter(Protocol):
|
||||||
|
def hit(self, key: str, *, limit: int, window_seconds: int) -> RateLimitResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryRateLimiter:
|
||||||
|
def __init__(self):
|
||||||
|
self._data: dict[str, tuple[int, datetime]] = {}
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def hit(self, key: str, *, limit: int, window_seconds: int) -> RateLimitResult:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self._lock:
|
||||||
|
count, expires_at = self._data.get(key, (0, now))
|
||||||
|
if expires_at <= now:
|
||||||
|
count = 0
|
||||||
|
expires_at = now + timedelta(seconds=max(int(window_seconds), 1))
|
||||||
|
count += 1
|
||||||
|
self._data[key] = (count, expires_at)
|
||||||
|
retry_after = max(0, int((expires_at - now).total_seconds()))
|
||||||
|
return RateLimitResult(allowed=count <= limit, retry_after_seconds=retry_after, current_value=count)
|
||||||
|
|
||||||
|
|
||||||
|
class RedisRateLimiter:
|
||||||
|
def __init__(self, client: redis.Redis):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def hit(self, key: str, *, limit: int, window_seconds: int) -> RateLimitResult:
|
||||||
|
count = int(self.client.incr(key))
|
||||||
|
if count == 1:
|
||||||
|
self.client.expire(key, int(max(window_seconds, 1)))
|
||||||
|
ttl = int(self.client.ttl(key))
|
||||||
|
if ttl < 0:
|
||||||
|
ttl = int(max(window_seconds, 1))
|
||||||
|
return RateLimitResult(allowed=count <= limit, retry_after_seconds=ttl, current_value=count)
|
||||||
|
|
||||||
|
|
||||||
|
_cached_limiter: RateLimiter | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_limiter() -> RateLimiter:
|
||||||
|
try:
|
||||||
|
client = redis.Redis.from_url(
|
||||||
|
settings.REDIS_URL,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_timeout=0.4,
|
||||||
|
socket_connect_timeout=0.4,
|
||||||
|
)
|
||||||
|
client.ping()
|
||||||
|
return RedisRateLimiter(client)
|
||||||
|
except Exception:
|
||||||
|
_LOG.warning("Redis limiter unavailable; fallback to in-memory limiter")
|
||||||
|
return InMemoryRateLimiter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_rate_limiter() -> RateLimiter:
|
||||||
|
global _cached_limiter
|
||||||
|
if _cached_limiter is None:
|
||||||
|
_cached_limiter = _build_limiter()
|
||||||
|
return _cached_limiter
|
||||||
|
|
||||||
|
|
||||||
|
def reset_rate_limiter_for_tests() -> None:
|
||||||
|
global _cached_limiter
|
||||||
|
_cached_limiter = None
|
||||||
129
app/services/security_audit.py
Normal file
129
app/services/security_audit.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, inspect
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.security_audit_log import SecurityAuditLog
|
||||||
|
from app.models.common import utcnow
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SUSPICIOUS_DENY_WINDOW_MINUTES = 10
|
||||||
|
SUSPICIOUS_DENY_THRESHOLD = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_or_none(raw: str | uuid.UUID | None) -> uuid.UUID | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, uuid.UUID):
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
return uuid.UUID(str(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_details(details: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
return {}
|
||||||
|
safe: dict[str, Any] = {}
|
||||||
|
for key, value in details.items():
|
||||||
|
if value is None or isinstance(value, (str, int, float, bool)):
|
||||||
|
safe[str(key)] = value
|
||||||
|
else:
|
||||||
|
safe[str(key)] = str(value)
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_suspicious_denied_download_alert(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
actor_role: str,
|
||||||
|
actor_subject: str,
|
||||||
|
actor_ip: str | None,
|
||||||
|
) -> None:
|
||||||
|
if not actor_subject and not actor_ip:
|
||||||
|
return
|
||||||
|
since = utcnow() - timedelta(minutes=SUSPICIOUS_DENY_WINDOW_MINUTES)
|
||||||
|
query = db.query(func.count(SecurityAuditLog.id)).filter(
|
||||||
|
SecurityAuditLog.created_at >= since,
|
||||||
|
SecurityAuditLog.action == "DOWNLOAD_OBJECT",
|
||||||
|
SecurityAuditLog.allowed.is_(False),
|
||||||
|
)
|
||||||
|
if actor_subject:
|
||||||
|
query = query.filter(SecurityAuditLog.actor_subject == actor_subject)
|
||||||
|
elif actor_ip:
|
||||||
|
query = query.filter(SecurityAuditLog.actor_ip == actor_ip)
|
||||||
|
denied_count = int(query.scalar() or 0)
|
||||||
|
if denied_count >= SUSPICIOUS_DENY_THRESHOLD:
|
||||||
|
logger.warning(
|
||||||
|
"SECURITY_ALERT repeated denied download attempts role=%s subject=%s ip=%s count=%s window_min=%s",
|
||||||
|
actor_role,
|
||||||
|
actor_subject or "-",
|
||||||
|
actor_ip or "-",
|
||||||
|
denied_count,
|
||||||
|
SUSPICIOUS_DENY_WINDOW_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_file_security_event(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
actor_role: str,
|
||||||
|
actor_subject: str,
|
||||||
|
actor_ip: str | None,
|
||||||
|
action: str,
|
||||||
|
scope: str,
|
||||||
|
allowed: bool,
|
||||||
|
reason: str | None = None,
|
||||||
|
object_key: str | None = None,
|
||||||
|
request_id: str | uuid.UUID | None = None,
|
||||||
|
attachment_id: str | uuid.UUID | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
responsible: str | None = None,
|
||||||
|
persist_now: bool = False,
|
||||||
|
) -> None:
|
||||||
|
# Security telemetry must not block business flow if DB log write fails.
|
||||||
|
try:
|
||||||
|
bind = db.get_bind()
|
||||||
|
if bind is None or not inspect(bind).has_table("security_audit_log"):
|
||||||
|
return
|
||||||
|
row = SecurityAuditLog(
|
||||||
|
actor_role=str(actor_role or "UNKNOWN").upper(),
|
||||||
|
actor_subject=str(actor_subject or "").strip(),
|
||||||
|
actor_ip=str(actor_ip or "").strip() or None,
|
||||||
|
action=str(action or "").strip().upper() or "UNKNOWN",
|
||||||
|
scope=str(scope or "").strip().upper() or "UNKNOWN",
|
||||||
|
object_key=str(object_key or "").strip() or None,
|
||||||
|
request_id=_uuid_or_none(request_id),
|
||||||
|
attachment_id=_uuid_or_none(attachment_id),
|
||||||
|
allowed=bool(allowed),
|
||||||
|
reason=(str(reason)[:400] if reason is not None else None),
|
||||||
|
details=_safe_details(details),
|
||||||
|
responsible=str(responsible or "Администратор системы").strip() or "Администратор системы",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
if not bool(allowed) and str(action or "").upper() == "DOWNLOAD_OBJECT":
|
||||||
|
_emit_suspicious_denied_download_alert(
|
||||||
|
db,
|
||||||
|
actor_role=row.actor_role,
|
||||||
|
actor_subject=row.actor_subject,
|
||||||
|
actor_ip=row.actor_ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
if persist_now:
|
||||||
|
db.commit()
|
||||||
|
except SQLAlchemyError:
|
||||||
|
if persist_now:
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
@ -29,6 +29,16 @@
|
||||||
CLOSED: "Закрыта",
|
CLOSED: "Закрыта",
|
||||||
REJECTED: "Отклонена",
|
REJECTED: "Отклонена",
|
||||||
};
|
};
|
||||||
|
const INVOICE_STATUS_LABELS = {
|
||||||
|
WAITING_PAYMENT: "Ожидает оплату",
|
||||||
|
PAID: "Оплачен",
|
||||||
|
CANCELED: "Отменен",
|
||||||
|
};
|
||||||
|
const STATUS_KIND_LABELS = {
|
||||||
|
DEFAULT: "Обычный",
|
||||||
|
INVOICE: "Выставление счета",
|
||||||
|
PAID: "Оплачено",
|
||||||
|
};
|
||||||
|
|
||||||
const REQUEST_UPDATE_EVENT_LABELS = {
|
const REQUEST_UPDATE_EVENT_LABELS = {
|
||||||
MESSAGE: "сообщение",
|
MESSAGE: "сообщение",
|
||||||
|
|
@ -42,6 +52,11 @@
|
||||||
endpoint: "/api/admin/crud/requests/query",
|
endpoint: "/api/admin/crud/requests/query",
|
||||||
sort: [{ field: "created_at", dir: "desc" }],
|
sort: [{ field: "created_at", dir: "desc" }],
|
||||||
},
|
},
|
||||||
|
invoices: {
|
||||||
|
table: "invoices",
|
||||||
|
endpoint: "/api/admin/invoices/query",
|
||||||
|
sort: [{ field: "issued_at", dir: "desc" }],
|
||||||
|
},
|
||||||
quotes: {
|
quotes: {
|
||||||
table: "quotes",
|
table: "quotes",
|
||||||
endpoint: "/api/admin/crud/quotes/query",
|
endpoint: "/api/admin/crud/quotes/query",
|
||||||
|
|
@ -99,6 +114,31 @@
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
TABLE_MUTATION_CONFIG.invoices = {
|
||||||
|
create: "/api/admin/invoices",
|
||||||
|
update: (id) => "/api/admin/invoices/" + id,
|
||||||
|
delete: (id) => "/api/admin/invoices/" + id,
|
||||||
|
};
|
||||||
|
const TABLE_KEY_ALIASES = {
|
||||||
|
form_fields: "formFields",
|
||||||
|
topic_required_fields: "topicRequiredFields",
|
||||||
|
topic_data_templates: "topicDataTemplates",
|
||||||
|
topic_status_transitions: "statusTransitions",
|
||||||
|
admin_users: "users",
|
||||||
|
admin_user_topics: "userTopics",
|
||||||
|
};
|
||||||
|
const TABLE_UNALIASES = Object.fromEntries(Object.entries(TABLE_KEY_ALIASES).map(([table, alias]) => [alias, table]));
|
||||||
|
const KNOWN_CONFIG_TABLE_KEYS = new Set([
|
||||||
|
"quotes",
|
||||||
|
"topics",
|
||||||
|
"statuses",
|
||||||
|
"formFields",
|
||||||
|
"topicRequiredFields",
|
||||||
|
"topicDataTemplates",
|
||||||
|
"statusTransitions",
|
||||||
|
"users",
|
||||||
|
"userTopics",
|
||||||
|
]);
|
||||||
|
|
||||||
function createTableState() {
|
function createTableState() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -111,6 +151,29 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function humanizeKey(value) {
|
||||||
|
const text = String(value || "")
|
||||||
|
.replace(/[_-]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
if (!text) return "-";
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaKindToFilterType(kind) {
|
||||||
|
if (kind === "boolean") return "boolean";
|
||||||
|
if (kind === "number") return "number";
|
||||||
|
if (kind === "date" || kind === "datetime") return "date";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaKindToRecordType(kind) {
|
||||||
|
if (kind === "boolean") return "boolean";
|
||||||
|
if (kind === "number") return "number";
|
||||||
|
if (kind === "json") return "json";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
function decodeJwtPayload(token) {
|
function decodeJwtPayload(token) {
|
||||||
try {
|
try {
|
||||||
const payload = token.split(".")[1] || "";
|
const payload = token.split(".")[1] || "";
|
||||||
|
|
@ -139,6 +202,14 @@
|
||||||
return STATUS_LABELS[code] || code || "-";
|
return STATUS_LABELS[code] || code || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invoiceStatusLabel(code) {
|
||||||
|
return INVOICE_STATUS_LABELS[code] || code || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusKindLabel(code) {
|
||||||
|
return STATUS_KIND_LABELS[code] || code || "-";
|
||||||
|
}
|
||||||
|
|
||||||
function boolLabel(value) {
|
function boolLabel(value) {
|
||||||
return value ? "Да" : "Нет";
|
return value ? "Да" : "Нет";
|
||||||
}
|
}
|
||||||
|
|
@ -821,6 +892,7 @@
|
||||||
|
|
||||||
const [tables, setTables] = useState({
|
const [tables, setTables] = useState({
|
||||||
requests: createTableState(),
|
requests: createTableState(),
|
||||||
|
invoices: createTableState(),
|
||||||
quotes: createTableState(),
|
quotes: createTableState(),
|
||||||
topics: createTableState(),
|
topics: createTableState(),
|
||||||
statuses: createTableState(),
|
statuses: createTableState(),
|
||||||
|
|
@ -831,6 +903,7 @@
|
||||||
users: createTableState(),
|
users: createTableState(),
|
||||||
userTopics: createTableState(),
|
userTopics: createTableState(),
|
||||||
});
|
});
|
||||||
|
const [tableCatalog, setTableCatalog] = useState([]);
|
||||||
|
|
||||||
const [dictionaries, setDictionaries] = useState({
|
const [dictionaries, setDictionaries] = useState({
|
||||||
topics: [],
|
topics: [],
|
||||||
|
|
@ -851,7 +924,7 @@
|
||||||
form: {},
|
form: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [configActiveKey, setConfigActiveKey] = useState("quotes");
|
const [configActiveKey, setConfigActiveKey] = useState("");
|
||||||
const [referencesExpanded, setReferencesExpanded] = useState(true);
|
const [referencesExpanded, setReferencesExpanded] = useState(true);
|
||||||
|
|
||||||
const [metaEntity, setMetaEntity] = useState("quotes");
|
const [metaEntity, setMetaEntity] = useState("quotes");
|
||||||
|
|
@ -924,6 +997,14 @@
|
||||||
.map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" }));
|
.map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" }));
|
||||||
}, [dictionaries.statuses]);
|
}, [dictionaries.statuses]);
|
||||||
|
|
||||||
|
const getInvoiceStatusOptions = useCallback(() => {
|
||||||
|
return Object.entries(INVOICE_STATUS_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getStatusKindOptions = useCallback(() => {
|
||||||
|
return Object.entries(STATUS_KIND_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getTopicOptions = useCallback(() => {
|
const getTopicOptions = useCallback(() => {
|
||||||
return (dictionaries.topics || [])
|
return (dictionaries.topics || [])
|
||||||
.filter((item) => item && item.code)
|
.filter((item) => item && item.code)
|
||||||
|
|
@ -953,6 +1034,51 @@
|
||||||
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" }));
|
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const tableCatalogMap = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
(tableCatalog || []).forEach((item) => {
|
||||||
|
if (!item || !item.key) return;
|
||||||
|
map[item.key] = item;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [tableCatalog]);
|
||||||
|
|
||||||
|
const dictionaryTableItems = useMemo(() => {
|
||||||
|
return (tableCatalog || [])
|
||||||
|
.filter((item) => item && item.section === "dictionary" && Array.isArray(item.actions) && item.actions.includes("query"))
|
||||||
|
.sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru"));
|
||||||
|
}, [tableCatalog]);
|
||||||
|
|
||||||
|
const resolveTableConfig = useCallback(
|
||||||
|
(tableKey) => {
|
||||||
|
if (TABLE_SERVER_CONFIG[tableKey]) return TABLE_SERVER_CONFIG[tableKey];
|
||||||
|
const meta = tableCatalogMap[tableKey];
|
||||||
|
if (!meta || !meta.table) return null;
|
||||||
|
const tableName = String(meta.table || tableKey);
|
||||||
|
return {
|
||||||
|
table: tableName,
|
||||||
|
endpoint: String(meta.query_endpoint || ("/api/admin/crud/" + tableName + "/query")),
|
||||||
|
sort: Array.isArray(meta.default_sort) && meta.default_sort.length ? meta.default_sort : [{ field: "created_at", dir: "desc" }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[tableCatalogMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolveMutationConfig = useCallback(
|
||||||
|
(tableKey) => {
|
||||||
|
if (TABLE_MUTATION_CONFIG[tableKey]) return TABLE_MUTATION_CONFIG[tableKey];
|
||||||
|
const meta = tableCatalogMap[tableKey];
|
||||||
|
if (!meta || !meta.table) return null;
|
||||||
|
const tableName = String(meta.table || tableKey);
|
||||||
|
return {
|
||||||
|
create: String(meta.create_endpoint || ("/api/admin/crud/" + tableName)),
|
||||||
|
update: (id) => String(meta.update_endpoint_template || ("/api/admin/crud/" + tableName + "/{id}")).replace("{id}", String(id)),
|
||||||
|
delete: (id) => String(meta.delete_endpoint_template || ("/api/admin/crud/" + tableName + "/{id}")).replace("{id}", String(id)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[tableCatalogMap]
|
||||||
|
);
|
||||||
|
|
||||||
const getFilterFields = useCallback(
|
const getFilterFields = useCallback(
|
||||||
(tableKey) => {
|
(tableKey) => {
|
||||||
if (tableKey === "requests") {
|
if (tableKey === "requests") {
|
||||||
|
|
@ -968,6 +1094,20 @@
|
||||||
{ field: "created_at", label: "Дата создания", type: "date" },
|
{ field: "created_at", label: "Дата создания", type: "date" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (tableKey === "invoices") {
|
||||||
|
return [
|
||||||
|
{ field: "invoice_number", label: "Номер счета", type: "text" },
|
||||||
|
{ field: "status", label: "Статус", type: "enum", options: getInvoiceStatusOptions },
|
||||||
|
{ field: "amount", label: "Сумма", type: "number" },
|
||||||
|
{ field: "currency", label: "Валюта", type: "text" },
|
||||||
|
{ field: "payer_display_name", label: "Плательщик", type: "text" },
|
||||||
|
{ field: "request_id", label: "ID заявки", type: "text" },
|
||||||
|
{ field: "issued_by_admin_user_id", label: "ID сотрудника", type: "text" },
|
||||||
|
{ field: "issued_at", label: "Дата формирования", type: "date" },
|
||||||
|
{ field: "paid_at", label: "Дата оплаты", type: "date" },
|
||||||
|
{ field: "created_at", label: "Дата создания", type: "date" },
|
||||||
|
];
|
||||||
|
}
|
||||||
if (tableKey === "quotes") {
|
if (tableKey === "quotes") {
|
||||||
return [
|
return [
|
||||||
{ field: "author", label: "Автор", type: "text" },
|
{ field: "author", label: "Автор", type: "text" },
|
||||||
|
|
@ -990,6 +1130,7 @@
|
||||||
return [
|
return [
|
||||||
{ field: "code", label: "Код", type: "text" },
|
{ field: "code", label: "Код", type: "text" },
|
||||||
{ field: "name", label: "Название", type: "text" },
|
{ field: "name", label: "Название", type: "text" },
|
||||||
|
{ field: "kind", label: "Тип", type: "enum", options: getStatusKindOptions },
|
||||||
{ field: "enabled", label: "Активен", type: "boolean" },
|
{ field: "enabled", label: "Активен", type: "boolean" },
|
||||||
{ field: "sort_order", label: "Порядок", type: "number" },
|
{ field: "sort_order", label: "Порядок", type: "number" },
|
||||||
{ field: "is_terminal", label: "Терминальный", type: "boolean" },
|
{ field: "is_terminal", label: "Терминальный", type: "boolean" },
|
||||||
|
|
@ -1056,13 +1197,37 @@
|
||||||
{ field: "created_at", label: "Дата создания", type: "date" },
|
{ field: "created_at", label: "Дата создания", type: "date" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [];
|
const meta = tableCatalogMap[tableKey];
|
||||||
|
if (!meta || !Array.isArray(meta.columns)) return [];
|
||||||
|
return (meta.columns || [])
|
||||||
|
.filter((column) => column && column.name && column.filterable !== false)
|
||||||
|
.map((column) => {
|
||||||
|
const name = String(column.name);
|
||||||
|
const label = String(column.label || humanizeKey(name));
|
||||||
|
if (name === "topic_code") return { field: name, label, type: "reference", options: getTopicOptions };
|
||||||
|
if (name === "status_code" || name === "from_status" || name === "to_status") {
|
||||||
|
return { field: name, label, type: "reference", options: getStatusOptions };
|
||||||
|
}
|
||||||
|
if (name === "field_key") return { field: name, label, type: "reference", options: getFormFieldKeyOptions };
|
||||||
|
return { field: name, label, type: metaKindToFilterType(column.kind) };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions]
|
[
|
||||||
|
tableCatalogMap,
|
||||||
|
getFormFieldKeyOptions,
|
||||||
|
getFormFieldTypeOptions,
|
||||||
|
getInvoiceStatusOptions,
|
||||||
|
getLawyerOptions,
|
||||||
|
getRoleOptions,
|
||||||
|
getStatusKindOptions,
|
||||||
|
getStatusOptions,
|
||||||
|
getTopicOptions,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getTableLabel = useCallback((tableKey) => {
|
const getTableLabel = useCallback((tableKey) => {
|
||||||
if (tableKey === "requests") return "Заявки";
|
if (tableKey === "requests") return "Заявки";
|
||||||
|
if (tableKey === "invoices") return "Счета";
|
||||||
if (tableKey === "quotes") return "Цитаты";
|
if (tableKey === "quotes") return "Цитаты";
|
||||||
if (tableKey === "topics") return "Темы";
|
if (tableKey === "topics") return "Темы";
|
||||||
if (tableKey === "statuses") return "Статусы";
|
if (tableKey === "statuses") return "Статусы";
|
||||||
|
|
@ -1072,8 +1237,11 @@
|
||||||
if (tableKey === "statusTransitions") return "Переходы статусов";
|
if (tableKey === "statusTransitions") return "Переходы статусов";
|
||||||
if (tableKey === "users") return "Пользователи";
|
if (tableKey === "users") return "Пользователи";
|
||||||
if (tableKey === "userTopics") return "Дополнительные темы юристов";
|
if (tableKey === "userTopics") return "Дополнительные темы юристов";
|
||||||
return "Таблица";
|
const meta = tableCatalogMap[tableKey];
|
||||||
}, []);
|
if (meta && meta.label) return String(meta.label);
|
||||||
|
const raw = TABLE_UNALIASES[tableKey] || tableKey;
|
||||||
|
return humanizeKey(raw);
|
||||||
|
}, [tableCatalogMap]);
|
||||||
|
|
||||||
const getRecordFields = useCallback(
|
const getRecordFields = useCallback(
|
||||||
(tableKey) => {
|
(tableKey) => {
|
||||||
|
|
@ -1094,6 +1262,17 @@
|
||||||
{ key: "total_attachments_bytes", label: "Размер вложений (байт)", type: "number", optional: true, defaultValue: "0" },
|
{ key: "total_attachments_bytes", label: "Размер вложений (байт)", type: "number", optional: true, defaultValue: "0" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (tableKey === "invoices") {
|
||||||
|
return [
|
||||||
|
{ key: "request_track_number", label: "Номер заявки", type: "text", required: true, createOnly: true },
|
||||||
|
{ key: "invoice_number", label: "Номер счета", type: "text", optional: true, placeholder: "Оставьте пустым для автогенерации" },
|
||||||
|
{ key: "status", label: "Статус", type: "enum", required: true, options: getInvoiceStatusOptions, defaultValue: "WAITING_PAYMENT" },
|
||||||
|
{ key: "amount", label: "Сумма", type: "number", required: true },
|
||||||
|
{ key: "currency", label: "Валюта", type: "text", optional: true, defaultValue: "RUB" },
|
||||||
|
{ key: "payer_display_name", label: "Плательщик (ФИО / компания)", type: "text", required: true },
|
||||||
|
{ key: "payer_details", label: "Реквизиты (JSON, шифруется)", type: "json", optional: true, omitIfEmpty: true, placeholder: "{\"inn\":\"...\"}" },
|
||||||
|
];
|
||||||
|
}
|
||||||
if (tableKey === "quotes") {
|
if (tableKey === "quotes") {
|
||||||
return [
|
return [
|
||||||
{ key: "author", label: "Автор", type: "text", required: true },
|
{ key: "author", label: "Автор", type: "text", required: true },
|
||||||
|
|
@ -1115,6 +1294,8 @@
|
||||||
return [
|
return [
|
||||||
{ key: "code", label: "Код", type: "text", required: true },
|
{ key: "code", label: "Код", type: "text", required: true },
|
||||||
{ key: "name", label: "Название", type: "text", required: true },
|
{ key: "name", label: "Название", type: "text", required: true },
|
||||||
|
{ key: "kind", label: "Тип", type: "enum", required: true, options: getStatusKindOptions, defaultValue: "DEFAULT" },
|
||||||
|
{ key: "invoice_template", label: "Шаблон счета", type: "textarea", optional: true, placeholder: "Доступные поля: {track_number}, {client_name}, {topic_code}, {amount}" },
|
||||||
{ key: "enabled", label: "Активен", type: "boolean", defaultValue: "true" },
|
{ key: "enabled", label: "Активен", type: "boolean", defaultValue: "true" },
|
||||||
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
|
{ key: "sort_order", label: "Порядок", type: "number", defaultValue: "0" },
|
||||||
{ key: "is_terminal", label: "Терминальный", type: "boolean", defaultValue: "false" },
|
{ key: "is_terminal", label: "Терминальный", type: "boolean", defaultValue: "false" },
|
||||||
|
|
@ -1188,9 +1369,33 @@
|
||||||
{ key: "topic_code", label: "Дополнительная тема", type: "reference", required: true, options: getTopicOptions },
|
{ key: "topic_code", label: "Дополнительная тема", type: "reference", required: true, options: getTopicOptions },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [];
|
const meta = tableCatalogMap[tableKey];
|
||||||
|
if (!meta || !Array.isArray(meta.columns)) return [];
|
||||||
|
return (meta.columns || [])
|
||||||
|
.filter((column) => column && column.name && column.editable)
|
||||||
|
.map((column) => {
|
||||||
|
const key = String(column.name || "");
|
||||||
|
const requiredOnCreate = Boolean(column.required_on_create);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: String(column.label || humanizeKey(key)),
|
||||||
|
type: metaKindToRecordType(column.kind),
|
||||||
|
requiredOnCreate,
|
||||||
|
optional: !requiredOnCreate,
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions]
|
[
|
||||||
|
tableCatalogMap,
|
||||||
|
getFormFieldKeyOptions,
|
||||||
|
getFormFieldTypeOptions,
|
||||||
|
getInvoiceStatusOptions,
|
||||||
|
getLawyerOptions,
|
||||||
|
getRoleOptions,
|
||||||
|
getStatusKindOptions,
|
||||||
|
getStatusOptions,
|
||||||
|
getTopicOptions,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getFieldDef = useCallback(
|
const getFieldDef = useCallback(
|
||||||
|
|
@ -1228,7 +1433,7 @@
|
||||||
const loadTable = useCallback(
|
const loadTable = useCallback(
|
||||||
async (tableKey, options, tokenOverride) => {
|
async (tableKey, options, tokenOverride) => {
|
||||||
const opts = options || {};
|
const opts = options || {};
|
||||||
const config = TABLE_SERVER_CONFIG[tableKey];
|
const config = resolveTableConfig(tableKey);
|
||||||
if (!config) return false;
|
if (!config) return false;
|
||||||
|
|
||||||
const current = tablesRef.current[tableKey] || createTableState();
|
const current = tablesRef.current[tableKey] || createTableState();
|
||||||
|
|
@ -1318,7 +1523,7 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tableKey === "formFields") {
|
if (tableKey === "formFields" || tableKey === "form_fields") {
|
||||||
setDictionaries((prev) => {
|
setDictionaries((prev) => {
|
||||||
const set = new Set(DEFAULT_FORM_FIELD_TYPES);
|
const set = new Set(DEFAULT_FORM_FIELD_TYPES);
|
||||||
(next.rows || []).forEach((row) => {
|
(next.rows || []).forEach((row) => {
|
||||||
|
|
@ -1336,7 +1541,7 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tableKey === "users") {
|
if (tableKey === "users" || tableKey === "admin_users") {
|
||||||
setDictionaries((prev) => {
|
setDictionaries((prev) => {
|
||||||
const map = new Map((prev.users || []).map((user) => [user.id, user]));
|
const map = new Map((prev.users || []).map((user) => [user.id, user]));
|
||||||
(next.rows || []).forEach((row) => {
|
(next.rows || []).forEach((row) => {
|
||||||
|
|
@ -1359,12 +1564,16 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, setStatus, setTableState]
|
[api, resolveTableConfig, setStatus, setTableState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadCurrentConfigTable = useCallback(
|
const loadCurrentConfigTable = useCallback(
|
||||||
async (resetOffset, tokenOverride, keyOverride) => {
|
async (resetOffset, tokenOverride, keyOverride) => {
|
||||||
const currentKey = keyOverride || configActiveKey;
|
const currentKey = keyOverride || configActiveKey;
|
||||||
|
if (!currentKey) {
|
||||||
|
setStatus("config", "Выберите справочник", "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
setStatus("config", "Загрузка...", "");
|
setStatus("config", "Загрузка...", "");
|
||||||
const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride);
|
const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
|
@ -1438,6 +1647,7 @@
|
||||||
if (!(tokenOverride !== undefined ? tokenOverride : token)) return;
|
if (!(tokenOverride !== undefined ? tokenOverride : token)) return;
|
||||||
if (section === "dashboard") return loadDashboard(tokenOverride);
|
if (section === "dashboard") return loadDashboard(tokenOverride);
|
||||||
if (section === "requests") return loadTable("requests", {}, tokenOverride);
|
if (section === "requests") return loadTable("requests", {}, tokenOverride);
|
||||||
|
if (section === "invoices") return loadTable("invoices", {}, tokenOverride);
|
||||||
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
|
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
|
||||||
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
|
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
|
||||||
if (section === "meta") return loadMeta(tokenOverride);
|
if (section === "meta") return loadMeta(tokenOverride);
|
||||||
|
|
@ -1451,18 +1661,28 @@
|
||||||
...prev,
|
...prev,
|
||||||
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
|
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
|
||||||
}));
|
}));
|
||||||
|
setTableCatalog([]);
|
||||||
|
|
||||||
if (roleOverride !== "ADMIN") return;
|
if (roleOverride !== "ADMIN") return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0);
|
const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0);
|
||||||
const usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0);
|
const usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0);
|
||||||
const [topicsData, statusesData, fieldsData, usersData] = await Promise.all([
|
const [catalogData, topicsData, statusesData, fieldsData, usersData] = await Promise.all([
|
||||||
|
api("/api/admin/crud/meta/tables", {}, tokenOverride),
|
||||||
api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride),
|
api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride),
|
||||||
api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride),
|
api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride),
|
||||||
api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride),
|
api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride),
|
||||||
api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride),
|
api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride),
|
||||||
]);
|
]);
|
||||||
|
const catalogRows = (catalogData.tables || [])
|
||||||
|
.filter((row) => row && row.table)
|
||||||
|
.map((row) => {
|
||||||
|
const tableName = String(row.table || "");
|
||||||
|
const key = TABLE_KEY_ALIASES[tableName] || String(row.key || tableName);
|
||||||
|
return { ...row, key, table: tableName };
|
||||||
|
});
|
||||||
|
setTableCatalog(catalogRows);
|
||||||
|
|
||||||
const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }]));
|
const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }]));
|
||||||
(statusesData.rows || []).forEach((row) => {
|
(statusesData.rows || []).forEach((row) => {
|
||||||
|
|
@ -1630,6 +1850,7 @@
|
||||||
if (field.type === "json") {
|
if (field.type === "json") {
|
||||||
const text = String(raw || "").trim();
|
const text = String(raw || "").trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
if (field.omitIfEmpty) return;
|
||||||
if (field.optional) payload[field.key] = null;
|
if (field.optional) payload[field.key] = null;
|
||||||
else payload[field.key] = {};
|
else payload[field.key] = {};
|
||||||
return;
|
return;
|
||||||
|
|
@ -1656,6 +1877,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {};
|
if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {};
|
||||||
|
if (tableKey === "invoices" && mode === "edit") delete payload.request_track_number;
|
||||||
return payload;
|
return payload;
|
||||||
},
|
},
|
||||||
[getRecordFields]
|
[getRecordFields]
|
||||||
|
|
@ -1666,7 +1888,7 @@
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const tableKey = recordModal.tableKey;
|
const tableKey = recordModal.tableKey;
|
||||||
if (!tableKey) return;
|
if (!tableKey) return;
|
||||||
const endpoints = TABLE_MUTATION_CONFIG[tableKey];
|
const endpoints = resolveMutationConfig(tableKey);
|
||||||
if (!endpoints) return;
|
if (!endpoints) return;
|
||||||
try {
|
try {
|
||||||
setStatus("recordForm", "Сохранение...", "");
|
setStatus("recordForm", "Сохранение...", "");
|
||||||
|
|
@ -1683,12 +1905,12 @@
|
||||||
setStatus("recordForm", "Ошибка: " + error.message, "error");
|
setStatus("recordForm", "Ошибка: " + error.message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, buildRecordPayload, closeRecordModal, loadTable, recordModal, setStatus]
|
[api, buildRecordPayload, closeRecordModal, loadTable, recordModal, resolveMutationConfig, setStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteRecord = useCallback(
|
const deleteRecord = useCallback(
|
||||||
async (tableKey, id) => {
|
async (tableKey, id) => {
|
||||||
const endpoints = TABLE_MUTATION_CONFIG[tableKey];
|
const endpoints = resolveMutationConfig(tableKey);
|
||||||
if (!endpoints) return;
|
if (!endpoints) return;
|
||||||
if (!confirm("Удалить запись?")) return;
|
if (!confirm("Удалить запись?")) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -1699,7 +1921,7 @@
|
||||||
setStatus(tableKey, "Ошибка удаления: " + error.message, "error");
|
setStatus(tableKey, "Ошибка удаления: " + error.message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, loadTable, setStatus]
|
[api, loadTable, resolveMutationConfig, setStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
const claimRequest = useCallback(
|
const claimRequest = useCallback(
|
||||||
|
|
@ -1717,6 +1939,57 @@
|
||||||
[api, loadTable, setStatus]
|
[api, loadTable, setStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openInvoiceRequest = useCallback(
|
||||||
|
async (row) => {
|
||||||
|
if (!row || !row.request_id) return;
|
||||||
|
try {
|
||||||
|
setActiveSection("requests");
|
||||||
|
await loadTable("requests", {});
|
||||||
|
await openRequestDetails(row.request_id);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore navigation errors and keep current state.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadTable, openRequestDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadInvoicePdf = useCallback(
|
||||||
|
async (row) => {
|
||||||
|
if (!row || !row.id || !token) return;
|
||||||
|
try {
|
||||||
|
setStatus("invoices", "Формируем PDF...", "");
|
||||||
|
const response = await fetch("/api/admin/invoices/" + row.id + "/pdf", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : {};
|
||||||
|
} catch (_) {
|
||||||
|
payload = { raw: text };
|
||||||
|
}
|
||||||
|
const message = payload.detail || payload.error || payload.raw || ("HTTP " + response.status);
|
||||||
|
throw new Error(translateApiError(String(message)));
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const fileName = (row.invoice_number || "invoice") + ".pdf";
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setStatus("invoices", "PDF скачан", "ok");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("invoices", "Ошибка скачивания: " + error.message, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setStatus, token]
|
||||||
|
);
|
||||||
|
|
||||||
const openReassignModal = useCallback(
|
const openReassignModal = useCallback(
|
||||||
(row) => {
|
(row) => {
|
||||||
const options = getLawyerOptions();
|
const options = getLawyerOptions();
|
||||||
|
|
@ -2025,10 +2298,12 @@
|
||||||
setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" });
|
setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" });
|
||||||
setDashboardData({ scope: "", cards: [], byStatus: {}, lawyerLoads: [], myUnreadByEvent: {} });
|
setDashboardData({ scope: "", cards: [], byStatus: {}, lawyerLoads: [], myUnreadByEvent: {} });
|
||||||
setMetaJson("");
|
setMetaJson("");
|
||||||
setConfigActiveKey("quotes");
|
setConfigActiveKey("");
|
||||||
setReferencesExpanded(true);
|
setReferencesExpanded(true);
|
||||||
|
setTableCatalog([]);
|
||||||
setTables({
|
setTables({
|
||||||
requests: createTableState(),
|
requests: createTableState(),
|
||||||
|
invoices: createTableState(),
|
||||||
quotes: createTableState(),
|
quotes: createTableState(),
|
||||||
topics: createTableState(),
|
topics: createTableState(),
|
||||||
statuses: createTableState(),
|
statuses: createTableState(),
|
||||||
|
|
@ -2110,6 +2385,15 @@
|
||||||
};
|
};
|
||||||
}, [bootstrapReferenceData, refreshSection, role, token]);
|
}, [bootstrapReferenceData, refreshSection, role, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dictionaryTableItems.length) {
|
||||||
|
if (configActiveKey) setConfigActiveKey("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasCurrent = dictionaryTableItems.some((item) => item.key === configActiveKey);
|
||||||
|
if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key);
|
||||||
|
}, [configActiveKey, dictionaryTableItems]);
|
||||||
|
|
||||||
const anyOverlayOpen = requestModal.open || recordModal.open || filterModal.open || reassignModal.open;
|
const anyOverlayOpen = requestModal.open || recordModal.open || filterModal.open || reassignModal.open;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.toggle("modal-open", anyOverlayOpen);
|
document.body.classList.toggle("modal-open", anyOverlayOpen);
|
||||||
|
|
@ -2132,6 +2416,7 @@
|
||||||
return [
|
return [
|
||||||
{ key: "dashboard", label: "Обзор" },
|
{ key: "dashboard", label: "Обзор" },
|
||||||
{ key: "requests", label: "Заявки" },
|
{ key: "requests", label: "Заявки" },
|
||||||
|
{ key: "invoices", label: "Счета" },
|
||||||
{ key: "meta", label: "Метаданные" },
|
{ key: "meta", label: "Метаданные" },
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -2145,10 +2430,39 @@
|
||||||
|
|
||||||
const recordModalFields = useMemo(() => {
|
const recordModalFields = useMemo(() => {
|
||||||
const all = getRecordFields(recordModal.tableKey);
|
const all = getRecordFields(recordModal.tableKey);
|
||||||
if (recordModal.mode !== "create") return all;
|
if (recordModal.mode !== "create") return all.filter((field) => !field.createOnly);
|
||||||
return all.filter((field) => !field.autoCreate);
|
return all.filter((field) => !field.autoCreate);
|
||||||
}, [getRecordFields, recordModal.mode, recordModal.tableKey]);
|
}, [getRecordFields, recordModal.mode, recordModal.tableKey]);
|
||||||
|
|
||||||
|
const activeConfigTableState = useMemo(() => {
|
||||||
|
return tables[configActiveKey] || createTableState();
|
||||||
|
}, [configActiveKey, tables]);
|
||||||
|
|
||||||
|
const activeConfigMeta = useMemo(() => tableCatalogMap[configActiveKey] || null, [configActiveKey, tableCatalogMap]);
|
||||||
|
const activeConfigActions = useMemo(() => {
|
||||||
|
return Array.isArray(activeConfigMeta?.actions) ? activeConfigMeta.actions : [];
|
||||||
|
}, [activeConfigMeta]);
|
||||||
|
const canCreateInConfig = activeConfigActions.includes("create");
|
||||||
|
const canUpdateInConfig = activeConfigActions.includes("update");
|
||||||
|
const canDeleteInConfig = activeConfigActions.includes("delete");
|
||||||
|
|
||||||
|
const genericConfigHeaders = useMemo(() => {
|
||||||
|
if (!activeConfigMeta || !Array.isArray(activeConfigMeta.columns)) return [];
|
||||||
|
const headers = (activeConfigMeta.columns || [])
|
||||||
|
.filter((column) => column && column.name)
|
||||||
|
.map((column) => {
|
||||||
|
const name = String(column.name);
|
||||||
|
return {
|
||||||
|
key: name,
|
||||||
|
label: String(column.label || humanizeKey(name)),
|
||||||
|
sortable: Boolean(column.sortable !== false),
|
||||||
|
field: name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (canUpdateInConfig || canDeleteInConfig) headers.push({ key: "actions", label: "Действия" });
|
||||||
|
return headers;
|
||||||
|
}, [activeConfigMeta, canDeleteInConfig, canUpdateInConfig]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="layout">
|
<div className="layout">
|
||||||
|
|
@ -2182,69 +2496,16 @@
|
||||||
</button>
|
</button>
|
||||||
{referencesExpanded ? (
|
{referencesExpanded ? (
|
||||||
<div className="menu-tree">
|
<div className="menu-tree">
|
||||||
<button
|
{dictionaryTableItems.map((item) => (
|
||||||
type="button"
|
<button
|
||||||
className={activeSection === "config" && configActiveKey === "quotes" ? "active" : ""}
|
key={item.key}
|
||||||
onClick={() => selectConfigNode("quotes")}
|
type="button"
|
||||||
>
|
className={activeSection === "config" && configActiveKey === item.key ? "active" : ""}
|
||||||
Цитаты
|
onClick={() => selectConfigNode(item.key)}
|
||||||
</button>
|
>
|
||||||
<button
|
{getTableLabel(item.key)}
|
||||||
type="button"
|
</button>
|
||||||
className={activeSection === "config" && configActiveKey === "topics" ? "active" : ""}
|
))}
|
||||||
onClick={() => selectConfigNode("topics")}
|
|
||||||
>
|
|
||||||
Темы
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={activeSection === "config" && configActiveKey === "statuses" ? "active" : ""}
|
|
||||||
onClick={() => selectConfigNode("statuses")}
|
|
||||||
>
|
|
||||||
Статусы
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={activeSection === "config" && configActiveKey === "formFields" ? "active" : ""}
|
|
||||||
onClick={() => selectConfigNode("formFields")}
|
|
||||||
>
|
|
||||||
Поля формы
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={activeSection === "config" && configActiveKey === "topicRequiredFields" ? "active" : ""}
|
|
||||||
onClick={() => selectConfigNode("topicRequiredFields")}
|
|
||||||
>
|
|
||||||
Обязательные поля темы
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={activeSection === "config" && configActiveKey === "topicDataTemplates" ? "active" : ""}
|
|
||||||
onClick={() => selectConfigNode("topicDataTemplates")}
|
|
||||||
>
|
|
||||||
Шаблоны дозапроса
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={activeSection === "config" && configActiveKey === "statusTransitions" ? "active" : ""}
|
|
||||||
onClick={() => selectConfigNode("statusTransitions")}
|
|
||||||
>
|
|
||||||
Переходы статусов
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={activeSection === "config" && configActiveKey === "users" ? "active" : ""}
|
|
||||||
onClick={() => selectConfigNode("users")}
|
|
||||||
>
|
|
||||||
Пользователи
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={activeSection === "config" && configActiveKey === "userTopics" ? "active" : ""}
|
|
||||||
onClick={() => selectConfigNode("userTopics")}
|
|
||||||
>
|
|
||||||
Темы юристов
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|
@ -2422,6 +2683,81 @@
|
||||||
<StatusLine status={getStatus("requests")} />
|
<StatusLine status={getStatus("requests")} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section active={activeSection === "invoices"} id="section-invoices">
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Счета</h2>
|
||||||
|
<p className="muted">Выставленные счета клиентам, статусы оплаты и выгрузка PDF.</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button className="btn secondary" type="button" onClick={() => loadTable("invoices", { resetOffset: true })}>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
<button className="btn" type="button" onClick={() => openCreateRecordModal("invoices")}>
|
||||||
|
Новый счет
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FilterToolbar
|
||||||
|
filters={tables.invoices.filters}
|
||||||
|
onOpen={() => openFilterModal("invoices")}
|
||||||
|
onRemove={(index) => removeFilterChip("invoices", index)}
|
||||||
|
onEdit={(index) => openFilterEditModal("invoices", index)}
|
||||||
|
getChipLabel={(clause) => {
|
||||||
|
const fieldDef = getFieldDef("invoices", clause.field);
|
||||||
|
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("invoices", clause);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
headers={[
|
||||||
|
{ key: "invoice_number", label: "Номер", sortable: true, field: "invoice_number" },
|
||||||
|
{ key: "status", label: "Статус", sortable: true, field: "status" },
|
||||||
|
{ key: "amount", label: "Сумма", sortable: true, field: "amount" },
|
||||||
|
{ key: "payer_display_name", label: "Плательщик", sortable: true, field: "payer_display_name" },
|
||||||
|
{ key: "request_track_number", label: "Заявка" },
|
||||||
|
{ key: "issued_by_name", label: "Выставил", sortable: true, field: "issued_by_admin_user_id" },
|
||||||
|
{ key: "issued_at", label: "Сформирован", sortable: true, field: "issued_at" },
|
||||||
|
{ key: "paid_at", label: "Оплачен", sortable: true, field: "paid_at" },
|
||||||
|
{ key: "actions", label: "Действия" },
|
||||||
|
]}
|
||||||
|
rows={tables.invoices.rows}
|
||||||
|
emptyColspan={9}
|
||||||
|
onSort={(field) => toggleTableSort("invoices", field)}
|
||||||
|
sortClause={(tables.invoices.sort && tables.invoices.sort[0]) || TABLE_SERVER_CONFIG.invoices.sort[0]}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td>
|
||||||
|
<code>{row.invoice_number || "-"}</code>
|
||||||
|
</td>
|
||||||
|
<td>{row.status_label || invoiceStatusLabel(row.status)}</td>
|
||||||
|
<td>{row.amount == null ? "-" : String(row.amount) + " " + String(row.currency || "RUB")}</td>
|
||||||
|
<td>{row.payer_display_name || "-"}</td>
|
||||||
|
<td>{row.request_track_number || row.request_id || "-"}</td>
|
||||||
|
<td>{row.issued_by_name || "-"}</td>
|
||||||
|
<td>{fmtDate(row.issued_at)}</td>
|
||||||
|
<td>{fmtDate(row.paid_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="table-actions">
|
||||||
|
<IconButton icon="👁" tooltip="Открыть заявку" onClick={() => openInvoiceRequest(row)} />
|
||||||
|
<IconButton icon="⬇" tooltip="Скачать PDF" onClick={() => downloadInvoicePdf(row)} />
|
||||||
|
<IconButton icon="✎" tooltip="Редактировать счет" onClick={() => openEditRecordModal("invoices", row)} />
|
||||||
|
{role === "ADMIN" ? (
|
||||||
|
<IconButton icon="🗑" tooltip="Удалить счет" onClick={() => deleteRecord("invoices", row.id)} tone="danger" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TablePager
|
||||||
|
tableState={tables.invoices}
|
||||||
|
onPrev={() => loadPrevPage("invoices")}
|
||||||
|
onNext={() => loadNextPage("invoices")}
|
||||||
|
onLoadAll={() => loadAllRows("invoices")}
|
||||||
|
/>
|
||||||
|
<StatusLine status={getStatus("invoices")} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section active={activeSection === "quotes"} id="section-quotes">
|
<Section active={activeSection === "quotes"} id="section-quotes">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -2501,13 +2837,15 @@
|
||||||
<div className="config-panel">
|
<div className="config-panel">
|
||||||
<div className="block">
|
<div className="block">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
||||||
<h3 style={{ margin: 0 }}>{getTableLabel(configActiveKey)}</h3>
|
<h3 style={{ margin: 0 }}>{configActiveKey ? getTableLabel(configActiveKey) : "Справочник не выбран"}</h3>
|
||||||
<button className="btn" type="button" onClick={() => openCreateRecordModal(configActiveKey)}>
|
{canCreateInConfig && configActiveKey ? (
|
||||||
Добавить
|
<button className="btn" type="button" onClick={() => openCreateRecordModal(configActiveKey)}>
|
||||||
</button>
|
Добавить
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<FilterToolbar
|
<FilterToolbar
|
||||||
filters={tables[configActiveKey].filters}
|
filters={activeConfigTableState.filters}
|
||||||
onOpen={() => openFilterModal(configActiveKey)}
|
onOpen={() => openFilterModal(configActiveKey)}
|
||||||
onRemove={(index) => removeFilterChip(configActiveKey, index)}
|
onRemove={(index) => removeFilterChip(configActiveKey, index)}
|
||||||
onEdit={(index) => openFilterEditModal(configActiveKey, index)}
|
onEdit={(index) => openFilterEditModal(configActiveKey, index)}
|
||||||
|
|
@ -2591,13 +2929,15 @@
|
||||||
headers={[
|
headers={[
|
||||||
{ key: "code", label: "Код", sortable: true, field: "code" },
|
{ key: "code", label: "Код", sortable: true, field: "code" },
|
||||||
{ key: "name", label: "Название", sortable: true, field: "name" },
|
{ key: "name", label: "Название", sortable: true, field: "name" },
|
||||||
|
{ key: "kind", label: "Тип", sortable: true, field: "kind" },
|
||||||
{ key: "enabled", label: "Активен", sortable: true, field: "enabled" },
|
{ key: "enabled", label: "Активен", sortable: true, field: "enabled" },
|
||||||
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
|
{ key: "sort_order", label: "Порядок", sortable: true, field: "sort_order" },
|
||||||
{ key: "is_terminal", label: "Терминальный", sortable: true, field: "is_terminal" },
|
{ key: "is_terminal", label: "Терминальный", sortable: true, field: "is_terminal" },
|
||||||
|
{ key: "invoice_template", label: "Шаблон счета" },
|
||||||
{ key: "actions", label: "Действия" },
|
{ key: "actions", label: "Действия" },
|
||||||
]}
|
]}
|
||||||
rows={tables.statuses.rows}
|
rows={tables.statuses.rows}
|
||||||
emptyColspan={6}
|
emptyColspan={8}
|
||||||
onSort={(field) => toggleTableSort("statuses", field)}
|
onSort={(field) => toggleTableSort("statuses", field)}
|
||||||
sortClause={(tables.statuses.sort && tables.statuses.sort[0]) || TABLE_SERVER_CONFIG.statuses.sort[0]}
|
sortClause={(tables.statuses.sort && tables.statuses.sort[0]) || TABLE_SERVER_CONFIG.statuses.sort[0]}
|
||||||
renderRow={(row) => (
|
renderRow={(row) => (
|
||||||
|
|
@ -2606,9 +2946,11 @@
|
||||||
<code>{row.code || "-"}</code>
|
<code>{row.code || "-"}</code>
|
||||||
</td>
|
</td>
|
||||||
<td>{row.name || "-"}</td>
|
<td>{row.name || "-"}</td>
|
||||||
|
<td>{statusKindLabel(row.kind)}</td>
|
||||||
<td>{boolLabel(row.enabled)}</td>
|
<td>{boolLabel(row.enabled)}</td>
|
||||||
<td>{String(row.sort_order ?? 0)}</td>
|
<td>{String(row.sort_order ?? 0)}</td>
|
||||||
<td>{boolLabel(row.is_terminal)}</td>
|
<td>{boolLabel(row.is_terminal)}</td>
|
||||||
|
<td>{row.invoice_template || "-"}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
<IconButton icon="✎" tooltip="Редактировать статус" onClick={() => openEditRecordModal("statuses", row)} />
|
<IconButton icon="✎" tooltip="Редактировать статус" onClick={() => openEditRecordModal("statuses", row)} />
|
||||||
|
|
@ -2866,8 +3208,44 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{configActiveKey && !KNOWN_CONFIG_TABLE_KEYS.has(configActiveKey) ? (
|
||||||
|
<DataTable
|
||||||
|
headers={genericConfigHeaders}
|
||||||
|
rows={activeConfigTableState.rows}
|
||||||
|
emptyColspan={Math.max(1, genericConfigHeaders.length)}
|
||||||
|
onSort={(field) => toggleTableSort(configActiveKey, field)}
|
||||||
|
sortClause={
|
||||||
|
(activeConfigTableState.sort && activeConfigTableState.sort[0]) ||
|
||||||
|
((resolveTableConfig(configActiveKey)?.sort || [])[0])
|
||||||
|
}
|
||||||
|
renderRow={(row) => (
|
||||||
|
<tr key={row.id || JSON.stringify(row)}>
|
||||||
|
{(activeConfigMeta?.columns || []).map((column) => {
|
||||||
|
const key = String(column.name || "");
|
||||||
|
const value = row[key];
|
||||||
|
if (column.kind === "boolean") return <td key={key}>{boolLabel(Boolean(value))}</td>;
|
||||||
|
if (column.kind === "date" || column.kind === "datetime") return <td key={key}>{fmtDate(value)}</td>;
|
||||||
|
if (column.kind === "json") return <td key={key}>{value == null ? "-" : JSON.stringify(value)}</td>;
|
||||||
|
return <td key={key}>{value == null || value === "" ? "-" : String(value)}</td>;
|
||||||
|
})}
|
||||||
|
{canUpdateInConfig || canDeleteInConfig ? (
|
||||||
|
<td>
|
||||||
|
<div className="table-actions">
|
||||||
|
{canUpdateInConfig ? (
|
||||||
|
<IconButton icon="✎" tooltip="Редактировать запись" onClick={() => openEditRecordModal(configActiveKey, row)} />
|
||||||
|
) : null}
|
||||||
|
{canDeleteInConfig ? (
|
||||||
|
<IconButton icon="🗑" tooltip="Удалить запись" onClick={() => deleteRecord(configActiveKey, row.id)} tone="danger" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<TablePager
|
<TablePager
|
||||||
tableState={tables[configActiveKey]}
|
tableState={activeConfigTableState}
|
||||||
onPrev={() => loadPrevPage(configActiveKey)}
|
onPrev={() => loadPrevPage(configActiveKey)}
|
||||||
onNext={() => loadNextPage(configActiveKey)}
|
onNext={() => loadNextPage(configActiveKey)}
|
||||||
onLoadAll={() => loadAllRows(configActiveKey)}
|
onLoadAll={() => loadAllRows(configActiveKey)}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: "Manrope", sans-serif;
|
font-family: "Manrope", sans-serif;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.modal-open { overflow: hidden; }
|
body.modal-open { overflow: hidden; }
|
||||||
|
|
@ -51,10 +52,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
width: min(var(--maxw), calc(100% - 2rem));
|
width: min(var(--maxw), calc(100% - 1.5rem));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
section { scroll-margin-top: 84px; }
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -493,6 +496,7 @@
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
color: #ecf2fb;
|
color: #ecf2fb;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
font-size: 16px;
|
||||||
padding: 0.72rem 0.8rem;
|
padding: 0.72rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -590,6 +594,7 @@
|
||||||
color: #d8e3f3;
|
color: #d8e3f3;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.simple-item time {
|
.simple-item time {
|
||||||
|
|
@ -615,6 +620,15 @@
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-row input[type="file"] {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.meta-row b {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes rise {
|
@keyframes rise {
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
@ -637,8 +651,21 @@
|
||||||
padding: 0.72rem 0;
|
padding: 0.72rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a,
|
||||||
|
.nav .btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
padding-top: 3.6rem;
|
padding-top: 2.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
|
|
@ -656,6 +683,88 @@
|
||||||
.cabinet-meta {
|
.cabinet-meta {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-list {
|
||||||
|
max-height: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.wrap {
|
||||||
|
width: calc(100% - 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding-top: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.card,
|
||||||
|
.expert,
|
||||||
|
.cabinet-card {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100vh;
|
||||||
|
min-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
padding: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: linear-gradient(160deg, #18222e, #121a23);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-foot .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -854,6 +963,11 @@
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="cabinet-card">
|
||||||
|
<h3>Счета и оплата</h3>
|
||||||
|
<ul class="simple-list" id="cabinet-invoices"></ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="cabinet-card">
|
<article class="cabinet-card">
|
||||||
<h3>История изменений</h3>
|
<h3>История изменений</h3>
|
||||||
<ul class="simple-list" id="cabinet-timeline"></ul>
|
<ul class="simple-list" id="cabinet-timeline"></ul>
|
||||||
|
|
@ -927,6 +1041,7 @@
|
||||||
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
|
const cabinetRequestUpdated = document.getElementById("cabinet-request-updated");
|
||||||
const cabinetMessages = document.getElementById("cabinet-messages");
|
const cabinetMessages = document.getElementById("cabinet-messages");
|
||||||
const cabinetFiles = document.getElementById("cabinet-files");
|
const cabinetFiles = document.getElementById("cabinet-files");
|
||||||
|
const cabinetInvoices = document.getElementById("cabinet-invoices");
|
||||||
const cabinetTimeline = document.getElementById("cabinet-timeline");
|
const cabinetTimeline = document.getElementById("cabinet-timeline");
|
||||||
const cabinetChatForm = document.getElementById("cabinet-chat-form");
|
const cabinetChatForm = document.getElementById("cabinet-chat-form");
|
||||||
const cabinetChatBody = document.getElementById("cabinet-chat-body");
|
const cabinetChatBody = document.getElementById("cabinet-chat-body");
|
||||||
|
|
@ -1060,6 +1175,44 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderInvoices(items) {
|
||||||
|
cabinetInvoices.innerHTML = "";
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach((item) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "simple-item";
|
||||||
|
|
||||||
|
const time = document.createElement("time");
|
||||||
|
time.textContent = "Сформирован: " + formatDate(item.issued_at);
|
||||||
|
li.appendChild(time);
|
||||||
|
|
||||||
|
const p = document.createElement("p");
|
||||||
|
const amount = Number(item.amount || 0).toLocaleString("ru-RU");
|
||||||
|
p.textContent =
|
||||||
|
(item.invoice_number || "Счет") +
|
||||||
|
" • " +
|
||||||
|
(item.status_label || item.status || "-") +
|
||||||
|
" • " +
|
||||||
|
amount +
|
||||||
|
" " +
|
||||||
|
(item.currency || "RUB");
|
||||||
|
li.appendChild(p);
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = item.download_url;
|
||||||
|
link.textContent = "Открыть / скачать PDF";
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.style.color = "#f6d7a8";
|
||||||
|
li.appendChild(link);
|
||||||
|
|
||||||
|
cabinetInvoices.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderTimeline(items) {
|
function renderTimeline(items) {
|
||||||
cabinetTimeline.innerHTML = "";
|
cabinetTimeline.innerHTML = "";
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
|
@ -1169,22 +1322,26 @@
|
||||||
async function refreshCabinetData() {
|
async function refreshCabinetData() {
|
||||||
if (!activeTrack) return;
|
if (!activeTrack) return;
|
||||||
|
|
||||||
const [messagesRes, filesRes, timelineRes] = await Promise.all([
|
const [messagesRes, filesRes, invoicesRes, timelineRes] = await Promise.all([
|
||||||
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"),
|
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/messages"),
|
||||||
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
|
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/attachments"),
|
||||||
|
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/invoices"),
|
||||||
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline")
|
fetch("/api/public/requests/" + encodeURIComponent(activeTrack) + "/timeline")
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const messagesData = await parseJsonSafe(messagesRes);
|
const messagesData = await parseJsonSafe(messagesRes);
|
||||||
const filesData = await parseJsonSafe(filesRes);
|
const filesData = await parseJsonSafe(filesRes);
|
||||||
|
const invoicesData = await parseJsonSafe(invoicesRes);
|
||||||
const timelineData = await parseJsonSafe(timelineRes);
|
const timelineData = await parseJsonSafe(timelineRes);
|
||||||
|
|
||||||
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
|
if (!messagesRes.ok) throw new Error(apiErrorDetail(messagesData, "Не удалось загрузить сообщения"));
|
||||||
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
|
if (!filesRes.ok) throw new Error(apiErrorDetail(filesData, "Не удалось загрузить файлы"));
|
||||||
|
if (!invoicesRes.ok) throw new Error(apiErrorDetail(invoicesData, "Не удалось загрузить счета"));
|
||||||
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
|
if (!timelineRes.ok) throw new Error(apiErrorDetail(timelineData, "Не удалось загрузить историю"));
|
||||||
|
|
||||||
renderMessages(messagesData);
|
renderMessages(messagesData);
|
||||||
renderFiles(filesData);
|
renderFiles(filesData);
|
||||||
|
renderInvoices(invoicesData);
|
||||||
renderTimeline(timelineData);
|
renderTimeline(timelineData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1366,6 +1523,7 @@
|
||||||
setCabinetEnabled(false);
|
setCabinetEnabled(false);
|
||||||
clearList(cabinetMessages, "Сообщений пока нет.");
|
clearList(cabinetMessages, "Сообщений пока нет.");
|
||||||
clearList(cabinetFiles, "Файлы пока не загружены.");
|
clearList(cabinetFiles, "Файлы пока не загружены.");
|
||||||
|
clearList(cabinetInvoices, "Счета пока не выставлены.");
|
||||||
clearList(cabinetTimeline, "История пока пуста.");
|
clearList(cabinetTimeline, "История пока пуста.");
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -41,14 +41,17 @@ def auto_assign_unclaimed():
|
||||||
lawyer_load: dict[str, int] = {str(lawyer_id): int(count) for lawyer_id, count in active_load_rows if lawyer_id}
|
lawyer_load: dict[str, int] = {str(lawyer_id): int(count) for lawyer_id, count in active_load_rows if lawyer_id}
|
||||||
|
|
||||||
active_lawyers = (
|
active_lawyers = (
|
||||||
db.query(AdminUser.id, AdminUser.primary_topic_code)
|
db.query(AdminUser.id, AdminUser.primary_topic_code, AdminUser.default_rate)
|
||||||
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
|
.filter(AdminUser.role == "LAWYER", AdminUser.is_active.is_(True))
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
active_lawyer_ids = {str(lawyer_id) for lawyer_id, _ in active_lawyers if lawyer_id}
|
active_lawyer_ids = {str(lawyer_id) for lawyer_id, _, _ in active_lawyers if lawyer_id}
|
||||||
|
lawyer_default_rate: dict[str, float | None] = {
|
||||||
|
str(lawyer_id): default_rate for lawyer_id, _, default_rate in active_lawyers if lawyer_id
|
||||||
|
}
|
||||||
|
|
||||||
primary_by_topic: dict[str, list[str]] = {}
|
primary_by_topic: dict[str, list[str]] = {}
|
||||||
for lawyer_id, primary_topic_code in active_lawyers:
|
for lawyer_id, primary_topic_code, _ in active_lawyers:
|
||||||
topic_code = str(primary_topic_code or "").strip()
|
topic_code = str(primary_topic_code or "").strip()
|
||||||
if not topic_code:
|
if not topic_code:
|
||||||
continue
|
continue
|
||||||
|
|
@ -96,6 +99,8 @@ def auto_assign_unclaimed():
|
||||||
continue
|
continue
|
||||||
selected = min(candidates, key=lambda lawyer_id: (lawyer_load.get(lawyer_id, 0), lawyer_id))
|
selected = min(candidates, key=lambda lawyer_id: (lawyer_load.get(lawyer_id, 0), lawyer_id))
|
||||||
req.assigned_lawyer_id = selected
|
req.assigned_lawyer_id = selected
|
||||||
|
if req.effective_rate is None:
|
||||||
|
req.effective_rate = lawyer_default_rate.get(selected)
|
||||||
req.updated_at = now
|
req.updated_at = now
|
||||||
req.responsible = "Администратор системы"
|
req.responsible = "Администратор системы"
|
||||||
lawyer_load[selected] = lawyer_load.get(selected, 0) + 1
|
lawyer_load[selected] = lawyer_load.get(selected, 0) + 1
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -59,6 +59,21 @@
|
||||||
- invoice is attached/sent to client through platform notification channel
|
- invoice is attached/sent to client through platform notification channel
|
||||||
- billing status can be included in topic-specific flow as regular transition node
|
- billing status can be included in topic-specific flow as regular transition node
|
||||||
|
|
||||||
|
### Implemented Billing Status Flow (`P25`)
|
||||||
|
- `statuses.kind` supports:
|
||||||
|
- `DEFAULT` (regular status)
|
||||||
|
- `INVOICE` (billing step: выставление счета)
|
||||||
|
- `PAID` (business payment fact status)
|
||||||
|
- `statuses.invoice_template` stores admin-managed invoice template with placeholders (`{track_number}`, `{client_name}`, `{topic_code}`, `{amount}` and others).
|
||||||
|
- On request status transition to `INVOICE` kind:
|
||||||
|
- system auto-creates waiting invoice (`WAITING_PAYMENT`) from template
|
||||||
|
- invoice is linked to request and available in ADMIN/LAWYER + public cabinet
|
||||||
|
- On request status transition to `PAID` kind:
|
||||||
|
- only ADMIN can perform this transition
|
||||||
|
- latest waiting invoice is marked as paid
|
||||||
|
- request payment fields are fixed (`invoice_amount`, `paid_at`, `paid_by_admin_id`)
|
||||||
|
- Multiple billing cycles in the same request are supported (sequential invoice->paid events).
|
||||||
|
|
||||||
### Implemented SLA Transition Config (`P18`)
|
### Implemented SLA Transition Config (`P18`)
|
||||||
- SLA configuration is stored in `topic_status_transitions.sla_hours`
|
- SLA configuration is stored in `topic_status_transitions.sla_hours`
|
||||||
- `sla_hours` is optional but if set must be integer > 0
|
- `sla_hours` is optional but if set must be integer > 0
|
||||||
|
|
@ -113,6 +128,12 @@
|
||||||
- Payment event stores who changed status and when (for salary/month reports)
|
- Payment event stores who changed status and when (for salary/month reports)
|
||||||
- A request may contain more than one payment event (multiple invoice-payment cycles)
|
- A request may contain more than one payment event (multiple invoice-payment cycles)
|
||||||
|
|
||||||
|
### Implemented Rate Rules (`P24`)
|
||||||
|
- On first assignment (`claim`, `reassign`, `auto-assign`, create/update request with assigned lawyer), `requests.effective_rate` is auto-filled from `admin_users.default_rate` if request rate is empty.
|
||||||
|
- If request already has `effective_rate`, assignment/reassignment does not overwrite it.
|
||||||
|
- LAWYER role cannot create/update request financial fields (`effective_rate`, `invoice_amount`, `paid_at`, `paid_by_admin_id`).
|
||||||
|
- Public client API does not expose internal request financial fields.
|
||||||
|
|
||||||
### Implemented Baseline For Dashboard (`P21`)
|
### Implemented Baseline For Dashboard (`P21`)
|
||||||
- Financial profile fields are persisted:
|
- Financial profile fields are persisted:
|
||||||
- `admin_users.default_rate`
|
- `admin_users.default_rate`
|
||||||
|
|
@ -148,3 +169,10 @@
|
||||||
- Salary calculation base:
|
- Salary calculation base:
|
||||||
- paid event = ADMIN changes request status to "Оплачено"
|
- paid event = ADMIN changes request status to "Оплачено"
|
||||||
- salary = paid request amount * lawyer salary percent
|
- salary = paid request amount * lawyer salary percent
|
||||||
|
|
||||||
|
## Implemented File Security Audit (`P26`)
|
||||||
|
- Added dedicated immutable security log table: `security_audit_log`.
|
||||||
|
- File operations in admin/public upload APIs now produce security events:
|
||||||
|
- `UPLOAD_INIT`, `UPLOAD_COMPLETE`, `DOWNLOAD_OBJECT`.
|
||||||
|
- Both successful and denied attempts are logged (including RBAC denials on download).
|
||||||
|
- `security_audit_log` is exposed in admin dictionaries as read-only (query/read only, no update/delete via universal CRUD).
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
## Public
|
## Public
|
||||||
- OTP verification required for request creation and request access
|
- OTP verification required for request creation and request access
|
||||||
- JWT in httpOnly cookie (7 days)
|
- JWT in httpOnly cookie (7 days)
|
||||||
- Rate limiting
|
- Rate limiting by IP + phone + track number (OTP send/verify)
|
||||||
- Protection from brute force
|
- Protection from brute force
|
||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
@ -14,8 +14,9 @@
|
||||||
## Data Protection
|
## Data Protection
|
||||||
- Messages and attachments from previous statuses are immutable after status change
|
- Messages and attachments from previous statuses are immutable after status change
|
||||||
- All actions logged
|
- All actions logged
|
||||||
|
- HTTP hardening headers and request correlation (`X-Request-ID`) are added at middleware level
|
||||||
|
|
||||||
## S3 & Personal Data (planned hardening)
|
## S3 & Personal Data (baseline)
|
||||||
- Files in S3 are treated as personal data (PII/ПДн)
|
- Files in S3 are treated as personal data (PII/ПДн)
|
||||||
- Security baseline for implementation:
|
- Security baseline for implementation:
|
||||||
- Access model:
|
- Access model:
|
||||||
|
|
@ -37,3 +38,14 @@
|
||||||
- Compliance posture:
|
- Compliance posture:
|
||||||
- map controls to РФ requirements for personal data protection and internal cyber policies
|
- map controls to РФ requirements for personal data protection and internal cyber policies
|
||||||
- formalize security checklist for release gates (threat review + access review + logging verification)
|
- formalize security checklist for release gates (threat review + access review + logging verification)
|
||||||
|
|
||||||
|
## Implemented Security Audit (`P26`)
|
||||||
|
- Added dedicated table `security_audit_log` (migration `0014_security_audit_log`) with fields:
|
||||||
|
- actor role/subject/ip, action, scope, object key, request/attachment IDs, allow/deny result, reason, details.
|
||||||
|
- File operations now write security events:
|
||||||
|
- `UPLOAD_INIT`, `UPLOAD_COMPLETE`, `DOWNLOAD_OBJECT` for admin and public upload/download flows.
|
||||||
|
- Denied attempts are logged too (including RBAC denials and invalid object access).
|
||||||
|
- RBAC hardening:
|
||||||
|
- universal CRUD for `security_audit_log` is read-only for ADMIN (`query`, `read`), no update/delete to preserve immutability.
|
||||||
|
- Suspicious activity signal:
|
||||||
|
- repeated denied `DOWNLOAD_OBJECT` events per subject/IP in short window emit server warning log.
|
||||||
|
|
|
||||||
|
|
@ -40,19 +40,19 @@
|
||||||
| P19 | сделано | SLA-check и overdue | Реализовать `sla_check`: контроль просрочек по переходам, расчет FRT/времени в статусе | Метрики и флаги просрочек обновляются по расписанию |
|
| P19 | сделано | SLA-check и overdue | Реализовать `sla_check`: контроль просрочек по переходам, расчет FRT/времени в статусе | Метрики и флаги просрочек обновляются по расписанию |
|
||||||
| P20 | сделано | Уведомления | Уведомления в Telegram (если подключен) + внутренние уведомления сайта по изменениям | При событиях (сообщения/файлы/статусы/SLA) уведомления доставляются |
|
| P20 | сделано | Уведомления | Уведомления в Telegram (если подключен) + внутренние уведомления сайта по изменениям | При событиях (сообщения/файлы/статусы/SLA) уведомления доставляются |
|
||||||
| P21 | сделано | Dashboard LAWYER/ADMIN | Расширить дашборды: назначенные/неназначенные, активные по статусам, непрочитанные, SLA, по каждому юристу: активная загрузка, сумма активных заявок, вал оплаченных за месяц, зарплата за месяц | Дашборды соответствуют ролям и данным из БД |
|
| P21 | сделано | Dashboard LAWYER/ADMIN | Расширить дашборды: назначенные/неназначенные, активные по статусам, непрочитанные, SLA, по каждому юристу: активная загрузка, сумма активных заявок, вал оплаченных за месяц, зарплата за месяц | Дашборды соответствуют ролям и данным из БД |
|
||||||
| P22 | к разработке | Тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers | Набор автотестов фиксирует регрессии критичных сценариев |
|
| P22 | сделано | Hardening/release | Полировка безопасности, логирования, лимитов, отказоустойчивости, документации API/UI и runbook | Проект готов к стабилизации и приемке |
|
||||||
| P23 | к разработке | Hardening/release | Полировка безопасности, логирования, лимитов, отказоустойчивости, документации API/UI и runbook | Проект готов к стабилизации и приемке |
|
| P23 | сделано | Mobile UX | Мобильная адаптация лендинга и клиентских форм (заявка, OTP, кабинет клиента: чат, файлы, история) | UI корректно работает на 320-768px, элементы доступны и читаемы без горизонтального скролла |
|
||||||
| P24 | к разработке | Mobile UX | Мобильная адаптация лендинга и клиентских форм (заявка, OTP, кабинет клиента: чат, файлы, история) | UI корректно работает на 320-768px, элементы доступны и читаемы без горизонтального скролла |
|
| P24 | сделано | Тарифы юристов | Добавить ставку и процент юриста (по умолчанию в профиле), а также фиксируемые в заявке поля ставки/суммы (override админом) | Финансовые поля заявки фиксируются и не зависят от последующих правок профиля; клиенту не показываются |
|
||||||
| P25 | к разработке | Тарифы юристов | Добавить ставку и процент юриста (по умолчанию в профиле), а также фиксируемые в заявке поля ставки/суммы (override админом) | Финансовые поля заявки фиксируются и не зависят от последующих правок профиля; клиенту не показываются |
|
| P25 | сделано | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) |
|
||||||
| P26 | к разработке | Биллинг-статус | Добавить тип статуса «выставление счета»: генерация счета из шаблона, отправка клиенту и фиксация события оплаты по смене статуса администратором на `Оплачено` | Для темы можно включить billing-этап, счет формируется и доставляется; факт оплаты фиксируется по событиям `Оплачено` (возможны множественные события в одной заявке) |
|
| P26 | сделано | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов |
|
||||||
| P27 | к разработке | Security Audit | Внедрить аудит безопасности и защиту ПДн для S3/файлов по требованиям РФ и кибербезопасности | Реализован журнал доступа, шифрование, RBAC/least-privilege, политика хранения и контроль инцидентов |
|
| P27 | сделано | Итоговое тестирование E2E | Покрыть ключевые бизнес-сценарии: OTP, claim, auto-assign v2, чат, файлы, SLA, уведомления, read markers и выполнить финальный регрессионный прогон | Набор автотестов фиксирует регрессии критичных сценариев и подтверждает готовность перед приемкой |
|
||||||
|
|
||||||
## Критический маршрут (обязательный порядок)
|
## Критический маршрут (обязательный порядок)
|
||||||
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
|
1. `P07 -> P08 -> P09 -> P10` (полный контур назначения).
|
||||||
2. `P11 -> P12 -> P13` (публичный клиентский контур).
|
2. `P11 -> P12 -> P13` (публичный клиентский контур).
|
||||||
3. `P14 -> P15 -> P16` (процесс работы по заявке).
|
3. `P14 -> P15 -> P16` (процесс работы по заявке).
|
||||||
4. `P17 -> P18 -> P25 -> P26 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика).
|
4. `P17 -> P18 -> P24 -> P25 -> P19 -> P20 -> P21` (файлы, SLA, тарифы/биллинг, аналитика).
|
||||||
5. `P22 -> P23 -> P24 -> P27` (стабилизация, mobile UX, security-аудит).
|
5. `P22 -> P23 -> P26 -> P27` (стабилизация, mobile UX, security-аудит, итоговые тесты в конце).
|
||||||
|
|
||||||
## Правила выполнения для ИИ-агента
|
## Правила выполнения для ИИ-агента
|
||||||
1. Не менять бизнес-правила без обновления `context/*.md`.
|
1. Не менять бизнес-правила без обновления `context/*.md`.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Runbook Проверок (Тесты и Валидация по Плану)
|
# Runbook Проверок (Тесты и Валидация по Плану)
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P23` и как их запускать.
|
Этот файл фиксирует, где находятся проверки для каждого пункта `P01-P27` и как их запускать.
|
||||||
Использовать перед переводом пункта в статус `сделано`.
|
Использовать перед переводом пункта в статус `сделано`.
|
||||||
|
|
||||||
## Базовые команды
|
## Базовые команды
|
||||||
|
|
@ -36,7 +36,7 @@ docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cac
|
||||||
| P08 | Ручной claim (без гонок) | `tests/test_admin_universal_crud.py` (claim-тесты) | команда как для `P03` |
|
| P08 | Ручной claim (без гонок) | `tests/test_admin_universal_crud.py` (claim-тесты) | команда как для `P03` |
|
||||||
| P09 | ADMIN-only переназначение | `tests/test_admin_universal_crud.py` (reassign-тесты) | команда как для `P03` |
|
| P09 | ADMIN-only переназначение | `tests/test_admin_universal_crud.py` (reassign-тесты) | команда как для `P03` |
|
||||||
| P10 | Auto-assign v2 приоритетов | `tests/test_auto_assign.py` | команда как для `P05` |
|
| P10 | Auto-assign v2 приоритетов | `tests/test_auto_assign.py` | команда как для `P05` |
|
||||||
| P11 | OTP create/view + 7-day cookie | `tests/test_public_requests.py` | `docker compose exec -T backend python -m unittest tests.test_public_requests -v` |
|
| P11 | OTP create/view + 7-day cookie + rate-limit | `tests/test_public_requests.py`, `tests/test_otp_rate_limit.py` | `docker compose exec -T backend python -m unittest tests.test_public_requests tests.test_otp_rate_limit -v` |
|
||||||
| P12 | Публичный кабинет (статус/чат/файлы/таймлайн) | `tests/test_public_cabinet.py` | `docker compose exec -T backend python -m unittest tests.test_public_cabinet -v` |
|
| P12 | Публичный кабинет (статус/чат/файлы/таймлайн) | `tests/test_public_cabinet.py` | `docker compose exec -T backend python -m unittest tests.test_public_cabinet -v` |
|
||||||
| P13 | Read/unread маркеры | `tests/test_public_requests.py`, `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py` | запустить 3 набора: `test_public_requests`, `test_admin_universal_crud`, `test_uploads_s3` |
|
| P13 | Read/unread маркеры | `tests/test_public_requests.py`, `tests/test_admin_universal_crud.py`, `tests/test_uploads_s3.py` | запустить 3 набора: `test_public_requests`, `test_admin_universal_crud`, `test_uploads_s3` |
|
||||||
| P14 | Валидация флоу статусов по темам | `tests/test_admin_universal_crud.py` (status-flow тесты) | команда как для `P03` |
|
| P14 | Валидация флоу статусов по темам | `tests/test_admin_universal_crud.py` (status-flow тесты) | команда как для `P03` |
|
||||||
|
|
@ -47,12 +47,12 @@ docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cac
|
||||||
| P19 | SLA overdue/FRT расчеты | `tests/test_worker_maintenance.py`, `tests/test_admin_universal_crud.py` (metrics) | `docker compose exec -T backend python -m unittest tests.test_worker_maintenance tests.test_admin_universal_crud -v`; проверить `overdue_by_transition` |
|
| P19 | SLA overdue/FRT расчеты | `tests/test_worker_maintenance.py`, `tests/test_admin_universal_crud.py` (metrics) | `docker compose exec -T backend python -m unittest tests.test_worker_maintenance tests.test_admin_universal_crud -v`; проверить `overdue_by_transition` |
|
||||||
| P20 | Уведомления | `tests/test_notifications.py`, а также регрессии `tests/test_public_cabinet.py`, `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_notifications tests.test_public_cabinet tests.test_uploads_s3 tests.test_worker_maintenance -v`; затем полный прогон |
|
| P20 | Уведомления | `tests/test_notifications.py`, а также регрессии `tests/test_public_cabinet.py`, `tests/test_uploads_s3.py`, `tests/test_worker_maintenance.py` | `docker compose exec -T backend python -m unittest tests.test_notifications tests.test_public_cabinet tests.test_uploads_s3 tests.test_worker_maintenance -v`; затем полный прогон |
|
||||||
| P21 | Dashboard ADMIN/LAWYER | `tests/test_admin_universal_crud.py` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance tests.test_admin_universal_crud -v`; проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц |
|
| P21 | Dashboard ADMIN/LAWYER | `tests/test_admin_universal_crud.py` (metrics/dashboard) + `tests/test_dashboard_finance.py` | `docker compose exec -T backend python -m unittest tests.test_dashboard_finance tests.test_admin_universal_crud -v`; проверить role-scope и метрики юристов: загрузка, сумма активных, вал за месяц, зарплата за месяц |
|
||||||
| P22 | E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон |
|
| P22 | Hardening/release | `tests/test_http_hardening.py` + весь regression + compile + миграции + UI build | `docker compose exec -T backend python -m unittest tests.test_http_hardening -v`; затем базовые команды 1-4 |
|
||||||
| P23 | Hardening/release | весь regression + compile + миграции + UI build | базовые команды 1-4 |
|
| P23 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать `admin.jsx` при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла |
|
||||||
| P24 | Мобильная адаптация лендинга/клиентских форм | `app/web/landing.html` + ручная проверка в mobile viewport | собрать `admin.jsx` при затрагивании админки + открыть `landing.html` в 320px/375px/768px, проверить формы/чат/файлы без горизонтального скролла |
|
| P24 | Ставки юриста и ставка заявки | `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | `docker compose exec -T backend python -m unittest tests.test_rates tests.test_admin_universal_crud -v`; проверка что public API не отдает поля ставок/процентов |
|
||||||
| P25 | Ставки юриста и ставка заявки | новые тесты `tests/test_rates.py` + интеграционные в `tests/test_admin_universal_crud.py` | прогон `test_rates` + `test_admin_universal_crud`; проверка что public API не отдает поля ставок/процентов |
|
| P25 | Billing-статус и шаблон счета | `tests/test_billing_flow.py`, `tests/test_invoices.py` + e2e статусных переходов | `docker compose exec -T backend python -m unittest tests.test_billing_flow tests.test_invoices tests.test_admin_universal_crud -v`; валидация автогенерации счета при billing-статусе и фиксации оплаты только при ADMIN->`Оплачено` (в т.ч. множественные оплаты в одной заявке) |
|
||||||
| P26 | Billing-статус и шаблон счета | новые тесты `tests/test_billing_flow.py` + e2e статусных переходов | прогон `test_billing_flow` + `test_admin_universal_crud`; валидация генерации счета и фиксации оплаты при ADMIN->\"Оплачено\" (в т.ч. множественные оплаты в одной заявке) |
|
| P26 | Security audit S3/ПДн | `tests/test_security_audit.py` + `tests/test_uploads_s3.py` + `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_security_audit tests.test_uploads_s3 tests.test_migrations -v`; проверить события allow/deny в `security_audit_log` и применимость миграции `0014_security_audit_log` |
|
||||||
| P27 | Security audit S3/ПДн | новые тесты `tests/test_security_audit.py` + `tests/test_uploads_s3.py` | прогон `test_security_audit` + `test_uploads_s3`; проверка логирования и ограничений доступа |
|
| P27 | Итоговые E2E критические сценарии | набор `tests/test_*.py` + новые E2E-тесты | базовые команды 1-3 + полный прогон |
|
||||||
|
|
||||||
## Минимальный чеклист закрытия пункта
|
## Минимальный чеклист закрытия пункта
|
||||||
1. Выполнить миграции (если были изменения схемы).
|
1. Выполнить миграции (если были изменения схемы).
|
||||||
|
|
@ -61,3 +61,6 @@ docker compose run --rm --no-deps --entrypoint sh frontend -lc "apk add --no-cac
|
||||||
4. Выполнить `compileall`.
|
4. Выполнить `compileall`.
|
||||||
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose.
|
5. Для изменений `admin.jsx` выполнить сборку `admin.jsx` через Docker Compose.
|
||||||
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
|
6. После успешной проверки обновить статус пункта в `context/10_development_execution_plan.md`.
|
||||||
|
|
||||||
|
## Последний регрессионный прогон
|
||||||
|
- `python -m unittest discover -s tests -p 'test_*.py' -v` — `91 tests OK`.
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,41 @@ class AdminUniversalCrudTests(unittest.TestCase):
|
||||||
actions = [row.action for row in db.query(AuditLog).filter(AuditLog.entity == "quotes", AuditLog.entity_id == quote_id).all()]
|
actions = [row.action for row in db.query(AuditLog).filter(AuditLog.entity == "quotes", AuditLog.entity_id == quote_id).all()]
|
||||||
self.assertEqual(set(actions), {"CREATE", "UPDATE", "DELETE"})
|
self.assertEqual(set(actions), {"CREATE", "UPDATE", "DELETE"})
|
||||||
|
|
||||||
|
def test_admin_table_catalog_lists_db_tables_for_dynamic_references(self):
|
||||||
|
admin_headers = self._auth_headers("ADMIN")
|
||||||
|
response = self.client.get("/api/admin/crud/meta/tables", headers=admin_headers)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
tables = payload.get("tables") or []
|
||||||
|
self.assertTrue(tables)
|
||||||
|
|
||||||
|
by_table = {row["table"]: row for row in tables}
|
||||||
|
self.assertIn("requests", by_table)
|
||||||
|
self.assertIn("invoices", by_table)
|
||||||
|
self.assertIn("quotes", by_table)
|
||||||
|
self.assertIn("statuses", by_table)
|
||||||
|
|
||||||
|
self.assertEqual(by_table["requests"]["section"], "main")
|
||||||
|
self.assertEqual(by_table["invoices"]["section"], "main")
|
||||||
|
self.assertEqual(by_table["quotes"]["section"], "dictionary")
|
||||||
|
self.assertTrue(by_table["quotes"]["default_sort"])
|
||||||
|
self.assertEqual(by_table["quotes"]["label"], "Цитаты")
|
||||||
|
self.assertEqual(by_table["request_data_requirements"]["label"], "Требования данных заявки")
|
||||||
|
quotes_columns = {col["name"]: col for col in (by_table["quotes"].get("columns") or [])}
|
||||||
|
self.assertEqual(quotes_columns["author"]["label"], "Автор")
|
||||||
|
self.assertEqual(quotes_columns["sort_order"]["label"], "Порядок")
|
||||||
|
self.assertTrue(all(str(col.get("label") or "").strip() for col in (by_table["quotes"].get("columns") or [])))
|
||||||
|
for table_name, table_meta in by_table.items():
|
||||||
|
expected_section = "main" if table_name in {"requests", "invoices"} else "dictionary"
|
||||||
|
self.assertEqual(table_meta.get("section"), expected_section)
|
||||||
|
|
||||||
|
admin_users_cols = {col["name"] for col in (by_table["admin_users"].get("columns") or [])}
|
||||||
|
self.assertNotIn("password_hash", admin_users_cols)
|
||||||
|
|
||||||
|
lawyer_headers = self._auth_headers("LAWYER")
|
||||||
|
forbidden = self.client.get("/api/admin/crud/meta/tables", headers=lawyer_headers)
|
||||||
|
self.assertEqual(forbidden.status_code, 403)
|
||||||
|
|
||||||
def test_lawyer_permissions_and_request_crud(self):
|
def test_lawyer_permissions_and_request_crud(self):
|
||||||
lawyer_headers = self._auth_headers("LAWYER")
|
lawyer_headers = self._auth_headers("LAWYER")
|
||||||
|
|
||||||
|
|
|
||||||
356
tests/test_billing_flow.py
Normal file
356
tests/test_billing_flow.py
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_jwt
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.invoice import Invoice
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.models.status_history import StatusHistory
|
||||||
|
from app.services.invoice_crypto import decrypt_requisites
|
||||||
|
|
||||||
|
|
||||||
|
class BillingFlowTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
|
||||||
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
Status.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Message.__table__.create(bind=cls.engine)
|
||||||
|
Attachment.__table__.create(bind=cls.engine)
|
||||||
|
StatusHistory.__table__.create(bind=cls.engine)
|
||||||
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
Invoice.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
Invoice.__table__.drop(bind=cls.engine)
|
||||||
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
StatusHistory.__table__.drop(bind=cls.engine)
|
||||||
|
Attachment.__table__.drop(bind=cls.engine)
|
||||||
|
Message.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
Status.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(Invoice))
|
||||||
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(StatusHistory))
|
||||||
|
db.execute(delete(Attachment))
|
||||||
|
db.execute(delete(Message))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(Status))
|
||||||
|
db.execute(delete(AdminUser))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _auth_headers(role: str, email: str, sub: str | None = None) -> dict[str, str]:
|
||||||
|
token = create_jwt(
|
||||||
|
{"sub": str(sub or uuid4()), "email": email, "role": role},
|
||||||
|
settings.ADMIN_JWT_SECRET,
|
||||||
|
timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
def _seed_statuses(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False, kind="DEFAULT"),
|
||||||
|
Status(
|
||||||
|
code="BILLING",
|
||||||
|
name="Выставление счета",
|
||||||
|
enabled=True,
|
||||||
|
sort_order=1,
|
||||||
|
is_terminal=False,
|
||||||
|
kind="INVOICE",
|
||||||
|
invoice_template="Счет по заявке {track_number}; клиент {client_name}; сумма {amount}",
|
||||||
|
),
|
||||||
|
Status(code="IN_PROGRESS", name="В работе", enabled=True, sort_order=2, is_terminal=False, kind="DEFAULT"),
|
||||||
|
Status(code="PAID", name="Оплачено", enabled=True, sort_order=3, is_terminal=False, kind="PAID"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def test_entering_billing_status_creates_waiting_invoice_from_template(self):
|
||||||
|
self._seed_statuses()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-BILL-1",
|
||||||
|
client_name="ООО Клиент",
|
||||||
|
client_phone="+79990000021",
|
||||||
|
status_code="NEW",
|
||||||
|
topic_code=None,
|
||||||
|
description="billing",
|
||||||
|
extra_fields={},
|
||||||
|
effective_rate=4300,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
|
||||||
|
admin_headers = self._auth_headers("ADMIN", "root@example.com")
|
||||||
|
changed = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"status_code": "BILLING"},
|
||||||
|
)
|
||||||
|
self.assertEqual(changed.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = db.get(Request, UUID(request_id))
|
||||||
|
self.assertIsNotNone(req)
|
||||||
|
self.assertEqual(req.status_code, "BILLING")
|
||||||
|
self.assertAlmostEqual(float(req.invoice_amount or 0), 4300.0, places=2)
|
||||||
|
|
||||||
|
rows = db.query(Invoice).filter(Invoice.request_id == req.id).all()
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
invoice = rows[0]
|
||||||
|
self.assertEqual(invoice.status, "WAITING_PAYMENT")
|
||||||
|
self.assertEqual(invoice.payer_display_name, "ООО Клиент")
|
||||||
|
self.assertAlmostEqual(float(invoice.amount or 0), 4300.0, places=2)
|
||||||
|
details = decrypt_requisites(invoice.payer_details_encrypted)
|
||||||
|
rendered = str((details or {}).get("template_rendered") or "")
|
||||||
|
self.assertIn("TRK-BILL-1", rendered)
|
||||||
|
self.assertIn("ООО Клиент", rendered)
|
||||||
|
|
||||||
|
def test_paid_status_requires_admin_and_marks_waiting_invoice_paid(self):
|
||||||
|
self._seed_statuses()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист",
|
||||||
|
email="lawyer-paid@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-BILL-2",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000022",
|
||||||
|
status_code="BILLING",
|
||||||
|
topic_code=None,
|
||||||
|
description="billing",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add_all([lawyer, req])
|
||||||
|
db.flush()
|
||||||
|
invoice = Invoice(
|
||||||
|
request_id=req.id,
|
||||||
|
invoice_number="INV-MANUAL-1",
|
||||||
|
status="WAITING_PAYMENT",
|
||||||
|
amount=7500,
|
||||||
|
currency="RUB",
|
||||||
|
payer_display_name=req.client_name,
|
||||||
|
payer_details_encrypted=None,
|
||||||
|
issued_by_admin_user_id=None,
|
||||||
|
issued_by_role="ADMIN",
|
||||||
|
issued_at=req.created_at,
|
||||||
|
paid_at=None,
|
||||||
|
responsible="root@example.com",
|
||||||
|
)
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
invoice_id = str(invoice.id)
|
||||||
|
|
||||||
|
lawyer_headers = self._auth_headers("LAWYER", "lawyer-paid@example.com", sub=lawyer_id)
|
||||||
|
blocked = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=lawyer_headers,
|
||||||
|
json={"status_code": "PAID"},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked.status_code, 403)
|
||||||
|
|
||||||
|
admin_headers = self._auth_headers("ADMIN", "root@example.com")
|
||||||
|
paid = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"status_code": "PAID"},
|
||||||
|
)
|
||||||
|
self.assertEqual(paid.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = db.get(Request, UUID(request_id))
|
||||||
|
inv = db.get(Invoice, UUID(invoice_id))
|
||||||
|
self.assertIsNotNone(req)
|
||||||
|
self.assertIsNotNone(inv)
|
||||||
|
self.assertEqual(inv.status, "PAID")
|
||||||
|
self.assertIsNotNone(inv.paid_at)
|
||||||
|
self.assertEqual(req.status_code, "PAID")
|
||||||
|
self.assertIsNotNone(req.paid_at)
|
||||||
|
self.assertEqual(str(req.paid_at), str(inv.paid_at))
|
||||||
|
self.assertIsNotNone(req.paid_by_admin_id)
|
||||||
|
self.assertAlmostEqual(float(req.invoice_amount or 0), 7500.0, places=2)
|
||||||
|
|
||||||
|
def test_paid_status_without_waiting_invoice_returns_400(self):
|
||||||
|
self._seed_statuses()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-BILL-3",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000023",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
topic_code=None,
|
||||||
|
description="billing",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
|
||||||
|
admin_headers = self._auth_headers("ADMIN", "root@example.com")
|
||||||
|
blocked = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"status_code": "PAID"},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked.status_code, 400)
|
||||||
|
self.assertIn("Ожидает оплату", blocked.json().get("detail", ""))
|
||||||
|
|
||||||
|
def test_multiple_billing_cycles_are_supported(self):
|
||||||
|
self._seed_statuses()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-BILL-4",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000024",
|
||||||
|
status_code="NEW",
|
||||||
|
topic_code=None,
|
||||||
|
description="billing",
|
||||||
|
extra_fields={},
|
||||||
|
effective_rate=1000,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
request_id = str(req.id)
|
||||||
|
|
||||||
|
admin_headers = self._auth_headers("ADMIN", "root@example.com")
|
||||||
|
|
||||||
|
first_billing = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"status_code": "BILLING"},
|
||||||
|
)
|
||||||
|
self.assertEqual(first_billing.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = db.get(Request, UUID(request_id))
|
||||||
|
first_invoice = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.request_id == req.id)
|
||||||
|
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(first_invoice)
|
||||||
|
first_invoice_id = str(first_invoice.id)
|
||||||
|
|
||||||
|
tune_first_amount = self.client.patch(
|
||||||
|
f"/api/admin/invoices/{first_invoice_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"amount": 1100},
|
||||||
|
)
|
||||||
|
self.assertEqual(tune_first_amount.status_code, 200)
|
||||||
|
|
||||||
|
first_paid = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"status_code": "PAID"},
|
||||||
|
)
|
||||||
|
self.assertEqual(first_paid.status_code, 200)
|
||||||
|
|
||||||
|
back_to_work = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"status_code": "IN_PROGRESS"},
|
||||||
|
)
|
||||||
|
self.assertEqual(back_to_work.status_code, 200)
|
||||||
|
|
||||||
|
set_second_amount = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"invoice_amount": 2500},
|
||||||
|
)
|
||||||
|
self.assertEqual(set_second_amount.status_code, 200)
|
||||||
|
|
||||||
|
second_billing = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"status_code": "BILLING"},
|
||||||
|
)
|
||||||
|
self.assertEqual(second_billing.status_code, 200)
|
||||||
|
|
||||||
|
second_paid = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"status_code": "PAID"},
|
||||||
|
)
|
||||||
|
self.assertEqual(second_paid.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = db.get(Request, UUID(request_id))
|
||||||
|
self.assertIsNotNone(req)
|
||||||
|
invoices = (
|
||||||
|
db.query(Invoice)
|
||||||
|
.filter(Invoice.request_id == req.id)
|
||||||
|
.order_by(Invoice.issued_at.asc(), Invoice.created_at.asc(), Invoice.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
self.assertEqual(len(invoices), 2)
|
||||||
|
self.assertEqual(invoices[0].status, "PAID")
|
||||||
|
self.assertEqual(invoices[1].status, "PAID")
|
||||||
|
self.assertIsNotNone(invoices[0].paid_at)
|
||||||
|
self.assertIsNotNone(invoices[1].paid_at)
|
||||||
|
self.assertAlmostEqual(float(invoices[0].amount or 0), 1100.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(invoices[1].amount or 0), 2500.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(req.invoice_amount or 0), 2500.0, places=2)
|
||||||
|
self.assertEqual(str(req.paid_at), str(invoices[1].paid_at))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
59
tests/test_http_hardening.py
Normal file
59
tests/test_http_hardening.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
class HttpHardeningTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def test_health_has_security_headers_and_request_id(self):
|
||||||
|
response = self.client.get("/health")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.assertEqual(response.headers.get("x-content-type-options"), "nosniff")
|
||||||
|
self.assertEqual(response.headers.get("x-frame-options"), "DENY")
|
||||||
|
self.assertEqual(response.headers.get("referrer-policy"), "no-referrer")
|
||||||
|
self.assertEqual(response.headers.get("x-permitted-cross-domain-policies"), "none")
|
||||||
|
self.assertEqual(response.headers.get("cross-origin-opener-policy"), "same-origin")
|
||||||
|
|
||||||
|
request_id = response.headers.get("x-request-id")
|
||||||
|
self.assertIsNotNone(request_id)
|
||||||
|
self.assertRegex(str(request_id), r"^[A-Za-z0-9._-]{1,128}$")
|
||||||
|
|
||||||
|
def test_valid_request_id_is_preserved(self):
|
||||||
|
external_request_id = "release-check-2026_02_23"
|
||||||
|
response = self.client.get("/health", headers={"X-Request-ID": external_request_id})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.headers.get("x-request-id"), external_request_id)
|
||||||
|
|
||||||
|
def test_invalid_request_id_is_replaced(self):
|
||||||
|
bad_request_id = "bad id with spaces"
|
||||||
|
response = self.client.get("/health", headers={"X-Request-ID": bad_request_id})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response_request_id = response.headers.get("x-request-id")
|
||||||
|
self.assertIsNotNone(response_request_id)
|
||||||
|
self.assertNotEqual(response_request_id, bad_request_id)
|
||||||
|
self.assertRegex(str(response_request_id), r"^[A-Za-z0-9._-]{1,128}$")
|
||||||
|
|
||||||
|
def test_error_response_keeps_security_headers_and_request_id(self):
|
||||||
|
# No public cookie => 401 from dependency, middleware headers must still be present.
|
||||||
|
response = self.client.get("/api/public/requests/TRK-UNKNOWN")
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
self.assertEqual(response.headers.get("x-content-type-options"), "nosniff")
|
||||||
|
self.assertEqual(response.headers.get("x-frame-options"), "DENY")
|
||||||
|
self.assertTrue(bool(response.headers.get("x-request-id")))
|
||||||
295
tests/test_invoices.py
Normal file
295
tests/test_invoices.py
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_jwt
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.invoice import Invoice
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.services.invoice_crypto import decrypt_requisites
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceApiTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Invoice.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
Invoice.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(Invoice))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(AdminUser))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
self.admin = AdminUser(
|
||||||
|
role="ADMIN",
|
||||||
|
name="Админ",
|
||||||
|
email="admin@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.lawyer_a = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист А",
|
||||||
|
email="lawyer-a@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.lawyer_b = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Б",
|
||||||
|
email="lawyer-b@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([self.admin, self.lawyer_a, self.lawyer_b])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
self.request_a = Request(
|
||||||
|
track_number="TRK-INV-A",
|
||||||
|
client_name="Клиент А",
|
||||||
|
client_phone="+79991110000",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
description="Заявка А",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(self.lawyer_a.id),
|
||||||
|
)
|
||||||
|
self.request_b = Request(
|
||||||
|
track_number="TRK-INV-B",
|
||||||
|
client_name="Клиент Б",
|
||||||
|
client_phone="+79992220000",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
description="Заявка Б",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(self.lawyer_b.id),
|
||||||
|
)
|
||||||
|
db.add_all([self.request_a, self.request_b])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
self.admin_id = str(self.admin.id)
|
||||||
|
self.lawyer_a_id = str(self.lawyer_a.id)
|
||||||
|
self.lawyer_b_id = str(self.lawyer_b.id)
|
||||||
|
self.request_a_id = str(self.request_a.id)
|
||||||
|
self.request_b_id = str(self.request_b.id)
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _admin_headers(sub: str, role: str, email: str) -> dict[str, str]:
|
||||||
|
token = create_jwt(
|
||||||
|
{"sub": str(sub), "email": email, "role": role},
|
||||||
|
settings.ADMIN_JWT_SECRET,
|
||||||
|
timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_cookie(track_number: str) -> dict[str, str]:
|
||||||
|
token = create_jwt(
|
||||||
|
{"sub": track_number, "purpose": "VIEW_REQUEST"},
|
||||||
|
settings.PUBLIC_JWT_SECRET,
|
||||||
|
timedelta(days=1),
|
||||||
|
)
|
||||||
|
return {settings.PUBLIC_COOKIE_NAME: token}
|
||||||
|
|
||||||
|
def test_admin_creates_invoice_and_data_is_encrypted(self):
|
||||||
|
headers = self._admin_headers(self.admin_id, "ADMIN", "admin@example.com")
|
||||||
|
payload = {
|
||||||
|
"request_id": self.request_a_id,
|
||||||
|
"amount": 12345.67,
|
||||||
|
"currency": "RUB",
|
||||||
|
"payer_display_name": 'ООО "Ромашка"',
|
||||||
|
"payer_details": {"inn": "7700000000", "kpp": "770001001"},
|
||||||
|
}
|
||||||
|
created = self.client.post("/api/admin/invoices", headers=headers, json=payload)
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
body = created.json()
|
||||||
|
self.assertEqual(body["request_id"], self.request_a_id)
|
||||||
|
self.assertEqual(body["request_track_number"], "TRK-INV-A")
|
||||||
|
self.assertEqual(body["status"], "WAITING_PAYMENT")
|
||||||
|
self.assertEqual(body["amount"], 12345.67)
|
||||||
|
self.assertTrue(str(body["invoice_number"]).startswith("INV-"))
|
||||||
|
|
||||||
|
invoice_id = body["id"]
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
row = db.get(Invoice, UUID(invoice_id))
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertIsNotNone(row.payer_details_encrypted)
|
||||||
|
self.assertNotIn("7700000000", str(row.payer_details_encrypted))
|
||||||
|
decrypted = decrypt_requisites(row.payer_details_encrypted)
|
||||||
|
self.assertEqual(decrypted["inn"], "7700000000")
|
||||||
|
self.assertEqual(decrypted["kpp"], "770001001")
|
||||||
|
|
||||||
|
def test_lawyer_scope_and_paid_restriction(self):
|
||||||
|
admin_headers = self._admin_headers(self.admin_id, "ADMIN", "admin@example.com")
|
||||||
|
lawyer_a_headers = self._admin_headers(self.lawyer_a_id, "LAWYER", "lawyer-a@example.com")
|
||||||
|
|
||||||
|
own_created = self.client.post(
|
||||||
|
"/api/admin/invoices",
|
||||||
|
headers=lawyer_a_headers,
|
||||||
|
json={
|
||||||
|
"request_id": self.request_a_id,
|
||||||
|
"amount": 5000,
|
||||||
|
"payer_display_name": "ИП Иванов",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(own_created.status_code, 201)
|
||||||
|
own_invoice_id = own_created.json()["id"]
|
||||||
|
|
||||||
|
blocked_paid_create = self.client.post(
|
||||||
|
"/api/admin/invoices",
|
||||||
|
headers=lawyer_a_headers,
|
||||||
|
json={
|
||||||
|
"request_id": self.request_a_id,
|
||||||
|
"amount": 6000,
|
||||||
|
"status": "PAID",
|
||||||
|
"payer_display_name": "ИП Иванов",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked_paid_create.status_code, 403)
|
||||||
|
|
||||||
|
blocked_paid_update = self.client.patch(
|
||||||
|
f"/api/admin/invoices/{own_invoice_id}",
|
||||||
|
headers=lawyer_a_headers,
|
||||||
|
json={"status": "PAID"},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked_paid_update.status_code, 403)
|
||||||
|
|
||||||
|
foreign_created = self.client.post(
|
||||||
|
"/api/admin/invoices",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"request_id": self.request_b_id, "amount": 7000, "payer_display_name": "ООО Бета"},
|
||||||
|
)
|
||||||
|
self.assertEqual(foreign_created.status_code, 201)
|
||||||
|
foreign_invoice_id = foreign_created.json()["id"]
|
||||||
|
|
||||||
|
listed = self.client.post(
|
||||||
|
"/api/admin/invoices/query",
|
||||||
|
headers=lawyer_a_headers,
|
||||||
|
json={"filters": [], "sort": [{"field": "created_at", "dir": "desc"}], "page": {"limit": 50, "offset": 0}},
|
||||||
|
)
|
||||||
|
self.assertEqual(listed.status_code, 200)
|
||||||
|
rows = listed.json()["rows"]
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]["id"], own_invoice_id)
|
||||||
|
|
||||||
|
foreign_get = self.client.get(f"/api/admin/invoices/{foreign_invoice_id}", headers=lawyer_a_headers)
|
||||||
|
self.assertEqual(foreign_get.status_code, 403)
|
||||||
|
|
||||||
|
foreign_pdf = self.client.get(f"/api/admin/invoices/{foreign_invoice_id}/pdf", headers=lawyer_a_headers)
|
||||||
|
self.assertEqual(foreign_pdf.status_code, 403)
|
||||||
|
|
||||||
|
def test_admin_marks_invoice_paid_and_request_is_updated(self):
|
||||||
|
headers = self._admin_headers(self.admin_id, "ADMIN", "admin@example.com")
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/admin/invoices",
|
||||||
|
headers=headers,
|
||||||
|
json={"request_id": self.request_a_id, "amount": 10000, "payer_display_name": "ООО Плательщик"},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
invoice_id = created.json()["id"]
|
||||||
|
|
||||||
|
paid = self.client.patch(
|
||||||
|
f"/api/admin/invoices/{invoice_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"status": "PAID"},
|
||||||
|
)
|
||||||
|
self.assertEqual(paid.status_code, 200)
|
||||||
|
paid_body = paid.json()
|
||||||
|
self.assertEqual(paid_body["status"], "PAID")
|
||||||
|
self.assertIsNotNone(paid_body["paid_at"])
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = db.get(Request, UUID(self.request_a_id))
|
||||||
|
self.assertIsNotNone(req)
|
||||||
|
self.assertEqual(float(req.invoice_amount or 0), 10000.0)
|
||||||
|
self.assertIsNotNone(req.paid_at)
|
||||||
|
self.assertEqual(req.paid_by_admin_id, self.admin_id)
|
||||||
|
|
||||||
|
def test_public_invoice_list_and_pdf_available_in_cabinet(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
row = Invoice(
|
||||||
|
request_id=UUID(self.request_a_id),
|
||||||
|
invoice_number=f"INV-TEST-{uuid4().hex[:6].upper()}",
|
||||||
|
status="WAITING_PAYMENT",
|
||||||
|
amount=9900,
|
||||||
|
currency="RUB",
|
||||||
|
payer_display_name="ООО Клиент",
|
||||||
|
payer_details_encrypted="",
|
||||||
|
issued_by_admin_user_id=UUID(self.admin_id),
|
||||||
|
issued_by_role="ADMIN",
|
||||||
|
issued_at=db.get(Request, UUID(self.request_a_id)).created_at,
|
||||||
|
responsible="admin@example.com",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
invoice_id = str(row.id)
|
||||||
|
|
||||||
|
unauthorized = self.client.get("/api/public/requests/TRK-INV-A/invoices")
|
||||||
|
self.assertEqual(unauthorized.status_code, 401)
|
||||||
|
|
||||||
|
cookies = self._public_cookie("TRK-INV-A")
|
||||||
|
listed = self.client.get("/api/public/requests/TRK-INV-A/invoices", cookies=cookies)
|
||||||
|
self.assertEqual(listed.status_code, 200)
|
||||||
|
rows = listed.json()
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]["id"], invoice_id)
|
||||||
|
self.assertIn("/api/public/requests/TRK-INV-A/invoices/", rows[0]["download_url"])
|
||||||
|
|
||||||
|
pdf = self.client.get(f"/api/public/requests/TRK-INV-A/invoices/{invoice_id}/pdf", cookies=cookies)
|
||||||
|
self.assertEqual(pdf.status_code, 200)
|
||||||
|
self.assertEqual(pdf.headers.get("content-type"), "application/pdf")
|
||||||
|
self.assertTrue(pdf.content.startswith(b"%PDF"))
|
||||||
|
|
||||||
|
denied = self.client.get(
|
||||||
|
f"/api/public/requests/TRK-INV-A/invoices/{invoice_id}/pdf",
|
||||||
|
cookies=self._public_cookie("TRK-INV-B"),
|
||||||
|
)
|
||||||
|
self.assertEqual(denied.status_code, 403)
|
||||||
|
|
@ -96,6 +96,8 @@ class MigrationTests(unittest.TestCase):
|
||||||
"admin_user_topics",
|
"admin_user_topics",
|
||||||
"topic_status_transitions",
|
"topic_status_transitions",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"invoices",
|
||||||
|
"security_audit_log",
|
||||||
"alembic_version",
|
"alembic_version",
|
||||||
}
|
}
|
||||||
tables = set(self.inspector.get_table_names())
|
tables = set(self.inspector.get_table_names())
|
||||||
|
|
@ -104,7 +106,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_alembic_version_is_set(self):
|
def test_alembic_version_is_set(self):
|
||||||
with self.engine.connect() as conn:
|
with self.engine.connect() as conn:
|
||||||
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
||||||
self.assertEqual(version, "0011_dashboard_financial_fields")
|
self.assertEqual(version, "0014_security_audit_log")
|
||||||
|
|
||||||
def test_responsible_column_exists_in_all_domain_tables(self):
|
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||||
tables = {
|
tables = {
|
||||||
|
|
@ -125,6 +127,8 @@ class MigrationTests(unittest.TestCase):
|
||||||
"admin_user_topics",
|
"admin_user_topics",
|
||||||
"topic_status_transitions",
|
"topic_status_transitions",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"invoices",
|
||||||
|
"security_audit_log",
|
||||||
}
|
}
|
||||||
for table in tables:
|
for table in tables:
|
||||||
columns = {column["name"] for column in self.inspector.get_columns(table)}
|
columns = {column["name"] for column in self.inspector.get_columns(table)}
|
||||||
|
|
@ -171,3 +175,22 @@ class MigrationTests(unittest.TestCase):
|
||||||
self.assertIn("invoice_amount", columns)
|
self.assertIn("invoice_amount", columns)
|
||||||
self.assertIn("paid_at", columns)
|
self.assertIn("paid_at", columns)
|
||||||
self.assertIn("paid_by_admin_id", columns)
|
self.assertIn("paid_by_admin_id", columns)
|
||||||
|
|
||||||
|
def test_invoices_contains_core_columns(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("invoices")}
|
||||||
|
self.assertIn("request_id", columns)
|
||||||
|
self.assertIn("invoice_number", columns)
|
||||||
|
self.assertIn("status", columns)
|
||||||
|
self.assertIn("amount", columns)
|
||||||
|
self.assertIn("currency", columns)
|
||||||
|
self.assertIn("payer_display_name", columns)
|
||||||
|
self.assertIn("payer_details_encrypted", columns)
|
||||||
|
self.assertIn("issued_by_admin_user_id", columns)
|
||||||
|
self.assertIn("issued_by_role", columns)
|
||||||
|
self.assertIn("issued_at", columns)
|
||||||
|
self.assertIn("paid_at", columns)
|
||||||
|
|
||||||
|
def test_statuses_contains_billing_columns(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("statuses")}
|
||||||
|
self.assertIn("kind", columns)
|
||||||
|
self.assertIn("invoice_template", columns)
|
||||||
|
|
|
||||||
141
tests/test_otp_rate_limit.py
Normal file
141
tests/test_otp_rate_limit.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.otp_session import OtpSession
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.services.rate_limit import InMemoryRateLimiter
|
||||||
|
|
||||||
|
|
||||||
|
class OtpRateLimitTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
OtpSession.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
OtpSession.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(OtpSession))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
Request(
|
||||||
|
track_number="TRK-OTP-RATE",
|
||||||
|
client_name="Тест",
|
||||||
|
client_phone="+79995550000",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
description="otp rate",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(app)
|
||||||
|
self.limiter = InMemoryRateLimiter()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_send_is_limited_by_phone(self):
|
||||||
|
with (
|
||||||
|
patch("app.api.public.otp.get_rate_limiter", return_value=self.limiter),
|
||||||
|
patch("app.api.public.otp.settings.OTP_RATE_LIMIT_WINDOW_SECONDS", 60),
|
||||||
|
patch("app.api.public.otp.settings.OTP_SEND_RATE_LIMIT", 1),
|
||||||
|
patch("app.api.public.otp._generate_code", return_value="111111"),
|
||||||
|
):
|
||||||
|
first = self.client.post(
|
||||||
|
"/api/public/otp/send",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": "+79991110000"},
|
||||||
|
)
|
||||||
|
self.assertEqual(first.status_code, 200)
|
||||||
|
|
||||||
|
second = self.client.post(
|
||||||
|
"/api/public/otp/send",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": "+79991110000"},
|
||||||
|
)
|
||||||
|
self.assertEqual(second.status_code, 429)
|
||||||
|
self.assertIn("Слишком много OTP-запросов", second.json().get("detail", ""))
|
||||||
|
|
||||||
|
def test_send_is_limited_by_ip(self):
|
||||||
|
with (
|
||||||
|
patch("app.api.public.otp.get_rate_limiter", return_value=self.limiter),
|
||||||
|
patch("app.api.public.otp.settings.OTP_RATE_LIMIT_WINDOW_SECONDS", 60),
|
||||||
|
patch("app.api.public.otp.settings.OTP_SEND_RATE_LIMIT", 1),
|
||||||
|
patch("app.api.public.otp._generate_code", return_value="111111"),
|
||||||
|
):
|
||||||
|
first = self.client.post(
|
||||||
|
"/api/public/otp/send",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": "+79991110001"},
|
||||||
|
)
|
||||||
|
self.assertEqual(first.status_code, 200)
|
||||||
|
|
||||||
|
# Same IP (testclient), other phone => blocked by IP bucket.
|
||||||
|
second = self.client.post(
|
||||||
|
"/api/public/otp/send",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": "+79991110002"},
|
||||||
|
)
|
||||||
|
self.assertEqual(second.status_code, 429)
|
||||||
|
|
||||||
|
def test_verify_is_limited(self):
|
||||||
|
with (
|
||||||
|
patch("app.api.public.otp.get_rate_limiter", return_value=self.limiter),
|
||||||
|
patch("app.api.public.otp.settings.OTP_RATE_LIMIT_WINDOW_SECONDS", 60),
|
||||||
|
patch("app.api.public.otp.settings.OTP_SEND_RATE_LIMIT", 10),
|
||||||
|
patch("app.api.public.otp.settings.OTP_VERIFY_RATE_LIMIT", 1),
|
||||||
|
patch("app.api.public.otp._generate_code", return_value="222222"),
|
||||||
|
):
|
||||||
|
sent = self.client.post(
|
||||||
|
"/api/public/otp/send",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": "+79992220000"},
|
||||||
|
)
|
||||||
|
self.assertEqual(sent.status_code, 200)
|
||||||
|
|
||||||
|
wrong_first = self.client.post(
|
||||||
|
"/api/public/otp/verify",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": "+79992220000", "code": "000000"},
|
||||||
|
)
|
||||||
|
self.assertEqual(wrong_first.status_code, 400)
|
||||||
|
|
||||||
|
wrong_second = self.client.post(
|
||||||
|
"/api/public/otp/verify",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": "+79992220000", "code": "111111"},
|
||||||
|
)
|
||||||
|
self.assertEqual(wrong_second.status_code, 429)
|
||||||
|
self.assertIn("Слишком много OTP-запросов", wrong_second.json().get("detail", ""))
|
||||||
508
tests/test_rates.py
Normal file
508
tests/test_rates.py
Normal file
|
|
@ -0,0 +1,508 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_jwt
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.admin_user_topic import AdminUserTopic
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.models.topic_required_field import TopicRequiredField
|
||||||
|
from app.workers.tasks import assign as assign_task
|
||||||
|
|
||||||
|
|
||||||
|
class RequestRatesTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
AdminUserTopic.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Status.__table__.create(bind=cls.engine)
|
||||||
|
TopicRequiredField.__table__.create(bind=cls.engine)
|
||||||
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
AuditLog.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
cls._old_session_local = assign_task.SessionLocal
|
||||||
|
assign_task.SessionLocal = cls.SessionLocal
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
assign_task.SessionLocal = cls._old_session_local
|
||||||
|
AuditLog.__table__.drop(bind=cls.engine)
|
||||||
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
TopicRequiredField.__table__.drop(bind=cls.engine)
|
||||||
|
Status.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUserTopic.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(TopicRequiredField))
|
||||||
|
db.execute(delete(Status))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(AdminUserTopic))
|
||||||
|
db.execute(delete(AdminUser))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _auth_headers(role: str, email: str, sub: str | None = None) -> dict[str, str]:
|
||||||
|
token = create_jwt(
|
||||||
|
{"sub": str(sub or uuid4()), "email": email, "role": role},
|
||||||
|
settings.ADMIN_JWT_SECRET,
|
||||||
|
timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
def test_claim_sets_effective_rate_from_lawyer_profile(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист",
|
||||||
|
email="lawyer-rate@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
default_rate=5000,
|
||||||
|
)
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-RATE-CLAIM-1",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000001",
|
||||||
|
status_code="NEW",
|
||||||
|
description="claim",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add_all([lawyer, req])
|
||||||
|
db.commit()
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
request_id = str(req.id)
|
||||||
|
|
||||||
|
headers = self._auth_headers("LAWYER", "lawyer-rate@example.com", sub=lawyer_id)
|
||||||
|
response = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=headers)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
row = db.get(Request, UUID(request_id))
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row.assigned_lawyer_id, lawyer_id)
|
||||||
|
self.assertAlmostEqual(float(row.effective_rate or 0), 5000.0, places=2)
|
||||||
|
|
||||||
|
def test_claim_keeps_existing_effective_rate(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист",
|
||||||
|
email="lawyer-fixed@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
default_rate=5000,
|
||||||
|
)
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-RATE-CLAIM-2",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000002",
|
||||||
|
status_code="NEW",
|
||||||
|
description="claim fixed",
|
||||||
|
extra_fields={},
|
||||||
|
effective_rate=7777,
|
||||||
|
)
|
||||||
|
db.add_all([lawyer, req])
|
||||||
|
db.commit()
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
request_id = str(req.id)
|
||||||
|
|
||||||
|
headers = self._auth_headers("LAWYER", "lawyer-fixed@example.com", sub=lawyer_id)
|
||||||
|
response = self.client.post(f"/api/admin/requests/{request_id}/claim", headers=headers)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
row = db.get(Request, UUID(request_id))
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertAlmostEqual(float(row.effective_rate or 0), 7777.0, places=2)
|
||||||
|
|
||||||
|
def test_reassign_sets_effective_rate_only_when_missing(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
from_lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="From",
|
||||||
|
email="from-rate@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
default_rate=1000,
|
||||||
|
)
|
||||||
|
to_lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="To",
|
||||||
|
email="to-rate@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
default_rate=9000,
|
||||||
|
)
|
||||||
|
db.add_all([from_lawyer, to_lawyer])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
fixed_req = Request(
|
||||||
|
track_number="TRK-RATE-REASSIGN-1",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000003",
|
||||||
|
status_code="NEW",
|
||||||
|
description="fixed",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(from_lawyer.id),
|
||||||
|
effective_rate=6500,
|
||||||
|
)
|
||||||
|
missing_req = Request(
|
||||||
|
track_number="TRK-RATE-REASSIGN-2",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000004",
|
||||||
|
status_code="NEW",
|
||||||
|
description="missing",
|
||||||
|
extra_fields={},
|
||||||
|
assigned_lawyer_id=str(from_lawyer.id),
|
||||||
|
effective_rate=None,
|
||||||
|
)
|
||||||
|
db.add_all([fixed_req, missing_req])
|
||||||
|
db.commit()
|
||||||
|
to_lawyer_id = str(to_lawyer.id)
|
||||||
|
fixed_id = str(fixed_req.id)
|
||||||
|
missing_id = str(missing_req.id)
|
||||||
|
|
||||||
|
admin_headers = self._auth_headers("ADMIN", "root@example.com")
|
||||||
|
fixed_reassign = self.client.post(
|
||||||
|
f"/api/admin/requests/{fixed_id}/reassign",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"lawyer_id": to_lawyer_id},
|
||||||
|
)
|
||||||
|
self.assertEqual(fixed_reassign.status_code, 200)
|
||||||
|
|
||||||
|
missing_reassign = self.client.post(
|
||||||
|
f"/api/admin/requests/{missing_id}/reassign",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"lawyer_id": to_lawyer_id},
|
||||||
|
)
|
||||||
|
self.assertEqual(missing_reassign.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
fixed = db.get(Request, UUID(fixed_id))
|
||||||
|
missing = db.get(Request, UUID(missing_id))
|
||||||
|
self.assertIsNotNone(fixed)
|
||||||
|
self.assertIsNotNone(missing)
|
||||||
|
self.assertAlmostEqual(float(fixed.effective_rate or 0), 6500.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(missing.effective_rate or 0), 9000.0, places=2)
|
||||||
|
|
||||||
|
def test_auto_assign_sets_effective_rate_when_missing(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Status(code="NEW", name="Новая", enabled=True, sort_order=0, is_terminal=False),
|
||||||
|
Status(code="CLOSED", name="Закрыта", enabled=True, sort_order=99, is_terminal=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Auto",
|
||||||
|
email="auto-rate@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
primary_topic_code="family",
|
||||||
|
default_rate=4200,
|
||||||
|
)
|
||||||
|
db.add(lawyer)
|
||||||
|
db.flush()
|
||||||
|
req_missing = Request(
|
||||||
|
track_number="TRK-RATE-AUTO-1",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000005",
|
||||||
|
topic_code="family",
|
||||||
|
status_code="NEW",
|
||||||
|
description="auto-missing",
|
||||||
|
extra_fields={},
|
||||||
|
created_at=now - timedelta(hours=30),
|
||||||
|
updated_at=now - timedelta(hours=30),
|
||||||
|
)
|
||||||
|
req_fixed = Request(
|
||||||
|
track_number="TRK-RATE-AUTO-2",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000006",
|
||||||
|
topic_code="family",
|
||||||
|
status_code="NEW",
|
||||||
|
description="auto-fixed",
|
||||||
|
extra_fields={},
|
||||||
|
effective_rate=3333,
|
||||||
|
created_at=now - timedelta(hours=29),
|
||||||
|
updated_at=now - timedelta(hours=29),
|
||||||
|
)
|
||||||
|
db.add_all([req_missing, req_fixed])
|
||||||
|
db.commit()
|
||||||
|
missing_id = str(req_missing.id)
|
||||||
|
fixed_id = str(req_fixed.id)
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
|
||||||
|
result = assign_task.auto_assign_unclaimed()
|
||||||
|
self.assertEqual(result["assigned"], 2)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
missing = db.get(Request, UUID(missing_id))
|
||||||
|
fixed = db.get(Request, UUID(fixed_id))
|
||||||
|
self.assertIsNotNone(missing)
|
||||||
|
self.assertIsNotNone(fixed)
|
||||||
|
self.assertEqual(missing.assigned_lawyer_id, lawyer_id)
|
||||||
|
self.assertEqual(fixed.assigned_lawyer_id, lawyer_id)
|
||||||
|
self.assertAlmostEqual(float(missing.effective_rate or 0), 4200.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(fixed.effective_rate or 0), 3333.0, places=2)
|
||||||
|
|
||||||
|
def test_lawyer_cannot_write_financial_fields(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Lawyer",
|
||||||
|
email="lawyer-finance@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(lawyer)
|
||||||
|
db.commit()
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
|
||||||
|
headers = self._auth_headers("LAWYER", "lawyer-finance@example.com", sub=lawyer_id)
|
||||||
|
|
||||||
|
blocked_create_legacy = self.client.post(
|
||||||
|
"/api/admin/requests",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"client_name": "Клиент",
|
||||||
|
"client_phone": "+79990000007",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "legacy",
|
||||||
|
"effective_rate": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked_create_legacy.status_code, 403)
|
||||||
|
|
||||||
|
blocked_create_crud = self.client.post(
|
||||||
|
"/api/admin/crud/requests",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"client_name": "Клиент",
|
||||||
|
"client_phone": "+79990000008",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "crud",
|
||||||
|
"invoice_amount": 500,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked_create_crud.status_code, 403)
|
||||||
|
|
||||||
|
created = self.client.post(
|
||||||
|
"/api/admin/requests",
|
||||||
|
headers=headers,
|
||||||
|
json={
|
||||||
|
"client_name": "Клиент",
|
||||||
|
"client_phone": "+79990000009",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "allowed",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
request_id = created.json()["id"]
|
||||||
|
|
||||||
|
blocked_patch_legacy = self.client.patch(
|
||||||
|
f"/api/admin/requests/{request_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"effective_rate": 200},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked_patch_legacy.status_code, 403)
|
||||||
|
|
||||||
|
blocked_patch_crud = self.client.patch(
|
||||||
|
f"/api/admin/crud/requests/{request_id}",
|
||||||
|
headers=headers,
|
||||||
|
json={"invoice_amount": 900},
|
||||||
|
)
|
||||||
|
self.assertEqual(blocked_patch_crud.status_code, 403)
|
||||||
|
|
||||||
|
def test_admin_assignment_autofills_effective_rate_in_create_and_update(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Rate",
|
||||||
|
email="admin-assign-rate@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
default_rate=6100,
|
||||||
|
)
|
||||||
|
db.add(lawyer)
|
||||||
|
db.commit()
|
||||||
|
lawyer_id = str(lawyer.id)
|
||||||
|
|
||||||
|
admin_headers = self._auth_headers("ADMIN", "root@example.com")
|
||||||
|
|
||||||
|
created_legacy = self.client.post(
|
||||||
|
"/api/admin/requests",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"client_name": "Клиент A",
|
||||||
|
"client_phone": "+79990000010",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "legacy create",
|
||||||
|
"assigned_lawyer_id": lawyer_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created_legacy.status_code, 201)
|
||||||
|
legacy_id = created_legacy.json()["id"]
|
||||||
|
|
||||||
|
created_crud = self.client.post(
|
||||||
|
"/api/admin/crud/requests",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"client_name": "Клиент B",
|
||||||
|
"client_phone": "+79990000011",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "crud create",
|
||||||
|
"assigned_lawyer_id": lawyer_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created_crud.status_code, 201)
|
||||||
|
crud_id = created_crud.json()["id"]
|
||||||
|
|
||||||
|
created_manual = self.client.post(
|
||||||
|
"/api/admin/requests",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"client_name": "Клиент C",
|
||||||
|
"client_phone": "+79990000012",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "manual rate",
|
||||||
|
"assigned_lawyer_id": lawyer_id,
|
||||||
|
"effective_rate": 7300,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created_manual.status_code, 201)
|
||||||
|
manual_id = created_manual.json()["id"]
|
||||||
|
|
||||||
|
created_unassigned_legacy = self.client.post(
|
||||||
|
"/api/admin/requests",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"client_name": "Клиент D",
|
||||||
|
"client_phone": "+79990000013",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "legacy update",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created_unassigned_legacy.status_code, 201)
|
||||||
|
unassigned_legacy_id = created_unassigned_legacy.json()["id"]
|
||||||
|
|
||||||
|
created_unassigned_crud = self.client.post(
|
||||||
|
"/api/admin/crud/requests",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"client_name": "Клиент E",
|
||||||
|
"client_phone": "+79990000014",
|
||||||
|
"status_code": "NEW",
|
||||||
|
"description": "crud update",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(created_unassigned_crud.status_code, 201)
|
||||||
|
unassigned_crud_id = created_unassigned_crud.json()["id"]
|
||||||
|
|
||||||
|
patched_legacy = self.client.patch(
|
||||||
|
f"/api/admin/requests/{unassigned_legacy_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"assigned_lawyer_id": lawyer_id},
|
||||||
|
)
|
||||||
|
self.assertEqual(patched_legacy.status_code, 200)
|
||||||
|
|
||||||
|
patched_crud = self.client.patch(
|
||||||
|
f"/api/admin/crud/requests/{unassigned_crud_id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"assigned_lawyer_id": lawyer_id},
|
||||||
|
)
|
||||||
|
self.assertEqual(patched_crud.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
legacy_row = db.get(Request, UUID(legacy_id))
|
||||||
|
crud_row = db.get(Request, UUID(crud_id))
|
||||||
|
manual_row = db.get(Request, UUID(manual_id))
|
||||||
|
patched_legacy_row = db.get(Request, UUID(unassigned_legacy_id))
|
||||||
|
patched_crud_row = db.get(Request, UUID(unassigned_crud_id))
|
||||||
|
|
||||||
|
self.assertAlmostEqual(float(legacy_row.effective_rate or 0), 6100.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(crud_row.effective_rate or 0), 6100.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(manual_row.effective_rate or 0), 7300.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(patched_legacy_row.effective_rate or 0), 6100.0, places=2)
|
||||||
|
self.assertAlmostEqual(float(patched_crud_row.effective_rate or 0), 6100.0, places=2)
|
||||||
|
|
||||||
|
def test_public_request_read_does_not_expose_financial_fields(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-RATE-PUBLIC-1",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990000015",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
description="public",
|
||||||
|
extra_fields={},
|
||||||
|
effective_rate=8800,
|
||||||
|
invoice_amount=12500,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
public_token = create_jwt(
|
||||||
|
{"sub": "TRK-RATE-PUBLIC-1", "purpose": "VIEW_REQUEST"},
|
||||||
|
settings.PUBLIC_JWT_SECRET,
|
||||||
|
timedelta(days=1),
|
||||||
|
)
|
||||||
|
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
|
||||||
|
|
||||||
|
response = self.client.get("/api/public/requests/TRK-RATE-PUBLIC-1", cookies=cookies)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertNotIn("effective_rate", body)
|
||||||
|
self.assertNotIn("invoice_amount", body)
|
||||||
|
self.assertNotIn("paid_at", body)
|
||||||
|
self.assertNotIn("paid_by_admin_id", body)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
226
tests/test_security_audit.py
Normal file
226
tests/test_security_audit.py
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, delete
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import create_jwt
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.security_audit_log import SecurityAuditLog
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBody:
|
||||||
|
def __init__(self, payload: bytes):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def iter_chunks(self, chunk_size=65536):
|
||||||
|
for i in range(0, len(self.payload), chunk_size):
|
||||||
|
yield self.payload[i : i + chunk_size]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeS3Storage:
|
||||||
|
def __init__(self):
|
||||||
|
self.objects = {}
|
||||||
|
|
||||||
|
def get_object(self, key: str) -> dict:
|
||||||
|
obj = self.objects.get(key)
|
||||||
|
if obj is None:
|
||||||
|
raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "GetObject")
|
||||||
|
return {"Body": _FakeBody(obj["content"]), "ContentType": obj["mime"], "ContentLength": obj["size"]}
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAuditTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Notification.__table__.create(bind=cls.engine)
|
||||||
|
Message.__table__.create(bind=cls.engine)
|
||||||
|
Attachment.__table__.create(bind=cls.engine)
|
||||||
|
SecurityAuditLog.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
SecurityAuditLog.__table__.drop(bind=cls.engine)
|
||||||
|
Attachment.__table__.drop(bind=cls.engine)
|
||||||
|
Message.__table__.drop(bind=cls.engine)
|
||||||
|
Notification.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(SecurityAuditLog))
|
||||||
|
db.execute(delete(Notification))
|
||||||
|
db.execute(delete(Attachment))
|
||||||
|
db.execute(delete(Message))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(AdminUser))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def override_get_db():
|
||||||
|
db = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
self.client = TestClient(app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _admin_headers(sub: str, role: str = "ADMIN", email: str = "admin@example.com") -> dict[str, str]:
|
||||||
|
token = create_jwt(
|
||||||
|
{"sub": sub, "email": email, "role": role},
|
||||||
|
settings.ADMIN_JWT_SECRET,
|
||||||
|
timedelta(minutes=30),
|
||||||
|
)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
def test_public_attachment_download_writes_security_allow_event(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-SEC-PUB-1",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990001010",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
key = f"requests/{req.id}/doc.pdf"
|
||||||
|
att = Attachment(
|
||||||
|
request_id=req.id,
|
||||||
|
message_id=None,
|
||||||
|
file_name="doc.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
size_bytes=1024,
|
||||||
|
s3_key=key,
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
db.add(att)
|
||||||
|
db.commit()
|
||||||
|
attachment_id = str(att.id)
|
||||||
|
track_number = req.track_number
|
||||||
|
|
||||||
|
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
|
||||||
|
public_token = create_jwt({"sub": track_number, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1))
|
||||||
|
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
|
||||||
|
|
||||||
|
with patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
response = self.client.get(f"/api/public/uploads/object/{attachment_id}", cookies=cookies)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.content, b"x" * 1024)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
rows = (
|
||||||
|
db.query(SecurityAuditLog)
|
||||||
|
.filter(SecurityAuditLog.action == "DOWNLOAD_OBJECT", SecurityAuditLog.actor_role == "CLIENT")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
row = rows[0]
|
||||||
|
self.assertTrue(row.allowed)
|
||||||
|
self.assertEqual(row.object_key, key)
|
||||||
|
self.assertEqual(str(row.attachment_id), attachment_id)
|
||||||
|
self.assertEqual(row.scope, "REQUEST_ATTACHMENT")
|
||||||
|
|
||||||
|
def test_admin_object_proxy_denied_writes_security_deny_event(self):
|
||||||
|
fake_s3 = _FakeS3Storage()
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
lawyer_a = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист А",
|
||||||
|
email="sec-lawyer-a@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
lawyer_b = AdminUser(
|
||||||
|
role="LAWYER",
|
||||||
|
name="Юрист Б",
|
||||||
|
email="sec-lawyer-b@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add_all([lawyer_a, lawyer_b])
|
||||||
|
db.flush()
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-SEC-ADM-1",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990002020",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="IN_PROGRESS",
|
||||||
|
assigned_lawyer_id=str(lawyer_b.id),
|
||||||
|
extra_fields={},
|
||||||
|
total_attachments_bytes=0,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
key = f"requests/{req.id}/proof.pdf"
|
||||||
|
db.add(
|
||||||
|
Attachment(
|
||||||
|
request_id=req.id,
|
||||||
|
file_name="proof.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
size_bytes=1024,
|
||||||
|
s3_key=key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
lawyer_a_id = str(lawyer_a.id)
|
||||||
|
|
||||||
|
token = self._admin_headers(sub=lawyer_a_id, role="LAWYER", email="sec-lawyer-a@example.com")["Authorization"].replace(
|
||||||
|
"Bearer ", ""
|
||||||
|
)
|
||||||
|
fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024}
|
||||||
|
with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3):
|
||||||
|
response = self.client.get(f"/api/admin/uploads/object/{key}?token={token}")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
rows = (
|
||||||
|
db.query(SecurityAuditLog)
|
||||||
|
.filter(SecurityAuditLog.action == "DOWNLOAD_OBJECT", SecurityAuditLog.actor_role == "LAWYER")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
row = rows[0]
|
||||||
|
self.assertFalse(row.allowed)
|
||||||
|
self.assertEqual(row.object_key, key)
|
||||||
|
self.assertIn("Недостаточно прав", str(row.reason or ""))
|
||||||
|
self.assertEqual(str(row.request_id), key.split("/")[1])
|
||||||
|
UUID(str(row.id))
|
||||||
|
|
@ -28,6 +28,16 @@
|
||||||
CLOSED: "\u0417\u0430\u043A\u0440\u044B\u0442\u0430",
|
CLOSED: "\u0417\u0430\u043A\u0440\u044B\u0442\u0430",
|
||||||
REJECTED: "\u041E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u0430"
|
REJECTED: "\u041E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u0430"
|
||||||
};
|
};
|
||||||
|
const INVOICE_STATUS_LABELS = {
|
||||||
|
WAITING_PAYMENT: "\u041E\u0436\u0438\u0434\u0430\u0435\u0442 \u043E\u043F\u043B\u0430\u0442\u0443",
|
||||||
|
PAID: "\u041E\u043F\u043B\u0430\u0447\u0435\u043D",
|
||||||
|
CANCELED: "\u041E\u0442\u043C\u0435\u043D\u0435\u043D"
|
||||||
|
};
|
||||||
|
const STATUS_KIND_LABELS = {
|
||||||
|
DEFAULT: "\u041E\u0431\u044B\u0447\u043D\u044B\u0439",
|
||||||
|
INVOICE: "\u0412\u044B\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u0441\u0447\u0435\u0442\u0430",
|
||||||
|
PAID: "\u041E\u043F\u043B\u0430\u0447\u0435\u043D\u043E"
|
||||||
|
};
|
||||||
const REQUEST_UPDATE_EVENT_LABELS = {
|
const REQUEST_UPDATE_EVENT_LABELS = {
|
||||||
MESSAGE: "\u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435",
|
MESSAGE: "\u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u0435",
|
||||||
ATTACHMENT: "\u0444\u0430\u0439\u043B",
|
ATTACHMENT: "\u0444\u0430\u0439\u043B",
|
||||||
|
|
@ -39,6 +49,11 @@
|
||||||
endpoint: "/api/admin/crud/requests/query",
|
endpoint: "/api/admin/crud/requests/query",
|
||||||
sort: [{ field: "created_at", dir: "desc" }]
|
sort: [{ field: "created_at", dir: "desc" }]
|
||||||
},
|
},
|
||||||
|
invoices: {
|
||||||
|
table: "invoices",
|
||||||
|
endpoint: "/api/admin/invoices/query",
|
||||||
|
sort: [{ field: "issued_at", dir: "desc" }]
|
||||||
|
},
|
||||||
quotes: {
|
quotes: {
|
||||||
table: "quotes",
|
table: "quotes",
|
||||||
endpoint: "/api/admin/crud/quotes/query",
|
endpoint: "/api/admin/crud/quotes/query",
|
||||||
|
|
@ -95,6 +110,31 @@
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
TABLE_MUTATION_CONFIG.invoices = {
|
||||||
|
create: "/api/admin/invoices",
|
||||||
|
update: (id) => "/api/admin/invoices/" + id,
|
||||||
|
delete: (id) => "/api/admin/invoices/" + id
|
||||||
|
};
|
||||||
|
const TABLE_KEY_ALIASES = {
|
||||||
|
form_fields: "formFields",
|
||||||
|
topic_required_fields: "topicRequiredFields",
|
||||||
|
topic_data_templates: "topicDataTemplates",
|
||||||
|
topic_status_transitions: "statusTransitions",
|
||||||
|
admin_users: "users",
|
||||||
|
admin_user_topics: "userTopics"
|
||||||
|
};
|
||||||
|
const TABLE_UNALIASES = Object.fromEntries(Object.entries(TABLE_KEY_ALIASES).map(([table, alias]) => [alias, table]));
|
||||||
|
const KNOWN_CONFIG_TABLE_KEYS = /* @__PURE__ */ new Set([
|
||||||
|
"quotes",
|
||||||
|
"topics",
|
||||||
|
"statuses",
|
||||||
|
"formFields",
|
||||||
|
"topicRequiredFields",
|
||||||
|
"topicDataTemplates",
|
||||||
|
"statusTransitions",
|
||||||
|
"users",
|
||||||
|
"userTopics"
|
||||||
|
]);
|
||||||
function createTableState() {
|
function createTableState() {
|
||||||
return {
|
return {
|
||||||
filters: [],
|
filters: [],
|
||||||
|
|
@ -105,6 +145,23 @@
|
||||||
rows: []
|
rows: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function humanizeKey(value) {
|
||||||
|
const text = String(value || "").replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
if (!text) return "-";
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
function metaKindToFilterType(kind) {
|
||||||
|
if (kind === "boolean") return "boolean";
|
||||||
|
if (kind === "number") return "number";
|
||||||
|
if (kind === "date" || kind === "datetime") return "date";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
function metaKindToRecordType(kind) {
|
||||||
|
if (kind === "boolean") return "boolean";
|
||||||
|
if (kind === "number") return "number";
|
||||||
|
if (kind === "json") return "json";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
function decodeJwtPayload(token) {
|
function decodeJwtPayload(token) {
|
||||||
try {
|
try {
|
||||||
const payload = token.split(".")[1] || "";
|
const payload = token.split(".")[1] || "";
|
||||||
|
|
@ -126,6 +183,12 @@
|
||||||
function statusLabel(code) {
|
function statusLabel(code) {
|
||||||
return STATUS_LABELS[code] || code || "-";
|
return STATUS_LABELS[code] || code || "-";
|
||||||
}
|
}
|
||||||
|
function invoiceStatusLabel(code) {
|
||||||
|
return INVOICE_STATUS_LABELS[code] || code || "-";
|
||||||
|
}
|
||||||
|
function statusKindLabel(code) {
|
||||||
|
return STATUS_KIND_LABELS[code] || code || "-";
|
||||||
|
}
|
||||||
function boolLabel(value) {
|
function boolLabel(value) {
|
||||||
return value ? "\u0414\u0430" : "\u041D\u0435\u0442";
|
return value ? "\u0414\u0430" : "\u041D\u0435\u0442";
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +269,10 @@
|
||||||
\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435: row.description || null,
|
\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435: row.description || null,
|
||||||
"\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F": row.extra_fields || {},
|
"\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F": row.extra_fields || {},
|
||||||
"\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0439 \u044E\u0440\u0438\u0441\u0442 (ID)": row.assigned_lawyer_id || null,
|
"\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0439 \u044E\u0440\u0438\u0441\u0442 (ID)": row.assigned_lawyer_id || null,
|
||||||
|
"\u0421\u0442\u0430\u0432\u043A\u0430 (\u0444\u0438\u043A\u0441.)": row.effective_rate ?? null,
|
||||||
|
"\u0421\u0443\u043C\u043C\u0430 \u0441\u0447\u0435\u0442\u0430": row.invoice_amount ?? null,
|
||||||
|
"\u041E\u043F\u043B\u0430\u0447\u0435\u043D\u043E": row.paid_at ? fmtDate(row.paid_at) : null,
|
||||||
|
"\u041E\u043F\u043B\u0430\u0442\u0443 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043B (ID)": row.paid_by_admin_id || null,
|
||||||
"\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u043A\u043B\u0438\u0435\u043D\u0442\u043E\u043C": boolLabel(Boolean(row.client_has_unread_updates)),
|
"\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u043A\u043B\u0438\u0435\u043D\u0442\u043E\u043C": boolLabel(Boolean(row.client_has_unread_updates)),
|
||||||
"\u0422\u0438\u043F \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F \u0434\u043B\u044F \u043A\u043B\u0438\u0435\u043D\u0442\u0430": row.client_unread_event_type ? REQUEST_UPDATE_EVENT_LABELS[row.client_unread_event_type] || row.client_unread_event_type : null,
|
"\u0422\u0438\u043F \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F \u0434\u043B\u044F \u043A\u043B\u0438\u0435\u043D\u0442\u0430": row.client_unread_event_type ? REQUEST_UPDATE_EVENT_LABELS[row.client_unread_event_type] || row.client_unread_event_type : null,
|
||||||
"\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u044E\u0440\u0438\u0441\u0442\u043E\u043C": boolLabel(Boolean(row.lawyer_has_unread_updates)),
|
"\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u044E\u0440\u0438\u0441\u0442\u043E\u043C": boolLabel(Boolean(row.lawyer_has_unread_updates)),
|
||||||
|
|
@ -467,9 +534,16 @@
|
||||||
const [role, setRole] = useState("");
|
const [role, setRole] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [activeSection, setActiveSection] = useState("dashboard");
|
const [activeSection, setActiveSection] = useState("dashboard");
|
||||||
const [dashboardData, setDashboardData] = useState({ cards: [], byStatus: {}, lawyerLoads: [] });
|
const [dashboardData, setDashboardData] = useState({
|
||||||
|
scope: "",
|
||||||
|
cards: [],
|
||||||
|
byStatus: {},
|
||||||
|
lawyerLoads: [],
|
||||||
|
myUnreadByEvent: {}
|
||||||
|
});
|
||||||
const [tables, setTables] = useState({
|
const [tables, setTables] = useState({
|
||||||
requests: createTableState(),
|
requests: createTableState(),
|
||||||
|
invoices: createTableState(),
|
||||||
quotes: createTableState(),
|
quotes: createTableState(),
|
||||||
topics: createTableState(),
|
topics: createTableState(),
|
||||||
statuses: createTableState(),
|
statuses: createTableState(),
|
||||||
|
|
@ -480,6 +554,7 @@
|
||||||
users: createTableState(),
|
users: createTableState(),
|
||||||
userTopics: createTableState()
|
userTopics: createTableState()
|
||||||
});
|
});
|
||||||
|
const [tableCatalog, setTableCatalog] = useState([]);
|
||||||
const [dictionaries, setDictionaries] = useState({
|
const [dictionaries, setDictionaries] = useState({
|
||||||
topics: [],
|
topics: [],
|
||||||
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
|
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name })),
|
||||||
|
|
@ -496,7 +571,7 @@
|
||||||
rowId: null,
|
rowId: null,
|
||||||
form: {}
|
form: {}
|
||||||
});
|
});
|
||||||
const [configActiveKey, setConfigActiveKey] = useState("quotes");
|
const [configActiveKey, setConfigActiveKey] = useState("");
|
||||||
const [referencesExpanded, setReferencesExpanded] = useState(true);
|
const [referencesExpanded, setReferencesExpanded] = useState(true);
|
||||||
const [metaEntity, setMetaEntity] = useState("quotes");
|
const [metaEntity, setMetaEntity] = useState("quotes");
|
||||||
const [metaJson, setMetaJson] = useState("");
|
const [metaJson, setMetaJson] = useState("");
|
||||||
|
|
@ -554,6 +629,12 @@
|
||||||
const getStatusOptions = useCallback(() => {
|
const getStatusOptions = useCallback(() => {
|
||||||
return (dictionaries.statuses || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" }));
|
return (dictionaries.statuses || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || statusLabel(item.code)) + " (" + item.code + ")" }));
|
||||||
}, [dictionaries.statuses]);
|
}, [dictionaries.statuses]);
|
||||||
|
const getInvoiceStatusOptions = useCallback(() => {
|
||||||
|
return Object.entries(INVOICE_STATUS_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
|
||||||
|
}, []);
|
||||||
|
const getStatusKindOptions = useCallback(() => {
|
||||||
|
return Object.entries(STATUS_KIND_LABELS).map(([code, name]) => ({ value: code, label: name + " (" + code + ")" }));
|
||||||
|
}, []);
|
||||||
const getTopicOptions = useCallback(() => {
|
const getTopicOptions = useCallback(() => {
|
||||||
return (dictionaries.topics || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || item.code) + " (" + item.code + ")" }));
|
return (dictionaries.topics || []).filter((item) => item && item.code).map((item) => ({ value: item.code, label: (item.name || item.code) + " (" + item.code + ")" }));
|
||||||
}, [dictionaries.topics]);
|
}, [dictionaries.topics]);
|
||||||
|
|
@ -572,6 +653,45 @@
|
||||||
const getRoleOptions = useCallback(() => {
|
const getRoleOptions = useCallback(() => {
|
||||||
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" }));
|
return Object.entries(ROLE_LABELS).map(([code, label]) => ({ value: code, label: label + " (" + code + ")" }));
|
||||||
}, []);
|
}, []);
|
||||||
|
const tableCatalogMap = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
(tableCatalog || []).forEach((item) => {
|
||||||
|
if (!item || !item.key) return;
|
||||||
|
map[item.key] = item;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [tableCatalog]);
|
||||||
|
const dictionaryTableItems = useMemo(() => {
|
||||||
|
return (tableCatalog || []).filter((item) => item && item.section === "dictionary" && Array.isArray(item.actions) && item.actions.includes("query")).sort((a, b) => String(a.label || a.key).localeCompare(String(b.label || b.key), "ru"));
|
||||||
|
}, [tableCatalog]);
|
||||||
|
const resolveTableConfig = useCallback(
|
||||||
|
(tableKey) => {
|
||||||
|
if (TABLE_SERVER_CONFIG[tableKey]) return TABLE_SERVER_CONFIG[tableKey];
|
||||||
|
const meta = tableCatalogMap[tableKey];
|
||||||
|
if (!meta || !meta.table) return null;
|
||||||
|
const tableName = String(meta.table || tableKey);
|
||||||
|
return {
|
||||||
|
table: tableName,
|
||||||
|
endpoint: String(meta.query_endpoint || "/api/admin/crud/" + tableName + "/query"),
|
||||||
|
sort: Array.isArray(meta.default_sort) && meta.default_sort.length ? meta.default_sort : [{ field: "created_at", dir: "desc" }]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[tableCatalogMap]
|
||||||
|
);
|
||||||
|
const resolveMutationConfig = useCallback(
|
||||||
|
(tableKey) => {
|
||||||
|
if (TABLE_MUTATION_CONFIG[tableKey]) return TABLE_MUTATION_CONFIG[tableKey];
|
||||||
|
const meta = tableCatalogMap[tableKey];
|
||||||
|
if (!meta || !meta.table) return null;
|
||||||
|
const tableName = String(meta.table || tableKey);
|
||||||
|
return {
|
||||||
|
create: String(meta.create_endpoint || "/api/admin/crud/" + tableName),
|
||||||
|
update: (id) => String(meta.update_endpoint_template || "/api/admin/crud/" + tableName + "/{id}").replace("{id}", String(id)),
|
||||||
|
delete: (id) => String(meta.delete_endpoint_template || "/api/admin/crud/" + tableName + "/{id}").replace("{id}", String(id))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[tableCatalogMap]
|
||||||
|
);
|
||||||
const getFilterFields = useCallback(
|
const getFilterFields = useCallback(
|
||||||
(tableKey) => {
|
(tableKey) => {
|
||||||
if (tableKey === "requests") {
|
if (tableKey === "requests") {
|
||||||
|
|
@ -581,6 +701,23 @@
|
||||||
{ field: "client_phone", label: "\u0422\u0435\u043B\u0435\u0444\u043E\u043D", type: "text" },
|
{ field: "client_phone", label: "\u0422\u0435\u043B\u0435\u0444\u043E\u043D", type: "text" },
|
||||||
{ field: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", type: "reference", options: getStatusOptions },
|
{ field: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", type: "reference", options: getStatusOptions },
|
||||||
{ field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions },
|
{ field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions },
|
||||||
|
{ field: "invoice_amount", label: "\u0421\u0443\u043C\u043C\u0430 \u0441\u0447\u0435\u0442\u0430", type: "number" },
|
||||||
|
{ field: "effective_rate", label: "\u0421\u0442\u0430\u0432\u043A\u0430", type: "number" },
|
||||||
|
{ field: "paid_at", label: "\u041E\u043F\u043B\u0430\u0447\u0435\u043D\u043E", type: "date" },
|
||||||
|
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (tableKey === "invoices") {
|
||||||
|
return [
|
||||||
|
{ field: "invoice_number", label: "\u041D\u043E\u043C\u0435\u0440 \u0441\u0447\u0435\u0442\u0430", type: "text" },
|
||||||
|
{ field: "status", label: "\u0421\u0442\u0430\u0442\u0443\u0441", type: "enum", options: getInvoiceStatusOptions },
|
||||||
|
{ field: "amount", label: "\u0421\u0443\u043C\u043C\u0430", type: "number" },
|
||||||
|
{ field: "currency", label: "\u0412\u0430\u043B\u044E\u0442\u0430", type: "text" },
|
||||||
|
{ field: "payer_display_name", label: "\u041F\u043B\u0430\u0442\u0435\u043B\u044C\u0449\u0438\u043A", type: "text" },
|
||||||
|
{ field: "request_id", label: "ID \u0437\u0430\u044F\u0432\u043A\u0438", type: "text" },
|
||||||
|
{ field: "issued_by_admin_user_id", label: "ID \u0441\u043E\u0442\u0440\u0443\u0434\u043D\u0438\u043A\u0430", type: "text" },
|
||||||
|
{ field: "issued_at", label: "\u0414\u0430\u0442\u0430 \u0444\u043E\u0440\u043C\u0438\u0440\u043E\u0432\u0430\u043D\u0438\u044F", type: "date" },
|
||||||
|
{ field: "paid_at", label: "\u0414\u0430\u0442\u0430 \u043E\u043F\u043B\u0430\u0442\u044B", type: "date" },
|
||||||
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
|
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -606,6 +743,7 @@
|
||||||
return [
|
return [
|
||||||
{ field: "code", label: "\u041A\u043E\u0434", type: "text" },
|
{ field: "code", label: "\u041A\u043E\u0434", type: "text" },
|
||||||
{ field: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text" },
|
{ field: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text" },
|
||||||
|
{ field: "kind", label: "\u0422\u0438\u043F", type: "enum", options: getStatusKindOptions },
|
||||||
{ field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" },
|
{ field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" },
|
||||||
{ field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" },
|
{ field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" },
|
||||||
{ field: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", type: "boolean" }
|
{ field: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", type: "boolean" }
|
||||||
|
|
@ -646,6 +784,7 @@
|
||||||
{ field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions },
|
{ field: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", options: getTopicOptions },
|
||||||
{ field: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", type: "reference", options: getStatusOptions },
|
{ field: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", type: "reference", options: getStatusOptions },
|
||||||
{ field: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", type: "reference", options: getStatusOptions },
|
{ field: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", type: "reference", options: getStatusOptions },
|
||||||
|
{ field: "sla_hours", label: "SLA (\u0447\u0430\u0441\u044B)", type: "number" },
|
||||||
{ field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" },
|
{ field: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" },
|
||||||
{ field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" }
|
{ field: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number" }
|
||||||
];
|
];
|
||||||
|
|
@ -656,6 +795,8 @@
|
||||||
{ field: "email", label: "Email", type: "text" },
|
{ field: "email", label: "Email", type: "text" },
|
||||||
{ field: "role", label: "\u0420\u043E\u043B\u044C", type: "enum", options: getRoleOptions },
|
{ field: "role", label: "\u0420\u043E\u043B\u044C", type: "enum", options: getRoleOptions },
|
||||||
{ field: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", type: "reference", options: getTopicOptions },
|
{ field: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", type: "reference", options: getTopicOptions },
|
||||||
|
{ field: "default_rate", label: "\u0421\u0442\u0430\u0432\u043A\u0430 \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E", type: "number" },
|
||||||
|
{ field: "salary_percent", label: "\u041F\u0440\u043E\u0446\u0435\u043D\u0442 \u0437\u0430\u0440\u043F\u043B\u0430\u0442\u044B", type: "number" },
|
||||||
{ field: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" },
|
{ field: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean" },
|
||||||
{ field: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", type: "text" },
|
{ field: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", type: "text" },
|
||||||
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
|
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
|
||||||
|
|
@ -669,12 +810,34 @@
|
||||||
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
|
{ field: "created_at", label: "\u0414\u0430\u0442\u0430 \u0441\u043E\u0437\u0434\u0430\u043D\u0438\u044F", type: "date" }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [];
|
const meta = tableCatalogMap[tableKey];
|
||||||
|
if (!meta || !Array.isArray(meta.columns)) return [];
|
||||||
|
return (meta.columns || []).filter((column) => column && column.name && column.filterable !== false).map((column) => {
|
||||||
|
const name = String(column.name);
|
||||||
|
const label = String(column.label || humanizeKey(name));
|
||||||
|
if (name === "topic_code") return { field: name, label, type: "reference", options: getTopicOptions };
|
||||||
|
if (name === "status_code" || name === "from_status" || name === "to_status") {
|
||||||
|
return { field: name, label, type: "reference", options: getStatusOptions };
|
||||||
|
}
|
||||||
|
if (name === "field_key") return { field: name, label, type: "reference", options: getFormFieldKeyOptions };
|
||||||
|
return { field: name, label, type: metaKindToFilterType(column.kind) };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions]
|
[
|
||||||
|
tableCatalogMap,
|
||||||
|
getFormFieldKeyOptions,
|
||||||
|
getFormFieldTypeOptions,
|
||||||
|
getInvoiceStatusOptions,
|
||||||
|
getLawyerOptions,
|
||||||
|
getRoleOptions,
|
||||||
|
getStatusKindOptions,
|
||||||
|
getStatusOptions,
|
||||||
|
getTopicOptions
|
||||||
|
]
|
||||||
);
|
);
|
||||||
const getTableLabel = useCallback((tableKey) => {
|
const getTableLabel = useCallback((tableKey) => {
|
||||||
if (tableKey === "requests") return "\u0417\u0430\u044F\u0432\u043A\u0438";
|
if (tableKey === "requests") return "\u0417\u0430\u044F\u0432\u043A\u0438";
|
||||||
|
if (tableKey === "invoices") return "\u0421\u0447\u0435\u0442\u0430";
|
||||||
if (tableKey === "quotes") return "\u0426\u0438\u0442\u0430\u0442\u044B";
|
if (tableKey === "quotes") return "\u0426\u0438\u0442\u0430\u0442\u044B";
|
||||||
if (tableKey === "topics") return "\u0422\u0435\u043C\u044B";
|
if (tableKey === "topics") return "\u0422\u0435\u043C\u044B";
|
||||||
if (tableKey === "statuses") return "\u0421\u0442\u0430\u0442\u0443\u0441\u044B";
|
if (tableKey === "statuses") return "\u0421\u0442\u0430\u0442\u0443\u0441\u044B";
|
||||||
|
|
@ -684,8 +847,11 @@
|
||||||
if (tableKey === "statusTransitions") return "\u041F\u0435\u0440\u0435\u0445\u043E\u0434\u044B \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432";
|
if (tableKey === "statusTransitions") return "\u041F\u0435\u0440\u0435\u0445\u043E\u0434\u044B \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432";
|
||||||
if (tableKey === "users") return "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0438";
|
if (tableKey === "users") return "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0438";
|
||||||
if (tableKey === "userTopics") return "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u0442\u0435\u043C\u044B \u044E\u0440\u0438\u0441\u0442\u043E\u0432";
|
if (tableKey === "userTopics") return "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u0442\u0435\u043C\u044B \u044E\u0440\u0438\u0441\u0442\u043E\u0432";
|
||||||
return "\u0422\u0430\u0431\u043B\u0438\u0446\u0430";
|
const meta = tableCatalogMap[tableKey];
|
||||||
}, []);
|
if (meta && meta.label) return String(meta.label);
|
||||||
|
const raw = TABLE_UNALIASES[tableKey] || tableKey;
|
||||||
|
return humanizeKey(raw);
|
||||||
|
}, [tableCatalogMap]);
|
||||||
const getRecordFields = useCallback(
|
const getRecordFields = useCallback(
|
||||||
(tableKey) => {
|
(tableKey) => {
|
||||||
if (tableKey === "requests") {
|
if (tableKey === "requests") {
|
||||||
|
|
@ -698,9 +864,24 @@
|
||||||
{ key: "description", label: "\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435", type: "textarea", optional: true },
|
{ key: "description", label: "\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435", type: "textarea", optional: true },
|
||||||
{ key: "extra_fields", label: "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F (JSON)", type: "json", optional: true, defaultValue: "{}" },
|
{ key: "extra_fields", label: "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F (JSON)", type: "json", optional: true, defaultValue: "{}" },
|
||||||
{ key: "assigned_lawyer_id", label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0439 \u044E\u0440\u0438\u0441\u0442 (ID)", type: "text", optional: true },
|
{ key: "assigned_lawyer_id", label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0439 \u044E\u0440\u0438\u0441\u0442 (ID)", type: "text", optional: true },
|
||||||
|
{ key: "effective_rate", label: "\u0421\u0442\u0430\u0432\u043A\u0430 (\u0444\u0438\u043A\u0441.)", type: "number", optional: true },
|
||||||
|
{ key: "invoice_amount", label: "\u0421\u0443\u043C\u043C\u0430 \u0441\u0447\u0435\u0442\u0430", type: "number", optional: true },
|
||||||
|
{ key: "paid_at", label: "\u0414\u0430\u0442\u0430 \u043E\u043F\u043B\u0430\u0442\u044B (ISO)", type: "text", optional: true, placeholder: "2026-02-23T12:00:00+03:00" },
|
||||||
|
{ key: "paid_by_admin_id", label: "\u041E\u043F\u043B\u0430\u0442\u0443 \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043B (ID)", type: "text", optional: true },
|
||||||
{ key: "total_attachments_bytes", label: "\u0420\u0430\u0437\u043C\u0435\u0440 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439 (\u0431\u0430\u0439\u0442)", type: "number", optional: true, defaultValue: "0" }
|
{ key: "total_attachments_bytes", label: "\u0420\u0430\u0437\u043C\u0435\u0440 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439 (\u0431\u0430\u0439\u0442)", type: "number", optional: true, defaultValue: "0" }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (tableKey === "invoices") {
|
||||||
|
return [
|
||||||
|
{ key: "request_track_number", label: "\u041D\u043E\u043C\u0435\u0440 \u0437\u0430\u044F\u0432\u043A\u0438", type: "text", required: true, createOnly: true },
|
||||||
|
{ key: "invoice_number", label: "\u041D\u043E\u043C\u0435\u0440 \u0441\u0447\u0435\u0442\u0430", type: "text", optional: true, placeholder: "\u041E\u0441\u0442\u0430\u0432\u044C\u0442\u0435 \u043F\u0443\u0441\u0442\u044B\u043C \u0434\u043B\u044F \u0430\u0432\u0442\u043E\u0433\u0435\u043D\u0435\u0440\u0430\u0446\u0438\u0438" },
|
||||||
|
{ key: "status", label: "\u0421\u0442\u0430\u0442\u0443\u0441", type: "enum", required: true, options: getInvoiceStatusOptions, defaultValue: "WAITING_PAYMENT" },
|
||||||
|
{ key: "amount", label: "\u0421\u0443\u043C\u043C\u0430", type: "number", required: true },
|
||||||
|
{ key: "currency", label: "\u0412\u0430\u043B\u044E\u0442\u0430", type: "text", optional: true, defaultValue: "RUB" },
|
||||||
|
{ key: "payer_display_name", label: "\u041F\u043B\u0430\u0442\u0435\u043B\u044C\u0449\u0438\u043A (\u0424\u0418\u041E / \u043A\u043E\u043C\u043F\u0430\u043D\u0438\u044F)", type: "text", required: true },
|
||||||
|
{ key: "payer_details", label: "\u0420\u0435\u043A\u0432\u0438\u0437\u0438\u0442\u044B (JSON, \u0448\u0438\u0444\u0440\u0443\u0435\u0442\u0441\u044F)", type: "json", optional: true, omitIfEmpty: true, placeholder: '{"inn":"..."}' }
|
||||||
|
];
|
||||||
|
}
|
||||||
if (tableKey === "quotes") {
|
if (tableKey === "quotes") {
|
||||||
return [
|
return [
|
||||||
{ key: "author", label: "\u0410\u0432\u0442\u043E\u0440", type: "text", required: true },
|
{ key: "author", label: "\u0410\u0432\u0442\u043E\u0440", type: "text", required: true },
|
||||||
|
|
@ -722,6 +903,8 @@
|
||||||
return [
|
return [
|
||||||
{ key: "code", label: "\u041A\u043E\u0434", type: "text", required: true },
|
{ key: "code", label: "\u041A\u043E\u0434", type: "text", required: true },
|
||||||
{ key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text", required: true },
|
{ key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", type: "text", required: true },
|
||||||
|
{ key: "kind", label: "\u0422\u0438\u043F", type: "enum", required: true, options: getStatusKindOptions, defaultValue: "DEFAULT" },
|
||||||
|
{ key: "invoice_template", label: "\u0428\u0430\u0431\u043B\u043E\u043D \u0441\u0447\u0435\u0442\u0430", type: "textarea", optional: true, placeholder: "\u0414\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0435 \u043F\u043E\u043B\u044F: {track_number}, {client_name}, {topic_code}, {amount}" },
|
||||||
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" },
|
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" },
|
||||||
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" },
|
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" },
|
||||||
{ key: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", type: "boolean", defaultValue: "false" }
|
{ key: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", type: "boolean", defaultValue: "false" }
|
||||||
|
|
@ -763,6 +946,7 @@
|
||||||
{ key: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions },
|
{ key: "topic_code", label: "\u0422\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions },
|
||||||
{ key: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", type: "reference", required: true, options: getStatusOptions },
|
{ key: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", type: "reference", required: true, options: getStatusOptions },
|
||||||
{ key: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", type: "reference", required: true, options: getStatusOptions },
|
{ key: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", type: "reference", required: true, options: getStatusOptions },
|
||||||
|
{ key: "sla_hours", label: "SLA (\u0447\u0430\u0441\u044B)", type: "number", optional: true },
|
||||||
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" },
|
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" },
|
||||||
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" }
|
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", type: "number", defaultValue: "0" }
|
||||||
];
|
];
|
||||||
|
|
@ -782,6 +966,8 @@
|
||||||
accept: "image/*"
|
accept: "image/*"
|
||||||
},
|
},
|
||||||
{ key: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", type: "reference", optional: true, options: getTopicOptions },
|
{ key: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", type: "reference", optional: true, options: getTopicOptions },
|
||||||
|
{ key: "default_rate", label: "\u0421\u0442\u0430\u0432\u043A\u0430 \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E", type: "number", optional: true },
|
||||||
|
{ key: "salary_percent", label: "\u041F\u0440\u043E\u0446\u0435\u043D\u0442 \u0437\u0430\u0440\u043F\u043B\u0430\u0442\u044B", type: "number", optional: true },
|
||||||
{ key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" },
|
{ key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", type: "boolean", defaultValue: "true" },
|
||||||
{ key: "password", label: "\u041F\u0430\u0440\u043E\u043B\u044C", type: "password", requiredOnCreate: true, optional: true, omitIfEmpty: true, placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043F\u0430\u0440\u043E\u043B\u044C" }
|
{ key: "password", label: "\u041F\u0430\u0440\u043E\u043B\u044C", type: "password", requiredOnCreate: true, optional: true, omitIfEmpty: true, placeholder: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043F\u0430\u0440\u043E\u043B\u044C" }
|
||||||
];
|
];
|
||||||
|
|
@ -792,9 +978,31 @@
|
||||||
{ key: "topic_code", label: "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u0430\u044F \u0442\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions }
|
{ key: "topic_code", label: "\u0414\u043E\u043F\u043E\u043B\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u0430\u044F \u0442\u0435\u043C\u0430", type: "reference", required: true, options: getTopicOptions }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [];
|
const meta = tableCatalogMap[tableKey];
|
||||||
|
if (!meta || !Array.isArray(meta.columns)) return [];
|
||||||
|
return (meta.columns || []).filter((column) => column && column.name && column.editable).map((column) => {
|
||||||
|
const key = String(column.name || "");
|
||||||
|
const requiredOnCreate = Boolean(column.required_on_create);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: String(column.label || humanizeKey(key)),
|
||||||
|
type: metaKindToRecordType(column.kind),
|
||||||
|
requiredOnCreate,
|
||||||
|
optional: !requiredOnCreate
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[getFormFieldKeyOptions, getFormFieldTypeOptions, getLawyerOptions, getRoleOptions, getStatusOptions, getTopicOptions]
|
[
|
||||||
|
tableCatalogMap,
|
||||||
|
getFormFieldKeyOptions,
|
||||||
|
getFormFieldTypeOptions,
|
||||||
|
getInvoiceStatusOptions,
|
||||||
|
getLawyerOptions,
|
||||||
|
getRoleOptions,
|
||||||
|
getStatusKindOptions,
|
||||||
|
getStatusOptions,
|
||||||
|
getTopicOptions
|
||||||
|
]
|
||||||
);
|
);
|
||||||
const getFieldDef = useCallback(
|
const getFieldDef = useCallback(
|
||||||
(tableKey, fieldName) => {
|
(tableKey, fieldName) => {
|
||||||
|
|
@ -827,7 +1035,7 @@
|
||||||
const loadTable = useCallback(
|
const loadTable = useCallback(
|
||||||
async (tableKey, options, tokenOverride) => {
|
async (tableKey, options, tokenOverride) => {
|
||||||
const opts = options || {};
|
const opts = options || {};
|
||||||
const config = TABLE_SERVER_CONFIG[tableKey];
|
const config = resolveTableConfig(tableKey);
|
||||||
if (!config) return false;
|
if (!config) return false;
|
||||||
const current = tablesRef.current[tableKey] || createTableState();
|
const current = tablesRef.current[tableKey] || createTableState();
|
||||||
const next = {
|
const next = {
|
||||||
|
|
@ -905,7 +1113,7 @@
|
||||||
return { ...prev, statuses: sortByName(Array.from(map.values())) };
|
return { ...prev, statuses: sortByName(Array.from(map.values())) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (tableKey === "formFields") {
|
if (tableKey === "formFields" || tableKey === "form_fields") {
|
||||||
setDictionaries((prev) => {
|
setDictionaries((prev) => {
|
||||||
const set = new Set(DEFAULT_FORM_FIELD_TYPES);
|
const set = new Set(DEFAULT_FORM_FIELD_TYPES);
|
||||||
(next.rows || []).forEach((row) => {
|
(next.rows || []).forEach((row) => {
|
||||||
|
|
@ -919,7 +1127,7 @@
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (tableKey === "users") {
|
if (tableKey === "users" || tableKey === "admin_users") {
|
||||||
setDictionaries((prev) => {
|
setDictionaries((prev) => {
|
||||||
const map = new Map((prev.users || []).map((user) => [user.id, user]));
|
const map = new Map((prev.users || []).map((user) => [user.id, user]));
|
||||||
(next.rows || []).forEach((row) => {
|
(next.rows || []).forEach((row) => {
|
||||||
|
|
@ -941,11 +1149,15 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, setStatus, setTableState]
|
[api, resolveTableConfig, setStatus, setTableState]
|
||||||
);
|
);
|
||||||
const loadCurrentConfigTable = useCallback(
|
const loadCurrentConfigTable = useCallback(
|
||||||
async (resetOffset, tokenOverride, keyOverride) => {
|
async (resetOffset, tokenOverride, keyOverride) => {
|
||||||
const currentKey = keyOverride || configActiveKey;
|
const currentKey = keyOverride || configActiveKey;
|
||||||
|
if (!currentKey) {
|
||||||
|
setStatus("config", "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A", "");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
setStatus("config", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", "");
|
setStatus("config", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", "");
|
||||||
const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride);
|
const ok = await loadTable(currentKey, { resetOffset: Boolean(resetOffset) }, tokenOverride);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
|
@ -961,23 +1173,38 @@
|
||||||
setStatus("dashboard", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", "");
|
setStatus("dashboard", "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430...", "");
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/admin/metrics/overview", {}, tokenOverride);
|
const data = await api("/api/admin/metrics/overview", {}, tokenOverride);
|
||||||
const cards = [
|
const scope = String(data.scope || role || "");
|
||||||
|
const cards = scope === "LAWYER" ? [
|
||||||
|
{ label: "\u041C\u043E\u0438 \u0437\u0430\u044F\u0432\u043A\u0438", value: data.assigned_total ?? 0 },
|
||||||
|
{ label: "\u041C\u043E\u0438 \u0430\u043A\u0442\u0438\u0432\u043D\u044B\u0435", value: data.active_assigned_total ?? 0 },
|
||||||
|
{ label: "\u041D\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.unassigned_total ?? 0 },
|
||||||
|
{ label: "\u041C\u043E\u0438 \u043D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043D\u044B\u0435", value: data.my_unread_updates ?? 0 },
|
||||||
|
{ label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: data.sla_overdue ?? 0 }
|
||||||
|
] : [
|
||||||
{ label: "\u041D\u043E\u0432\u044B\u0435", value: data.new ?? 0 },
|
{ label: "\u041D\u043E\u0432\u044B\u0435", value: data.new ?? 0 },
|
||||||
|
{ label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.assigned_total ?? 0 },
|
||||||
|
{ label: "\u041D\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044B\u0435", value: data.unassigned_total ?? 0 },
|
||||||
{ label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: data.sla_overdue ?? 0 },
|
{ label: "\u041F\u0440\u043E\u0441\u0440\u043E\u0447\u0435\u043D\u043E SLA", value: data.sla_overdue ?? 0 },
|
||||||
{ label: "\u0421\u0440\u0435\u0434\u043D\u0438\u0439 FRT (\u043C\u0438\u043D)", value: data.frt_avg_minutes ?? "-" },
|
{ label: "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u044E\u0440\u0438\u0441\u0442\u0430\u043C\u0438", value: data.unread_for_lawyers ?? 0 },
|
||||||
{ label: "\u0413\u0440\u0443\u043F\u043F \u043F\u043E \u0441\u0442\u0430\u0442\u0443\u0441\u0430\u043C", value: Object.keys(data.by_status || {}).length }
|
{ label: "\u041D\u0435\u043F\u0440\u043E\u0447\u0438\u0442\u0430\u043D\u043E \u043A\u043B\u0438\u0435\u043D\u0442\u0430\u043C\u0438", value: data.unread_for_clients ?? 0 }
|
||||||
];
|
];
|
||||||
const localized = {};
|
const localized = {};
|
||||||
Object.entries(data.by_status || {}).forEach(([code, count]) => {
|
Object.entries(data.by_status || {}).forEach(([code, count]) => {
|
||||||
localized[statusLabel(code)] = count;
|
localized[statusLabel(code)] = count;
|
||||||
});
|
});
|
||||||
setDashboardData({ cards, byStatus: localized, lawyerLoads: data.lawyer_loads || [] });
|
setDashboardData({
|
||||||
|
scope,
|
||||||
|
cards,
|
||||||
|
byStatus: localized,
|
||||||
|
lawyerLoads: data.lawyer_loads || [],
|
||||||
|
myUnreadByEvent: data.my_unread_by_event || {}
|
||||||
|
});
|
||||||
setStatus("dashboard", "\u0414\u0430\u043D\u043D\u044B\u0435 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u044B", "ok");
|
setStatus("dashboard", "\u0414\u0430\u043D\u043D\u044B\u0435 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u044B", "ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("dashboard", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error");
|
setStatus("dashboard", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, setStatus]
|
[api, role, setStatus]
|
||||||
);
|
);
|
||||||
const loadMeta = useCallback(
|
const loadMeta = useCallback(
|
||||||
async (tokenOverride) => {
|
async (tokenOverride) => {
|
||||||
|
|
@ -998,6 +1225,7 @@
|
||||||
if (!(tokenOverride !== void 0 ? tokenOverride : token)) return;
|
if (!(tokenOverride !== void 0 ? tokenOverride : token)) return;
|
||||||
if (section === "dashboard") return loadDashboard(tokenOverride);
|
if (section === "dashboard") return loadDashboard(tokenOverride);
|
||||||
if (section === "requests") return loadTable("requests", {}, tokenOverride);
|
if (section === "requests") return loadTable("requests", {}, tokenOverride);
|
||||||
|
if (section === "invoices") return loadTable("invoices", {}, tokenOverride);
|
||||||
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
|
if (section === "quotes" && canAccessSection(role, "quotes")) return loadTable("quotes", {}, tokenOverride);
|
||||||
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
|
if (section === "config" && canAccessSection(role, "config")) return loadCurrentConfigTable(false, tokenOverride);
|
||||||
if (section === "meta") return loadMeta(tokenOverride);
|
if (section === "meta") return loadMeta(tokenOverride);
|
||||||
|
|
@ -1010,16 +1238,24 @@
|
||||||
...prev,
|
...prev,
|
||||||
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name }))
|
statuses: Object.entries(STATUS_LABELS).map(([code, name]) => ({ code, name }))
|
||||||
}));
|
}));
|
||||||
|
setTableCatalog([]);
|
||||||
if (roleOverride !== "ADMIN") return;
|
if (roleOverride !== "ADMIN") return;
|
||||||
try {
|
try {
|
||||||
const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0);
|
const body = buildUniversalQuery([], [{ field: "sort_order", dir: "asc" }], 500, 0);
|
||||||
const usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0);
|
const usersBody = buildUniversalQuery([], [{ field: "created_at", dir: "desc" }], 500, 0);
|
||||||
const [topicsData, statusesData, fieldsData, usersData] = await Promise.all([
|
const [catalogData, topicsData, statusesData, fieldsData, usersData] = await Promise.all([
|
||||||
|
api("/api/admin/crud/meta/tables", {}, tokenOverride),
|
||||||
api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride),
|
api("/api/admin/crud/topics/query", { method: "POST", body }, tokenOverride),
|
||||||
api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride),
|
api("/api/admin/crud/statuses/query", { method: "POST", body }, tokenOverride),
|
||||||
api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride),
|
api("/api/admin/crud/form_fields/query", { method: "POST", body }, tokenOverride),
|
||||||
api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride)
|
api("/api/admin/crud/admin_users/query", { method: "POST", body: usersBody }, tokenOverride)
|
||||||
]);
|
]);
|
||||||
|
const catalogRows = (catalogData.tables || []).filter((row) => row && row.table).map((row) => {
|
||||||
|
const tableName = String(row.table || "");
|
||||||
|
const key = TABLE_KEY_ALIASES[tableName] || String(row.key || tableName);
|
||||||
|
return { ...row, key, table: tableName };
|
||||||
|
});
|
||||||
|
setTableCatalog(catalogRows);
|
||||||
const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }]));
|
const statusesMap = new Map(Object.entries(STATUS_LABELS).map(([code, name]) => [code, { code, name }]));
|
||||||
(statusesData.rows || []).forEach((row) => {
|
(statusesData.rows || []).forEach((row) => {
|
||||||
if (!row.code) return;
|
if (!row.code) return;
|
||||||
|
|
@ -1172,6 +1408,7 @@
|
||||||
if (field.type === "json") {
|
if (field.type === "json") {
|
||||||
const text = String(raw || "").trim();
|
const text = String(raw || "").trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
if (field.omitIfEmpty) return;
|
||||||
if (field.optional) payload[field.key] = null;
|
if (field.optional) payload[field.key] = null;
|
||||||
else payload[field.key] = {};
|
else payload[field.key] = {};
|
||||||
return;
|
return;
|
||||||
|
|
@ -1196,6 +1433,7 @@
|
||||||
payload[field.key] = value;
|
payload[field.key] = value;
|
||||||
});
|
});
|
||||||
if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {};
|
if (tableKey === "requests" && !payload.extra_fields) payload.extra_fields = {};
|
||||||
|
if (tableKey === "invoices" && mode === "edit") delete payload.request_track_number;
|
||||||
return payload;
|
return payload;
|
||||||
},
|
},
|
||||||
[getRecordFields]
|
[getRecordFields]
|
||||||
|
|
@ -1205,7 +1443,7 @@
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const tableKey = recordModal.tableKey;
|
const tableKey = recordModal.tableKey;
|
||||||
if (!tableKey) return;
|
if (!tableKey) return;
|
||||||
const endpoints = TABLE_MUTATION_CONFIG[tableKey];
|
const endpoints = resolveMutationConfig(tableKey);
|
||||||
if (!endpoints) return;
|
if (!endpoints) return;
|
||||||
try {
|
try {
|
||||||
setStatus("recordForm", "\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435...", "");
|
setStatus("recordForm", "\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435...", "");
|
||||||
|
|
@ -1222,11 +1460,11 @@
|
||||||
setStatus("recordForm", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error");
|
setStatus("recordForm", "\u041E\u0448\u0438\u0431\u043A\u0430: " + error.message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, buildRecordPayload, closeRecordModal, loadTable, recordModal, setStatus]
|
[api, buildRecordPayload, closeRecordModal, loadTable, recordModal, resolveMutationConfig, setStatus]
|
||||||
);
|
);
|
||||||
const deleteRecord = useCallback(
|
const deleteRecord = useCallback(
|
||||||
async (tableKey, id) => {
|
async (tableKey, id) => {
|
||||||
const endpoints = TABLE_MUTATION_CONFIG[tableKey];
|
const endpoints = resolveMutationConfig(tableKey);
|
||||||
if (!endpoints) return;
|
if (!endpoints) return;
|
||||||
if (!confirm("\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C?")) return;
|
if (!confirm("\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C?")) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -1237,7 +1475,7 @@
|
||||||
setStatus(tableKey, "\u041E\u0448\u0438\u0431\u043A\u0430 \u0443\u0434\u0430\u043B\u0435\u043D\u0438\u044F: " + error.message, "error");
|
setStatus(tableKey, "\u041E\u0448\u0438\u0431\u043A\u0430 \u0443\u0434\u0430\u043B\u0435\u043D\u0438\u044F: " + error.message, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, loadTable, setStatus]
|
[api, loadTable, resolveMutationConfig, setStatus]
|
||||||
);
|
);
|
||||||
const claimRequest = useCallback(
|
const claimRequest = useCallback(
|
||||||
async (requestId) => {
|
async (requestId) => {
|
||||||
|
|
@ -1253,6 +1491,54 @@
|
||||||
},
|
},
|
||||||
[api, loadTable, setStatus]
|
[api, loadTable, setStatus]
|
||||||
);
|
);
|
||||||
|
const openInvoiceRequest = useCallback(
|
||||||
|
async (row) => {
|
||||||
|
if (!row || !row.request_id) return;
|
||||||
|
try {
|
||||||
|
setActiveSection("requests");
|
||||||
|
await loadTable("requests", {});
|
||||||
|
await openRequestDetails(row.request_id);
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadTable, openRequestDetails]
|
||||||
|
);
|
||||||
|
const downloadInvoicePdf = useCallback(
|
||||||
|
async (row) => {
|
||||||
|
if (!row || !row.id || !token) return;
|
||||||
|
try {
|
||||||
|
setStatus("invoices", "\u0424\u043E\u0440\u043C\u0438\u0440\u0443\u0435\u043C PDF...", "");
|
||||||
|
const response = await fetch("/api/admin/invoices/" + row.id + "/pdf", {
|
||||||
|
headers: { Authorization: "Bearer " + token }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : {};
|
||||||
|
} catch (_) {
|
||||||
|
payload = { raw: text };
|
||||||
|
}
|
||||||
|
const message = payload.detail || payload.error || payload.raw || "HTTP " + response.status;
|
||||||
|
throw new Error(translateApiError(String(message)));
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const fileName = (row.invoice_number || "invoice") + ".pdf";
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setStatus("invoices", "PDF \u0441\u043A\u0430\u0447\u0430\u043D", "ok");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("invoices", "\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u043A\u0430\u0447\u0438\u0432\u0430\u043D\u0438\u044F: " + error.message, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setStatus, token]
|
||||||
|
);
|
||||||
const openReassignModal = useCallback(
|
const openReassignModal = useCallback(
|
||||||
(row) => {
|
(row) => {
|
||||||
const options = getLawyerOptions();
|
const options = getLawyerOptions();
|
||||||
|
|
@ -1532,12 +1818,14 @@
|
||||||
setRequestModal({ open: false, jsonText: "" });
|
setRequestModal({ open: false, jsonText: "" });
|
||||||
setFilterModal({ open: false, tableKey: null, field: "", op: "=", rawValue: "", editIndex: null });
|
setFilterModal({ open: false, tableKey: null, field: "", op: "=", rawValue: "", editIndex: null });
|
||||||
setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" });
|
setReassignModal({ open: false, requestId: null, trackNumber: "", lawyerId: "" });
|
||||||
setDashboardData({ cards: [], byStatus: {}, lawyerLoads: [] });
|
setDashboardData({ scope: "", cards: [], byStatus: {}, lawyerLoads: [], myUnreadByEvent: {} });
|
||||||
setMetaJson("");
|
setMetaJson("");
|
||||||
setConfigActiveKey("quotes");
|
setConfigActiveKey("");
|
||||||
setReferencesExpanded(true);
|
setReferencesExpanded(true);
|
||||||
|
setTableCatalog([]);
|
||||||
setTables({
|
setTables({
|
||||||
requests: createTableState(),
|
requests: createTableState(),
|
||||||
|
invoices: createTableState(),
|
||||||
quotes: createTableState(),
|
quotes: createTableState(),
|
||||||
topics: createTableState(),
|
topics: createTableState(),
|
||||||
statuses: createTableState(),
|
statuses: createTableState(),
|
||||||
|
|
@ -1611,6 +1899,14 @@
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [bootstrapReferenceData, refreshSection, role, token]);
|
}, [bootstrapReferenceData, refreshSection, role, token]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dictionaryTableItems.length) {
|
||||||
|
if (configActiveKey) setConfigActiveKey("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasCurrent = dictionaryTableItems.some((item) => item.key === configActiveKey);
|
||||||
|
if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key);
|
||||||
|
}, [configActiveKey, dictionaryTableItems]);
|
||||||
const anyOverlayOpen = requestModal.open || recordModal.open || filterModal.open || reassignModal.open;
|
const anyOverlayOpen = requestModal.open || recordModal.open || filterModal.open || reassignModal.open;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.toggle("modal-open", anyOverlayOpen);
|
document.body.classList.toggle("modal-open", anyOverlayOpen);
|
||||||
|
|
@ -1631,6 +1927,7 @@
|
||||||
return [
|
return [
|
||||||
{ key: "dashboard", label: "\u041E\u0431\u0437\u043E\u0440" },
|
{ key: "dashboard", label: "\u041E\u0431\u0437\u043E\u0440" },
|
||||||
{ key: "requests", label: "\u0417\u0430\u044F\u0432\u043A\u0438" },
|
{ key: "requests", label: "\u0417\u0430\u044F\u0432\u043A\u0438" },
|
||||||
|
{ key: "invoices", label: "\u0421\u0447\u0435\u0442\u0430" },
|
||||||
{ key: "meta", label: "\u041C\u0435\u0442\u0430\u0434\u0430\u043D\u043D\u044B\u0435" }
|
{ key: "meta", label: "\u041C\u0435\u0442\u0430\u0434\u0430\u043D\u043D\u044B\u0435" }
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -1641,9 +1938,33 @@
|
||||||
const filterTableLabel = useMemo(() => getTableLabel(filterModal.tableKey), [filterModal.tableKey, getTableLabel]);
|
const filterTableLabel = useMemo(() => getTableLabel(filterModal.tableKey), [filterModal.tableKey, getTableLabel]);
|
||||||
const recordModalFields = useMemo(() => {
|
const recordModalFields = useMemo(() => {
|
||||||
const all = getRecordFields(recordModal.tableKey);
|
const all = getRecordFields(recordModal.tableKey);
|
||||||
if (recordModal.mode !== "create") return all;
|
if (recordModal.mode !== "create") return all.filter((field) => !field.createOnly);
|
||||||
return all.filter((field) => !field.autoCreate);
|
return all.filter((field) => !field.autoCreate);
|
||||||
}, [getRecordFields, recordModal.mode, recordModal.tableKey]);
|
}, [getRecordFields, recordModal.mode, recordModal.tableKey]);
|
||||||
|
const activeConfigTableState = useMemo(() => {
|
||||||
|
return tables[configActiveKey] || createTableState();
|
||||||
|
}, [configActiveKey, tables]);
|
||||||
|
const activeConfigMeta = useMemo(() => tableCatalogMap[configActiveKey] || null, [configActiveKey, tableCatalogMap]);
|
||||||
|
const activeConfigActions = useMemo(() => {
|
||||||
|
return Array.isArray(activeConfigMeta?.actions) ? activeConfigMeta.actions : [];
|
||||||
|
}, [activeConfigMeta]);
|
||||||
|
const canCreateInConfig = activeConfigActions.includes("create");
|
||||||
|
const canUpdateInConfig = activeConfigActions.includes("update");
|
||||||
|
const canDeleteInConfig = activeConfigActions.includes("delete");
|
||||||
|
const genericConfigHeaders = useMemo(() => {
|
||||||
|
if (!activeConfigMeta || !Array.isArray(activeConfigMeta.columns)) return [];
|
||||||
|
const headers = (activeConfigMeta.columns || []).filter((column) => column && column.name).map((column) => {
|
||||||
|
const name = String(column.name);
|
||||||
|
return {
|
||||||
|
key: name,
|
||||||
|
label: String(column.label || humanizeKey(name)),
|
||||||
|
sortable: Boolean(column.sortable !== false),
|
||||||
|
field: name
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (canUpdateInConfig || canDeleteInConfig) headers.push({ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" });
|
||||||
|
return headers;
|
||||||
|
}, [activeConfigMeta, canDeleteInConfig, canUpdateInConfig]);
|
||||||
return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "layout" }, /* @__PURE__ */ React.createElement("aside", { className: "sidebar" }, /* @__PURE__ */ React.createElement("div", { className: "logo" }, /* @__PURE__ */ React.createElement("a", { href: "/" }, "\u041F\u0440\u0430\u0432\u043E\u0432\u043E\u0439 \u0442\u0440\u0435\u043A\u0435\u0440")), /* @__PURE__ */ React.createElement("nav", { className: "menu" }, menuItems.map((item) => /* @__PURE__ */ React.createElement(
|
return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "layout" }, /* @__PURE__ */ React.createElement("aside", { className: "sidebar" }, /* @__PURE__ */ React.createElement("div", { className: "logo" }, /* @__PURE__ */ React.createElement("a", { href: "/" }, "\u041F\u0440\u0430\u0432\u043E\u0432\u043E\u0439 \u0442\u0440\u0435\u043A\u0435\u0440")), /* @__PURE__ */ React.createElement("nav", { className: "menu" }, menuItems.map((item) => /* @__PURE__ */ React.createElement(
|
||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
|
|
@ -1665,79 +1986,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438 " + (referencesExpanded ? "\u25BE" : "\u25B8")
|
"\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438 " + (referencesExpanded ? "\u25BE" : "\u25B8")
|
||||||
), referencesExpanded ? /* @__PURE__ */ React.createElement("div", { className: "menu-tree" }, /* @__PURE__ */ React.createElement(
|
), referencesExpanded ? /* @__PURE__ */ React.createElement("div", { className: "menu-tree" }, dictionaryTableItems.map((item) => /* @__PURE__ */ React.createElement(
|
||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
|
key: item.key,
|
||||||
type: "button",
|
type: "button",
|
||||||
className: activeSection === "config" && configActiveKey === "quotes" ? "active" : "",
|
className: activeSection === "config" && configActiveKey === item.key ? "active" : "",
|
||||||
onClick: () => selectConfigNode("quotes")
|
onClick: () => selectConfigNode(item.key)
|
||||||
},
|
},
|
||||||
"\u0426\u0438\u0442\u0430\u0442\u044B"
|
getTableLabel(item.key)
|
||||||
), /* @__PURE__ */ React.createElement(
|
))) : null) : null), /* @__PURE__ */ React.createElement("div", { className: "auth-box" }, token && role ? /* @__PURE__ */ React.createElement(React.Fragment, null, "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044C: ", /* @__PURE__ */ React.createElement("b", null, email), /* @__PURE__ */ React.createElement("br", null), "\u0420\u043E\u043B\u044C: ", /* @__PURE__ */ React.createElement("b", null, roleLabel(role))) : "\u041D\u0435 \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u043E\u0432\u0430\u043D"), /* @__PURE__ */ React.createElement("div", { style: { marginTop: "0.75rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: refreshAll }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn danger", type: "button", onClick: logout }, "\u0412\u044B\u0439\u0442\u0438"))), /* @__PURE__ */ React.createElement("main", { className: "main" }, /* @__PURE__ */ React.createElement("div", { className: "topbar" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h1", null, "\u041F\u0430\u043D\u0435\u043B\u044C \u0430\u0434\u043C\u0438\u043D\u0438\u0441\u0442\u0440\u0430\u0442\u043E\u0440\u0430"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "UniversalQuery, RBAC \u0438 \u0430\u0443\u0434\u0438\u0442 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0439 \u043F\u043E \u043A\u043B\u044E\u0447\u0435\u0432\u044B\u043C \u0441\u0443\u0449\u043D\u043E\u0441\u0442\u044F\u043C \u0441\u0438\u0441\u0442\u0435\u043C\u044B.")), /* @__PURE__ */ React.createElement("span", { className: "badge" }, "\u0440\u043E\u043B\u044C: ", roleLabel(role))), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "dashboard", id: "section-dashboard" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u041E\u0431\u0437\u043E\u0440 \u043C\u0435\u0442\u0440\u0438\u043A"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0421\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435 \u0437\u0430\u044F\u0432\u043E\u043A \u0438 SLA-\u043C\u043E\u043D\u0438\u0442\u043E\u0440\u0438\u043D\u0433."))), /* @__PURE__ */ React.createElement("div", { className: "cards" }, dashboardData.cards.map((card) => /* @__PURE__ */ React.createElement("div", { className: "card", key: card.label }, /* @__PURE__ */ React.createElement("p", null, card.label), /* @__PURE__ */ React.createElement("b", null, card.value)))), /* @__PURE__ */ React.createElement("div", { className: "json" }, JSON.stringify(dashboardData.byStatus || {}, null, 2)), dashboardData.scope === "LAWYER" ? /* @__PURE__ */ React.createElement("div", { className: "json", style: { marginTop: "0.5rem" } }, JSON.stringify(dashboardData.myUnreadByEvent || {}, null, 2)) : null, /* @__PURE__ */ React.createElement("div", { style: { marginTop: "0.85rem" } }, /* @__PURE__ */ React.createElement("h3", { style: { margin: "0 0 0.55rem" } }, "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u044E\u0440\u0438\u0441\u0442\u043E\u0432"), /* @__PURE__ */ React.createElement(
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
className: activeSection === "config" && configActiveKey === "topics" ? "active" : "",
|
|
||||||
onClick: () => selectConfigNode("topics")
|
|
||||||
},
|
|
||||||
"\u0422\u0435\u043C\u044B"
|
|
||||||
), /* @__PURE__ */ React.createElement(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
className: activeSection === "config" && configActiveKey === "statuses" ? "active" : "",
|
|
||||||
onClick: () => selectConfigNode("statuses")
|
|
||||||
},
|
|
||||||
"\u0421\u0442\u0430\u0442\u0443\u0441\u044B"
|
|
||||||
), /* @__PURE__ */ React.createElement(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
className: activeSection === "config" && configActiveKey === "formFields" ? "active" : "",
|
|
||||||
onClick: () => selectConfigNode("formFields")
|
|
||||||
},
|
|
||||||
"\u041F\u043E\u043B\u044F \u0444\u043E\u0440\u043C\u044B"
|
|
||||||
), /* @__PURE__ */ React.createElement(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
className: activeSection === "config" && configActiveKey === "topicRequiredFields" ? "active" : "",
|
|
||||||
onClick: () => selectConfigNode("topicRequiredFields")
|
|
||||||
},
|
|
||||||
"\u041E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u044B\u0435 \u043F\u043E\u043B\u044F \u0442\u0435\u043C\u044B"
|
|
||||||
), /* @__PURE__ */ React.createElement(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
className: activeSection === "config" && configActiveKey === "topicDataTemplates" ? "active" : "",
|
|
||||||
onClick: () => selectConfigNode("topicDataTemplates")
|
|
||||||
},
|
|
||||||
"\u0428\u0430\u0431\u043B\u043E\u043D\u044B \u0434\u043E\u0437\u0430\u043F\u0440\u043E\u0441\u0430"
|
|
||||||
), /* @__PURE__ */ React.createElement(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
className: activeSection === "config" && configActiveKey === "statusTransitions" ? "active" : "",
|
|
||||||
onClick: () => selectConfigNode("statusTransitions")
|
|
||||||
},
|
|
||||||
"\u041F\u0435\u0440\u0435\u0445\u043E\u0434\u044B \u0441\u0442\u0430\u0442\u0443\u0441\u043E\u0432"
|
|
||||||
), /* @__PURE__ */ React.createElement(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
className: activeSection === "config" && configActiveKey === "users" ? "active" : "",
|
|
||||||
onClick: () => selectConfigNode("users")
|
|
||||||
},
|
|
||||||
"\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0438"
|
|
||||||
), /* @__PURE__ */ React.createElement(
|
|
||||||
"button",
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
className: activeSection === "config" && configActiveKey === "userTopics" ? "active" : "",
|
|
||||||
onClick: () => selectConfigNode("userTopics")
|
|
||||||
},
|
|
||||||
"\u0422\u0435\u043C\u044B \u044E\u0440\u0438\u0441\u0442\u043E\u0432"
|
|
||||||
)) : null) : null), /* @__PURE__ */ React.createElement("div", { className: "auth-box" }, token && role ? /* @__PURE__ */ React.createElement(React.Fragment, null, "\u041F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044C: ", /* @__PURE__ */ React.createElement("b", null, email), /* @__PURE__ */ React.createElement("br", null), "\u0420\u043E\u043B\u044C: ", /* @__PURE__ */ React.createElement("b", null, roleLabel(role))) : "\u041D\u0435 \u0430\u0432\u0442\u043E\u0440\u0438\u0437\u043E\u0432\u0430\u043D"), /* @__PURE__ */ React.createElement("div", { style: { marginTop: "0.75rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: refreshAll }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn danger", type: "button", onClick: logout }, "\u0412\u044B\u0439\u0442\u0438"))), /* @__PURE__ */ React.createElement("main", { className: "main" }, /* @__PURE__ */ React.createElement("div", { className: "topbar" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h1", null, "\u041F\u0430\u043D\u0435\u043B\u044C \u0430\u0434\u043C\u0438\u043D\u0438\u0441\u0442\u0440\u0430\u0442\u043E\u0440\u0430"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "UniversalQuery, RBAC \u0438 \u0430\u0443\u0434\u0438\u0442 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0439 \u043F\u043E \u043A\u043B\u044E\u0447\u0435\u0432\u044B\u043C \u0441\u0443\u0449\u043D\u043E\u0441\u0442\u044F\u043C \u0441\u0438\u0441\u0442\u0435\u043C\u044B.")), /* @__PURE__ */ React.createElement("span", { className: "badge" }, "\u0440\u043E\u043B\u044C: ", roleLabel(role))), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "dashboard", id: "section-dashboard" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u041E\u0431\u0437\u043E\u0440 \u043C\u0435\u0442\u0440\u0438\u043A"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0421\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u0435 \u0437\u0430\u044F\u0432\u043E\u043A \u0438 SLA-\u043C\u043E\u043D\u0438\u0442\u043E\u0440\u0438\u043D\u0433."))), /* @__PURE__ */ React.createElement("div", { className: "cards" }, dashboardData.cards.map((card) => /* @__PURE__ */ React.createElement("div", { className: "card", key: card.label }, /* @__PURE__ */ React.createElement("p", null, card.label), /* @__PURE__ */ React.createElement("b", null, card.value)))), /* @__PURE__ */ React.createElement("div", { className: "json" }, JSON.stringify(dashboardData.byStatus || {}, null, 2)), /* @__PURE__ */ React.createElement("div", { style: { marginTop: "0.85rem" } }, /* @__PURE__ */ React.createElement("h3", { style: { margin: "0 0 0.55rem" } }, "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u044E\u0440\u0438\u0441\u0442\u043E\u0432"), /* @__PURE__ */ React.createElement(
|
|
||||||
DataTable,
|
DataTable,
|
||||||
{
|
{
|
||||||
headers: [
|
headers: [
|
||||||
|
|
@ -1745,11 +2003,14 @@
|
||||||
{ key: "email", label: "Email" },
|
{ key: "email", label: "Email" },
|
||||||
{ key: "primary_topic_code", label: "\u041E\u0441\u043D\u043E\u0432\u043D\u0430\u044F \u0442\u0435\u043C\u0430" },
|
{ key: "primary_topic_code", label: "\u041E\u0441\u043D\u043E\u0432\u043D\u0430\u044F \u0442\u0435\u043C\u0430" },
|
||||||
{ key: "active_load", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u044B\u0435 \u0437\u0430\u044F\u0432\u043A\u0438" },
|
{ key: "active_load", label: "\u0410\u043A\u0442\u0438\u0432\u043D\u044B\u0435 \u0437\u0430\u044F\u0432\u043A\u0438" },
|
||||||
{ key: "total_assigned", label: "\u0412\u0441\u0435\u0433\u043E \u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E" }
|
{ key: "total_assigned", label: "\u0412\u0441\u0435\u0433\u043E \u043D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u043E" },
|
||||||
|
{ key: "active_amount", label: "\u0421\u0443\u043C\u043C\u0430 \u0430\u043A\u0442\u0438\u0432\u043D\u044B\u0445" },
|
||||||
|
{ key: "monthly_paid_gross", label: "\u0412\u0430\u043B \u043E\u043F\u043B\u0430\u0442 \u0437\u0430 \u043C\u0435\u0441\u044F\u0446" },
|
||||||
|
{ key: "monthly_salary", label: "\u0417\u0430\u0440\u043F\u043B\u0430\u0442\u0430 \u0437\u0430 \u043C\u0435\u0441\u044F\u0446" }
|
||||||
],
|
],
|
||||||
rows: dashboardData.lawyerLoads || [],
|
rows: dashboardData.lawyerLoads || [],
|
||||||
emptyColspan: 5,
|
emptyColspan: 8,
|
||||||
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.lawyer_id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "user-identity" }, /* @__PURE__ */ React.createElement(UserAvatar, { name: row.name, email: row.email, avatarUrl: row.avatar_url, accessToken: token, size: 32 }), /* @__PURE__ */ React.createElement("div", { className: "user-identity-text" }, /* @__PURE__ */ React.createElement("b", null, row.name || "-")))), /* @__PURE__ */ React.createElement("td", null, row.email || "-"), /* @__PURE__ */ React.createElement("td", null, row.primary_topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, String(row.active_load ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.total_assigned ?? 0)))
|
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.lawyer_id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "user-identity" }, /* @__PURE__ */ React.createElement(UserAvatar, { name: row.name, email: row.email, avatarUrl: row.avatar_url, accessToken: token, size: 32 }), /* @__PURE__ */ React.createElement("div", { className: "user-identity-text" }, /* @__PURE__ */ React.createElement("b", null, row.name || "-")))), /* @__PURE__ */ React.createElement("td", null, row.email || "-"), /* @__PURE__ */ React.createElement("td", null, row.primary_topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, String(row.active_load ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.total_assigned ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.active_amount ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.monthly_paid_gross ?? 0)), /* @__PURE__ */ React.createElement("td", null, String(row.monthly_salary ?? 0)))
|
||||||
}
|
}
|
||||||
)), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("dashboard") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "requests", id: "section-requests" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0417\u0430\u044F\u0432\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0421\u0435\u0440\u0432\u0435\u0440\u043D\u0430\u044F \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u0446\u0438\u044F \u0438 \u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440 \u043A\u043B\u0438\u0435\u043D\u0442\u0441\u043A\u0438\u0445 \u0437\u0430\u044F\u0432\u043E\u043A.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("requests", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("requests") }, "\u041D\u043E\u0432\u0430\u044F \u0437\u0430\u044F\u0432\u043A\u0430"))), /* @__PURE__ */ React.createElement(
|
)), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("dashboard") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "requests", id: "section-requests" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0417\u0430\u044F\u0432\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0421\u0435\u0440\u0432\u0435\u0440\u043D\u0430\u044F \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u0446\u0438\u044F \u0438 \u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440 \u043A\u043B\u0438\u0435\u043D\u0442\u0441\u043A\u0438\u0445 \u0437\u0430\u044F\u0432\u043E\u043A.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("requests", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("requests") }, "\u041D\u043E\u0432\u0430\u044F \u0437\u0430\u044F\u0432\u043A\u0430"))), /* @__PURE__ */ React.createElement(
|
||||||
FilterToolbar,
|
FilterToolbar,
|
||||||
|
|
@ -1773,15 +2034,17 @@
|
||||||
{ key: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "status_code" },
|
{ key: "status_code", label: "\u0421\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "status_code" },
|
||||||
{ key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" },
|
{ key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" },
|
||||||
{ key: "assigned_lawyer_id", label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D", sortable: true, field: "assigned_lawyer_id" },
|
{ key: "assigned_lawyer_id", label: "\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D", sortable: true, field: "assigned_lawyer_id" },
|
||||||
|
{ key: "invoice_amount", label: "\u0421\u0447\u0435\u0442", sortable: true, field: "invoice_amount" },
|
||||||
|
{ key: "paid_at", label: "\u041E\u043F\u043B\u0430\u0447\u0435\u043D\u043E", sortable: true, field: "paid_at" },
|
||||||
{ key: "updates", label: "\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F" },
|
{ key: "updates", label: "\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F" },
|
||||||
{ key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D\u0430", sortable: true, field: "created_at" },
|
{ key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D\u0430", sortable: true, field: "created_at" },
|
||||||
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
|
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
|
||||||
],
|
],
|
||||||
rows: tables.requests.rows,
|
rows: tables.requests.rows,
|
||||||
emptyColspan: 9,
|
emptyColspan: 11,
|
||||||
onSort: (field) => toggleTableSort("requests", field),
|
onSort: (field) => toggleTableSort("requests", field),
|
||||||
sortClause: tables.requests.sort && tables.requests.sort[0] || TABLE_SERVER_CONFIG.requests.sort[0],
|
sortClause: tables.requests.sort && tables.requests.sort[0] || TABLE_SERVER_CONFIG.requests.sort[0],
|
||||||
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.track_number || "-")), /* @__PURE__ */ React.createElement("td", null, row.client_name || "-"), /* @__PURE__ */ React.createElement("td", null, row.client_phone || "-"), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.status_code)), /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.assigned_lawyer_id || "-"), /* @__PURE__ */ React.createElement("td", null, renderRequestUpdatesCell(row, role)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, role === "LAWYER" && !row.assigned_lawyer_id ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F4E5}", tooltip: "\u0412\u0437\u044F\u0442\u044C \u0432 \u0440\u0430\u0431\u043E\u0442\u0443", onClick: () => claimRequest(row.id) }) : null, role === "ADMIN" && row.assigned_lawyer_id ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u21C4", tooltip: "\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0438\u0442\u044C", onClick: () => openReassignModal(row) }) : null, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F441}", tooltip: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openRequestDetails(row.id) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openEditRecordModal("requests", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => deleteRecord("requests", row.id), tone: "danger" }))))
|
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.track_number || "-")), /* @__PURE__ */ React.createElement("td", null, row.client_name || "-"), /* @__PURE__ */ React.createElement("td", null, row.client_phone || "-"), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.status_code)), /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.assigned_lawyer_id || "-"), /* @__PURE__ */ React.createElement("td", null, row.invoice_amount == null ? "-" : String(row.invoice_amount)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.paid_at)), /* @__PURE__ */ React.createElement("td", null, renderRequestUpdatesCell(row, role)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, role === "LAWYER" && !row.assigned_lawyer_id ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F4E5}", tooltip: "\u0412\u0437\u044F\u0442\u044C \u0432 \u0440\u0430\u0431\u043E\u0442\u0443", onClick: () => claimRequest(row.id) }) : null, role === "ADMIN" && row.assigned_lawyer_id ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u21C4", tooltip: "\u041F\u0435\u0440\u0435\u043D\u0430\u0437\u043D\u0430\u0447\u0438\u0442\u044C", onClick: () => openReassignModal(row) }) : null, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F441}", tooltip: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openRequestDetails(row.id) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openEditRecordModal("requests", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => deleteRecord("requests", row.id), tone: "danger" }))))
|
||||||
}
|
}
|
||||||
), /* @__PURE__ */ React.createElement(
|
), /* @__PURE__ */ React.createElement(
|
||||||
TablePager,
|
TablePager,
|
||||||
|
|
@ -1791,7 +2054,47 @@
|
||||||
onNext: () => loadNextPage("requests"),
|
onNext: () => loadNextPage("requests"),
|
||||||
onLoadAll: () => loadAllRows("requests")
|
onLoadAll: () => loadAllRows("requests")
|
||||||
}
|
}
|
||||||
), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("requests") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "quotes", id: "section-quotes" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0426\u0438\u0442\u0430\u0442\u044B"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0423\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u043F\u0443\u0431\u043B\u0438\u0447\u043D\u043E\u0439 \u043B\u0435\u043D\u0442\u043E\u0439 \u0446\u0438\u0442\u0430\u0442 \u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u043D\u044B\u043C\u0438 \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u043C\u0438.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("quotes", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("quotes") }, "\u041D\u043E\u0432\u0430\u044F \u0446\u0438\u0442\u0430\u0442\u0430"))), /* @__PURE__ */ React.createElement(
|
), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("requests") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "invoices", id: "section-invoices" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u0447\u0435\u0442\u0430"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0412\u044B\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u043D\u044B\u0435 \u0441\u0447\u0435\u0442\u0430 \u043A\u043B\u0438\u0435\u043D\u0442\u0430\u043C, \u0441\u0442\u0430\u0442\u0443\u0441\u044B \u043E\u043F\u043B\u0430\u0442\u044B \u0438 \u0432\u044B\u0433\u0440\u0443\u0437\u043A\u0430 PDF.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("invoices", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("invoices") }, "\u041D\u043E\u0432\u044B\u0439 \u0441\u0447\u0435\u0442"))), /* @__PURE__ */ React.createElement(
|
||||||
|
FilterToolbar,
|
||||||
|
{
|
||||||
|
filters: tables.invoices.filters,
|
||||||
|
onOpen: () => openFilterModal("invoices"),
|
||||||
|
onRemove: (index) => removeFilterChip("invoices", index),
|
||||||
|
onEdit: (index) => openFilterEditModal("invoices", index),
|
||||||
|
getChipLabel: (clause) => {
|
||||||
|
const fieldDef = getFieldDef("invoices", clause.field);
|
||||||
|
return (fieldDef ? fieldDef.label : clause.field) + " " + OPERATOR_LABELS[clause.op] + " " + getFilterValuePreview("invoices", clause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
), /* @__PURE__ */ React.createElement(
|
||||||
|
DataTable,
|
||||||
|
{
|
||||||
|
headers: [
|
||||||
|
{ key: "invoice_number", label: "\u041D\u043E\u043C\u0435\u0440", sortable: true, field: "invoice_number" },
|
||||||
|
{ key: "status", label: "\u0421\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "status" },
|
||||||
|
{ key: "amount", label: "\u0421\u0443\u043C\u043C\u0430", sortable: true, field: "amount" },
|
||||||
|
{ key: "payer_display_name", label: "\u041F\u043B\u0430\u0442\u0435\u043B\u044C\u0449\u0438\u043A", sortable: true, field: "payer_display_name" },
|
||||||
|
{ key: "request_track_number", label: "\u0417\u0430\u044F\u0432\u043A\u0430" },
|
||||||
|
{ key: "issued_by_name", label: "\u0412\u044B\u0441\u0442\u0430\u0432\u0438\u043B", sortable: true, field: "issued_by_admin_user_id" },
|
||||||
|
{ key: "issued_at", label: "\u0421\u0444\u043E\u0440\u043C\u0438\u0440\u043E\u0432\u0430\u043D", sortable: true, field: "issued_at" },
|
||||||
|
{ key: "paid_at", label: "\u041E\u043F\u043B\u0430\u0447\u0435\u043D", sortable: true, field: "paid_at" },
|
||||||
|
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
|
||||||
|
],
|
||||||
|
rows: tables.invoices.rows,
|
||||||
|
emptyColspan: 9,
|
||||||
|
onSort: (field) => toggleTableSort("invoices", field),
|
||||||
|
sortClause: tables.invoices.sort && tables.invoices.sort[0] || TABLE_SERVER_CONFIG.invoices.sort[0],
|
||||||
|
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.invoice_number || "-")), /* @__PURE__ */ React.createElement("td", null, row.status_label || invoiceStatusLabel(row.status)), /* @__PURE__ */ React.createElement("td", null, row.amount == null ? "-" : String(row.amount) + " " + String(row.currency || "RUB")), /* @__PURE__ */ React.createElement("td", null, row.payer_display_name || "-"), /* @__PURE__ */ React.createElement("td", null, row.request_track_number || row.request_id || "-"), /* @__PURE__ */ React.createElement("td", null, row.issued_by_name || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.issued_at)), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.paid_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F441}", tooltip: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0437\u0430\u044F\u0432\u043A\u0443", onClick: () => openInvoiceRequest(row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u2B07", tooltip: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C PDF", onClick: () => downloadInvoicePdf(row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0447\u0435\u0442", onClick: () => openEditRecordModal("invoices", row) }), role === "ADMIN" ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0447\u0435\u0442", onClick: () => deleteRecord("invoices", row.id), tone: "danger" }) : null)))
|
||||||
|
}
|
||||||
|
), /* @__PURE__ */ React.createElement(
|
||||||
|
TablePager,
|
||||||
|
{
|
||||||
|
tableState: tables.invoices,
|
||||||
|
onPrev: () => loadPrevPage("invoices"),
|
||||||
|
onNext: () => loadNextPage("invoices"),
|
||||||
|
onLoadAll: () => loadAllRows("invoices")
|
||||||
|
}
|
||||||
|
), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("invoices") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "quotes", id: "section-quotes" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0426\u0438\u0442\u0430\u0442\u044B"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0423\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u043F\u0443\u0431\u043B\u0438\u0447\u043D\u043E\u0439 \u043B\u0435\u043D\u0442\u043E\u0439 \u0446\u0438\u0442\u0430\u0442 \u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u043D\u044B\u043C\u0438 \u0444\u0438\u043B\u044C\u0442\u0440\u0430\u043C\u0438.")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "0.5rem" } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadTable("quotes", { resetOffset: true }) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C"), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal("quotes") }, "\u041D\u043E\u0432\u0430\u044F \u0446\u0438\u0442\u0430\u0442\u0430"))), /* @__PURE__ */ React.createElement(
|
||||||
FilterToolbar,
|
FilterToolbar,
|
||||||
{
|
{
|
||||||
filters: tables.quotes.filters,
|
filters: tables.quotes.filters,
|
||||||
|
|
@ -1829,10 +2132,10 @@
|
||||||
onNext: () => loadNextPage("quotes"),
|
onNext: () => loadNextPage("quotes"),
|
||||||
onLoadAll: () => loadAllRows("quotes")
|
onLoadAll: () => loadAllRows("quotes")
|
||||||
}
|
}
|
||||||
), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("quotes") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "config", id: "section-config" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u0432 \u0434\u0435\u0440\u0435\u0432\u0435 \u0441\u043B\u0435\u0432\u0430.")), /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadCurrentConfigTable(true) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C")), /* @__PURE__ */ React.createElement("div", { className: "config-layout" }, /* @__PURE__ */ React.createElement("div", { className: "config-panel" }, /* @__PURE__ */ React.createElement("div", { className: "block" }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" } }, /* @__PURE__ */ React.createElement("h3", { style: { margin: 0 } }, getTableLabel(configActiveKey)), /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal(configActiveKey) }, "\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C")), /* @__PURE__ */ React.createElement(
|
), /* @__PURE__ */ React.createElement(StatusLine, { status: getStatus("quotes") })), /* @__PURE__ */ React.createElement(Section, { active: activeSection === "config", id: "section-config" }, /* @__PURE__ */ React.createElement("div", { className: "section-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", null, "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\u0438"), /* @__PURE__ */ React.createElement("p", { className: "muted" }, "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u0432 \u0434\u0435\u0440\u0435\u0432\u0435 \u0441\u043B\u0435\u0432\u0430.")), /* @__PURE__ */ React.createElement("button", { className: "btn secondary", type: "button", onClick: () => loadCurrentConfigTable(true) }, "\u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C")), /* @__PURE__ */ React.createElement("div", { className: "config-layout" }, /* @__PURE__ */ React.createElement("div", { className: "config-panel" }, /* @__PURE__ */ React.createElement("div", { className: "block" }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" } }, /* @__PURE__ */ React.createElement("h3", { style: { margin: 0 } }, configActiveKey ? getTableLabel(configActiveKey) : "\u0421\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D"), canCreateInConfig && configActiveKey ? /* @__PURE__ */ React.createElement("button", { className: "btn", type: "button", onClick: () => openCreateRecordModal(configActiveKey) }, "\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C") : null), /* @__PURE__ */ React.createElement(
|
||||||
FilterToolbar,
|
FilterToolbar,
|
||||||
{
|
{
|
||||||
filters: tables[configActiveKey].filters,
|
filters: activeConfigTableState.filters,
|
||||||
onOpen: () => openFilterModal(configActiveKey),
|
onOpen: () => openFilterModal(configActiveKey),
|
||||||
onRemove: (index) => removeFilterChip(configActiveKey, index),
|
onRemove: (index) => removeFilterChip(configActiveKey, index),
|
||||||
onEdit: (index) => openFilterEditModal(configActiveKey, index),
|
onEdit: (index) => openFilterEditModal(configActiveKey, index),
|
||||||
|
|
@ -1881,16 +2184,18 @@
|
||||||
headers: [
|
headers: [
|
||||||
{ key: "code", label: "\u041A\u043E\u0434", sortable: true, field: "code" },
|
{ key: "code", label: "\u041A\u043E\u0434", sortable: true, field: "code" },
|
||||||
{ key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", sortable: true, field: "name" },
|
{ key: "name", label: "\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435", sortable: true, field: "name" },
|
||||||
|
{ key: "kind", label: "\u0422\u0438\u043F", sortable: true, field: "kind" },
|
||||||
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "enabled" },
|
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "enabled" },
|
||||||
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" },
|
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" },
|
||||||
{ key: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", sortable: true, field: "is_terminal" },
|
{ key: "is_terminal", label: "\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0439", sortable: true, field: "is_terminal" },
|
||||||
|
{ key: "invoice_template", label: "\u0428\u0430\u0431\u043B\u043E\u043D \u0441\u0447\u0435\u0442\u0430" },
|
||||||
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
|
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
|
||||||
],
|
],
|
||||||
rows: tables.statuses.rows,
|
rows: tables.statuses.rows,
|
||||||
emptyColspan: 6,
|
emptyColspan: 8,
|
||||||
onSort: (field) => toggleTableSort("statuses", field),
|
onSort: (field) => toggleTableSort("statuses", field),
|
||||||
sortClause: tables.statuses.sort && tables.statuses.sort[0] || TABLE_SERVER_CONFIG.statuses.sort[0],
|
sortClause: tables.statuses.sort && tables.statuses.sort[0] || TABLE_SERVER_CONFIG.statuses.sort[0],
|
||||||
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.code || "-")), /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_terminal)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => openEditRecordModal("statuses", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => deleteRecord("statuses", row.id), tone: "danger" }))))
|
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("code", null, row.code || "-")), /* @__PURE__ */ React.createElement("td", null, row.name || "-"), /* @__PURE__ */ React.createElement("td", null, statusKindLabel(row.kind)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_terminal)), /* @__PURE__ */ React.createElement("td", null, row.invoice_template || "-"), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => openEditRecordModal("statuses", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0442\u0430\u0442\u0443\u0441", onClick: () => deleteRecord("statuses", row.id), tone: "danger" }))))
|
||||||
}
|
}
|
||||||
) : null, configActiveKey === "formFields" ? /* @__PURE__ */ React.createElement(
|
) : null, configActiveKey === "formFields" ? /* @__PURE__ */ React.createElement(
|
||||||
DataTable,
|
DataTable,
|
||||||
|
|
@ -1970,15 +2275,16 @@
|
||||||
{ key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" },
|
{ key: "topic_code", label: "\u0422\u0435\u043C\u0430", sortable: true, field: "topic_code" },
|
||||||
{ key: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", sortable: true, field: "from_status" },
|
{ key: "from_status", label: "\u0418\u0437 \u0441\u0442\u0430\u0442\u0443\u0441\u0430", sortable: true, field: "from_status" },
|
||||||
{ key: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "to_status" },
|
{ key: "to_status", label: "\u0412 \u0441\u0442\u0430\u0442\u0443\u0441", sortable: true, field: "to_status" },
|
||||||
|
{ key: "sla_hours", label: "SLA (\u0447\u0430\u0441\u044B)", sortable: true, field: "sla_hours" },
|
||||||
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "enabled" },
|
{ key: "enabled", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "enabled" },
|
||||||
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" },
|
{ key: "sort_order", label: "\u041F\u043E\u0440\u044F\u0434\u043E\u043A", sortable: true, field: "sort_order" },
|
||||||
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
|
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
|
||||||
],
|
],
|
||||||
rows: tables.statusTransitions.rows,
|
rows: tables.statusTransitions.rows,
|
||||||
emptyColspan: 6,
|
emptyColspan: 7,
|
||||||
onSort: (field) => toggleTableSort("statusTransitions", field),
|
onSort: (field) => toggleTableSort("statusTransitions", field),
|
||||||
sortClause: tables.statusTransitions.sort && tables.statusTransitions.sort[0] || TABLE_SERVER_CONFIG.statusTransitions.sort[0],
|
sortClause: tables.statusTransitions.sort && tables.statusTransitions.sort[0] || TABLE_SERVER_CONFIG.statusTransitions.sort[0],
|
||||||
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.from_status)), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.to_status)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(
|
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.from_status)), /* @__PURE__ */ React.createElement("td", null, statusLabel(row.to_status)), /* @__PURE__ */ React.createElement("td", null, row.sla_hours == null ? "-" : String(row.sla_hours)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.enabled)), /* @__PURE__ */ React.createElement("td", null, String(row.sort_order ?? 0)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(
|
||||||
IconButton,
|
IconButton,
|
||||||
{
|
{
|
||||||
icon: "\u270E",
|
icon: "\u270E",
|
||||||
|
|
@ -2003,16 +2309,18 @@
|
||||||
{ key: "email", label: "Email", sortable: true, field: "email" },
|
{ key: "email", label: "Email", sortable: true, field: "email" },
|
||||||
{ key: "role", label: "\u0420\u043E\u043B\u044C", sortable: true, field: "role" },
|
{ key: "role", label: "\u0420\u043E\u043B\u044C", sortable: true, field: "role" },
|
||||||
{ key: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", sortable: true, field: "primary_topic_code" },
|
{ key: "primary_topic_code", label: "\u041F\u0440\u043E\u0444\u0438\u043B\u044C (\u0442\u0435\u043C\u0430)", sortable: true, field: "primary_topic_code" },
|
||||||
|
{ key: "default_rate", label: "\u0421\u0442\u0430\u0432\u043A\u0430", sortable: true, field: "default_rate" },
|
||||||
|
{ key: "salary_percent", label: "\u041F\u0440\u043E\u0446\u0435\u043D\u0442", sortable: true, field: "salary_percent" },
|
||||||
{ key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "is_active" },
|
{ key: "is_active", label: "\u0410\u043A\u0442\u0438\u0432\u0435\u043D", sortable: true, field: "is_active" },
|
||||||
{ key: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", sortable: true, field: "responsible" },
|
{ key: "responsible", label: "\u041E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0435\u043D\u043D\u044B\u0439", sortable: true, field: "responsible" },
|
||||||
{ key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D", sortable: true, field: "created_at" },
|
{ key: "created_at", label: "\u0421\u043E\u0437\u0434\u0430\u043D", sortable: true, field: "created_at" },
|
||||||
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
|
{ key: "actions", label: "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u044F" }
|
||||||
],
|
],
|
||||||
rows: tables.users.rows,
|
rows: tables.users.rows,
|
||||||
emptyColspan: 8,
|
emptyColspan: 10,
|
||||||
onSort: (field) => toggleTableSort("users", field),
|
onSort: (field) => toggleTableSort("users", field),
|
||||||
sortClause: tables.users.sort && tables.users.sort[0] || TABLE_SERVER_CONFIG.users.sort[0],
|
sortClause: tables.users.sort && tables.users.sort[0] || TABLE_SERVER_CONFIG.users.sort[0],
|
||||||
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "user-identity" }, /* @__PURE__ */ React.createElement(UserAvatar, { name: row.name, email: row.email, avatarUrl: row.avatar_url, accessToken: token, size: 32 }), /* @__PURE__ */ React.createElement("div", { className: "user-identity-text" }, /* @__PURE__ */ React.createElement("b", null, row.name || "-")))), /* @__PURE__ */ React.createElement("td", null, row.email || "-"), /* @__PURE__ */ React.createElement("td", null, roleLabel(row.role)), /* @__PURE__ */ React.createElement("td", null, row.primary_topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_active)), /* @__PURE__ */ React.createElement("td", null, row.responsible || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", onClick: () => openEditRecordModal("users", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", onClick: () => deleteRecord("users", row.id), tone: "danger" }))))
|
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "user-identity" }, /* @__PURE__ */ React.createElement(UserAvatar, { name: row.name, email: row.email, avatarUrl: row.avatar_url, accessToken: token, size: 32 }), /* @__PURE__ */ React.createElement("div", { className: "user-identity-text" }, /* @__PURE__ */ React.createElement("b", null, row.name || "-")))), /* @__PURE__ */ React.createElement("td", null, row.email || "-"), /* @__PURE__ */ React.createElement("td", null, roleLabel(row.role)), /* @__PURE__ */ React.createElement("td", null, row.primary_topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.default_rate == null ? "-" : String(row.default_rate)), /* @__PURE__ */ React.createElement("td", null, row.salary_percent == null ? "-" : String(row.salary_percent)), /* @__PURE__ */ React.createElement("td", null, boolLabel(row.is_active)), /* @__PURE__ */ React.createElement("td", null, row.responsible || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", onClick: () => openEditRecordModal("users", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F", onClick: () => deleteRecord("users", row.id), tone: "danger" }))))
|
||||||
}
|
}
|
||||||
) : null, configActiveKey === "userTopics" ? /* @__PURE__ */ React.createElement(
|
) : null, configActiveKey === "userTopics" ? /* @__PURE__ */ React.createElement(
|
||||||
DataTable,
|
DataTable,
|
||||||
|
|
@ -2034,10 +2342,27 @@
|
||||||
return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, lawyerLabel), /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.responsible || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0432\u044F\u0437\u044C", onClick: () => openEditRecordModal("userTopics", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0432\u044F\u0437\u044C", onClick: () => deleteRecord("userTopics", row.id), tone: "danger" }))));
|
return /* @__PURE__ */ React.createElement("tr", { key: row.id }, /* @__PURE__ */ React.createElement("td", null, lawyerLabel), /* @__PURE__ */ React.createElement("td", null, row.topic_code || "-"), /* @__PURE__ */ React.createElement("td", null, row.responsible || "-"), /* @__PURE__ */ React.createElement("td", null, fmtDate(row.created_at)), /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0432\u044F\u0437\u044C", onClick: () => openEditRecordModal("userTopics", row) }), /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u0432\u044F\u0437\u044C", onClick: () => deleteRecord("userTopics", row.id), tone: "danger" }))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
) : null, configActiveKey && !KNOWN_CONFIG_TABLE_KEYS.has(configActiveKey) ? /* @__PURE__ */ React.createElement(
|
||||||
|
DataTable,
|
||||||
|
{
|
||||||
|
headers: genericConfigHeaders,
|
||||||
|
rows: activeConfigTableState.rows,
|
||||||
|
emptyColspan: Math.max(1, genericConfigHeaders.length),
|
||||||
|
onSort: (field) => toggleTableSort(configActiveKey, field),
|
||||||
|
sortClause: activeConfigTableState.sort && activeConfigTableState.sort[0] || (resolveTableConfig(configActiveKey)?.sort || [])[0],
|
||||||
|
renderRow: (row) => /* @__PURE__ */ React.createElement("tr", { key: row.id || JSON.stringify(row) }, (activeConfigMeta?.columns || []).map((column) => {
|
||||||
|
const key = String(column.name || "");
|
||||||
|
const value = row[key];
|
||||||
|
if (column.kind === "boolean") return /* @__PURE__ */ React.createElement("td", { key }, boolLabel(Boolean(value)));
|
||||||
|
if (column.kind === "date" || column.kind === "datetime") return /* @__PURE__ */ React.createElement("td", { key }, fmtDate(value));
|
||||||
|
if (column.kind === "json") return /* @__PURE__ */ React.createElement("td", { key }, value == null ? "-" : JSON.stringify(value));
|
||||||
|
return /* @__PURE__ */ React.createElement("td", { key }, value == null || value === "" ? "-" : String(value));
|
||||||
|
}), canUpdateInConfig || canDeleteInConfig ? /* @__PURE__ */ React.createElement("td", null, /* @__PURE__ */ React.createElement("div", { className: "table-actions" }, canUpdateInConfig ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u270E", tooltip: "\u0420\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C", onClick: () => openEditRecordModal(configActiveKey, row) }) : null, canDeleteInConfig ? /* @__PURE__ */ React.createElement(IconButton, { icon: "\u{1F5D1}", tooltip: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C", onClick: () => deleteRecord(configActiveKey, row.id), tone: "danger" }) : null)) : null)
|
||||||
|
}
|
||||||
) : null, /* @__PURE__ */ React.createElement(
|
) : null, /* @__PURE__ */ React.createElement(
|
||||||
TablePager,
|
TablePager,
|
||||||
{
|
{
|
||||||
tableState: tables[configActiveKey],
|
tableState: activeConfigTableState,
|
||||||
onPrev: () => loadPrevPage(configActiveKey),
|
onPrev: () => loadPrevPage(configActiveKey),
|
||||||
onNext: () => loadNextPage(configActiveKey),
|
onNext: () => loadNextPage(configActiveKey),
|
||||||
onLoadAll: () => loadAllRows(configActiveKey)
|
onLoadAll: () => loadAllRows(configActiveKey)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue